From 420d0c7244bd8872173a55df9bfb5b9a6d5e18fb Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Thu, 26 Mar 2026 11:44:53 +0100 Subject: [PATCH 1/9] refactor: STORIF-335 - AccessKeyDrawer relpaced by OMC_AccessKeyDrawer. --- .../AccessKeyLanding/AccessKeyDrawer.test.tsx | 97 ---- .../AccessKeyLanding/AccessKeyDrawer.tsx | 440 ++++++++++-------- .../AccessKeyLanding/AccessKeyLanding.tsx | 28 +- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 346 -------------- .../AccessKeyLanding/utils.test.ts | 2 +- .../ObjectStorage/AccessKeyLanding/utils.ts | 2 +- 6 files changed, 256 insertions(+), 659 deletions(-) delete mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx delete mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx deleted file mode 100644 index 6bf0c7863fd..00000000000 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.test.tsx +++ /dev/null @@ -1,97 +0,0 @@ -import { screen } from '@testing-library/react'; -import * as React from 'react'; - -import { objectStorageBucketFactory } from 'src/factories/objectStorage'; -import { renderWithTheme } from 'src/utilities/testHelpers'; - -import { AccessKeyDrawer, getDefaultScopes } from './AccessKeyDrawer'; -import { getUpdatedScopes } from './AccessTable'; - -import type { AccessKeyDrawerProps } from './AccessKeyDrawer'; -import type { MODE } from './types'; -import type { ObjectStorageKeyBucketAccess } from '@linode/api-v4/lib/object-storage/types'; - -describe('AccessKeyDrawer', () => { - const props: AccessKeyDrawerProps = { - isRestrictedUser: false, - mode: 'creating' as MODE, - onClose: vi.fn(), - onSubmit: vi.fn(), - open: true, - }; - renderWithTheme(); - it('renders without crashing', () => { - expect(screen.getByTestId('drawer-title')).toBeInTheDocument(); - }); - - describe('default scopes helper method', () => { - const mockBuckets = objectStorageBucketFactory.buildList(5); - it('should return an item for each bucket', () => { - expect(getDefaultScopes(mockBuckets)).toHaveLength(mockBuckets.length); - }); - - it('should return objects with the correct shape', () => { - const bucket = mockBuckets[0]; - expect(getDefaultScopes([bucket])[0]).toEqual({ - bucket_name: bucket.label, - cluster: bucket.cluster, - permissions: 'none', - region: 'us-east', - }); - }); - - it('should sort the permissions by cluster', () => { - const usaBucket = objectStorageBucketFactory.build({ - cluster: 'us-east-1', - }); - const germanBucket = objectStorageBucketFactory.build({ - cluster: 'eu-central-1', - }); - const asiaBucket = objectStorageBucketFactory.build({ - cluster: 'ap-south-1', - }); - const unsortedBuckets = [usaBucket, germanBucket, asiaBucket]; - expect( - getDefaultScopes(unsortedBuckets).map((scope) => scope.cluster) - ).toEqual(['ap-south-1', 'eu-central-1', 'us-east-1']); - }); - }); - - describe('Updating scopes', () => { - const mockBuckets = objectStorageBucketFactory.buildList(3); - - const mockScopes = getDefaultScopes(mockBuckets); - - it('should update the correct scope', () => { - const newScope = { - ...mockScopes[2], - permissions: 'read_write', - } as ObjectStorageKeyBucketAccess; - expect(getUpdatedScopes(mockScopes, newScope)[2]).toHaveProperty( - 'permissions', - 'read_write' - ); - }); - - it('should leave other scopes unchanged', () => { - const newScope = { - ...mockScopes[2], - access: 'read_write', - } as ObjectStorageKeyBucketAccess; - const updatedScopes = getUpdatedScopes(mockScopes, newScope); - expect(updatedScopes[0]).toEqual(mockScopes[0]); - expect(updatedScopes[1]).toEqual(mockScopes[1]); - expect(updatedScopes.length).toEqual(mockScopes.length); - }); - - it('should handle crappy input', () => { - const newScope = { - bucket_name: 'not-real', - cluster: 'totally-fake', - permissions: 'read_only', - region: 'us-east', - } as ObjectStorageKeyBucketAccess; - expect(getUpdatedScopes(mockScopes, newScope)).toEqual(mockScopes); - }); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index 49999cb275e..6d30b31e979 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -7,16 +7,27 @@ import { TextField, Typography, } from '@linode/ui'; -import { createObjectStorageKeysSchema } from '@linode/validation/lib/objectStorageKeys.schema'; -import { Formik } from 'formik'; -import * as React from 'react'; +import { sortByString } from '@linode/utilities'; +import { + createObjectStorageKeysSchema, + updateObjectStorageKeysSchema, +} from '@linode/validation'; +import { useFormik } from 'formik'; +import React, { useEffect, useState } from 'react'; import { Link } from 'src/components/Link'; +import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { confirmObjectStorage } from '../utilities'; +import { AccessKeyRegions } from './AccessKeyRegions/AccessKeyRegions'; import { LimitedAccessControls } from './LimitedAccessControls'; +import { + generateUpdatePayload, + hasAccessBeenSelectedForAllBuckets, + hasLabelOrRegionsChanged, +} from './utils'; import type { MODE } from './types'; import type { @@ -25,8 +36,9 @@ import type { ObjectStorageKey, ObjectStorageKeyBucketAccess, ObjectStorageKeyBucketAccessPermissions, + Region, UpdateObjectStorageKeyPayload, -} from '@linode/api-v4/lib/object-storage'; +} from '@linode/api-v4'; import type { FormikProps } from 'formik'; export interface AccessKeyDrawerProps { @@ -37,122 +49,174 @@ export interface AccessKeyDrawerProps { onClose: () => void; onSubmit: ( values: CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload, - formikProps: FormikProps + formikProps: FormikProps< + CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload + > ) => void; open: boolean; } -interface FormState { +// Access key scopes displayed in the drawer can have no permission or "No Access" selected, which are not valid API permissions. +export interface DisplayedAccessKeyScope + extends Omit { + permissions: null | ObjectStorageKeyBucketAccessPermissions; +} + +export interface FormState { bucket_access: null | ObjectStorageKeyBucketAccess[]; label: string; + regions: string[]; } /** * Helpers for converting a list of buckets * on the user's account into a list of * bucket_access in the shape the API will expect, - * sorted by cluster. + * sorted by region. */ -export const sortByCluster = ( - a: ObjectStorageKeyBucketAccess, - b: ObjectStorageKeyBucketAccess -) => { - if (a.cluster > b.cluster) { - return 1; - } - if (a.cluster < b.cluster) { - return -1; - } - return 0; -}; + +export const sortByRegion = + (regionLookup: { [key: string]: Region }) => + (a: DisplayedAccessKeyScope, b: DisplayedAccessKeyScope) => { + if (!a.region || !b.region) { + return 0; + } + + return sortByString( + regionLookup[a.region].label, + regionLookup[b.region].label, + 'asc' + ); + }; export const getDefaultScopes = ( - buckets: ObjectStorageBucket[] -): ObjectStorageKeyBucketAccess[] => + buckets: ObjectStorageBucket[], + regionLookup: { [key: string]: Region } = {} +): DisplayedAccessKeyScope[] => buckets .map((thisBucket) => ({ bucket_name: thisBucket.label, cluster: thisBucket.cluster, - permissions: 'none' as ObjectStorageKeyBucketAccessPermissions, - region: thisBucket.region ?? '', + permissions: null, + region: thisBucket.region, })) - .sort(sortByCluster); + .sort(sortByRegion(regionLookup)); export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const { isRestrictedUser, mode, objectStorageKey, onClose, onSubmit, open } = props; - const { data: accountSettings } = useAccountSettings(); + const { regionsByIdMap } = useObjectStorageRegions(); const { - data: objectStorageBucketsResponse, + data: objectStorageBuckets, error: bucketsError, isLoading: areBucketsLoading, } = useObjectStorageBuckets(); - const buckets = objectStorageBucketsResponse?.buckets || []; + const { data: accountSettings } = useAccountSettings(); - const hasBuckets = buckets?.length > 0; + const buckets = objectStorageBuckets?.buckets || []; - const hidePermissionsTable = - bucketsError || objectStorageBucketsResponse?.buckets.length === 0; + const hasBuckets = buckets?.length > 0; const createMode = mode === 'creating'; - const [dialogOpen, setDialogOpen] = React.useState(false); + const [dialogOpen, setDialogOpen] = useState(false); // This is for local display management only, not part of the payload // and so not included in Formik's types - const [limitedAccessChecked, setLimitedAccessChecked] = React.useState(false); + const [limitedAccessChecked, setLimitedAccessChecked] = useState(false); - React.useEffect(() => { - if (open) { - setLimitedAccessChecked(false); - } - }, [open]); - - const title = createMode ? 'Create Access Key' : 'Edit Access Key Label'; + const title = createMode ? 'Create Access Key' : 'Edit Access Key'; const initialLabelValue = !createMode && objectStorageKey ? objectStorageKey.label : ''; + const initialRegions = + !createMode && objectStorageKey?.regions + ? objectStorageKey.regions.map((region) => region.id) + : []; + const initialValues: FormState = { - bucket_access: getDefaultScopes(buckets), + bucket_access: [], label: initialLabelValue, + regions: initialRegions, }; - const handleSubmit = ( - values: CreateObjectStorageKeyPayload, - formikProps: FormikProps - ) => { - // If the user hasn't toggled the Limited Access button, - // don't include any bucket_access information in the payload. - - // If any/all values are 'none', don't include them in the response. - let payload = {}; - if ( - mode === 'creating' && - values?.bucket_access !== null && - limitedAccessChecked - ) { - const access = values?.bucket_access ?? []; - payload = { - ...values, - bucket_access: access.filter( - (thisAccess) => thisAccess.permissions !== 'none' - ), - }; - } else { - payload = { ...values, bucket_access: null }; - } + const formik = useFormik({ + initialValues, + onSubmit: (values) => { + // If the user hasn't toggled the Limited Access button, + // don't include any bucket_access information in the payload. - if (mode === 'editing') { - payload = { - label: values.label, - }; - } - return onSubmit(payload, formikProps); + // If any/all permissions are 'none' or null, don't include them in the response. + const access = values.bucket_access ?? []; + + const payload = limitedAccessChecked + ? { + ...values, + bucket_access: access.filter( + (thisAccess: DisplayedAccessKeyScope) => + thisAccess.permissions !== 'none' && + thisAccess.permissions !== null + ), + } + : { ...values, bucket_access: null }; + + const updatePayload = generateUpdatePayload(values, initialValues); + + if (mode !== 'creating') { + onSubmit(updatePayload, formik); + } else { + onSubmit(payload, formik); + } + }, + validateOnBlur: true, + validationSchema: createMode + ? createObjectStorageKeysSchema + : updateObjectStorageKeysSchema, + }); + + // @TODO OBJ Multicluster: The objectStorageKey check is a temporary fix to handle error cases when the feature flag is enabled without Mock Service Worker (MSW). This can be removed during the feature flag cleanup. + const isSaveDisabled = + isRestrictedUser || + (mode !== 'creating' && + objectStorageKey?.regions && + !hasLabelOrRegionsChanged(formik.values, objectStorageKey)) || + (mode === 'creating' && + limitedAccessChecked && + !hasAccessBeenSelectedForAllBuckets(formik.values.bucket_access)); + + const beforeSubmit = () => { + confirmObjectStorage( + accountSettings?.object_storage || 'active', + formik, + () => setDialogOpen(true) + ); + }; + + const handleScopeUpdate = (newScopes: ObjectStorageKeyBucketAccess[]) => { + formik.setFieldValue('bucket_access', newScopes); + }; + + const handleToggleAccess = () => { + setLimitedAccessChecked((checked) => !checked); + // Reset scopes + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && formik.values.regions.includes(bucket.region) + ); + + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsByIdMap) + ); }; + useEffect(() => { + setLimitedAccessChecked(false); + formik.resetForm({ values: initialValues }); + }, [open]); + return ( { {areBucketsLoading ? ( ) : ( - - {(formikProps) => { - const { - errors, - handleBlur, - handleChange, - handleSubmit, - isSubmitting, - setFieldValue, - status, - values, - } = formikProps; - - const beforeSubmit = () => { - confirmObjectStorage( - accountSettings?.object_storage || 'active', - formikProps, - () => setDialogOpen(true) + <> + {formik.status && ( + + )} + + {isRestrictedUser && ( + + )} + + {/* Explainer copy if we're in 'creating' mode */} + {createMode && ( + + Generate an Access Key for use with an{' '} + + S3-compatible client + + . + + )} + + {!hasBuckets ? ( + + This key will have unlimited access to all buckets on your + account. The option to create a limited access key is only + available after creating one or more buckets. + + ) : null} + + + { + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && values.includes(bucket.region) + ); + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsByIdMap) ); - }; - - const handleScopeUpdate = ( - newScopes: ObjectStorageKeyBucketAccess[] - ) => { - setFieldValue('bucket_access', newScopes); - }; - - const handleToggleAccess = () => { - setLimitedAccessChecked((checked) => !checked); - // Reset scopes - setFieldValue('bucket_access', getDefaultScopes(buckets)); - }; - - return ( - <> - {status && ( - - )} - - {isRestrictedUser && ( - - )} - - {/* Explainer copy if we're in 'creating' mode */} - {createMode && ( - - Generate an Access Key for use with an{' '} - - S3-compatible client - - . - - )} - - {!hasBuckets ? ( - - This key will have unlimited access to all buckets on your - account. The option to create a limited access key is only - available after creating one or more buckets. - - ) : null} - - - {createMode && !hidePermissionsTable ? ( - - ) : null} - - setDialogOpen(false)} - open={dialogOpen} - /> - - ); - }} - + formik.setFieldValue('regions', values); + }} + required + selectedRegion={formik.values.regions} + /> + {createMode && ( + ({ + marginTop: theme.spacing(2), + })} + > + Unlimited S3 access key can be used to create buckets in the + selected region using S3 Endpoint returned on successful creation + of the key. + + )} + {createMode && !bucketsError && ( + + )} + + setDialogOpen(false)} + open={dialogOpen} + /> + )} ); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index e624b21443f..cc3608b7ec4 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -23,7 +23,6 @@ import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable/AccessKeyTable'; -import { OMC_AccessKeyDrawer } from './OMC_AccessKeyDrawer'; import { RevokeAccessKeyDialog } from './RevokeAccessKeyDialog'; import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; @@ -301,25 +300,14 @@ export const AccessKeyLanding = (props: Props) => { pageSize={pagination.pageSize} /> - {isObjMultiClusterEnabled ? ( - - ) : ( - - )} + void; - onSubmit: ( - values: CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload, - formikProps: FormikProps< - CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload - > - ) => void; - open: boolean; -} - -// Access key scopes displayed in the drawer can have no permission or "No Access" selected, which are not valid API permissions. -export interface DisplayedAccessKeyScope - extends Omit { - permissions: null | ObjectStorageKeyBucketAccessPermissions; -} - -export interface FormState { - bucket_access: null | ObjectStorageKeyBucketAccess[]; - label: string; - regions: string[]; -} - -/** - * Helpers for converting a list of buckets - * on the user's account into a list of - * bucket_access in the shape the API will expect, - * sorted by region. - */ - -export const sortByRegion = - (regionLookup: { [key: string]: Region }) => - (a: DisplayedAccessKeyScope, b: DisplayedAccessKeyScope) => { - if (!a.region || !b.region) { - return 0; - } - - return sortByString( - regionLookup[a.region].label, - regionLookup[b.region].label, - 'asc' - ); - }; - -export const getDefaultScopes = ( - buckets: ObjectStorageBucket[], - regionLookup: { [key: string]: Region } = {} -): DisplayedAccessKeyScope[] => - buckets - .map((thisBucket) => ({ - bucket_name: thisBucket.label, - cluster: thisBucket.cluster, - permissions: null, - region: thisBucket.region, - })) - .sort(sortByRegion(regionLookup)); - -export const OMC_AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { isRestrictedUser, mode, objectStorageKey, onClose, onSubmit, open } = - props; - - const { regionsByIdMap } = useObjectStorageRegions(); - - const { - data: objectStorageBuckets, - error: bucketsError, - isLoading: areBucketsLoading, - } = useObjectStorageBuckets(); - - const { data: accountSettings } = useAccountSettings(); - - const buckets = objectStorageBuckets?.buckets || []; - - const hasBuckets = buckets?.length > 0; - - const createMode = mode === 'creating'; - - const [dialogOpen, setDialogOpen] = useState(false); - // This is for local display management only, not part of the payload - // and so not included in Formik's types - const [limitedAccessChecked, setLimitedAccessChecked] = useState(false); - - const title = createMode ? 'Create Access Key' : 'Edit Access Key'; - - const initialLabelValue = - !createMode && objectStorageKey ? objectStorageKey.label : ''; - - const initialRegions = - !createMode && objectStorageKey?.regions - ? objectStorageKey.regions.map((region) => region.id) - : []; - - const initialValues: FormState = { - bucket_access: [], - label: initialLabelValue, - regions: initialRegions, - }; - - const formik = useFormik({ - initialValues, - onSubmit: (values) => { - // If the user hasn't toggled the Limited Access button, - // don't include any bucket_access information in the payload. - - // If any/all permissions are 'none' or null, don't include them in the response. - const access = values.bucket_access ?? []; - - const payload = limitedAccessChecked - ? { - ...values, - bucket_access: access.filter( - (thisAccess: DisplayedAccessKeyScope) => - thisAccess.permissions !== 'none' && - thisAccess.permissions !== null - ), - } - : { ...values, bucket_access: null }; - - const updatePayload = generateUpdatePayload(values, initialValues); - - if (mode !== 'creating') { - onSubmit(updatePayload, formik); - } else { - onSubmit(payload, formik); - } - }, - validateOnBlur: true, - validationSchema: createMode - ? createObjectStorageKeysSchema - : updateObjectStorageKeysSchema, - }); - - // @TODO OBJ Multicluster: The objectStorageKey check is a temporary fix to handle error cases when the feature flag is enabled without Mock Service Worker (MSW). This can be removed during the feature flag cleanup. - const isSaveDisabled = - isRestrictedUser || - (mode !== 'creating' && - objectStorageKey?.regions && - !hasLabelOrRegionsChanged(formik.values, objectStorageKey)) || - (mode === 'creating' && - limitedAccessChecked && - !hasAccessBeenSelectedForAllBuckets(formik.values.bucket_access)); - - const beforeSubmit = () => { - confirmObjectStorage( - accountSettings?.object_storage || 'active', - formik, - () => setDialogOpen(true) - ); - }; - - const handleScopeUpdate = (newScopes: ObjectStorageKeyBucketAccess[]) => { - formik.setFieldValue('bucket_access', newScopes); - }; - - const handleToggleAccess = () => { - setLimitedAccessChecked((checked) => !checked); - // Reset scopes - const bucketsInRegions = buckets?.filter( - (bucket) => bucket.region && formik.values.regions.includes(bucket.region) - ); - - formik.setFieldValue( - 'bucket_access', - getDefaultScopes(bucketsInRegions, regionsByIdMap) - ); - }; - - useEffect(() => { - setLimitedAccessChecked(false); - formik.resetForm({ values: initialValues }); - }, [open]); - - return ( - - {areBucketsLoading ? ( - - ) : ( - <> - {formik.status && ( - - )} - - {isRestrictedUser && ( - - )} - - {/* Explainer copy if we're in 'creating' mode */} - {createMode && ( - - Generate an Access Key for use with an{' '} - - S3-compatible client - - . - - )} - - {!hasBuckets ? ( - - This key will have unlimited access to all buckets on your - account. The option to create a limited access key is only - available after creating one or more buckets. - - ) : null} - - - { - const bucketsInRegions = buckets?.filter( - (bucket) => bucket.region && values.includes(bucket.region) - ); - formik.setFieldValue( - 'bucket_access', - getDefaultScopes(bucketsInRegions, regionsByIdMap) - ); - formik.setFieldValue('regions', values); - }} - required - selectedRegion={formik.values.regions} - /> - {createMode && ( - ({ - marginTop: theme.spacing(2), - })} - > - Unlimited S3 access key can be used to create buckets in the - selected region using S3 Endpoint returned on successful creation - of the key. - - )} - {createMode && !bucketsError && ( - - )} - - setDialogOpen(false)} - open={dialogOpen} - /> - - )} - - ); -}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts index 7eed687e51b..847d2a4072e 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts @@ -4,7 +4,7 @@ import { hasLabelOrRegionsChanged, } from './utils'; -import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; +import type { DisplayedAccessKeyScope, FormState } from './AccessKeyDrawer'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; describe('generateUpdatePayload', () => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts index 4bad381c612..2d278e0e8fc 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts @@ -1,6 +1,6 @@ import { areArraysEqual, sortByString } from '@linode/utilities'; -import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; +import type { DisplayedAccessKeyScope, FormState } from './AccessKeyDrawer'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; type UpdatePayload = From 99e895b4ba684ad3e14d399c72e170a97767f460 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Thu, 26 Mar 2026 13:15:41 +0100 Subject: [PATCH 2/9] refactor: STORIF-335 - Access key mutation queries created. --- .../src/queries/object-storage/queries.ts | 119 +++++++++++++++++- 1 file changed, 113 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index 7670e05273b..d35fba873bf 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -1,6 +1,7 @@ import { cancelObjectStorage, createBucket, + createObjectStorageKeys, deleteBucket, deleteBucketWithRegion, deleteSSLCert, @@ -10,8 +11,10 @@ import { getObjectStorageKeys, getObjectURL, getSSLCert, + revokeObjectStorageKey, updateBucketAccess, updateObjectACL, + updateObjectStorageKey, uploadSSLCert, } from '@linode/api-v4'; import { @@ -34,6 +37,11 @@ import { import { OBJECT_STORAGE_DELIMITER as delimiter } from 'src/constants'; import { useFlags } from 'src/hooks/useFlags'; +import { + sendCreateAccessKeyEvent, + sendEditAccessKeyEvent, + sendRevokeAccessKeyEvent, +} from 'src/utilities/analytics/customEventAnalytics'; import { getAllBucketsFromEndpoints, @@ -50,6 +58,7 @@ import type { APIError, CreateObjectStorageBucketPayload, CreateObjectStorageBucketSSLPayload, + CreateObjectStorageKeyPayload, CreateObjectStorageObjectURLPayload, ObjectStorageBucket, ObjectStorageBucketAccess, @@ -64,6 +73,7 @@ import type { PriceType, ResourcePage, UpdateObjectStorageBucketAccessPayload, + UpdateObjectStorageKeyPayload, } from '@linode/api-v4'; export const objectStorageQueries = createQueryKeys('object-storage', { @@ -118,6 +128,109 @@ export const objectStorageQueries = createQueryKeys('object-storage', { }, }); +/** + * Object Storage Access Keys + */ + +export const useObjectStorageAccessKeys = (params: Params) => + useQuery, APIError[]>({ + ...objectStorageQueries.accessKeys(params), + placeholderData: keepPreviousData, + }); + +// TODO: Optimize to use tanstack cache +export const useObjectStorageAccessKey = (id: number) => { + const queryClient = useQueryClient(); + + if (id === -1) { + return {}; + } + + const queries = queryClient.getQueriesData({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + for (const [, data] of queries) { + const accessKey = (data as ResourcePage)?.data?.find( + (key) => key.id === id + ); + if (accessKey) { + return { data: accessKey }; + } + } + + return { data: undefined }; +}; + +export const useCreateAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + ObjectStorageKey, + APIError[], + CreateObjectStorageKeyPayload + >({ + mutationFn: createObjectStorageKeys, + onSuccess() { + // Invalidate account settings because object storage will become enabled + // if a user created their first bucket. + queryClient.invalidateQueries({ + queryKey: accountQueries.settings.queryKey, + }); + + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendCreateAccessKeyEvent(); + }, + onError() { + // We also need to refresh account settings on failure, since, depending + // on the error, Object Storage service might have actually been enabled. + queryClient.invalidateQueries({ + queryKey: accountQueries.settings.queryKey, + }); + }, + }); +}; + +export const useUpdateAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation< + ObjectStorageKey, + APIError[], + { data: UpdateObjectStorageKeyPayload; id: number } + >({ + mutationFn: ({ id, data }) => updateObjectStorageKey(id, data), + onSuccess() { + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendEditAccessKeyEvent(); + }, + }); +}; + +export const useDeleteAccessKeyMutation = () => { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id) => revokeObjectStorageKey(id), + onSuccess() { + // Invalidate access keys query + queryClient.invalidateQueries({ + queryKey: objectStorageQueries.accessKeys._def, + }); + + // @analytics + sendRevokeAccessKeyEvent(); + }, + }); +}; + export const useObjectStorageEndpoints = (enabled = true) => { const flags = useFlags(); const { data: account } = useAccount(); @@ -196,12 +309,6 @@ export const useObjectStorageBuckets = (enabled: boolean = true) => { }; }; -export const useObjectStorageAccessKeys = (params: Params) => - useQuery, APIError[]>({ - ...objectStorageQueries.accessKeys(params), - placeholderData: keepPreviousData, - }); - export const useBucketAccess = ( clusterOrRegion: string, bucket: string, From cd6f0f28dc57929460ef94568a6221cf17bb005f Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Thu, 26 Mar 2026 14:42:22 +0100 Subject: [PATCH 3/9] refactor: STORIF-335 - AccessKeysDrawer refactored. --- .../AccessKeyLanding/AccessKeyDrawer.tsx | 367 +++++++++++------- .../AccessKeyLanding/AccessKeyLanding.tsx | 167 +------- 2 files changed, 243 insertions(+), 291 deletions(-) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index 6d30b31e979..39d4cc7f645 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -7,7 +7,7 @@ import { TextField, Typography, } from '@linode/ui'; -import { sortByString } from '@linode/utilities'; +import { sortByString, useOpenClose } from '@linode/utilities'; import { createObjectStorageKeysSchema, updateObjectStorageKeysSchema, @@ -17,7 +17,13 @@ import React, { useEffect, useState } from 'react'; import { Link } from 'src/components/Link'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; -import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; +import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; +import { + useCreateAccessKeyMutation, + useObjectStorageBuckets, + useUpdateAccessKeyMutation, +} from 'src/queries/object-storage/queries'; +import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { confirmObjectStorage } from '../utilities'; @@ -39,7 +45,7 @@ import type { Region, UpdateObjectStorageKeyPayload, } from '@linode/api-v4'; -import type { FormikProps } from 'formik'; +import type { FormikHelpers } from 'formik'; export interface AccessKeyDrawerProps { isRestrictedUser: boolean; @@ -47,12 +53,6 @@ export interface AccessKeyDrawerProps { // If the mode is 'editing', we should have an ObjectStorageKey to edit objectStorageKey?: ObjectStorageKey; onClose: () => void; - onSubmit: ( - values: CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload, - formikProps: FormikProps< - CreateObjectStorageKeyPayload | UpdateObjectStorageKeyPayload - > - ) => void; open: boolean; } @@ -103,8 +103,12 @@ export const getDefaultScopes = ( .sort(sortByRegion(regionLookup)); export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { isRestrictedUser, mode, objectStorageKey, onClose, onSubmit, open } = - props; + const { isRestrictedUser, mode, objectStorageKey, onClose, open } = props; + + const displayKeysDialog = useOpenClose(); + // Key to display in Confirmation Modal upon creation + const [keyToDisplay, setKeyToDisplay] = + React.useState(null); const { regionsByIdMap } = useObjectStorageRegions(); @@ -115,6 +119,8 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } = useObjectStorageBuckets(); const { data: accountSettings } = useAccountSettings(); + const { mutateAsync: createAccessKey } = useCreateAccessKeyMutation(); + const { mutateAsync: updateAccessKey } = useUpdateAccessKeyMutation(); const buckets = objectStorageBuckets?.buckets || []; @@ -143,6 +149,92 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { regions: initialRegions, }; + const handleCreateKey = ( + values: CreateObjectStorageKeyPayload, + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers + ) => { + // Clear out status (used for general errors) + setStatus(null); + setSubmitting(true); + + createAccessKey(values) + .then((data) => { + setSubmitting(false); + + setKeyToDisplay(data); + + onClose(); + displayKeysDialog.open(); + }) + .catch((errorResponse) => { + setSubmitting(false); + + const errors = getAPIErrorOrDefault( + errorResponse, + 'There was an issue creating your Access Key.' + ); + const mappedErrors = getErrorMap(['label'], errors); + + // `status` holds general errors + if (mappedErrors.none) { + setStatus(mappedErrors.none); + } + + setErrors(mappedErrors); + }); + }; + + const handleEditKey = ( + values: UpdateObjectStorageKeyPayload, + { + setErrors, + setStatus, + setSubmitting, + }: FormikHelpers + ) => { + // This shouldn't happen, but just in case. + if (!objectStorageKey) { + return; + } + + // Clear out status (used for general errors) + setStatus(null); + + // If the new label is the same as the old one, no need to make an API + // request. Just close the drawer and return early. + if (values.label === objectStorageKey.label) { + return onClose(); + } + + setSubmitting(true); + + updateAccessKey({ data: values, id: objectStorageKey.id }) + .then((_) => { + setSubmitting(false); + onClose(); + }) + .catch((errorResponse) => { + setSubmitting(false); + + const errors = getAPIErrorOrDefault( + errorResponse, + 'There was an issue updating your Access Key.' + ); + const mappedErrors = getErrorMap(['label'], errors); + + // `status` holds general errors + if (mappedErrors.none) { + setStatus(mappedErrors.none); + } + + setErrors(mappedErrors); + }); + }; + const formik = useFormik({ initialValues, onSubmit: (values) => { @@ -166,9 +258,9 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { const updatePayload = generateUpdatePayload(values, initialValues); if (mode !== 'creating') { - onSubmit(updatePayload, formik); + handleEditKey(updatePayload, formik); } else { - onSubmit(payload, formik); + handleCreateKey(payload, formik); } }, validateOnBlur: true, @@ -218,129 +310,138 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { }, [open]); return ( - - {areBucketsLoading ? ( - - ) : ( - <> - {formik.status && ( - + + {areBucketsLoading ? ( + + ) : ( + <> + {formik.status && ( + + )} + + {isRestrictedUser && ( + + )} + + {/* Explainer copy if we're in 'creating' mode */} + {createMode && ( + + Generate an Access Key for use with an{' '} + + S3-compatible client + + . + + )} + + {!hasBuckets ? ( + + This key will have unlimited access to all buckets on your + account. The option to create a limited access key is only + available after creating one or more buckets. + + ) : null} + + - )} - - {isRestrictedUser && ( - { + const bucketsInRegions = buckets?.filter( + (bucket) => bucket.region && values.includes(bucket.region) + ); + formik.setFieldValue( + 'bucket_access', + getDefaultScopes(bucketsInRegions, regionsByIdMap) + ); + formik.setFieldValue('regions', values); + }} + required + selectedRegion={formik.values.regions} /> - )} - - {/* Explainer copy if we're in 'creating' mode */} - {createMode && ( - - Generate an Access Key for use with an{' '} - ({ + marginTop: theme.spacing(2), + })} > - S3-compatible client - - . - - )} - - {!hasBuckets ? ( - - This key will have unlimited access to all buckets on your - account. The option to create a limited access key is only - available after creating one or more buckets. - - ) : null} - - - { - const bucketsInRegions = buckets?.filter( - (bucket) => bucket.region && values.includes(bucket.region) - ); - formik.setFieldValue( - 'bucket_access', - getDefaultScopes(bucketsInRegions, regionsByIdMap) - ); - formik.setFieldValue('regions', values); - }} - required - selectedRegion={formik.values.regions} - /> - {createMode && ( - ({ - marginTop: theme.spacing(2), - })} - > - Unlimited S3 access key can be used to create buckets in the - selected region using S3 Endpoint returned on successful creation - of the key. - - )} - {createMode && !bucketsError && ( - + )} + {createMode && !bucketsError && ( + + )} + + setDialogOpen(false)} + open={dialogOpen} /> - )} - - setDialogOpen(false)} - open={dialogOpen} - /> - - )} - + + )} + + + + ); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index cc3608b7ec4..8f2fd5cdbbe 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -1,26 +1,16 @@ -import { - createObjectStorageKeys, - revokeObjectStorageKey, - updateObjectStorageKey, -} from '@linode/api-v4/lib/object-storage'; -import { useAccountSettings } from '@linode/queries'; import { useErrors, useOpenClose } from '@linode/utilities'; import { useNavigate } from '@tanstack/react-router'; import * as React from 'react'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; import { usePaginationV2 } from 'src/hooks/usePaginationV2'; -import { useObjectStorageAccessKeys } from 'src/queries/object-storage/queries'; import { - sendCreateAccessKeyEvent, - sendEditAccessKeyEvent, - sendRevokeAccessKeyEvent, -} from 'src/utilities/analytics/customEventAnalytics'; -import { getAPIErrorOrDefault, getErrorMap } from 'src/utilities/errorUtils'; + useDeleteAccessKeyMutation, + useObjectStorageAccessKeys, +} from 'src/queries/object-storage/queries'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable/AccessKeyTable'; import { RevokeAccessKeyDialog } from './RevokeAccessKeyDialog'; @@ -30,9 +20,8 @@ import type { MODE, OpenAccessDrawer } from './types'; import type { CreateObjectStorageKeyPayload, ObjectStorageKey, - UpdateObjectStorageKeyPayload, } from '@linode/api-v4/lib/object-storage'; -import type { FormikBag, FormikHelpers } from 'formik'; +import type { FormikBag } from 'formik'; interface Props { accessDrawerOpen: boolean; @@ -60,17 +49,11 @@ export const AccessKeyLanding = (props: Props) => { preferenceKey: 'object-storage-keys-table', }); - const { data, error, isLoading, refetch } = useObjectStorageAccessKeys({ + const { data, error, isLoading } = useObjectStorageAccessKeys({ page: pagination.page, page_size: pagination.pageSize, }); - - const { data: accountSettings, refetch: requestAccountSettings } = - useAccountSettings(); - - // Key to display in Confirmation Modal upon creation - const [keyToDisplay, setKeyToDisplay] = - React.useState(null); + const { mutateAsync: deleteAccessKey } = useDeleteAccessKeyMutation(); // Key to rename (by clicking on a key's kebab menu ) const [keyToEdit, setKeyToEdit] = React.useState( @@ -84,11 +67,8 @@ export const AccessKeyLanding = (props: Props) => { const [isRevoking, setIsRevoking] = React.useState(false); const [revokeErrors, setRevokeErrors] = useErrors(); - const displayKeysDialog = useOpenClose(); const revokeKeysDialog = useOpenClose(); - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); - // Redirect to base access keys route if current page has no data // TODO: Remove this implementation and replace `usePagination` with `usePaginate` hook. See [M3-10442] React.useEffect(() => { @@ -108,123 +88,6 @@ export const AccessKeyLanding = (props: Props) => { } }, [data, isLoading, pagination.page, navigate]); - const handleCreateKey = ( - values: CreateObjectStorageKeyPayload, - { - setErrors, - setStatus, - setSubmitting, - }: FormikHelpers - ) => { - // Clear out status (used for general errors) - setStatus(null); - setSubmitting(true); - - createObjectStorageKeys(values) - .then((data) => { - setSubmitting(false); - - setKeyToDisplay(data); - - // "Refresh" keys to include the newly created key - refetch(); - - props.closeAccessDrawer(); - displayKeysDialog.open(); - - // If our Redux Store says that the user doesn't have OBJ enabled, - // it probably means they have just enabled it with the creation - // of this key. In that case, update the Redux Store so that - // subsequently created keys don't need to go through the - // confirmation flow. - if (accountSettings?.object_storage === 'disabled') { - requestAccountSettings(); - } - - // @analytics - sendCreateAccessKeyEvent(); - }) - .catch((errorResponse) => { - // We also need to refresh account settings on failure, since, depending - // on the error, Object Storage service might have actually been enabled. - if (accountSettings?.object_storage === 'disabled') { - requestAccountSettings(); - } - - setSubmitting(false); - - const errors = getAPIErrorOrDefault( - errorResponse, - 'There was an issue creating your Access Key.' - ); - const mappedErrors = getErrorMap(['label'], errors); - - // `status` holds general errors - if (mappedErrors.none) { - setStatus(mappedErrors.none); - } - - setErrors(mappedErrors); - }); - }; - - const handleEditKey = ( - values: UpdateObjectStorageKeyPayload, - { - setErrors, - setStatus, - setSubmitting, - }: FormikHelpers - ) => { - // This shouldn't happen, but just in case. - if (!keyToEdit) { - return; - } - - // Clear out status (used for general errors) - setStatus(null); - - // If the new label is the same as the old one, no need to make an API - // request. Just close the drawer and return early. - if (values.label === keyToEdit.label) { - return closeAccessDrawer(); - } - - setSubmitting(true); - - updateObjectStorageKey( - keyToEdit.id, - isObjMultiClusterEnabled ? values : { label: values.label } - ) - .then((_) => { - setSubmitting(false); - - // "Refresh" keys to display the newly updated key - refetch(); - - closeAccessDrawer(); - - // @analytics - sendEditAccessKeyEvent(); - }) - .catch((errorResponse) => { - setSubmitting(false); - - const errors = getAPIErrorOrDefault( - errorResponse, - 'There was an issue updating your Access Key.' - ); - const mappedErrors = getErrorMap(['label'], errors); - - // `status` holds general errors - if (mappedErrors.none) { - setStatus(mappedErrors.none); - } - - setErrors(mappedErrors); - }); - }; - const handleRevokeKeys = () => { // This shouldn't happen, but just in case. if (!keyToRevoke) { @@ -234,17 +97,11 @@ export const AccessKeyLanding = (props: Props) => { setIsRevoking(true); setRevokeErrors([]); - revokeObjectStorageKey(keyToRevoke.id) + deleteAccessKey(keyToRevoke.id) .then((_) => { setIsRevoking(false); - // "Refresh" keys to remove the newly revoked key - refetch(); - revokeKeysDialog.close(); - - // @analytics - sendRevokeAccessKeyEvent(); }) .catch((errorResponse) => { setIsRevoking(false); @@ -305,7 +162,6 @@ export const AccessKeyLanding = (props: Props) => { mode={mode} objectStorageKey={keyToEdit ? keyToEdit : undefined} onClose={closeAccessDrawer} - onSubmit={mode === 'creating' ? handleCreateKey : handleEditKey} open={accessDrawerOpen} /> @@ -314,12 +170,7 @@ export const AccessKeyLanding = (props: Props) => { onClose={closeAccessDrawer} open={mode === 'viewing' && accessDrawerOpen} /> - + Date: Thu, 26 Mar 2026 15:05:57 +0100 Subject: [PATCH 4/9] refactor: STORIF-335 - CreateBucketDrawer replaced by OMC_CreateBucketDrawer --- ...styles.ts => CreateBucketDrawer.styles.ts} | 0 .../BucketLanding/CreateBucketDrawer.test.tsx | 120 ++--- .../BucketLanding/CreateBucketDrawer.tsx | 311 ++++++++++--- .../OMC_CreateBucketDrawer.test.tsx | 84 ---- .../BucketLanding/OMC_CreateBucketDrawer.tsx | 427 ------------------ .../ObjectStorage/ObjectStorageLanding.tsx | 22 +- 6 files changed, 324 insertions(+), 640 deletions(-) rename packages/manager/src/features/ObjectStorage/BucketLanding/{OMC_CreateBucketDrawer.styles.ts => CreateBucketDrawer.styles.ts} (100%) delete mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx delete mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.styles.ts b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx index 78667fa9c7c..e0c7fe19bb8 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx @@ -1,15 +1,10 @@ -import { regionFactory } from '@linode/utilities'; -import { waitFor } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; +import { fireEvent } from '@testing-library/react'; import * as React from 'react'; -import { - accountSettingsFactory, - objectStorageClusterFactory, -} from 'src/factories'; +import { objectStorageEndpointsFactory } from 'src/factories'; import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithTheme } from 'src/utilities/testHelpers'; +import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; import { CreateBucketDrawer } from './CreateBucketDrawer'; @@ -19,60 +14,71 @@ const props = { }; describe('CreateBucketDrawer', () => { - it.skip('Should show a general error notice if the API returns one', async () => { - server.use( - http.post('*/object-storage/buckets', () => { - return HttpResponse.json( - { errors: [{ reason: 'Object Storage is offline!' }] }, - { - status: 500, - } - ); - }), - http.get('*/regions', async () => { - return HttpResponse.json( - makeResourcePage( - regionFactory.buildList(1, { id: 'us-east', label: 'Newark, NJ' }) - ) - ); - }), - http.get('*object-storage/clusters', () => { - return HttpResponse.json( - makeResourcePage( - objectStorageClusterFactory.buildList(1, { - id: 'us-east-1', - region: 'us-east', - }) - ) - ); - }), - http.get('*/account/settings', () => { - return HttpResponse.json( - accountSettingsFactory.build({ object_storage: 'active' }) - ); - }) - ); + beforeEach(() => { + vi.resetAllMocks(); + }); + + it('should render the drawer', () => { + const { getByTestId, getByText, queryByText } = + renderWithThemeAndHookFormContext({ + component: , + options: { + flags: { + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }, + }, + }); - const { findByText, getByLabelText, getByPlaceholderText, getByTestId } = - renderWithTheme(); + expect(getByTestId('drawer-title')).toBeVisible(); + expect(getByText('Bucket Name')).toBeVisible(); + expect(getByText('Region')).toBeVisible(); + expect(getByText('Cancel')).toBeVisible(); + expect(getByTestId('create-bucket-button')).toBeVisible(); + expect(queryByText('Object Storage Endpoint Type')).not.toBeInTheDocument(); + }); - await userEvent.type( - getByLabelText('Label', { exact: false }), - 'my-test-bucket' - ); + it( + 'should not display the endpoint selector if regions is not selected', + server.boundary(async () => { + server.use( + http.get('*/v4/object-storage/endpoints', () => { + return HttpResponse.json( + makeResourcePage([ + objectStorageEndpointsFactory.build({ + endpoint_type: 'E0', + region: 'us-sea', + s3_endpoint: null, + }), + ]) + ); + }) + ); - // We must waitFor because we need to load region and cluster data from the API - await waitFor(() => - userEvent.selectOptions( - getByPlaceholderText('Select a Region'), - 'Newark, NJ (us-east-1)' - ) - ); + const { queryByText } = renderWithThemeAndHookFormContext({ + component: , + options: { + flags: { + objMultiCluster: true, + objectStorageGen2: { enabled: true }, + }, + }, + }); - const saveButton = getByTestId('create-bucket-button'); + expect( + queryByText('Object Storage Endpoint Type') + ).not.toBeInTheDocument(); + }) + ); - await userEvent.click(saveButton); + it('should close the drawer', () => { + const { getByText } = renderWithThemeAndHookFormContext({ + component: , + }); - await findByText('Object Storage is offline!'); + const cancelButton = getByText('Cancel'); + expect(cancelButton).toBeVisible(); + fireEvent.click(cancelButton); + expect(props.onClose).toHaveBeenCalled(); }); }); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx index fc2e4d3f96a..72362311e15 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx @@ -5,15 +5,22 @@ import { useMutateAccountAgreements, useNetworkTransferPricesQuery, useProfile, - useRegionsQuery, } from '@linode/queries'; -import { ActionsPanel, Drawer, Notice, TextField } from '@linode/ui'; +import { + ActionsPanel, + Autocomplete, + Drawer, + Notice, + TextField, + Typography, +} from '@linode/ui'; import { CreateBucketSchema } from '@linode/validation'; -import { styled } from '@mui/material/styles'; import * as React from 'react'; import { Controller, useForm } from 'react-hook-form'; -import { EUAgreementCheckbox } from 'src/features/Account/Agreements/EUAgreementCheckbox'; +import { Link } from 'src/components/Link'; +import { BucketRateLimitTable } from 'src/features/ObjectStorage/BucketLanding/BucketRateLimitTable'; +import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; import { useCreateBucketMutation, useObjectStorageBuckets, @@ -26,22 +33,50 @@ import { reportAgreementSigningError } from 'src/utilities/reportAgreementSignin import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { QuotasInfoNotice } from '../QuotasInfoNotice'; -import ClusterSelect from './ClusterSelect'; +import { BucketRegions } from './BucketRegions'; +import { StyledEUAgreementCheckbox } from './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 CreateBucketDrawer = (props: Props) => { const { data: profile } = useProfile(); const { isOpen, onClose } = props; const isRestrictedUser = profile?.restricted; - const { data: regions } = useRegionsQuery(); + const { + availableStorageRegions, + isStorageEndpointsLoading, + storageEndpoints, + } = useObjectStorageRegions(); const { data: bucketsData } = useObjectStorageBuckets(); @@ -65,40 +100,46 @@ export const 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: { - cluster: '', cors_enabled: true, + endpoint_type: undefined, label: '', + region: '', + s3_endpoint: undefined, }, mode: 'onBlur', resolver: yupResolver(CreateBucketSchema), }); - const watchCluster = watch('cluster'); + const watchRegion = watch('region'); const onSubmit = async (data: CreateObjectStorageBucketPayload) => { try { await createBucket(data); - if (data.cluster) { - sendCreateBucketEvent(data.cluster); + if (data.region) { + sendCreateBucketEvent(data.region); } - if (hasSignedAgreement) { + if (state.hasSignedAgreement) { try { await updateAccountAgreements({ eu_model: true }); } catch (error) { @@ -114,31 +155,133 @@ export const CreateBucketDrawer = (props: Props) => { } }; - const handleBucketFormSubmit = (e: React.FormEvent) => { + const handleClose = () => { + reset(); + onClose(); + }; + + 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(storageEndpoints) && !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 clusterRegion = watchCluster - ? regions?.find((region) => watchCluster.includes(region.id)) + const selectedRegion = watchRegion + ? availableStorageRegions?.find((region) => watchRegion === region.id) : undefined; + const filteredEndpoints = storageEndpoints?.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 { showGDPRCheckbox } = getGDPRDetails({ agreements, profile, - regions, - selectedRegionId: clusterRegion?.id ?? '', + regions: availableStorageRegions, + selectedRegionId: selectedRegion?.id ?? '', }); - const handleClose = () => { - reset(); - onClose(); + 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 (
@@ -156,51 +299,109 @@ export const CreateBucketDrawer = (props: Props) => { ( + render={({ field }) => ( )} - rules={{ required: 'Bucket name is required' }} /> ( - ( + field.onChange(value)} + error={errors.region?.message} + onBlur={field.onBlur} + onChange={(value) => { + field.onChange(value); + }} required - selectedCluster={field.value ?? undefined} + selectedRegion={field.value} /> )} - rules={{ required: 'Cluster is required' }} /> - {clusterRegion?.id && } - {showGDPRCheckbox && ( + {selectedRegion?.id && } + {Boolean(storageEndpoints) && selectedRegion && ( + <> + ( + + updateEndpointType(endpointOption) + } + options={filteredEndpointOptions ?? []} + placeholder="Object Storage Endpoint Type" + textFieldProps={{ + containerProps: { + sx: { + '> .MuiFormHelperText-root': { + marginBottom: 1, + }, + }, + }, + helperText: ( + + Endpoint types impact the performance, capacity, and + rate limits for your bucket. Understand{' '} + + endpoint types + + . + + ), + helperTextPosition: 'top', + }} + value={selectedEndpointOption} + /> + )} + /> + {Boolean(storageEndpoints) && selectedEndpointOption && ( + + )} + + )} + {showGDPRCheckbox ? ( setHasSignedAgreement(e.target.checked)} + checked={state.hasSignedAgreement} + onChange={(e) => + setState((prev) => ({ + ...prev, + hasSignedAgreement: e.target.checked, + })) + } /> - )} + ) : null} { /> setIsEnableObjDialogOpen(false)} - open={isEnableObjDialogOpen} - regionId={clusterRegion?.id} + onClose={() => + setState((prev) => ({ + ...prev, + isEnableObjDialogOpen: false, + })) + } + open={state.isEnableObjDialogOpen} + regionId={selectedRegion?.id} />
); }; - -const StyledEUAgreementCheckbox = styled(EUAgreementCheckbox, { - label: 'StyledEUAgreementCheckbox', -})(({ theme }) => ({ - marginButton: theme.spacing(3), - marginTop: theme.spacing(3), -})); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx deleted file mode 100644 index 5e3050f2f14..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import { fireEvent } from '@testing-library/react'; -import * as React from 'react'; - -import { objectStorageEndpointsFactory } from 'src/factories'; -import { makeResourcePage } from 'src/mocks/serverHandlers'; -import { http, HttpResponse, server } from 'src/mocks/testServer'; -import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; - -import { OMC_CreateBucketDrawer } from './OMC_CreateBucketDrawer'; - -const props = { - isOpen: true, - onClose: vi.fn(), -}; - -describe('OMC_CreateBucketDrawer', () => { - beforeEach(() => { - vi.resetAllMocks(); - }); - - it('should render the drawer', () => { - const { getByTestId, getByText, queryByText } = - renderWithThemeAndHookFormContext({ - component: , - options: { - flags: { - objMultiCluster: true, - objectStorageGen2: { enabled: true }, - }, - }, - }); - - expect(getByTestId('drawer-title')).toBeVisible(); - expect(getByText('Bucket Name')).toBeVisible(); - expect(getByText('Region')).toBeVisible(); - expect(getByText('Cancel')).toBeVisible(); - expect(getByTestId('create-bucket-button')).toBeVisible(); - expect(queryByText('Object Storage Endpoint Type')).not.toBeInTheDocument(); - }); - - it( - 'should not display the endpoint selector if regions is not selected', - server.boundary(async () => { - server.use( - http.get('*/v4/object-storage/endpoints', () => { - return HttpResponse.json( - makeResourcePage([ - objectStorageEndpointsFactory.build({ - endpoint_type: 'E0', - region: 'us-sea', - s3_endpoint: null, - }), - ]) - ); - }) - ); - - const { queryByText } = renderWithThemeAndHookFormContext({ - component: , - options: { - flags: { - objMultiCluster: true, - objectStorageGen2: { enabled: true }, - }, - }, - }); - - expect( - queryByText('Object Storage Endpoint Type') - ).not.toBeInTheDocument(); - }) - ); - - it('should close the drawer', () => { - const { getByText } = renderWithThemeAndHookFormContext({ - component: , - }); - - const cancelButton = getByText('Cancel'); - expect(cancelButton).toBeVisible(); - fireEvent.click(cancelButton); - expect(props.onClose).toHaveBeenCalled(); - }); -}); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx deleted file mode 100644 index 80efcdb0223..00000000000 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx +++ /dev/null @@ -1,427 +0,0 @@ -import { yupResolver } from '@hookform/resolvers/yup'; -import { - useAccountAgreements, - useAccountSettings, - useMutateAccountAgreements, - useNetworkTransferPricesQuery, - useProfile, -} from '@linode/queries'; -import { - ActionsPanel, - Autocomplete, - Drawer, - Notice, - TextField, - Typography, -} from '@linode/ui'; -import { CreateBucketSchema } from '@linode/validation'; -import * as React from 'react'; -import { Controller, useForm } from 'react-hook-form'; - -import { Link } from 'src/components/Link'; -import { BucketRateLimitTable } from 'src/features/ObjectStorage/BucketLanding/BucketRateLimitTable'; -import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; -import { - useCreateBucketMutation, - useObjectStorageBuckets, - useObjectStorageTypesQuery, -} from 'src/queries/object-storage/queries'; -import { sendCreateBucketEvent } from 'src/utilities/analytics/customEventAnalytics'; -import { getGDPRDetails } from 'src/utilities/formatRegion'; -import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; -import { reportAgreementSigningError } from 'src/utilities/reportAgreementSigningError'; - -import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; -import { QuotasInfoNotice } from '../QuotasInfoNotice'; -import { BucketRegions } from './BucketRegions'; -import { StyledEUAgreementCheckbox } from './OMC_CreateBucketDrawer.styles'; -import { OveragePricing } from './OveragePricing'; - -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 { - availableStorageRegions, - isStorageEndpointsLoading, - storageEndpoints, - } = useObjectStorageRegions(); - - const { data: bucketsData } = useObjectStorageBuckets(); - - const { - data: objTypes, - isError: isErrorObjTypes, - isInitialLoading: isLoadingObjTypes, - } = useObjectStorageTypesQuery(isOpen); - const { - data: transferTypes, - isError: isErrorTransferTypes, - isInitialLoading: isLoadingTransferTypes, - } = useNetworkTransferPricesQuery(isOpen); - - const isErrorTypes = isErrorTransferTypes || isErrorObjTypes; - const isLoadingTypes = isLoadingTransferTypes || isLoadingObjTypes; - const isInvalidPrice = - !objTypes || !transferTypes || isErrorTypes || isErrorTransferTypes; - - const { isPending, mutateAsync: createBucket } = useCreateBucketMutation(); - const { data: agreements } = useAccountAgreements(); - const { mutateAsync: updateAccountAgreements } = useMutateAccountAgreements(); - const { data: accountSettings } = useAccountSettings(); - - 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', - resolver: yupResolver(CreateBucketSchema), - }); - - const watchRegion = watch('region'); - - const onSubmit = async (data: CreateObjectStorageBucketPayload) => { - try { - await createBucket(data); - - if (data.region) { - sendCreateBucketEvent(data.region); - } - - if (state.hasSignedAgreement) { - try { - await updateAccountAgreements({ eu_model: true }); - } catch (error) { - reportAgreementSigningError(error); - } - } - - handleClose(); - } catch (errors) { - for (const error of errors) { - setError(error?.field ?? 'root', { message: error.reason }); - } - } - }; - - const handleClose = () => { - reset(); - onClose(); - }; - - 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(storageEndpoints) && !formValues.endpoint_type) { - setError('endpoint_type', { - message: 'Endpoint Type is required.', - type: 'manual', - }); - return; - } - - if (accountSettings?.object_storage !== 'active') { - setState((prev) => ({ ...prev, isEnableObjDialogOpen: true })); - } else { - await handleSubmit(onSubmit)(e); - } - }; - - const selectedRegion = watchRegion - ? availableStorageRegions?.find((region) => watchRegion === region.id) - : undefined; - - const filteredEndpoints = storageEndpoints?.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 { showGDPRCheckbox } = getGDPRDetails({ - agreements, - profile, - regions: availableStorageRegions, - 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 ( - -
- - {isRestrictedUser && ( - - )} - {errors.root?.message && ( - - )} - ( - - )} - /> - ( - { - field.onChange(value); - }} - required - selectedRegion={field.value} - /> - )} - /> - {selectedRegion?.id && } - {Boolean(storageEndpoints) && selectedRegion && ( - <> - ( - - updateEndpointType(endpointOption) - } - options={filteredEndpointOptions ?? []} - placeholder="Object Storage Endpoint Type" - textFieldProps={{ - containerProps: { - sx: { - '> .MuiFormHelperText-root': { - marginBottom: 1, - }, - }, - }, - helperText: ( - - Endpoint types impact the performance, capacity, and - rate limits for your bucket. Understand{' '} - - endpoint types - - . - - ), - helperTextPosition: 'top', - }} - value={selectedEndpointOption} - /> - )} - /> - {Boolean(storageEndpoints) && selectedEndpointOption && ( - - )} - - )} - {showGDPRCheckbox ? ( - - setState((prev) => ({ - ...prev, - hasSignedAgreement: e.target.checked, - })) - } - /> - ) : null} - - - setState((prev) => ({ - ...prev, - isEnableObjDialogOpen: false, - })) - } - open={state.isEnableObjDialogOpen} - regionId={selectedRegion?.id} - /> - -
- ); -}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 00dd326b68f..b67150c39d7 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -13,17 +13,16 @@ import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { TanStackTabLinkList } from 'src/components/Tabs/TanStackTabLinkList'; import { useFlags } from 'src/hooks/useFlags'; -import { Tab, useTabs } from 'src/hooks/useTabs'; +import { useTabs } from 'src/hooks/useTabs'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { getRestrictedResourceText } from '../Account/utils'; import { BillingNotice } from './BillingNotice'; import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; -import { OMC_CreateBucketDrawer } from './BucketLanding/OMC_CreateBucketDrawer'; -import { useIsObjMultiClusterEnabled } from './hooks/useIsObjectStorageGen2Enabled'; import type { MODE } from './AccessKeyLanding/types'; +import type { Tab } from 'src/hooks/useTabs'; const SummaryLanding = React.lazy(() => import('./SummaryLanding/SummaryLanding').then((module) => ({ @@ -43,8 +42,6 @@ export const ObjectStorageLanding = () => { const [mode, setMode] = React.useState('creating'); - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); - const { data: profile } = useProfile(); const { data: accountSettings } = useAccountSettings(); @@ -193,17 +190,10 @@ export const ObjectStorageLanding = () => { - {isObjMultiClusterEnabled ? ( - navigate({ to: '/object-storage/buckets' })} - /> - ) : ( - navigate({ to: '/object-storage/buckets' })} - /> - )} + navigate({ to: '/object-storage/buckets' })} + /> ); From 543fd9f3f76c54d42a41eaf23979aa0ecd721bc1 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Thu, 26 Mar 2026 16:35:52 +0100 Subject: [PATCH 5/9] refactor: STORIF-335 - BucketDrawerOutlet component created. --- .../BucketDetailsDrawer.test.tsx | 75 +++++-------------- .../BucketLanding/BucketDetailsDrawer.tsx | 22 +++--- .../BucketLanding/BucketDrawerOutlet.tsx | 23 ++++++ .../BucketLanding/OMC_BucketLanding.tsx | 24 ++---- .../BucketLanding/hooks/useBucketDrawers.tsx | 48 ++++++++++++ .../ObjectStorage/ObjectStorageLanding.tsx | 13 ++-- .../src/queries/object-storage/queries.ts | 28 +++++++ .../manager/src/routes/objectStorage/index.ts | 10 +++ 8 files changed, 152 insertions(+), 91 deletions(-) create mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx create mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 26ac6c68455..598ea4ea9c2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -34,6 +34,7 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), useRegionQuery: vi.fn().mockReturnValue({}), useRegionsQuery: vi.fn().mockReturnValue({}), + useObjectStorageBucket: vi.fn().mockReturnValue({}), })); // Mock the queries @@ -59,6 +60,7 @@ vi.mock('src/queries/object-storage/queries', async () => { return { ...actual, useObjectStorageClusters: queryMocks.useObjectStorageClusters, + useObjectStorageBucket: queryMocks.useObjectStorageBucket, }; }); @@ -78,6 +80,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { queryMocks.useRegionQuery.mockReturnValue({ data: region }); queryMocks.useRegionsQuery.mockReturnValue({ data: [region] }); queryMocks.useObjectStorageClusters.mockReturnValue({ data: [] }); + queryMocks.useObjectStorageBucket.mockReturnValue({ data: bucket }); // These utils are used in the component vi.mocked(formatDate).mockReturnValue('2019-12-12'); @@ -91,13 +94,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders correctly when open', () => { renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , }); expect(screen.getByText(bucket.label)).toBeInTheDocument(); @@ -112,13 +109,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('does not render when closed', () => { renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , }); expect(screen.queryByText(bucket.label)).not.toBeInTheDocument(); @@ -126,27 +117,17 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders correctly with objMultiCluster disabled', () => { renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , }); expect(screen.getByTestId('cluster')).toHaveTextContent(region.id); }); it('handles undefined selectedBucket gracefully', () => { + queryMocks.useObjectStorageBucket.mockReturnValue({ data: undefined }); + renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , }); expect(screen.getByText('Bucket Detail')).toBeInTheDocument(); @@ -156,13 +137,7 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders AccessSelect when cluster and bucketLabel are available', async () => { renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -177,14 +152,10 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('does not render AccessSelect when cluster or bucketLabel is missing', async () => { const bucketWithoutCluster = { ...bucket, cluster: '' }; + queryMocks.useObjectStorageBucket.mockReturnValue(bucketWithoutCluster); + renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -205,15 +176,13 @@ describe('BucketDetailDrawer: Gen2 UI', () => { id: e3Bucket.region, }); + beforeEach(() => { + queryMocks.useObjectStorageBucket.mockReturnValue({ data: e3Bucket }); + }); + it('renders correctly when open', () => { renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -234,13 +203,7 @@ describe('BucketDetailDrawer: Gen2 UI', () => { it("doesn't show the CORS switch for E2 and E3 buckets", async () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: ( - - ), + component: , options: { flags: { objectStorageGen2: { enabled: true } }, }, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 6ffcf11205f..35826eeb614 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -2,28 +2,32 @@ import { useProfile, useRegionQuery, useRegionsQuery } from '@linode/queries'; import { Divider, Drawer, Typography } from '@linode/ui'; import { pluralize, readableBytes, truncateMiddle } from '@linode/utilities'; import { styled } from '@mui/material/styles'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; -import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; +import { + useObjectStorageBucket, + useObjectStorageClusters, +} from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; import { AccessSelect } from '../BucketDetail/AccessTab/AccessSelect'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; -import type { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; - export interface BucketDetailsDrawerProps { + isOpen: boolean; onClose: () => void; - open: boolean; - selectedBucket: ObjectStorageBucket | undefined; } export const BucketDetailsDrawer = React.memo( (props: BucketDetailsDrawerProps) => { - const { onClose, open, selectedBucket } = props; + const { onClose, isOpen } = props; + const { regionId, bucketName } = useParams({ strict: false }); + + const { data: bucket } = useObjectStorageBucket(regionId, bucketName); const { cluster, @@ -34,7 +38,7 @@ export const BucketDetailsDrawer = React.memo( objects, region, size, - } = selectedBucket ?? {}; + } = bucket ?? {}; const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); @@ -65,7 +69,7 @@ export const BucketDetailsDrawer = React.memo( return ( {formattedCreated && ( @@ -109,7 +113,7 @@ export const BucketDetailsDrawer = React.memo( {typeof objects === 'number' && ( {pluralize('object', 'objects', objects)} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx new file mode 100644 index 00000000000..4914453f2a0 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx @@ -0,0 +1,23 @@ +import React from 'react'; + +import { BucketDetailsDrawer } from './BucketDetailsDrawer'; +import { CreateBucketDrawer } from './CreateBucketDrawer'; +import { useBucketDrawers } from './hooks/useBucketDrawers'; + +export const BucketDrawerOutlet = () => { + const { drawer, closeDrawer } = useBucketDrawers(); + + return ( + <> + + + + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx index cc0d8c1dd48..c88bf4ed9c3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -21,9 +21,9 @@ import { } from 'src/utilities/analytics/customEventAnalytics'; import { CancelNotice } from '../CancelNotice'; -import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { BucketLandingEmptyState } from './BucketLandingEmptyState'; import { BucketTable } from './BucketTable'; +import { useBucketDrawers } from './hooks/useBucketDrawers'; import type { APIError, ObjectStorageBucket } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -55,26 +55,17 @@ export const OMC_BucketLanding = (props: Props) => { const { classes } = useStyles(); + const { openDrawer } = useBucketDrawers(); + const removeBucketConfirmationDialog = useOpenClose(); const [isLoading, setIsLoading] = React.useState(false); const [error, setError] = React.useState(undefined); - const [bucketDetailDrawerOpen, setBucketDetailDrawerOpen] = - React.useState(false); const [selectedBucket, setSelectedBucket] = React.useState< ObjectStorageBucket | undefined >(undefined); - const handleClickDetails = (bucket: ObjectStorageBucket) => { - setBucketDetailDrawerOpen(true); - setSelectedBucket(bucket); - }; - - const closeBucketDetailDrawer = () => { - setBucketDetailDrawerOpen(false); - }; - const handleClickRemove = (bucket: ObjectStorageBucket) => { setSelectedBucket(bucket); setError(undefined); @@ -200,7 +191,9 @@ export const OMC_BucketLanding = (props: Props) => { + openDrawer('bucket-details', bucket.region, bucket.label) + } handleClickRemove={handleClickRemove} handleOrderChange={handleOrderChange} order={order} @@ -256,11 +249,6 @@ export const OMC_BucketLanding = (props: Props) => { Account Settings. */} {buckets.length === 1 && } - ); }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx new file mode 100644 index 00000000000..1f8eee3556d --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx @@ -0,0 +1,48 @@ +import { useMatch, useNavigate } from '@tanstack/react-router'; + +type BucketDrawers = 'bucket-details' | 'create-bucket'; + +const BUCKETS_BASE_URL = '/object-storage/buckets'; + +export const useBucketDrawers = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + + function getDrawer(): BucketDrawers | null { + switch (routeId) { + case `${BUCKETS_BASE_URL}/$regionId/$bucketName/details`: + return 'bucket-details'; + case `${BUCKETS_BASE_URL}/create`: + return 'create-bucket'; + default: + return null; + } + } + + function openDrawer( + drawer: BucketDrawers, + regionId?: string, + bucketName?: string + ) { + switch (drawer) { + case 'bucket-details': + navigate({ + to: `${BUCKETS_BASE_URL}/${regionId}/${bucketName}/details`, + }); + break; + case 'create-bucket': + navigate({ to: `${BUCKETS_BASE_URL}/create` }); + break; + } + } + + function closeDrawer() { + navigate({ to: BUCKETS_BASE_URL }); + } + + return { + drawer: getDrawer(), + openDrawer, + closeDrawer, + }; +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index b67150c39d7..287917e2ca7 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -18,7 +18,7 @@ import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { getRestrictedResourceText } from '../Account/utils'; import { BillingNotice } from './BillingNotice'; -import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; +import { BucketDrawerOutlet } from './BucketLanding/BucketDrawerOutlet'; import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; import type { MODE } from './AccessKeyLanding/types'; @@ -123,7 +123,7 @@ export const ObjectStorageLanding = () => { } return ( - + <> { - - navigate({ to: '/object-storage/buckets' })} - /> - + + + ); }; diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index d35fba873bf..dac021e41d4 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -309,6 +309,34 @@ export const useObjectStorageBuckets = (enabled: boolean = true) => { }; }; +// TODO: Optimize to use tanstack cache +export const useObjectStorageBucket = ( + region: string | undefined, + label: string | undefined +) => { + const queryClient = useQueryClient(); + + if (!region || !label) { + return {}; + } + + const queries = queryClient.getQueriesData({ + queryKey: objectStorageQueries.buckets.queryKey, + }); + + for (const [, data] of queries) { + const bucket = (data as { buckets: ObjectStorageBucket[] })?.buckets?.find( + (bucket) => bucket.region === region && bucket.label === label + ); + + if (bucket) { + return { data: bucket }; + } + } + + return { data: undefined }; +}; + export const useBucketAccess = ( clusterOrRegion: string, bucket: string, diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index ad2d05b180c..6736d81742a 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -61,6 +61,15 @@ const objectStorageBucketCreateRoute = createRoute({ ) ); +const objectStorageBucketDetailsRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'buckets/$regionId/$bucketName/details', +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + const objectStorageAccessKeyCreateRoute = createRoute({ getParentRoute: () => objectStorageRoute, path: 'access-keys/create', @@ -122,6 +131,7 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageBucketsLandingRoute, objectStorageAccessKeysLandingRoute, objectStorageBucketCreateRoute, + objectStorageBucketDetailsRoute, objectStorageAccessKeyCreateRoute, ]), objectStorageBucketDetailRoute.addChildren([ From 3c94b343b03f29b048f36e1a10ebe129fc6e7d5e Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Fri, 27 Mar 2026 12:23:29 +0100 Subject: [PATCH 6/9] refactro: STORIF-335 AccessKeysDrawerOutlet component created. --- .../AccessKeyLanding/AccessKeyDrawer.tsx | 20 +-- .../AccessKeyLanding.test.tsx | 4 - .../AccessKeyLanding/AccessKeyLanding.tsx | 49 +------- .../AccessKeyTable/AccessKeyActionMenu.tsx | 22 ++-- .../AccessKeyTable/AccessKeyTable.test.tsx | 1 - .../AccessKeyTable/AccessKeyTable.tsx | 119 ++++++------------ .../AccessKeyTable/AccessKeyTableBody.tsx | 16 +-- .../AccessKeyTable/AccessKeyTableRow.tsx | 25 +--- .../AccessKeyTable/HostNameTableCell.test.tsx | 18 +-- .../AccessKeyTable/HostNameTableCell.tsx | 14 +-- .../AccessKeysDrawerOutlet.tsx | 41 ++++++ .../AccessKeyLanding/HostNamesDrawer.test.tsx | 50 ++++---- .../AccessKeyLanding/HostNamesDrawer.tsx | 16 ++- .../ViewPermissionsDrawer.tsx | 14 ++- .../hooks/useAccessKeyDrawers.tsx | 62 +++++++++ .../ObjectStorage/ObjectStorageLanding.tsx | 35 ++---- .../src/queries/object-storage/queries.ts | 4 +- .../manager/src/routes/objectStorage/index.ts | 53 ++++++-- 18 files changed, 281 insertions(+), 282 deletions(-) create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx create mode 100644 packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx index 39d4cc7f645..0389a4bf5e9 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx @@ -1,4 +1,4 @@ -import { useAccountSettings } from '@linode/queries'; +import { useAccountSettings, useProfile } from '@linode/queries'; import { ActionsPanel, CircleProgress, @@ -12,6 +12,7 @@ import { createObjectStorageKeysSchema, updateObjectStorageKeysSchema, } from '@linode/validation'; +import { useParams } from '@tanstack/react-router'; import { useFormik } from 'formik'; import React, { useEffect, useState } from 'react'; @@ -20,6 +21,7 @@ import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObj import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; import { useCreateAccessKeyMutation, + useObjectStorageAccessKey, useObjectStorageBuckets, useUpdateAccessKeyMutation, } from 'src/queries/object-storage/queries'; @@ -48,12 +50,11 @@ import type { import type { FormikHelpers } from 'formik'; export interface AccessKeyDrawerProps { - isRestrictedUser: boolean; + isOpen: boolean; mode: MODE; // If the mode is 'editing', we should have an ObjectStorageKey to edit objectStorageKey?: ObjectStorageKey; onClose: () => void; - open: boolean; } // Access key scopes displayed in the drawer can have no permission or "No Access" selected, which are not valid API permissions. @@ -103,7 +104,11 @@ export const getDefaultScopes = ( .sort(sortByRegion(regionLookup)); export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { isRestrictedUser, mode, objectStorageKey, onClose, open } = props; + const { mode, onClose, isOpen } = props; + const { accessKeyId } = useParams({ strict: false }); + + const { data: profile } = useProfile(); + const isRestrictedUser = profile?.restricted ?? false; const displayKeysDialog = useOpenClose(); // Key to display in Confirmation Modal upon creation @@ -119,6 +124,7 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } = useObjectStorageBuckets(); const { data: accountSettings } = useAccountSettings(); + const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { mutateAsync: createAccessKey } = useCreateAccessKeyMutation(); const { mutateAsync: updateAccessKey } = useUpdateAccessKeyMutation(); @@ -307,13 +313,13 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { useEffect(() => { setLimitedAccessChecked(false); formik.resetForm({ values: initialValues }); - }, [open]); + }, [isOpen]); return ( <> @@ -361,7 +367,7 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index 8f2fd5cdbbe..d965c4ca5be 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -11,12 +11,10 @@ import { } from 'src/queries/object-storage/queries'; import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { AccessKeyDrawer } from './AccessKeyDrawer'; import { AccessKeyTable } from './AccessKeyTable/AccessKeyTable'; +import { useAccessKeyDrawers } from './hooks/useAccessKeyDrawers'; import { RevokeAccessKeyDialog } from './RevokeAccessKeyDialog'; -import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; -import type { MODE, OpenAccessDrawer } from './types'; import type { CreateObjectStorageKeyPayload, ObjectStorageKey, @@ -24,23 +22,13 @@ import type { import type { FormikBag } from 'formik'; interface Props { - accessDrawerOpen: boolean; - closeAccessDrawer: () => void; isRestrictedUser: boolean; - mode: MODE; - openAccessDrawer: (mode: MODE) => void; } export type FormikProps = FormikBag; export const AccessKeyLanding = (props: Props) => { - const { - accessDrawerOpen, - closeAccessDrawer, - isRestrictedUser, - mode, - openAccessDrawer, - } = props; + const { isRestrictedUser } = props; const navigate = useNavigate(); const pagination = usePaginationV2({ @@ -55,10 +43,8 @@ export const AccessKeyLanding = (props: Props) => { }); const { mutateAsync: deleteAccessKey } = useDeleteAccessKeyMutation(); - // Key to rename (by clicking on a key's kebab menu ) - const [keyToEdit, setKeyToEdit] = React.useState( - null - ); + const { drawer } = useAccessKeyDrawers(); + const isCreateAccessDrawerOpen = drawer === 'create-access-key'; // Key to revoke (by clicking on a key's kebab menu ) const [keyToRevoke, setKeyToRevoke] = React.useState( @@ -114,16 +100,6 @@ export const AccessKeyLanding = (props: Props) => { }); }; - const openDrawer: OpenAccessDrawer = ( - mode: MODE, - objectStorageKey: null | ObjectStorageKey = null - ) => { - setKeyToEdit(objectStorageKey); - if (mode !== 'creating') { - openAccessDrawer(mode); - } - }; - const openRevokeDialog = (objectStorageKey: ObjectStorageKey) => { setKeyToRevoke(objectStorageKey); revokeKeysDialog.open(); @@ -137,7 +113,7 @@ export const AccessKeyLanding = (props: Props) => { return (
{ error={error} isLoading={isLoading} isRestrictedUser={isRestrictedUser} - openDrawer={openDrawer} openRevokeDialog={openRevokeDialog} /> { pageSize={pagination.pageSize} /> - - - - void; openRevokeDialog: (key: ObjectStorageKey) => void; } export const AccessKeyActionMenu = (props: Props) => { - const { - label, - objectStorageKey, - openDrawer, - openHostnamesDrawer, - openRevokeDialog, - } = props; + const { label, objectStorageKey, openRevokeDialog } = props; + + const { openDrawer } = useAccessKeyDrawers(); const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); @@ -37,20 +31,22 @@ export const AccessKeyActionMenu = (props: Props) => { const actions = [ { onClick: () => { - openDrawer('editing', objectStorageKey); + openDrawer('edit-access-key', objectStorageKey.id); }, title: isObjMultiClusterEnabled ? 'Edit' : 'Edit Label', }, { onClick: () => { - openDrawer('viewing', objectStorageKey); + openDrawer('access-key-permissions', objectStorageKey.id); }, title: 'Permissions', }, ...(isObjMultiClusterEnabled ? [ { - onClick: openHostnamesDrawer, + onClick: () => { + openDrawer('access-key-hostnames', objectStorageKey.id); + }, title: 'View Regions/S3 Hostnames', }, ] diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx index d0f68f2e7e8..2a42a3d3ff7 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.test.tsx @@ -12,7 +12,6 @@ describe('ObjectStorageKeyTable', () => { error: undefined, isLoading: false, isRestrictedUser: false, - openDrawer: vi.fn(), openRevokeDialog: vi.fn(), }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx index 535da66a385..8adb0d64ee2 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTable.tsx @@ -1,105 +1,66 @@ -import { useAccount } from '@linode/queries'; import { Hidden } from '@linode/ui'; -import { isFeatureEnabledV2 } from '@linode/utilities'; -import React, { useState } from 'react'; +import React from 'react'; 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 { useFlags } from 'src/hooks/useFlags'; -import { HostNamesDrawer } from '../HostNamesDrawer'; +import { useIsObjMultiClusterEnabled } from '../../hooks/useIsObjectStorageGen2Enabled'; import { AccessKeyTableBody } from './AccessKeyTableBody'; -import type { OpenAccessDrawer } from '../types'; -import type { - APIError, - ObjectStorageKey, - ObjectStorageKeyRegions, -} from '@linode/api-v4'; +import type { APIError, ObjectStorageKey } from '@linode/api-v4'; export interface AccessKeyTableProps { data: ObjectStorageKey[] | undefined; error: APIError[] | null | undefined; isLoading: boolean; isRestrictedUser: boolean; - openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; } export const AccessKeyTable = (props: AccessKeyTableProps) => { - const { - data, - error, - isLoading, - isRestrictedUser, - openDrawer, - openRevokeDialog, - } = props; + const { data, error, isLoading, isRestrictedUser, openRevokeDialog } = props; - const [showHostNamesDrawer, setShowHostNamesDrawers] = - useState(false); - const [hostNames, setHostNames] = useState([]); - - const flags = useFlags(); - const { data: account } = useAccount(); - - const isObjMultiClusterEnabled = isFeatureEnabledV2( - 'Object Storage Access Key Regions', - Boolean(flags.objMultiCluster), - account?.capabilities ?? [] - ); + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( - <> - - - - ({ - [theme.breakpoints.up('md')]: { - minWidth: 120, - }, - })} - > - Label - - Access Key - {isObjMultiClusterEnabled && ( - - Regions/S3 Hostnames - - )} - - - - - - -
- {isObjMultiClusterEnabled && ( - setShowHostNamesDrawers(false)} - open={showHostNamesDrawer} - regions={hostNames} + + + + ({ + [theme.breakpoints.up('md')]: { + minWidth: 120, + }, + })} + > + Label + + Access Key + {isObjMultiClusterEnabled && ( + + Regions/S3 Hostnames + + )} + + + + + - )} - + +
); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx index 34c0c9493a2..9e905731e64 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableBody.tsx @@ -6,12 +6,7 @@ import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading' import { AccessKeyTableRow } from './AccessKeyTableRow'; -import type { OpenAccessDrawer } from '../types'; -import type { - APIError, - ObjectStorageKey, - ObjectStorageKeyRegions, -} from '@linode/api-v4'; +import type { APIError, ObjectStorageKey } from '@linode/api-v4'; interface Props { data: ObjectStorageKey[] | undefined; @@ -19,10 +14,7 @@ interface Props { isLoading: boolean; isObjMultiClusterEnabled: boolean; isRestrictedUser: boolean; - openDrawer: OpenAccessDrawer; openRevokeDialog: (objectStorageKey: ObjectStorageKey) => void; - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; } export const AccessKeyTableBody = (props: Props) => { @@ -32,10 +24,7 @@ export const AccessKeyTableBody = (props: Props) => { isLoading, isObjMultiClusterEnabled, isRestrictedUser, - openDrawer, openRevokeDialog, - setHostNames, - setShowHostNamesDrawers, } = props; const cols = isObjMultiClusterEnabled ? 4 : 3; @@ -69,10 +58,7 @@ export const AccessKeyTableBody = (props: Props) => { return data?.map((key) => ( )); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx index 735e987349f..19196ce9a61 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/AccessKeyTableRow.tsx @@ -14,25 +14,15 @@ import { useFlags } from 'src/hooks/useFlags'; import { AccessKeyActionMenu } from './AccessKeyActionMenu'; import { HostNameTableCell } from './HostNameTableCell'; -import type { OpenAccessDrawer } from '../types'; -import type { ObjectStorageKey, ObjectStorageKeyRegions } from '@linode/api-v4'; +import type { ObjectStorageKey } from '@linode/api-v4'; interface Props { - openDrawer: OpenAccessDrawer; openRevokeDialog: (storageKeyData: ObjectStorageKey) => void; - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; } export const AccessKeyTableRow = (props: Props) => { - const { - openDrawer, - openRevokeDialog, - setHostNames, - setShowHostNamesDrawers, - storageKeyData, - } = props; + const { openRevokeDialog, storageKeyData } = props; const { data: account } = useAccount(); const flags = useFlags(); @@ -56,22 +46,13 @@ export const AccessKeyTableRow = (props: Props) => { {isObjMultiClusterEnabled && ( - + )} { - setShowHostNamesDrawers(true); - setHostNames(storageKeyData.regions); - }} openRevokeDialog={openRevokeDialog} /> diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx index 3bae9ecbc45..d97229407a5 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.test.tsx @@ -16,11 +16,7 @@ describe('HostNameTableCell', () => { regions: [], }); const { getByText } = renderWithTheme( - + ); expect(getByText('None')).toBeInTheDocument(); @@ -47,11 +43,7 @@ describe('HostNameTableCell', () => { }) ); const { findByText } = renderWithTheme( - + ); const hostname = await findByText('US, Newark, NJ: alpha.test.com'); @@ -83,11 +75,7 @@ describe('HostNameTableCell', () => { }) ); const { getByText } = renderWithTheme( - + ); await waitFor(() => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index e16fa7a665b..eb2ccd34ced 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -7,16 +7,17 @@ import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { TableCell } from 'src/components/TableCell'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; +import { useAccessKeyDrawers } from '../hooks/useAccessKeyDrawers'; + import type { ObjectStorageKey, ObjectStorageKeyRegions } from '@linode/api-v4'; interface Props { - setHostNames: (hostNames: ObjectStorageKeyRegions[]) => void; - setShowHostNamesDrawers: (show: boolean) => void; storageKeyData: ObjectStorageKey; } export const HostNameTableCell = (props: Props) => { - const { setHostNames, setShowHostNamesDrawers, storageKeyData } = props; + const { storageKeyData } = props; + const { openDrawer } = useAccessKeyDrawers(); const { availableStorageRegions, regionsByIdMap } = useObjectStorageRegions(); @@ -49,12 +50,7 @@ export const HostNameTableCell = (props: Props) => { {showMultipleRegions ? ( <> | + {pluralize('region', 'regions', regions.length - 1)} |  - { - setHostNames(regions); - setShowHostNamesDrawers(true); - }} - > + openDrawer('access-key-hostnames')}> Show All diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx new file mode 100644 index 00000000000..e7f25d8f538 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx @@ -0,0 +1,41 @@ +import React from 'react'; + +import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; +import { AccessKeyDrawer } from './AccessKeyDrawer'; +import { useAccessKeyDrawers } from './hooks/useAccessKeyDrawers'; +import { HostNamesDrawer } from './HostNamesDrawer'; +import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; + +export const AccessKeysDrawerOutlet = () => { + const { drawer, closeDrawer } = useAccessKeyDrawers(); + + const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + + return ( + <> + + + + + + + {isObjMultiClusterEnabled && ( + + )} + + ); +}; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx index 7ad193597b8..ab93c5bff90 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx @@ -3,6 +3,7 @@ import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import React from 'react'; +import { objectStorageKeyFactory } from 'src/factories'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { HostNamesDrawer } from './HostNamesDrawer'; @@ -10,18 +11,6 @@ import { HostNamesDrawer } from './HostNamesDrawer'; // Mock the onClose function const mockOnClose = vi.fn(); -// Mock regions data -const mockS3Regions = [ - { - id: 'region1', - s3_endpoint: 'endpoint1', - }, - { - id: 'region2', - s3_endpoint: 'endpoint2', - }, -]; - vi.mock('@linode/queries', async (importOriginal) => ({ ...(await importOriginal()), useRegionsQuery: vi.fn(() => ({ @@ -32,15 +21,30 @@ vi.mock('@linode/queries', async (importOriginal) => ({ })), })); +vi.mock('src/queries/object-storage/queries', async () => { + const actual = await vi.importActual('src/queries/object-storage/queries'); + return { + ...actual, + useObjectStorageAccessKey: vi.fn().mockReturnValue({ + data: objectStorageKeyFactory.build({ + regions: [ + { + id: 'region1', + s3_endpoint: 'endpoint1', + }, + { + id: 'region2', + s3_endpoint: 'endpoint2', + }, + ], + }), + }), + }; +}); + describe('HostNamesDrawer', () => { it('renders the drawer with regions and copyable text', () => { - renderWithTheme( - - ); + renderWithTheme(); expect( screen.getByRole('dialog', { name: 'Regions / S3 Hostnames' }) @@ -65,13 +69,7 @@ describe('HostNamesDrawer', () => { }); it('calls onClose when the drawer is closed', async () => { - renderWithTheme( - - ); + renderWithTheme(); const closeButton = screen.getByRole('button', { name: 'Close drawer' }); await userEvent.click(closeButton); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index eb9db364d80..b06ac84817b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -1,29 +1,33 @@ import { Box, Drawer } from '@linode/ui'; +import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; +import { useObjectStorageAccessKey } from 'src/queries/object-storage/queries'; import { CopyAllHostnames } from './CopyAllHostnames'; -import type { ObjectStorageKeyRegions } from '@linode/api-v4'; - interface Props { + isOpen: boolean; onClose: () => void; - open: boolean; - regions: ObjectStorageKeyRegions[]; } export const HostNamesDrawer = (props: Props) => { - const { onClose, open, regions } = props; + const { onClose, isOpen } = props; + const { accessKeyId } = useParams({ strict: false }); + + const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { availableStorageRegions, regionsByIdMap } = useObjectStorageRegions(); + const regions = objectStorageKey?.regions || []; + if (!availableStorageRegions || !regionsByIdMap) { return null; } return ( - + ({ marginTop: theme.spacing(3) })}> void; - open: boolean; } export const ViewPermissionsDrawer = (props: Props) => { - const { objectStorageKey, onClose, open } = props; + const { onClose, isOpen } = props; + const { accessKeyId } = useParams({ strict: false }); + const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx new file mode 100644 index 00000000000..b7f024fe0c8 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx @@ -0,0 +1,62 @@ +import { useMatch, useNavigate } from '@tanstack/react-router'; + +type AccessKeyDrawers = + | 'access-key-hostnames' + | 'access-key-permissions' + | 'create-access-key' + | 'edit-access-key'; + +const ACCESS_KEYS_BASE_URL = '/object-storage/access-keys'; + +export const useAccessKeyDrawers = () => { + const navigate = useNavigate(); + const { routeId } = useMatch({ strict: false }); + + function getDrawer(): AccessKeyDrawers | null { + switch (routeId) { + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/edit`: + return 'edit-access-key'; + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/hostnames`: + return 'access-key-hostnames'; + case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/permissions`: + return 'access-key-permissions'; + case `${ACCESS_KEYS_BASE_URL}/create`: + return 'create-access-key'; + default: + return null; + } + } + + function openDrawer(drawer: AccessKeyDrawers, accessKeyId?: number) { + switch (drawer) { + case 'access-key-hostnames': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/hostnames`, + }); + break; + case 'access-key-permissions': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/permissions`, + }); + break; + case 'create-access-key': + navigate({ to: `${ACCESS_KEYS_BASE_URL}/create` }); + break; + case 'edit-access-key': + navigate({ + to: `${ACCESS_KEYS_BASE_URL}/${accessKeyId}/edit`, + }); + break; + } + } + + function closeDrawer() { + navigate({ to: ACCESS_KEYS_BASE_URL }); + } + + return { + drawer: getDrawer(), + openDrawer, + closeDrawer, + }; +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 287917e2ca7..7e4c0ade8cd 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -1,5 +1,4 @@ import { useAccountSettings, useProfile } from '@linode/queries'; -import { useOpenClose } from '@linode/utilities'; import { styled } from '@mui/material/styles'; import { useMatch, useNavigate } from '@tanstack/react-router'; import * as React from 'react'; @@ -17,11 +16,13 @@ import { useTabs } from 'src/hooks/useTabs'; import { useObjectStorageBuckets } from 'src/queries/object-storage/queries'; import { getRestrictedResourceText } from '../Account/utils'; +import { AccessKeysDrawerOutlet } from './AccessKeyLanding/AccessKeysDrawerOutlet'; +import { useAccessKeyDrawers } from './AccessKeyLanding/hooks/useAccessKeyDrawers'; import { BillingNotice } from './BillingNotice'; import { BucketDrawerOutlet } from './BucketLanding/BucketDrawerOutlet'; +import { useBucketDrawers } from './BucketLanding/hooks/useBucketDrawers'; import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; -import type { MODE } from './AccessKeyLanding/types'; import type { Tab } from 'src/hooks/useTabs'; const SummaryLanding = React.lazy(() => @@ -40,10 +41,10 @@ export const ObjectStorageLanding = () => { const navigate = useNavigate(); const match = useMatch({ strict: false }); - const [mode, setMode] = React.useState('creating'); - const { data: profile } = useProfile(); const { data: accountSettings } = useAccountSettings(); + const { openDrawer: openBucketDrawer } = useBucketDrawers(); + const { openDrawer: openAccessKeyDrawer } = useAccessKeyDrawers(); const isRestrictedUser = profile?.restricted ?? false; @@ -96,26 +97,16 @@ export const ObjectStorageLanding = () => { ? 'Create Access Key' : 'Create Bucket'; - const openDrawer = useOpenClose(); - - const handleOpenAccessDrawer = (mode: MODE) => { - setMode(mode); - openDrawer.open(); - }; - const createButtonAction = () => { if (isAccessKeysTab) { - navigate({ to: '/object-storage/access-keys/create' }); - handleOpenAccessDrawer('creating'); + openAccessKeyDrawer('create-access-key'); } else { - navigate({ to: '/object-storage/buckets/create' }); + openBucketDrawer('create-bucket'); } }; const isSummaryOpened = match.routeId === '/object-storage/summary'; const isCreateBucketOpen = match.routeId === '/object-storage/buckets/create'; - const isCreateAccessKeyOpen = - match.routeId === '/object-storage/access-keys/create'; // TODO: Remove when OBJ Summary is enabled if (match.routeId === '/object-storage/summary' && !objSummaryPage) { @@ -176,22 +167,14 @@ export const ObjectStorageLanding = () => { /> - { - navigate({ to: '/object-storage/access-keys' }); - openDrawer.close(); - }} - isRestrictedUser={isRestrictedUser} - mode={mode} - openAccessDrawer={handleOpenAccessDrawer} - /> + + ); }; diff --git a/packages/manager/src/queries/object-storage/queries.ts b/packages/manager/src/queries/object-storage/queries.ts index dac021e41d4..45e6f5719cf 100644 --- a/packages/manager/src/queries/object-storage/queries.ts +++ b/packages/manager/src/queries/object-storage/queries.ts @@ -139,10 +139,10 @@ export const useObjectStorageAccessKeys = (params: Params) => }); // TODO: Optimize to use tanstack cache -export const useObjectStorageAccessKey = (id: number) => { +export const useObjectStorageAccessKey = (id: number | undefined) => { const queryClient = useQueryClient(); - if (id === -1) { + if (!id) { return {}; } diff --git a/packages/manager/src/routes/objectStorage/index.ts b/packages/manager/src/routes/objectStorage/index.ts index 6736d81742a..a8e938f63dd 100644 --- a/packages/manager/src/routes/objectStorage/index.ts +++ b/packages/manager/src/routes/objectStorage/index.ts @@ -43,27 +43,27 @@ const objectStorageBucketsLandingRoute = createRoute({ ) ); -const objectStorageAccessKeysLandingRoute = createRoute({ +const objectStorageBucketCreateRoute = createRoute({ getParentRoute: () => objectStorageRoute, - path: 'access-keys', + path: 'buckets/create', }).lazy(() => import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( (m) => m.objectStorageLandingLazyRoute ) ); -const objectStorageBucketCreateRoute = createRoute({ +const objectStorageBucketDetailsRoute = createRoute({ getParentRoute: () => objectStorageRoute, - path: 'buckets/create', + path: 'buckets/$regionId/$bucketName/details', }).lazy(() => import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( (m) => m.objectStorageLandingLazyRoute ) ); -const objectStorageBucketDetailsRoute = createRoute({ +const objectStorageAccessKeysLandingRoute = createRoute({ getParentRoute: () => objectStorageRoute, - path: 'buckets/$regionId/$bucketName/details', + path: 'access-keys', }).lazy(() => import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( (m) => m.objectStorageLandingLazyRoute @@ -79,6 +79,42 @@ const objectStorageAccessKeyCreateRoute = createRoute({ ) ); +const objectStorageAccessKeyEditRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/edit', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeyPermissionsRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/permissions', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + +const objectStorageAccessKeyHostnamesRoute = createRoute({ + getParentRoute: () => objectStorageRoute, + path: 'access-keys/$accessKeyId/hostnames', + parseParams: (params) => ({ + accessKeyId: Number(params.accessKeyId), + }), +}).lazy(() => + import('src/features/ObjectStorage/objectStorageLandingLazyRoute').then( + (m) => m.objectStorageLandingLazyRoute + ) +); + const objectStorageBucketDetailRoute = createRoute({ getParentRoute: () => objectStorageRoute, path: 'buckets/$clusterId/$bucketName', @@ -129,10 +165,13 @@ export const objectStorageRouteTree = objectStorageRoute.addChildren([ objectStorageIndexRoute.addChildren([ objectStorageSummaryLandingRoute, objectStorageBucketsLandingRoute, - objectStorageAccessKeysLandingRoute, objectStorageBucketCreateRoute, objectStorageBucketDetailsRoute, + objectStorageAccessKeysLandingRoute, objectStorageAccessKeyCreateRoute, + objectStorageAccessKeyEditRoute, + objectStorageAccessKeyPermissionsRoute, + objectStorageAccessKeyHostnamesRoute, ]), objectStorageBucketDetailRoute.addChildren([ objectStorageBucketDetailObjectsRoute, From 722bd73d141b412f9bb7e6544c9bee9adeb581f7 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Fri, 27 Mar 2026 14:00:58 +0100 Subject: [PATCH 7/9] [REVERT AFTER THE REVIEW] Unrenamed files. --- .../AccessKeyLanding/AccessKeysDrawerOutlet.tsx | 2 +- .../{AccessKeyDrawer.tsx => OMC_AccessKeyDrawer.tsx} | 0 .../features/ObjectStorage/AccessKeyLanding/utils.test.ts | 2 +- .../src/features/ObjectStorage/AccessKeyLanding/utils.ts | 5 ++++- .../ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx | 2 +- ...cketDrawer.styles.ts => OMC_CreateBucketDrawer.styles.ts} | 0 ...BucketDrawer.test.tsx => OMC_CreateBucketDrawer.test.tsx} | 2 +- .../{CreateBucketDrawer.tsx => OMC_CreateBucketDrawer.tsx} | 2 +- 8 files changed, 9 insertions(+), 6 deletions(-) rename packages/manager/src/features/ObjectStorage/AccessKeyLanding/{AccessKeyDrawer.tsx => OMC_AccessKeyDrawer.tsx} (100%) rename packages/manager/src/features/ObjectStorage/BucketLanding/{CreateBucketDrawer.styles.ts => OMC_CreateBucketDrawer.styles.ts} (100%) rename packages/manager/src/features/ObjectStorage/BucketLanding/{CreateBucketDrawer.test.tsx => OMC_CreateBucketDrawer.test.tsx} (97%) rename packages/manager/src/features/ObjectStorage/BucketLanding/{CreateBucketDrawer.tsx => OMC_CreateBucketDrawer.tsx} (99%) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx index e7f25d8f538..2ecfa385ebe 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeysDrawerOutlet.tsx @@ -1,9 +1,9 @@ import React from 'react'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; -import { AccessKeyDrawer } from './AccessKeyDrawer'; import { useAccessKeyDrawers } from './hooks/useAccessKeyDrawers'; import { HostNamesDrawer } from './HostNamesDrawer'; +import { AccessKeyDrawer } from './OMC_AccessKeyDrawer'; import { ViewPermissionsDrawer } from './ViewPermissionsDrawer'; export const AccessKeysDrawerOutlet = () => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx similarity index 100% rename from packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyDrawer.tsx rename to packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts index 847d2a4072e..7eed687e51b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.test.ts @@ -4,7 +4,7 @@ import { hasLabelOrRegionsChanged, } from './utils'; -import type { DisplayedAccessKeyScope, FormState } from './AccessKeyDrawer'; +import type { DisplayedAccessKeyScope, FormState } from './OMC_AccessKeyDrawer'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; describe('generateUpdatePayload', () => { diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts index 2d278e0e8fc..a02b335929b 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/utils.ts @@ -1,6 +1,9 @@ import { areArraysEqual, sortByString } from '@linode/utilities'; -import type { DisplayedAccessKeyScope, FormState } from './AccessKeyDrawer'; +import type { + DisplayedAccessKeyScope, + FormState, +} from './OMC_AccessKeyDrawer.js'; import type { ObjectStorageKey } from '@linode/api-v4/lib/object-storage'; type UpdatePayload = diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx index 4914453f2a0..c61f0e669da 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { BucketDetailsDrawer } from './BucketDetailsDrawer'; -import { CreateBucketDrawer } from './CreateBucketDrawer'; import { useBucketDrawers } from './hooks/useBucketDrawers'; +import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; export const BucketDrawerOutlet = () => { const { drawer, closeDrawer } = useBucketDrawers(); diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.styles.ts b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.styles.ts similarity index 100% rename from packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.styles.ts rename to packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.styles.ts diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx similarity index 97% rename from packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx rename to packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx index e0c7fe19bb8..18d4029f84c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.test.tsx @@ -6,7 +6,7 @@ import { makeResourcePage } from 'src/mocks/serverHandlers'; import { http, HttpResponse, server } from 'src/mocks/testServer'; import { renderWithThemeAndHookFormContext } from 'src/utilities/testHelpers'; -import { CreateBucketDrawer } from './CreateBucketDrawer'; +import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; const props = { isOpen: true, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx similarity index 99% rename from packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx rename to packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx index 72362311e15..4ce76cbb89c 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/CreateBucketDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_CreateBucketDrawer.tsx @@ -34,7 +34,7 @@ import { reportAgreementSigningError } from 'src/utilities/reportAgreementSignin import { EnableObjectStorageModal } from '../EnableObjectStorageModal'; import { QuotasInfoNotice } from '../QuotasInfoNotice'; import { BucketRegions } from './BucketRegions'; -import { StyledEUAgreementCheckbox } from './CreateBucketDrawer.styles'; +import { StyledEUAgreementCheckbox } from './OMC_CreateBucketDrawer.styles'; import { OveragePricing } from './OveragePricing'; import type { From 998134e0acfdfc352279bfe752a264a33f00c3d4 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Fri, 27 Mar 2026 14:20:00 +0100 Subject: [PATCH 8/9] refactor: STORIF-335 - Comments resolved. --- .../AccessKeyLanding/AccessKeyLanding.tsx | 4 +- .../AccessKeysDrawerOutlet.tsx | 26 ++++--- .../AccessKeyLanding/HostNamesDrawer.test.tsx | 50 +++++++------ .../AccessKeyLanding/HostNamesDrawer.tsx | 9 +-- .../AccessKeyLanding/OMC_AccessKeyDrawer.tsx | 6 +- .../ViewPermissionsDrawer.tsx | 10 +-- .../hooks/useAccessKeyDrawers.tsx | 25 ++++--- .../BucketDetailsDrawer.test.tsx | 74 ++++++++++++++----- .../BucketLanding/BucketDetailsDrawer.tsx | 14 ++-- .../BucketLanding/BucketDrawerOutlet.tsx | 12 ++- .../BucketLanding/hooks/useBucketDrawers.tsx | 22 ++++-- 11 files changed, 155 insertions(+), 97 deletions(-) diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx index d965c4ca5be..d82964dcedf 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyLanding.tsx @@ -44,7 +44,7 @@ export const AccessKeyLanding = (props: Props) => { const { mutateAsync: deleteAccessKey } = useDeleteAccessKeyMutation(); const { drawer } = useAccessKeyDrawers(); - const isCreateAccessDrawerOpen = drawer === 'create-access-key'; + const isCreateAccessKeyDrawerOpen = drawer?.type === 'create-access-key'; // Key to revoke (by clicking on a key's kebab menu ) const [keyToRevoke, setKeyToRevoke] = React.useState( @@ -113,7 +113,7 @@ export const AccessKeyLanding = (props: Props) => { return (
{ const { drawer, closeDrawer } = useAccessKeyDrawers(); - const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); + const { data: objectStorageKey } = useObjectStorageAccessKey( + drawer?.accessKeyId + ); return ( <> - {isObjMultiClusterEnabled && ( - - )} + ); }; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx index ab93c5bff90..2f797718837 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.test.tsx @@ -11,6 +11,19 @@ import { HostNamesDrawer } from './HostNamesDrawer'; // Mock the onClose function const mockOnClose = vi.fn(); +const mockAccessKey = objectStorageKeyFactory.build({ + regions: [ + { + id: 'region1', + s3_endpoint: 'endpoint1', + }, + { + id: 'region2', + s3_endpoint: 'endpoint2', + }, + ], +}); + vi.mock('@linode/queries', async (importOriginal) => ({ ...(await importOriginal()), useRegionsQuery: vi.fn(() => ({ @@ -21,30 +34,15 @@ vi.mock('@linode/queries', async (importOriginal) => ({ })), })); -vi.mock('src/queries/object-storage/queries', async () => { - const actual = await vi.importActual('src/queries/object-storage/queries'); - return { - ...actual, - useObjectStorageAccessKey: vi.fn().mockReturnValue({ - data: objectStorageKeyFactory.build({ - regions: [ - { - id: 'region1', - s3_endpoint: 'endpoint1', - }, - { - id: 'region2', - s3_endpoint: 'endpoint2', - }, - ], - }), - }), - }; -}); - describe('HostNamesDrawer', () => { it('renders the drawer with regions and copyable text', () => { - renderWithTheme(); + renderWithTheme( + + ); expect( screen.getByRole('dialog', { name: 'Regions / S3 Hostnames' }) @@ -69,7 +67,13 @@ describe('HostNamesDrawer', () => { }); it('calls onClose when the drawer is closed', async () => { - renderWithTheme(); + renderWithTheme( + + ); const closeButton = screen.getByRole('button', { name: 'Close drawer' }); await userEvent.click(closeButton); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx index b06ac84817b..4d63e955f7c 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/HostNamesDrawer.tsx @@ -1,23 +1,22 @@ import { Box, Drawer } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { CopyableTextField } from 'src/components/CopyableTextField/CopyableTextField'; import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObjectStorageRegions'; -import { useObjectStorageAccessKey } from 'src/queries/object-storage/queries'; import { CopyAllHostnames } from './CopyAllHostnames'; +import type { ObjectStorageKey } from '@linode/api-v4'; + interface Props { isOpen: boolean; + objectStorageKey?: ObjectStorageKey; onClose: () => void; } export const HostNamesDrawer = (props: Props) => { - const { onClose, isOpen } = props; - const { accessKeyId } = useParams({ strict: false }); + const { onClose, isOpen, objectStorageKey } = props; - const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { availableStorageRegions, regionsByIdMap } = useObjectStorageRegions(); const regions = objectStorageKey?.regions || []; diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx index 0389a4bf5e9..ba7b12f579a 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/OMC_AccessKeyDrawer.tsx @@ -12,7 +12,6 @@ import { createObjectStorageKeysSchema, updateObjectStorageKeysSchema, } from '@linode/validation'; -import { useParams } from '@tanstack/react-router'; import { useFormik } from 'formik'; import React, { useEffect, useState } from 'react'; @@ -21,7 +20,6 @@ import { useObjectStorageRegions } from 'src/features/ObjectStorage/hooks/useObj import { SecretTokenDialog } from 'src/features/Profile/SecretTokenDialog/SecretTokenDialog'; import { useCreateAccessKeyMutation, - useObjectStorageAccessKey, useObjectStorageBuckets, useUpdateAccessKeyMutation, } from 'src/queries/object-storage/queries'; @@ -104,8 +102,7 @@ export const getDefaultScopes = ( .sort(sortByRegion(regionLookup)); export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { - const { mode, onClose, isOpen } = props; - const { accessKeyId } = useParams({ strict: false }); + const { mode, onClose, isOpen, objectStorageKey } = props; const { data: profile } = useProfile(); const isRestrictedUser = profile?.restricted ?? false; @@ -124,7 +121,6 @@ export const AccessKeyDrawer = (props: AccessKeyDrawerProps) => { } = useObjectStorageBuckets(); const { data: accountSettings } = useAccountSettings(); - const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { mutateAsync: createAccessKey } = useCreateAccessKeyMutation(); const { mutateAsync: updateAccessKey } = useUpdateAccessKeyMutation(); diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx index 054d2fb7134..7d1c2ae5611 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/ViewPermissionsDrawer.tsx @@ -1,23 +1,21 @@ import { Drawer, Typography } from '@linode/ui'; -import { useParams } from '@tanstack/react-router'; import * as React from 'react'; -import { useObjectStorageAccessKey } from 'src/queries/object-storage/queries'; - import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; import { AccessTable } from './AccessTable'; import { BucketPermissionsTable } from './BucketPermissionsTable'; +import type { ObjectStorageKey } from '@linode/api-v4'; + export interface Props { isOpen: boolean; + objectStorageKey?: ObjectStorageKey; onClose: () => void; } export const ViewPermissionsDrawer = (props: Props) => { - const { onClose, isOpen } = props; - const { accessKeyId } = useParams({ strict: false }); + const { onClose, isOpen, objectStorageKey } = props; - const { data: objectStorageKey } = useObjectStorageAccessKey(accessKeyId); const { isObjMultiClusterEnabled } = useIsObjMultiClusterEnabled(); return ( diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx index b7f024fe0c8..246913288c3 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/hooks/useAccessKeyDrawers.tsx @@ -1,6 +1,7 @@ -import { useMatch, useNavigate } from '@tanstack/react-router'; +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; +import { useMemo } from 'react'; -type AccessKeyDrawers = +type AccessKeyDrawerType = | 'access-key-hostnames' | 'access-key-permissions' | 'create-access-key' @@ -8,26 +9,32 @@ type AccessKeyDrawers = const ACCESS_KEYS_BASE_URL = '/object-storage/access-keys'; +interface AccessKeyDrawerState { + accessKeyId?: number; + type: AccessKeyDrawerType; +} + export const useAccessKeyDrawers = () => { const navigate = useNavigate(); const { routeId } = useMatch({ strict: false }); + const { accessKeyId } = useParams({ strict: false }); - function getDrawer(): AccessKeyDrawers | null { + function getDrawer(): AccessKeyDrawerState | null { switch (routeId) { case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/edit`: - return 'edit-access-key'; + return { accessKeyId, type: 'edit-access-key' }; case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/hostnames`: - return 'access-key-hostnames'; + return { accessKeyId, type: 'access-key-hostnames' }; case `${ACCESS_KEYS_BASE_URL}/$accessKeyId/permissions`: - return 'access-key-permissions'; + return { accessKeyId, type: 'access-key-permissions' }; case `${ACCESS_KEYS_BASE_URL}/create`: - return 'create-access-key'; + return { type: 'create-access-key' }; default: return null; } } - function openDrawer(drawer: AccessKeyDrawers, accessKeyId?: number) { + function openDrawer(drawer: AccessKeyDrawerType, accessKeyId?: number) { switch (drawer) { case 'access-key-hostnames': navigate({ @@ -55,7 +62,7 @@ export const useAccessKeyDrawers = () => { } return { - drawer: getDrawer(), + drawer: useMemo(() => getDrawer(), [routeId]), openDrawer, closeDrawer, }; diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx index 598ea4ea9c2..1e8e6dd54c3 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.test.tsx @@ -34,7 +34,6 @@ const queryMocks = vi.hoisted(() => ({ useProfile: vi.fn().mockReturnValue({}), useRegionQuery: vi.fn().mockReturnValue({}), useRegionsQuery: vi.fn().mockReturnValue({}), - useObjectStorageBucket: vi.fn().mockReturnValue({}), })); // Mock the queries @@ -60,7 +59,6 @@ vi.mock('src/queries/object-storage/queries', async () => { return { ...actual, useObjectStorageClusters: queryMocks.useObjectStorageClusters, - useObjectStorageBucket: queryMocks.useObjectStorageBucket, }; }); @@ -80,7 +78,6 @@ describe('BucketDetailsDrawer: Legacy UI', () => { queryMocks.useRegionQuery.mockReturnValue({ data: region }); queryMocks.useRegionsQuery.mockReturnValue({ data: [region] }); queryMocks.useObjectStorageClusters.mockReturnValue({ data: [] }); - queryMocks.useObjectStorageBucket.mockReturnValue({ data: bucket }); // These utils are used in the component vi.mocked(formatDate).mockReturnValue('2019-12-12'); @@ -94,7 +91,13 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders correctly when open', () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect(screen.getByText(bucket.label)).toBeInTheDocument(); @@ -109,7 +112,13 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('does not render when closed', () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect(screen.queryByText(bucket.label)).not.toBeInTheDocument(); @@ -117,17 +126,27 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders correctly with objMultiCluster disabled', () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect(screen.getByTestId('cluster')).toHaveTextContent(region.id); }); it('handles undefined selectedBucket gracefully', () => { - queryMocks.useObjectStorageBucket.mockReturnValue({ data: undefined }); - renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), }); expect(screen.getByText('Bucket Detail')).toBeInTheDocument(); @@ -137,7 +156,13 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('renders AccessSelect when cluster and bucketLabel are available', async () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -152,10 +177,15 @@ describe('BucketDetailsDrawer: Legacy UI', () => { it('does not render AccessSelect when cluster or bucketLabel is missing', async () => { const bucketWithoutCluster = { ...bucket, cluster: '' }; - queryMocks.useObjectStorageBucket.mockReturnValue(bucketWithoutCluster); renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -176,13 +206,15 @@ describe('BucketDetailDrawer: Gen2 UI', () => { id: e3Bucket.region, }); - beforeEach(() => { - queryMocks.useObjectStorageBucket.mockReturnValue({ data: e3Bucket }); - }); - it('renders correctly when open', () => { renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), options: { flags: { objectStorageGen2: { enabled: true } }, }, @@ -203,7 +235,13 @@ describe('BucketDetailDrawer: Gen2 UI', () => { it("doesn't show the CORS switch for E2 and E3 buckets", async () => { const { getByText } = renderWithThemeAndHookFormContext({ - component: , + component: ( + + ), options: { flags: { objectStorageGen2: { enabled: true } }, }, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index 35826eeb614..96de92774de 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -2,32 +2,28 @@ import { useProfile, useRegionQuery, useRegionsQuery } from '@linode/queries'; import { Divider, Drawer, Typography } from '@linode/ui'; import { pluralize, readableBytes, truncateMiddle } from '@linode/utilities'; import { styled } from '@mui/material/styles'; -import { useParams } from '@tanstack/react-router'; import * as React from 'react'; import { CopyTooltip } from 'src/components/CopyTooltip/CopyTooltip'; import { Link } from 'src/components/Link'; import { MaskableText } from 'src/components/MaskableText/MaskableText'; -import { - useObjectStorageBucket, - useObjectStorageClusters, -} from 'src/queries/object-storage/queries'; +import { useObjectStorageClusters } from 'src/queries/object-storage/queries'; import { formatDate } from 'src/utilities/formatDate'; import { AccessSelect } from '../BucketDetail/AccessTab/AccessSelect'; import { useIsObjMultiClusterEnabled } from '../hooks/useIsObjectStorageGen2Enabled'; +import type { ObjectStorageBucket } from '@linode/api-v4'; + export interface BucketDetailsDrawerProps { + bucket?: ObjectStorageBucket; isOpen: boolean; onClose: () => void; } export const BucketDetailsDrawer = React.memo( (props: BucketDetailsDrawerProps) => { - const { onClose, isOpen } = props; - const { regionId, bucketName } = useParams({ strict: false }); - - const { data: bucket } = useObjectStorageBucket(regionId, bucketName); + const { onClose, isOpen, bucket } = props; const { cluster, diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx index c61f0e669da..120bd6dcdd6 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDrawerOutlet.tsx @@ -1,5 +1,7 @@ import React from 'react'; +import { useObjectStorageBucket } from 'src/queries/object-storage/queries'; + import { BucketDetailsDrawer } from './BucketDetailsDrawer'; import { useBucketDrawers } from './hooks/useBucketDrawers'; import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; @@ -7,15 +9,21 @@ import { CreateBucketDrawer } from './OMC_CreateBucketDrawer'; export const BucketDrawerOutlet = () => { const { drawer, closeDrawer } = useBucketDrawers(); + const { data: bucket } = useObjectStorageBucket( + drawer?.regionId, + drawer?.bucketName + ); + return ( <> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx index 1f8eee3556d..d215d363d83 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/hooks/useBucketDrawers.tsx @@ -1,26 +1,34 @@ -import { useMatch, useNavigate } from '@tanstack/react-router'; +import { useMatch, useNavigate, useParams } from '@tanstack/react-router'; +import { useMemo } from 'react'; -type BucketDrawers = 'bucket-details' | 'create-bucket'; +type BucketDrawerType = 'bucket-details' | 'create-bucket'; const BUCKETS_BASE_URL = '/object-storage/buckets'; +interface BucketDrawerState { + bucketName?: string; + regionId?: string; + type: BucketDrawerType; +} + export const useBucketDrawers = () => { const navigate = useNavigate(); const { routeId } = useMatch({ strict: false }); + const { regionId, bucketName } = useParams({ strict: false }); - function getDrawer(): BucketDrawers | null { + function getDrawer(): BucketDrawerState | null { switch (routeId) { case `${BUCKETS_BASE_URL}/$regionId/$bucketName/details`: - return 'bucket-details'; + return { type: 'bucket-details', regionId, bucketName }; case `${BUCKETS_BASE_URL}/create`: - return 'create-bucket'; + return { type: 'create-bucket' }; default: return null; } } function openDrawer( - drawer: BucketDrawers, + drawer: BucketDrawerType, regionId?: string, bucketName?: string ) { @@ -41,7 +49,7 @@ export const useBucketDrawers = () => { } return { - drawer: getDrawer(), + drawer: useMemo(() => getDrawer(), [routeId]), openDrawer, closeDrawer, }; From 6588e542c5df3941630074ec7289db8eb4636cd4 Mon Sep 17 00:00:00 2001 From: Dmytro Chyrva Date: Fri, 27 Mar 2026 18:27:10 +0100 Subject: [PATCH 9/9] refactor: STORIF-335 - Test fixed. --- .../core/objectStorage/access-key.e2e.spec.ts | 41 +++++++++++++------ .../objectStorage/access-keys.smoke.spec.ts | 11 ++++- .../enable-object-storage.spec.ts | 14 ++++++- .../object-storage.smoke.spec.ts | 4 +- ...bject-storage-objects-multicluster.spec.ts | 2 +- .../AccessKeyTable/HostNameTableCell.tsx | 6 ++- 6 files changed, 58 insertions(+), 20 deletions(-) diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts index 50a79b5f9f3..9579f88f8cb 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-key.e2e.spec.ts @@ -42,8 +42,8 @@ describe('object storage access key end-to-end tests', () => { mockGetAccount(accountFactory.build({ capabilities: ['Object Storage'] })); mockAppendFeatureFlags({ - objMultiCluster: false, - objectStorageGen2: { enabled: false }, + objMultiCluster: true, + objectStorageGen2: { enabled: true }, }); cy.visitWithLogin('/object-storage/access-keys'); @@ -59,8 +59,14 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click(); - cy.focused().type(keyLabel); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(keyLabel); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .should('be.visible') @@ -124,6 +130,7 @@ describe('object storage access key end-to-end tests', () => { const bucketClusterObj = chooseCluster(); const bucketRequest = createObjectStorageBucketFactoryLegacy.build({ cluster: bucketClusterObj.id, + cors_enabled: true, label: bucketLabel, // Default factory sets `cluster` and `region`, but API does not accept `region` yet. region: undefined, @@ -140,8 +147,8 @@ describe('object storage access key end-to-end tests', () => { accountFactory.build({ capabilities: ['Object Storage'] }) ); mockAppendFeatureFlags({ - objMultiCluster: false, - objectStorageGen2: { enabled: false }, + objMultiCluster: true, + objectStorageGen2: { enabled: true }, }); interceptGetAccessKeys().as('getKeys'); @@ -160,10 +167,20 @@ describe('object storage access key end-to-end tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByText('Label').click(); - cy.focused().type(keyLabel); - cy.findByLabelText('Limited Access').click(); - cy.findByLabelText('Select read-only for all').click(); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(keyLabel); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + + cy.focused().click(); + + cy.findByLabelText('Limited Access', { exact: false }).click(); + cy.findByLabelText('Select read-only for all', { + exact: false, + }).click(); ui.buttonGroup .findButtonByTitle('Create Access Key') @@ -209,8 +226,8 @@ describe('object storage access key end-to-end tests', () => { }); }); - const permissionLabel = `This token has read-only access for ${bucketClusterObj.id}-${bucketLabel}`; - cy.findByLabelText(permissionLabel).should('be.visible'); + const permissionLabel = `This access key has the following permissions:`; + cy.findByText(permissionLabel).should('be.visible'); }); }); }); diff --git a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts index 7ac22a68551..e9c710f7e15 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/access-keys.smoke.spec.ts @@ -50,12 +50,19 @@ describe('object storage access keys smoke tests', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label').click(); - cy.focused().type(mockAccessKey.label); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(mockAccessKey.label); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Atlanta, {enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .as('qaCreateAccessKey') .scrollIntoView(); + cy.get('@qaCreateAccessKey') .should('be.visible') .should('be.enabled') 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 ce5a8e16584..d10d658a58a 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 @@ -298,10 +298,14 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label') + cy.findByLabelText('Label', { exact: false }) .should('be.visible') .type(mockAccessKey.label); + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Jakarta, ID{enter}'); + ui.buttonGroup .findButtonByTitle('Create Access Key') .should('be.visible') @@ -360,7 +364,13 @@ describe('Object Storage enrollment', () => { .findByTitle('Create Access Key') .should('be.visible') .within(() => { - cy.findByLabelText('Label').should('be.visible').type(randomLabel()); + cy.findByLabelText('Label', { exact: false }) + .should('be.visible') + .type(randomLabel()); + + cy.findByLabelText('Regions', { exact: false }) + .should('be.visible') + .type('Jakarta, ID{enter}'); ui.buttonGroup .findButtonByTitle('Create Access Key') diff --git a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts index c11d6fd890f..97c7f4507e6 100644 --- a/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorage/object-storage.smoke.spec.ts @@ -43,7 +43,7 @@ describe('object storage smoke tests', () => { mockAppendFeatureFlags({ gecko2: false, objMultiCluster: true, - objectStorageGen2: { enabled: false }, + objectStorageGen2: { enabled: true }, }).as('getFeatureFlags'); mockGetBuckets([]).as('getBuckets'); @@ -65,7 +65,7 @@ describe('object storage smoke tests', () => { cy.findByLabelText('Bucket Name (required)').click(); cy.focused().type(bucketLabel); ui.regionSelect.find().click(); - cy.focused().type(`${mockCluster.id}{enter}`); + cy.focused().type(`${mockRegion.label}{enter}`); ui.buttonGroup .findButtonByTitle('Create Bucket') .should('be.visible') diff --git a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts index 95814459df2..a3a003e204c 100644 --- a/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts +++ b/packages/manager/cypress/e2e/core/objectStorageMulticluster/object-storage-objects-multicluster.spec.ts @@ -27,7 +27,7 @@ const emptyFolderMessage = 'This folder is empty.'; * @returns Non-empty bucket error message. */ const getNonEmptyBucketMessage = (bucketLabel: string) => { - return `Bucket ${bucketLabel} is not empty. Please delete all objects and try again.`; + return `The specified bucket '${bucketLabel}' is not empty. Please delete all objects before retrying.`; }; /** diff --git a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx index eb2ccd34ced..d9239fcdc01 100644 --- a/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx +++ b/packages/manager/src/features/ObjectStorage/AccessKeyLanding/AccessKeyTable/HostNameTableCell.tsx @@ -50,7 +50,11 @@ export const HostNameTableCell = (props: Props) => { {showMultipleRegions ? ( <> | + {pluralize('region', 'regions', regions.length - 1)} |  - openDrawer('access-key-hostnames')}> + + openDrawer('access-key-hostnames', storageKeyData.id) + } + > Show All