From 306868dcc883c8c8e639e28a8fd3f994d3f076b7 Mon Sep 17 00:00:00 2001 From: cpathipa <119517080+cpathipa@users.noreply.github.com> Date: Wed, 13 Mar 2024 12:09:50 -0500 Subject: [PATCH 001/286] upcoming: [M3-7798] - Update Buckets landing page to use regions instead of clusters. (#10244) * upcoming: [M3-7798] - Update Buckets landing page to use regions instead of clusters * render UnavailableRegionsDisplay * Render OMC_BucketLanding when FF is enabled * MSW to handle view and update bucket access. * MSW - handle delete bucket * Added changeset: Update Buckets landing page to use regions instead of clusters. * Consistent @TODO formatting * Added changeset: Add temporary deleteBucketWithRegion method for OBJ Multicluster * Update packages/manager/.changeset/pr-10244-upcoming-features-1709333927883.md Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * Update packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> * PR - feedback - @DevDW * Update object-storage.smoke.spec.ts * Fix the broken obj smoke test --------- Co-authored-by: Dajahi Wiley Co-authored-by: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> --- ...r-10244-upcoming-features-1709582607964.md | 5 + packages/api-v4/src/object-storage/buckets.ts | 29 ++ ...r-10244-upcoming-features-1709333927883.md | 5 + .../object-storage.smoke.spec.ts | 8 +- .../BucketLanding/BucketDetailsDrawer.tsx | 57 +++- .../BucketLanding/BucketTable.tsx | 2 +- .../BucketLanding/BucketTableRow.tsx | 26 +- .../BucketLanding/OMC_BucketLanding.tsx | 315 ++++++++++++++++++ .../ObjectStorage/ObjectStorageLanding.tsx | 7 +- packages/manager/src/mocks/serverHandlers.ts | 23 ++ packages/manager/src/queries/objectStorage.ts | 40 ++- 11 files changed, 493 insertions(+), 24 deletions(-) create mode 100644 packages/api-v4/.changeset/pr-10244-upcoming-features-1709582607964.md create mode 100644 packages/manager/.changeset/pr-10244-upcoming-features-1709333927883.md create mode 100644 packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx diff --git a/packages/api-v4/.changeset/pr-10244-upcoming-features-1709582607964.md b/packages/api-v4/.changeset/pr-10244-upcoming-features-1709582607964.md new file mode 100644 index 00000000000..9461382e69b --- /dev/null +++ b/packages/api-v4/.changeset/pr-10244-upcoming-features-1709582607964.md @@ -0,0 +1,5 @@ +--- +"@linode/api-v4": Upcoming Features +--- + +Add temporary deleteBucketWithRegion method for OBJ Multicluster ([#10244](https://github.com/linode/manager/pull/10244)) diff --git a/packages/api-v4/src/object-storage/buckets.ts b/packages/api-v4/src/object-storage/buckets.ts index 52f09af3ce0..360c47c6e86 100644 --- a/packages/api-v4/src/object-storage/buckets.ts +++ b/packages/api-v4/src/object-storage/buckets.ts @@ -123,6 +123,35 @@ export const deleteBucket = ({ setMethod('DELETE') ); +/** + * deleteBucketWithRegion + * + * Removes a Bucket from your account with region. + * + * NOTE: Attempting to delete a non-empty bucket will result in an error. + */ +/* + @TODO OBJ Multicluster: deleteBucketWithRegion is a function, + once feature is rolled out we replace it with existing deleteBucket + by updating it with region instead of cluster. + */ + +export const deleteBucketWithRegion = ({ + region, + label, +}: { + region: string; + label: string; +}) => + Request( + setURL( + `${API_ROOT}/object-storage/buckets/${encodeURIComponent( + region + )}/${encodeURIComponent(label)}` + ), + setMethod('DELETE') + ); + /** * Returns a list of Objects in a given Bucket. */ diff --git a/packages/manager/.changeset/pr-10244-upcoming-features-1709333927883.md b/packages/manager/.changeset/pr-10244-upcoming-features-1709333927883.md new file mode 100644 index 00000000000..25fd0dae0d3 --- /dev/null +++ b/packages/manager/.changeset/pr-10244-upcoming-features-1709333927883.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Buckets landing page to use regions instead of clusters ([#10244](https://github.com/linode/manager/pull/10244)) 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 8974866fb6b..261c4a10491 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 @@ -68,11 +68,9 @@ describe('object storage smoke tests', () => { cy.visitWithLogin('/object-storage'); cy.wait(['@getRegions', '@getBuckets']); - ui.button - .findByTitle('Create Bucket') - .should('be.visible') - .should('be.enabled') - .click(); + ui.entityHeader.find().within(() => { + ui.button.findByTitle('Create Bucket').should('be.visible').click(); + }); ui.drawer .findByTitle('Create Bucket') diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx index d5b82d90652..091b3ea6f64 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketDetailsDrawer.tsx @@ -1,3 +1,4 @@ +import { Region } from '@linode/api-v4'; import { ACLType, getBucketAccess, @@ -11,9 +12,12 @@ import { Divider } from 'src/components/Divider'; import { Drawer } from 'src/components/Drawer'; import { Link } from 'src/components/Link'; import { Typography } from 'src/components/Typography'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; import { useProfile } from 'src/queries/profile'; import { useRegionsQuery } from 'src/queries/regions'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { formatDate } from 'src/utilities/formatDate'; import { pluralize } from 'src/utilities/pluralize'; import { truncateMiddle } from 'src/utilities/truncate'; @@ -22,6 +26,7 @@ import { readableBytes } from 'src/utilities/unitConversions'; import { AccessSelect } from '../BucketDetail/AccessSelect'; export interface BucketDetailsDrawerProps { bucketLabel?: string; + bucketRegion?: Region; cluster?: string; created?: string; hostname?: string; @@ -35,6 +40,7 @@ export const BucketDetailsDrawer = React.memo( (props: BucketDetailsDrawerProps) => { const { bucketLabel, + bucketRegion, cluster, created, hostname, @@ -44,6 +50,16 @@ export const BucketDetailsDrawer = React.memo( size, } = props; + const flags = useFlags(); + const { account } = useAccountManagement(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + + // @TODO OBJ Multicluster: Once the feature is rolled out to production, we can clean this up by removing the useObjectStorageClusters and useRegionsQuery, which will not be required at that time. const { data: clusters } = useObjectStorageClusters(); const { data: regions } = useRegionsQuery(); const { data: profile } = useProfile(); @@ -70,13 +86,15 @@ export const BucketDetailsDrawer = React.memo( Created: {formattedCreated} ) : null} - - {cluster ? ( + {isObjMultiClusterEnabled ? ( + + {bucketRegion?.label} + + ) : cluster ? ( {region?.label ?? cluster} ) : null} - {hostname ? ( @@ -85,38 +103,55 @@ export const BucketDetailsDrawer = React.memo( ) : null} - {formattedCreated || cluster ? ( ) : null} - {typeof size === 'number' ? ( {readableBytes(size).formatted} ) : null} - + {/* @TODO OBJ Multicluster: use region instead of cluster if isObjMultiClusterEnabled. */} {typeof objectsNumber === 'number' ? ( - + {pluralize('object', 'objects', objectsNumber)} ) : null} - {typeof size === 'number' || typeof objectsNumber === 'number' ? ( ) : null} - + {/* @TODO OBJ Multicluster: use region instead of cluster if isObjMultiClusterEnabled + to getBucketAccess and updateBucketAccess. */} {cluster && bucketLabel ? ( + getBucketAccess( + isObjMultiClusterEnabled && bucketRegion + ? bucketRegion.id + : cluster, + bucketLabel + ) + } updateAccess={(acl: ACLType, cors_enabled: boolean) => { // Don't send the ACL with the payload if it's "custom", since it's // not valid (though it's a valid return type). const payload = acl === 'custom' ? { cors_enabled } : { acl, cors_enabled }; - return updateBucketAccess(cluster, bucketLabel, payload); + return updateBucketAccess( + isObjMultiClusterEnabled && bucketRegion + ? bucketRegion.id + : cluster, + bucketLabel, + payload + ); }} - getAccess={() => getBucketAccess(cluster, bucketLabel)} name={bucketLabel} variant="bucket" /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx index f5fd6bdea87..4b53b9a89a2 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTable.tsx @@ -140,7 +140,7 @@ const RenderData: React.FC = (props) => { {data.map((bucket, index) => ( onDetails(bucket)} onRemove={() => onRemove(bucket)} /> diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx index 6ea9e6c8556..115736fcd1b 100644 --- a/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/BucketTableRow.tsx @@ -6,8 +6,12 @@ import { DateTimeDisplay } from 'src/components/DateTimeDisplay'; import { Hidden } from 'src/components/Hidden'; import { TableCell } from 'src/components/TableCell'; import { Typography } from 'src/components/Typography'; +import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useFlags } from 'src/hooks/useFlags'; import { useObjectStorageClusters } from 'src/queries/objectStorage'; import { useRegionsQuery } from 'src/queries/regions'; +import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; +import { getRegionsByRegionId } from 'src/utilities/regions'; import { readableBytes } from 'src/utilities/unitConversions'; import { BucketActionMenu } from './BucketActionMenu'; @@ -34,14 +38,26 @@ export const BucketTableRow = (props: BucketTableRowProps) => { objects, onDetails, onRemove, + region, size, } = props; const { data: clusters } = useObjectStorageClusters(); const { data: regions } = useRegionsQuery(); + const flags = useFlags(); + const { account } = useAccountManagement(); + + const isObjMultiClusterEnabled = isFeatureEnabled( + 'Object Storage Access Key Regions', + Boolean(flags.objMultiCluster), + account?.capabilities ?? [] + ); + const actualCluster = clusters?.find((c) => c.id === cluster); - const region = regions?.find((r) => r.id === actualCluster?.region); + const clusterRegion = regions?.find((r) => r.id === actualCluster?.region); + + const regionsLookup = regions && getRegionsByRegionId(regions); return ( @@ -51,7 +67,9 @@ export const BucketTableRow = (props: BucketTableRowProps) => { {label}{' '} @@ -65,7 +83,9 @@ export const BucketTableRow = (props: BucketTableRowProps) => { - {region?.label ?? cluster} + {isObjMultiClusterEnabled && regionsLookup && region + ? regionsLookup[region].label + : clusterRegion?.label ?? cluster} diff --git a/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx new file mode 100644 index 00000000000..1cc77733566 --- /dev/null +++ b/packages/manager/src/features/ObjectStorage/BucketLanding/OMC_BucketLanding.tsx @@ -0,0 +1,315 @@ +import { Region } from '@linode/api-v4'; +import { ObjectStorageBucket } from '@linode/api-v4/lib/object-storage'; +import { APIError } from '@linode/api-v4/lib/types'; +import Grid from '@mui/material/Unstable_Grid2'; +import { Theme } from '@mui/material/styles'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { CircleProgress } from 'src/components/CircleProgress'; +import { DocumentTitleSegment } from 'src/components/DocumentTitle'; +import { ErrorState } from 'src/components/ErrorState/ErrorState'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import OrderBy from 'src/components/OrderBy'; +import { TransferDisplay } from 'src/components/TransferDisplay/TransferDisplay'; +import { TypeToConfirmDialog } from 'src/components/TypeToConfirmDialog/TypeToConfirmDialog'; +import { Typography } from 'src/components/Typography'; +import { useOpenClose } from 'src/hooks/useOpenClose'; +import { + BucketError, + useDeleteBucketWithRegionMutation, + useObjectStorageBucketsFromRegions, +} from 'src/queries/objectStorage'; +import { useProfile } from 'src/queries/profile'; +import { useRegionsQuery } from 'src/queries/regions'; +import { + sendDeleteBucketEvent, + sendDeleteBucketFailedEvent, +} from 'src/utilities/analytics'; +import { getRegionsByRegionId } from 'src/utilities/regions'; +import { readableBytes } from 'src/utilities/unitConversions'; + +import { CancelNotice } from '../CancelNotice'; +import { BucketDetailsDrawer } from './BucketDetailsDrawer'; +import { BucketLandingEmptyState } from './BucketLandingEmptyState'; +import { BucketTable } from './BucketTable'; + +const useStyles = makeStyles()((theme: Theme) => ({ + copy: { + marginTop: theme.spacing(), + }, +})); + +export const OMC_BucketLanding = () => { + const { data: profile } = useProfile(); + + const isRestrictedUser = profile?.restricted; + + const { + data: regions, + error: regionErrors, + isLoading: areRegionsLoading, + } = useRegionsQuery(); + + const regionsLookup = regions && getRegionsByRegionId(regions); + + const regionsSupportObjectStorage = regions?.filter((region) => + region.capabilities.includes('Object Storage') + ); + + const { + data: objectStorageBucketsResponse, + error: bucketsErrors, + isLoading: areBucketsLoading, + } = useObjectStorageBucketsFromRegions(regionsSupportObjectStorage); + + const { mutateAsync: deleteBucket } = useDeleteBucketWithRegionMutation(); + + const { classes } = useStyles(); + + const removeBucketConfirmationDialog = useOpenClose(); + const [bucketToRemove, setBucketToRemove] = React.useState< + ObjectStorageBucket | undefined + >(undefined); + const [isLoading, setIsLoading] = React.useState(false); + const [error, setError] = React.useState(undefined); + const [ + bucketDetailDrawerOpen, + setBucketDetailDrawerOpen, + ] = React.useState(false); + const [bucketForDetails, setBucketForDetails] = React.useState< + ObjectStorageBucket | undefined + >(undefined); + + const handleClickDetails = (bucket: ObjectStorageBucket) => { + setBucketDetailDrawerOpen(true); + setBucketForDetails(bucket); + }; + + const closeBucketDetailDrawer = () => { + setBucketDetailDrawerOpen(false); + }; + + const handleClickRemove = (bucket: ObjectStorageBucket) => { + setBucketToRemove(bucket); + setError(undefined); + removeBucketConfirmationDialog.open(); + }; + + const removeBucket = async () => { + // This shouldn't happen, but just in case (and to get TS to quit complaining...) + if (!bucketToRemove) { + return; + } + + setError(undefined); + setIsLoading(true); + + const { label, region } = bucketToRemove; + if (region) { + try { + await deleteBucket({ label, region }); + removeBucketConfirmationDialog.close(); + setIsLoading(false); + + // @analytics + sendDeleteBucketEvent(region); + } catch (e) { + // @analytics + sendDeleteBucketFailedEvent(region); + + setIsLoading(false); + setError(e); + } + } + }; + + const closeRemoveBucketConfirmationDialog = React.useCallback(() => { + removeBucketConfirmationDialog.close(); + }, [removeBucketConfirmationDialog]); + + // @TODO OBJ Multicluster - region is defined as an optional field in BucketError. Once the feature is rolled out to production, we could clean this up and remove the filter. + const unavailableRegions = objectStorageBucketsResponse?.errors + ?.map((error: BucketError) => error.region) + .filter((region): region is Region => region !== undefined); + + if (isRestrictedUser) { + return ; + } + + if (regionErrors || bucketsErrors) { + return ( + + ); + } + + if ( + areRegionsLoading || + areBucketsLoading || + objectStorageBucketsResponse === undefined + ) { + return ; + } + + if (objectStorageBucketsResponse?.buckets.length === 0) { + return ( + <> + {unavailableRegions && unavailableRegions.length > 0 && ( + + )} + + + ); + } + + const totalUsage = sumBucketUsage(objectStorageBucketsResponse.buckets); + const bucketLabel = bucketToRemove ? bucketToRemove.label : ''; + + return ( + + + {unavailableRegions && unavailableRegions.length > 0 && ( + + )} + + + {({ data: orderedData, handleOrderChange, order, orderBy }) => { + const bucketTableProps = { + data: orderedData, + handleClickDetails, + handleClickRemove, + handleOrderChange, + order, + orderBy, + }; + return ; + }} + + {/* If there's more than one Bucket, display the total usage. */} + {objectStorageBucketsResponse.buckets.length > 1 ? ( + + Total storage used: {readableBytes(totalUsage).formatted} + + ) : null} + 1 ? 8 : 18} + /> + + + + + Warning: Deleting a bucket is permanent and + can’t be undone. + + + + A bucket must be empty before deleting it. Please{' '} + + delete all objects + + , or use{' '} + + another tool + {' '} + to force deletion. + + {/* If the user is attempting to delete their last Bucket, remind them + that they will still be billed unless they cancel Object Storage in + Account Settings. */} + {objectStorageBucketsResponse?.buckets.length === 1 && ( + + )} + + + + ); +}; + +const RenderEmpty = () => { + return ; +}; + +interface UnavailableRegionsDisplayProps { + unavailableRegions: Region[]; +} + +const UnavailableRegionsDisplay = React.memo( + ({ unavailableRegions }: UnavailableRegionsDisplayProps) => { + const regionsAffected = unavailableRegions.map( + (unavailableRegion) => unavailableRegion.label + ); + + return ; + } +); + +interface BannerProps { + regionsAffected: string[]; +} + +const Banner = React.memo(({ regionsAffected }: BannerProps) => { + const moreThanOneRegionAffected = regionsAffected.length > 1; + + return ( + + + There was an error loading buckets in{' '} + {moreThanOneRegionAffected + ? 'the following regions:' + : `${regionsAffected[0]}.`} +
    + {moreThanOneRegionAffected && + regionsAffected.map((thisRegion) => ( +
  • {thisRegion}
  • + ))} +
+ If you have buckets in{' '} + {moreThanOneRegionAffected ? 'these regions' : regionsAffected[0]}, you + may not see them listed below. +
+
+ ); +}); + +export const sumBucketUsage = (buckets: ObjectStorageBucket[]) => { + return buckets.reduce((acc, thisBucket) => { + acc += thisBucket.size; + return acc; + }, 0); +}; diff --git a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx index 4b48d85ef4a..31ab0718237 100644 --- a/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx +++ b/packages/manager/src/features/ObjectStorage/ObjectStorageLanding.tsx @@ -26,6 +26,7 @@ import { isFeatureEnabled } from 'src/utilities/accountCapabilities'; import { MODE } from './AccessKeyLanding/types'; import { CreateBucketDrawer } from './BucketLanding/CreateBucketDrawer'; +import { OMC_BucketLanding } from './BucketLanding/OMC_BucketLanding'; import { OMC_CreateBucketDrawer } from './BucketLanding/OMC_CreateBucketDrawer'; const BucketLanding = React.lazy(() => @@ -158,7 +159,11 @@ export const ObjectStorageLanding = () => { }> - + {isObjMultiClusterEnabled ? ( + + ) : ( + + )} { + await sleep(2000); + return res( + ctx.json({ + acl: 'private', + acl_xml: + '2a2ce653-20dd-43f1-b803-e8a924ee63742a2ce653-20dd-43f1-b803-e8a924ee63742a2ce653-20dd-43f1-b803-e8a924ee63742a2ce653-20dd-43f1-b803-e8a924ee6374FULL_CONTROL', + cors_enabled: true, + cors_xml: + 'GETPUTDELETEHEADPOST**', + }) + ); + }), + rest.put('*object-storage/buckets/*/*/access', async (req, res, ctx) => { + await sleep(2000); + return res(ctx.json({})); + }), rest.get('*object-storage/buckets/*/*/ssl', async (req, res, ctx) => { await sleep(2000); return res(ctx.json({ ssl: false })); @@ -927,6 +944,10 @@ export const handlers = [ await sleep(2000); return res(ctx.json({})); }), + rest.delete('*object-storage/buckets/*/*', async (req, res, ctx) => { + await sleep(2000); + return res(ctx.json({})); + }), rest.get('*/object-storage/buckets/*/*/object-list', (req, res, ctx) => { const pageSize = Number(req.url.searchParams.get('page_size') || 100); const marker = req.url.searchParams.get('marker'); @@ -980,6 +1001,8 @@ export const handlers = [ const buckets = objectStorageBucketFactory.buildList(1, { cluster: `${region}-1`, + hostname: `obj-bucket-1.${region}.linodeobjects.com`, + label: `obj-bucket-1`, region, }); diff --git a/packages/manager/src/queries/objectStorage.ts b/packages/manager/src/queries/objectStorage.ts index 88ab970d800..675410ed0de 100644 --- a/packages/manager/src/queries/objectStorage.ts +++ b/packages/manager/src/queries/objectStorage.ts @@ -11,6 +11,7 @@ import { Region, createBucket, deleteBucket, + deleteBucketWithRegion, deleteSSLCert, getBucket, getBuckets, @@ -96,7 +97,7 @@ export const useObjectStorageBucketsFromRegions = ( enabled: boolean = true ) => useQuery( - [`${queryKey}-buckets-from-regions`], + [`${queryKey}-buckets`], () => getAllBucketsFromRegions(regions), { ...queryPresets.longLived, @@ -160,6 +161,39 @@ export const useDeleteBucketMutation = () => { ); }; +/* + @TODO OBJ Multicluster: useDeleteBucketWithRegionMutation is a temporary hook, + once feature is rolled out we replace it with existing useDeleteBucketMutation + by updating it with region instead of cluster. + */ + +export const useDeleteBucketWithRegionMutation = () => { + const queryClient = useQueryClient(); + return useMutation<{}, APIError[], { label: string; region: string }>( + (data) => deleteBucketWithRegion(data), + { + onSuccess: (_, variables) => { + queryClient.setQueryData( + [`${queryKey}-buckets`], + (oldData) => { + return { + buckets: + oldData?.buckets.filter( + (bucket: ObjectStorageBucket) => + !( + bucket.region === variables.region && + bucket.label === variables.label + ) + ) || [], + errors: oldData?.errors || [], + }; + } + ); + }, + } + ); +}; + export const useObjectBucketDetailsInfiniteQuery = ( cluster: string, bucket: string, @@ -229,11 +263,11 @@ export const getAllBucketsFromRegions = async ( const data = await Promise.all(promises); - const bucketsPerCluster = data.filter((item) => + const bucketsPerRegion = data.filter((item) => Array.isArray(item) ) as ObjectStorageBucket[][]; - const buckets = bucketsPerCluster.reduce((acc, val) => acc.concat(val), []); + const buckets = bucketsPerRegion.reduce((acc, val) => acc.concat(val), []); const errors = data.filter((item) => !Array.isArray(item)) as BucketError[]; From 0914e6d6b36e15339e3b19041cd77d55dc650c9d Mon Sep 17 00:00:00 2001 From: Banks Nussman <115251059+bnussman-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 16:00:09 -0400 Subject: [PATCH 002/286] change: [M3-7860] - Source ACLB region info from API data and use Jakarta instead of Sydney (#10274) * source region info from the API * clean up * Added changeset: Source ACLB region info from API data and use Jakarta instead of Sydney --------- Co-authored-by: Banks Nussman --- .../pr-10274-changed-1710189301907.md | 5 +++ .../LoadBalancerRegions.tsx | 5 ++- .../LoadBalancerRegions.tsx | 37 ++++++++-------- .../LoadBalancerSummary.tsx | 14 +----- .../LoadBalancerDetail/Settings/Region.tsx | 4 +- .../LoadBalancerLanding/LoadBalancerRow.tsx | 23 ++++------ .../LoadBalancerLanding/RegionItem.test.tsx | 31 +++++++++++++ .../LoadBalancerLanding/RegionItem.tsx | 44 +++++++++++++++++++ .../LoadBalancerLanding/RegionsCell.tsx | 21 --------- .../src/features/LoadBalancers/constants.ts | 8 ++++ 10 files changed, 122 insertions(+), 70 deletions(-) create mode 100644 packages/manager/.changeset/pr-10274-changed-1710189301907.md create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.test.tsx create mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.tsx delete mode 100644 packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionsCell.tsx diff --git a/packages/manager/.changeset/pr-10274-changed-1710189301907.md b/packages/manager/.changeset/pr-10274-changed-1710189301907.md new file mode 100644 index 00000000000..b91410db9cf --- /dev/null +++ b/packages/manager/.changeset/pr-10274-changed-1710189301907.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Source ACLB region info from API data and use Jakarta instead of Sydney ([#10274](https://github.com/linode/manager/pull/10274)) diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx index 9cbbe71c658..a6177feef9c 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerCreate/LoadBalancerRegions.tsx @@ -7,7 +7,8 @@ import { BetaChip } from 'src/components/BetaChip/BetaChip'; import { Paper } from 'src/components/Paper'; import { Typography } from 'src/components/Typography'; -import { LoadBalancerRegions as Regions } from '../LoadBalancerDetail/LoadBalancerRegions'; +import { ACLB_BETA_REGION_IDS } from '../constants'; +import { LoadBalancerRegionsList } from '../LoadBalancerDetail/LoadBalancerRegions'; interface Props { sx?: SxProps; @@ -28,7 +29,7 @@ export const LoadBalancerRegions = ({ sx }: Props) => { No charges will be incurred.
- + ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx index df3a6fe58e6..7ac9d80c147 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerRegions.tsx @@ -1,27 +1,28 @@ import React from 'react'; -import { Flag } from 'src/components/Flag'; -import { Stack } from 'src/components/Stack'; -import { Typography } from 'src/components/Typography'; +import { Stack, StackProps } from 'src/components/Stack'; -import type { Country } from '@linode/api-v4'; +import { RegionItem } from '../LoadBalancerLanding/RegionItem'; -export const regions = [ - { country: 'us', id: 'us-mia', label: 'Miami, FL' }, - { country: 'us', id: 'us-lax', label: 'Los Angeles, CA' }, - { country: 'fr', id: 'fr-par', label: 'Paris, FR' }, - { country: 'jp', id: 'jp-osa', label: 'Osaka, JP' }, - { country: 'au', id: 'ap-southeast', label: 'Sydney, AU' }, -]; +interface Props extends StackProps { + /** + * Disables the country flag that shows before the region label + * @default false + */ + hideFlags?: boolean; + /** + * The region ids + */ + regionIds: string[]; +} + +export const LoadBalancerRegionsList = (props: Props) => { + const { hideFlags, regionIds, ...rest } = props; -export const LoadBalancerRegions = () => { return ( - - {regions.map((region) => ( - - - {`${region.label} (${region.id})`} - + + {regionIds?.map((regionId) => ( + ))} ); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx index 15229e82e43..66502d81c39 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/LoadBalancerSummary.tsx @@ -7,11 +7,10 @@ import { Stack } from 'src/components/Stack'; import { Typography } from 'src/components/Typography'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { useLoadBalancerQuery } from 'src/queries/aclb/loadbalancers'; -// import { useRegionsQuery } from 'src/queries/regions'; import { Ports } from '../LoadBalancerLanding/Ports'; import { LoadBalancerEndpointHealth } from './LoadBalancerEndpointHealth'; -import { LoadBalancerRegions } from './LoadBalancerRegions'; +import { LoadBalancerRegionsList } from './LoadBalancerRegions'; export const LoadBalancerSummary = () => { const { loadbalancerId } = useParams<{ loadbalancerId: string }>(); @@ -19,7 +18,6 @@ export const LoadBalancerSummary = () => { const id = Number(loadbalancerId); const { data: loadbalancer } = useLoadBalancerQuery(id); - // const { data: regions } = useRegionsQuery(); const items = [ { @@ -44,15 +42,7 @@ export const LoadBalancerSummary = () => { }, { title: 'Regions', - value: , - // Uncomment the line below to show the regions returned by the API. - // value: ( - // - // {loadbalancer?.regions - // .map((region) => regions?.find((r) => r.id === region)?.label) - // .join(', ')} - // - // ), + value: , }, ]; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx index dfb79fa01dd..e978bcfc05f 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerDetail/Settings/Region.tsx @@ -10,7 +10,7 @@ import { useLoadBalancerQuery, } from 'src/queries/aclb/loadbalancers'; -import { LoadBalancerRegions } from '../LoadBalancerRegions'; +import { LoadBalancerRegionsList } from '../LoadBalancerRegions'; interface Props { loadbalancerId: number; @@ -42,7 +42,7 @@ export const Region = ({ loadbalancerId }: Props) => { />{' '} Load Balancer regions can not be changed during beta. - + diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx index 659279dfea4..18e98ca1739 100644 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/LoadBalancerRow.tsx @@ -3,14 +3,12 @@ import * as React from 'react'; import { Link } from 'react-router-dom'; import { Hidden } from 'src/components/Hidden'; -import { Stack } from 'src/components/Stack'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; -import { Typography } from 'src/components/Typography'; import { IPAddress } from 'src/features/Linodes/LinodesLanding/IPAddress'; import { LoadBalancerEndpointHealth } from '../LoadBalancerDetail/LoadBalancerEndpointHealth'; -import { regions as alphaRegions } from '../LoadBalancerDetail/LoadBalancerRegions'; +import { LoadBalancerRegionsList } from '../LoadBalancerDetail/LoadBalancerRegions'; import { LoadBalancerActionsMenu } from './LoadBalancerActionsMenu'; import { Ports } from './Ports'; @@ -24,7 +22,7 @@ interface Props { } export const LoadBalancerRow = ({ handlers, loadBalancer }: Props) => { - const { hostname, id, label } = loadBalancer; + const { hostname, id, label, regions } = loadBalancer; return ( { - - {alphaRegions.map(({ id, label }) => ( - - {label} ({id}) - - ))} - - {/* Uncomment the code below to show the regions returned by the API */} - {/* {regions.map((region) => ( - - ))} */} + diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.test.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.test.tsx new file mode 100644 index 00000000000..d47a261ac7b --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.test.tsx @@ -0,0 +1,31 @@ +import React from 'react'; + +import { regionFactory } from 'src/factories'; +import { makeResourcePage } from 'src/mocks/serverHandlers'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { RegionItem } from './RegionItem'; + +describe('RegionItem', () => { + it('renders the regions label from API data', async () => { + const region = regionFactory.build(); + + server.use( + rest.get('*/v4/regions', (req, res, ctx) => { + return res(ctx.json(makeResourcePage([region]))); + }) + ); + + const { findByText } = renderWithTheme(); + + await findByText(`${region.label} (${region.id})`); + }); + it('renders the region id when there is no API region data', async () => { + const region = regionFactory.build(); + + const { getByText } = renderWithTheme(); + + expect(getByText(region.id)).toBeVisible(); + }); +}); diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.tsx new file mode 100644 index 00000000000..b40a7478d04 --- /dev/null +++ b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionItem.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; + +import { Flag } from 'src/components/Flag'; +import { Stack } from 'src/components/Stack'; +import { Typography } from 'src/components/Typography'; +import { useRegionsQuery } from 'src/queries/regions'; + +import type { Country } from '@linode/api-v4'; + +interface Props { + /** + * Shows a country flag for the region + * @default false + */ + hideFlag?: boolean; + /** + * The region id + */ + regionId: string; +} + +export const RegionItem = ({ hideFlag, regionId }: Props) => { + const { data: regions } = useRegionsQuery(); + + const region = regions?.find((r) => r.id === regionId); + + if (!region) { + return ( + + {!hideFlag && } + {regionId} + + ); + } + + return ( + + {!hideFlag && } + + {region.label} ({region.id}) + + + ); +}; diff --git a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionsCell.tsx b/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionsCell.tsx deleted file mode 100644 index b1e8ee86be0..00000000000 --- a/packages/manager/src/features/LoadBalancers/LoadBalancerLanding/RegionsCell.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from 'react'; - -import { useRegionsQuery } from 'src/queries/regions'; -import { Typography } from 'src/components/Typography'; - -interface Props { - region: string; -} - -export const RegionsCell = (props: Props) => { - const { region } = props; - const { data: regions } = useRegionsQuery(); - - const actualRegion = regions?.find((r) => r.id === region); - - return ( - - {actualRegion?.label ? `${actualRegion.label} (${region})` : region} - - ); -}; diff --git a/packages/manager/src/features/LoadBalancers/constants.ts b/packages/manager/src/features/LoadBalancers/constants.ts index f9dc045f297..e47c7357ada 100644 --- a/packages/manager/src/features/LoadBalancers/constants.ts +++ b/packages/manager/src/features/LoadBalancers/constants.ts @@ -14,3 +14,11 @@ export const ACLB_DOCS = { ServiceTargetCertificates: `${ACLB_DOCS_ROOT}/guides/certificates/#service-target-certificates`, TLSCertificates: `${ACLB_DOCS_ROOT}/guides/certificates`, }; + +export const ACLB_BETA_REGION_IDS = [ + 'us-mia', + 'us-lax', + 'fr-par', + 'jp-osa', + 'id-cgk', +]; From 3cd01328a44f557a47bc0ab834dabcc28c01903f Mon Sep 17 00:00:00 2001 From: Mariah Jacobs <114685994+mjac0bs@users.noreply.github.com> Date: Wed, 13 Mar 2024 14:06:54 -0600 Subject: [PATCH 003/286] fix: [M3-7826-v2] - Fix email displaying in top menu for all users without a company name (#10276) * Fix logic with incorrect fallback value; update tests * Clean up test * Address feedback: use util, add util unit tests * Address the other edge-case for child with restricted account access --- .../TopMenu/UserMenu/UserMenu.test.tsx | 13 ++-- .../features/TopMenu/UserMenu/UserMenu.tsx | 15 ++-- .../features/TopMenu/UserMenu/utils.test.ts | 77 +++++++++++++++++++ .../src/features/TopMenu/UserMenu/utils.ts | 35 +++++++++ 4 files changed, 126 insertions(+), 14 deletions(-) create mode 100644 packages/manager/src/features/TopMenu/UserMenu/utils.test.ts create mode 100644 packages/manager/src/features/TopMenu/UserMenu/utils.ts diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx index 9254b677082..fc1ef0d6421 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.test.tsx @@ -17,7 +17,7 @@ describe('UserMenu', () => { expect(getByRole('button')).toBeInTheDocument(); }); - it("shows a parent user's username and company name for a parent user", async () => { + it("shows a parent user's username and company name in the TopMenu for a parent user", async () => { server.use( rest.get('*/account', (req, res, ctx) => { return res( @@ -44,7 +44,7 @@ describe('UserMenu', () => { expect(await findByText('Parent Company')).toBeInTheDocument(); }); - it("shows the parent user's username and child company name for a proxy user", async () => { + it("shows the parent user's username and child company name in the TopMenu for a proxy user", async () => { server.use( rest.get('*/account', (req, res, ctx) => { return res( @@ -71,7 +71,7 @@ describe('UserMenu', () => { expect(await findByText('Child Company')).toBeInTheDocument(); }); - it("shows the child user's username and company name for a child user", async () => { + it("shows the child user's username and company name in the TopMenu for a child user", async () => { server.use( rest.get('*/account', (req, res, ctx) => { return res( @@ -95,7 +95,7 @@ describe('UserMenu', () => { expect(await findByText('Child Company')).toBeInTheDocument(); }); - it("shows the user's username and no company name for a regular user", async () => { + it("shows the user's username and no company name in the TopMenu for a regular user", async () => { server.use( rest.get('*/account', (req, res, ctx) => { return res(ctx.json(accountFactory.build({ company: 'Test Company' }))); @@ -117,6 +117,7 @@ describe('UserMenu', () => { }); expect(await findByText('regular-user')).toBeInTheDocument(); + // Should not be displayed for regular users, only parent/child/proxy users. expect(queryByText('Test Company')).not.toBeInTheDocument(); }); @@ -145,7 +146,7 @@ describe('UserMenu', () => { expect(within(userMenuPopover).getByText('Switch Account')).toBeVisible(); }); - it('hides Switch Account button for parent accounts lacking child_account_access', async () => { + it('hides Switch Account button in the dropdown menu for parent accounts lacking child_account_access', async () => { server.use( rest.get('*/account/users/*/grants', (req, res, ctx) => { return res( @@ -198,7 +199,7 @@ describe('UserMenu', () => { expect(within(userMenuPopover).getByText('Switch Account')).toBeVisible(); }); - it('shows the parent email for a parent user if their company name is not set', async () => { + it('shows the parent email for a parent user in the top menu and dropdown menu if their company name is unavailable', async () => { // Mock a forbidden request to the /account endpoint, which happens if Billing (Account) Access is None. server.use( rest.get('*/account/users/*/grants', (req, res, ctx) => { diff --git a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx index ad6f5101565..8c01df5b72d 100644 --- a/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx +++ b/packages/manager/src/features/TopMenu/UserMenu/UserMenu.tsx @@ -27,6 +27,8 @@ import { useGrants, useProfile } from 'src/queries/profile'; import { sendSwitchAccountEvent } from 'src/utilities/analytics'; import { getStorage, setStorage } from 'src/utilities/storage'; +import { getCompanyNameOrEmail } from './utils'; + interface MenuLink { display: string; hide?: boolean; @@ -81,14 +83,11 @@ export const UserMenu = React.memo(() => { const open = Boolean(anchorEl); const id = open ? 'user-menu-popover' : undefined; - // If there is no company name to identify an account, fall back on the email. - // Covers an edge case in which a restricted parent user without `account_access` cannot access the account company. - const companyNameOrEmail = - hasParentChildAccountAccess && - profile?.user_type !== 'default' && - account?.company - ? account.company - : profile?.email; + const companyNameOrEmail = getCompanyNameOrEmail({ + company: account?.company, + isParentChildFeatureEnabled: hasParentChildAccountAccess, + profile, + }); const { isParentTokenExpired } = useParentTokenManagement({ isProxyUser }); diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts new file mode 100644 index 00000000000..a1d8be27b1b --- /dev/null +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.test.ts @@ -0,0 +1,77 @@ +import { UserType } from '@linode/api-v4'; + +import { profileFactory } from 'src/factories'; + +import { getCompanyNameOrEmail } from './utils'; + +const MOCK_COMPANY_NAME = 'Test Company, LLC'; + +describe('getCompanyNameOrEmail', () => { + it('returns the company name for a parent/child/proxy user who has one', async () => { + const newUserTypes = ['parent', 'child', 'proxy']; + + newUserTypes.forEach((newUserType: UserType) => { + const actual = getCompanyNameOrEmail({ + company: MOCK_COMPANY_NAME, + isParentChildFeatureEnabled: true, + profile: profileFactory.build({ user_type: newUserType }), + }); + const expected = MOCK_COMPANY_NAME; + expect(actual).toEqual(expected); + }); + }); + + it('returns email of a parent user who does not have (access to) a company name', async () => { + const parentEmail = 'parent@email.com'; + + const actual = getCompanyNameOrEmail({ + company: undefined, + isParentChildFeatureEnabled: true, + profile: profileFactory.build({ + email: parentEmail, + user_type: 'parent', + }), + }); + const expected = parentEmail; + expect(actual).toEqual(expected); + }); + + it("returns undefined for a child user who does not have (access to) a company name, since we don't need to display it", async () => { + const childEmail = 'child@email.com'; + + const actual = getCompanyNameOrEmail({ + company: undefined, + isParentChildFeatureEnabled: true, + profile: profileFactory.build({ + email: childEmail, + user_type: 'child', + }), + }); + const expected = undefined; + expect(actual).toEqual(expected); + }); + + it('returns undefined for the company/email of a regular (default) user', async () => { + const actual = getCompanyNameOrEmail({ + company: MOCK_COMPANY_NAME, + isParentChildFeatureEnabled: true, + profile: profileFactory.build({ user_type: 'default' }), + }); + const expected = undefined; + expect(actual).toEqual(expected); + }); + + it('returns undefined for the company/email of all users when the parent/child feature is not enabled', async () => { + const allUserTypes = ['parent', 'child', 'proxy', 'default']; + + allUserTypes.forEach((userType: UserType) => { + const actual = getCompanyNameOrEmail({ + company: MOCK_COMPANY_NAME, + isParentChildFeatureEnabled: false, + profile: profileFactory.build({ user_type: userType }), + }); + const expected = undefined; + expect(actual).toEqual(expected); + }); + }); +}); diff --git a/packages/manager/src/features/TopMenu/UserMenu/utils.ts b/packages/manager/src/features/TopMenu/UserMenu/utils.ts new file mode 100644 index 00000000000..54b7ca28b75 --- /dev/null +++ b/packages/manager/src/features/TopMenu/UserMenu/utils.ts @@ -0,0 +1,35 @@ +import { Profile } from '@linode/api-v4'; + +export interface CompanyNameOrEmailOptions { + company: string | undefined; + isParentChildFeatureEnabled: boolean; + profile: Profile | undefined; +} + +/** + * This util will determine a string displayed in the top and user menus that indicates the parent/child account that the user is viewing. + * + * @returns company name for parent/child/proxy users when available, email in the case of the restricted parent, or undefined for regular users + */ +export const getCompanyNameOrEmail = ({ + company, + isParentChildFeatureEnabled, + profile, +}: CompanyNameOrEmailOptions) => { + const isParentChildOrProxyUser = profile?.user_type !== 'default'; + const isParentUser = profile?.user_type === 'parent'; + + // Return early if we do not need the company name or email. + if (!isParentChildFeatureEnabled || !profile || !isParentChildOrProxyUser) { + return undefined; + } + + // For parent users lacking `account_access`: without a company name to identify an account, fall back on the email. + // We do not need to do this for child users lacking `account_access` because we do not need to display the email. + if (isParentUser && !company) { + return profile.email; + } + + // In all other parent/child/proxy cases, company will be available, as it is a required field. + return company; +}; From b59e7942c5e506e39a7da6d413df95ec63f2f3c2 Mon Sep 17 00:00:00 2001 From: Jaalah Ramos <125309814+jaalah-akamai@users.noreply.github.com> Date: Wed, 13 Mar 2024 19:13:29 -0400 Subject: [PATCH 004/286] fix: [M3-7867] -Ensure IP / Mask for firewall rules drawer correctly populates (#10279) Co-authored-by: Jaalah Ramos --- packages/manager/.changeset/pr-10279-fixed-1710360079232.md | 5 +++++ .../src/components/MultipleIPInput/MultipleIPInput.tsx | 2 +- .../Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx | 1 - 3 files changed, 6 insertions(+), 2 deletions(-) create mode 100644 packages/manager/.changeset/pr-10279-fixed-1710360079232.md diff --git a/packages/manager/.changeset/pr-10279-fixed-1710360079232.md b/packages/manager/.changeset/pr-10279-fixed-1710360079232.md new file mode 100644 index 00000000000..f3d5532d533 --- /dev/null +++ b/packages/manager/.changeset/pr-10279-fixed-1710360079232.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Fixed +--- + +Ensure IP / Mask for firewall rules drawer correctly populates ([#10279](https://github.com/linode/manager/pull/10279)) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 63642a7184e..6d241a3b2df 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -105,7 +105,7 @@ export const MultipleIPInput = React.memo((props: Props) => { e: React.FocusEvent, idx: number ) => { - if (!onBlur) { + if (!onBlur || e.target.value === '') { return; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 321c6719d84..4278f323a9e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -293,7 +293,6 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { {values.addresses === 'ip/netmask' && ( Date: Wed, 13 Mar 2024 19:14:09 -0400 Subject: [PATCH 005/286] upcoming: [M3-7866] - Improve Proxy Account Visibility with Distinct Visual Indicators (#10277) Co-authored-by: Jaalah Ramos --- ...r-10277-upcoming-features-1710340806902.md | 5 ++ .../manager/src/assets/icons/parent-child.svg | 1 + .../src/components/GravatarForProxy.tsx | 47 ++++++++++++ .../DisplaySettings/DisplaySettings.tsx | 75 +++++++++++-------- .../features/TopMenu/UserMenu/UserMenu.tsx | 9 ++- 5 files changed, 103 insertions(+), 34 deletions(-) create mode 100644 packages/manager/.changeset/pr-10277-upcoming-features-1710340806902.md create mode 100644 packages/manager/src/assets/icons/parent-child.svg create mode 100644 packages/manager/src/components/GravatarForProxy.tsx diff --git a/packages/manager/.changeset/pr-10277-upcoming-features-1710340806902.md b/packages/manager/.changeset/pr-10277-upcoming-features-1710340806902.md new file mode 100644 index 00000000000..cfd85b7cacd --- /dev/null +++ b/packages/manager/.changeset/pr-10277-upcoming-features-1710340806902.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Improve Proxy Account Visibility with Distinct Visual Indicators ([#10277](https://github.com/linode/manager/pull/10277)) diff --git a/packages/manager/src/assets/icons/parent-child.svg b/packages/manager/src/assets/icons/parent-child.svg new file mode 100644 index 00000000000..4edbab05ed4 --- /dev/null +++ b/packages/manager/src/assets/icons/parent-child.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/manager/src/components/GravatarForProxy.tsx b/packages/manager/src/components/GravatarForProxy.tsx new file mode 100644 index 00000000000..5bbef1d15b1 --- /dev/null +++ b/packages/manager/src/components/GravatarForProxy.tsx @@ -0,0 +1,47 @@ +import { styled } from '@mui/material/styles'; +import * as React from 'react'; + +import ProxyUserIcon from 'src/assets/icons/parent-child.svg'; +import { Box } from 'src/components/Box'; + +interface Props { + height?: number; + width?: number; +} + +export const GravatarForProxy = ({ height = 34, width = 34 }: Props) => { + return ( + ({ + background: `linear-gradient(60deg, ${theme.color.blue}, ${theme.color.teal} )`, + backgroundSize: '300% 300%', + borderRadius: '50%', + height, + padding: '3px', + width, + })} + > + ({ + background: theme.color.white, + borderRadius: '50%', + color: theme.palette.text.primary, + height: `calc(${height}px - 6px)`, + overflow: 'hidden', + position: 'relative', + width: `calc(${width}px - 6px)`, + })} + > + + + + ); +}; + +const StyledProxyUserIcon = styled(ProxyUserIcon, { + label: 'styledProxyUserIcon', +})(() => ({ + bottom: 0, + left: 0, + position: 'absolute', +})); diff --git a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx index d86f9f66760..bff3afe0926 100644 --- a/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx +++ b/packages/manager/src/features/Profile/DisplaySettings/DisplaySettings.tsx @@ -13,12 +13,12 @@ import { Paper } from 'src/components/Paper'; import { SingleTextFieldForm } from 'src/components/SingleTextFieldForm/SingleTextFieldForm'; import { TooltipIcon } from 'src/components/TooltipIcon'; import { Typography } from 'src/components/Typography'; +import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; import { useNotificationsQuery } from 'src/queries/accountNotifications'; import { useMutateProfile, useProfile } from 'src/queries/profile'; import { ApplicationState } from 'src/store'; import { TimezoneForm } from './TimezoneForm'; -import { RESTRICTED_FIELD_TOOLTIP } from 'src/features/Account/constants'; export const DisplaySettings = () => { const theme = useTheme(); @@ -68,39 +68,48 @@ export const DisplaySettings = () => { return ( - - -
- - Profile photo - + + - - - Create, upload, and manage your globally recognized avatar from a - single place with Gravatar. - - - Manage photo - -
-
- +
+ + Profile photo + + + + Create, upload, and manage your globally recognized avatar from + a single place with Gravatar. + + + Manage photo + +
+ + + + )} +