From 31e49d53df0696a7e671e119f6893edea11f1af0 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Fri, 11 Apr 2025 20:13:19 -0400 Subject: [PATCH 01/10] Initial commit - adapt UI to OBJ only --- .../src/features/Account/Quotas/Quotas.tsx | 158 ++++++------------ .../features/Account/Quotas/QuotasTable.tsx | 8 +- .../src/features/Account/Quotas/utils.ts | 7 +- 3 files changed, 53 insertions(+), 120 deletions(-) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index 6db0c64c2eb..b5902920e12 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -1,130 +1,76 @@ -import { quotaTypes } from '@linode/api-v4'; -import { useIsGeckoEnabled } from '@linode/shared'; -import { Divider, Paper, Select, Stack, Typography } from '@linode/ui'; +import { Divider, Notice, Paper, Select, Stack, Typography } from '@linode/ui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; -import { DocsLink } from 'src/components/DocsLink/DocsLink'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; -import { useFlags } from 'src/hooks/useFlags'; +import { Link } from 'src/components/Link'; import { QuotasTable } from './QuotasTable'; import { useGetLocationsForQuotaService } from './utils'; -import type { Quota, QuotaType } from '@linode/api-v4'; +import type { Quota } from '@linode/api-v4'; import type { SelectOption } from '@linode/ui'; import type { Theme } from '@mui/material'; export const Quotas = () => { - const flags = useFlags(); - const { isGeckoLAEnabled } = useIsGeckoEnabled( - flags.gecko2?.enabled, - flags.gecko2?.la - ); const history = useHistory(); - const [selectedService, setSelectedService] = React.useState< - SelectOption - >({ - label: 'Linodes', - value: 'linode', - }); - const [selectedLocation, setSelectedLocation] = React.useState | null>(null); - const locationData = useGetLocationsForQuotaService(selectedService.value); - - const serviceOptions = Object.entries(quotaTypes).map(([key, value]) => ({ - label: value, - value: key as QuotaType, - })); + const [selectedLocation, setSelectedLocation] = + React.useState>(null); + const locationData = useGetLocationsForQuotaService('object-storage'); - const { regions, s3Endpoints } = locationData; + const { s3Endpoints } = locationData; const isFetchingLocations = 'isFetchingS3Endpoints' in locationData ? locationData.isFetchingS3Endpoints : locationData.isFetchingRegions; - // Handlers - const onSelectServiceChange = ( - _event: React.SyntheticEvent, - value: SelectOption - ) => { - setSelectedService(value); - setSelectedLocation(null); - // remove search params - history.push('/account/quotas'); - }; - return ( <> ({ - marginTop: theme.spacing(2), + marginTop: theme.spacingFunction(16), })} variant="outlined" > + Object Storage + + + View your Object Storage quotas by applying the endpoint filter + below.{' '} + + Learn more about quotas + + . + + { - setSelectedLocation({ - label: value?.label, - value: value?.value, - }); - history.push('/account/quotas'); - }} - options={ - s3Endpoints?.map((location) => ({ - label: location.label, - value: location.value, - })) ?? [] - } - placeholder={ - isFetchingLocations - ? `Loading ${selectedService.label} S3 endpoints...` - : 'Select an Object Storage S3 endpoint' - } - disabled={isFetchingLocations} - label="Object Storage Endpoint" - loading={isFetchingLocations} - searchable - sx={{ flexGrow: 1, mr: 2 }} - /> - ) : ( - { - setSelectedLocation({ - label: region.label, - value: region.id, - }); - history.push('/account/quotas'); - }} - placeholder={ - isFetchingLocations - ? `Loading ${selectedService.label} regions...` - : `Select a region for ${selectedService.label}` - } - currentCapability={undefined} - disableClearable - disabled={isFetchingLocations} - isGeckoLAEnabled={isGeckoLAEnabled} - loading={isFetchingLocations} - noOptionsText={`No resource found for ${selectedService.label}`} - regions={regions ?? []} - sx={{ flexGrow: 1, mr: 2 }} - value={selectedLocation?.value} - /> - )} { marginBottom={2} > Quotas - ({ - position: 'relative', - top: `-${theme.spacing(2)}`, - })} - alignItems="center" - direction="row" - spacing={3} - > - {/* TODO LIMITS_M1: update once link is available */} - - This table shows quotas and usage. If you need to increase a quota, - select Request an Increase from the Actions menu. + select Request Increase from the Actions menu. Usage can also be + found using the S3 APIs. diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index 7008d4cdd9e..5f8f6a925bd 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -26,7 +26,7 @@ import type { AttachmentError } from 'src/features/Support/SupportTicketDetail/S const quotaRowMinHeight = 58; interface QuotasTableProps { - selectedLocation: SelectOption | null; + selectedLocation: null | SelectOption; selectedService: SelectOption; } @@ -96,7 +96,7 @@ export const QuotasTable = (props: QuotasTableProps) => { <> ({ - marginTop: theme.spacing(2), + marginTop: theme.spacingFunction(16), minWidth: theme.breakpoints.values.sm, })} > @@ -159,13 +159,13 @@ export const QuotasTable = (props: QuotasTableProps) => { )} setSupportModalOpen(false)} + open={supportModalOpen} sx={{ '& .MuiDialog-paper': { width: '600px', }, }} - onClose={() => setSupportModalOpen(false)} - open={supportModalOpen} title="Increase Quota" > {selectedQuota && ( diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index 533880c9a43..6ed67b36ee3 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -1,11 +1,7 @@ import { useRegionsQuery } from '@linode/queries'; import { object, string } from 'yup'; -import { - GLOBAL_QUOTA_LABEL, - GLOBAL_QUOTA_VALUE, - regionSelectGlobalOption, -} from 'src/components/RegionSelect/constants'; +import { regionSelectGlobalOption } from 'src/components/RegionSelect/constants'; import { useObjectStorageEndpoints } from 'src/queries/object-storage/queries'; import type { QuotaIncreaseFormFields } from './QuotasIncreaseForm'; @@ -53,7 +49,6 @@ export const useGetLocationsForQuotaService = ( isFetchingS3Endpoints, regions: null, s3Endpoints: [ - ...[{ label: GLOBAL_QUOTA_LABEL, value: GLOBAL_QUOTA_VALUE }], ...(s3Endpoints ?? []) .map((s3Endpoint) => { if (!s3Endpoint.s3_endpoint) { From d3e21b474dc9fb45834a0c5cc745bed63987be08 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Mon, 21 Apr 2025 17:11:28 -0400 Subject: [PATCH 02/10] first batch of UI updates --- packages/api-v4/src/quotas/types.ts | 43 +++++++--------- packages/manager/src/factories/quotas.ts | 2 +- .../src/features/Account/Quotas/Quotas.tsx | 6 ++- .../Account/Quotas/QuotasTable.test.tsx | 6 +-- .../features/Account/Quotas/QuotasTable.tsx | 4 +- .../Account/Quotas/QuotasTableRow.tsx | 50 ++++++++++++++++--- .../src/mocks/presets/crud/handlers/quotas.ts | 20 ++++---- 7 files changed, 82 insertions(+), 49 deletions(-) diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index 4819a243afa..a4d7a84e99c 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -1,25 +1,27 @@ -import { ObjectStorageEndpointTypes } from 'src/object-storage'; -import { Region } from 'src/regions'; - +import type { StorageSymbol } from '../../../utilities/src/helpers/unitConversions'; +import type { ObjectStorageEndpointTypes } from 'src/object-storage'; +import type { Region } from 'src/regions'; /** * A Quota is a service used limit that is rated based on service metrics such * as vCPUs used, instances or storage size. */ export interface Quota { /** - * A unique identifier for the quota. + * Longer explanatory description for the quota. */ - quota_id: number; + description: string; /** - * Customer facing label describing the quota. + * The OBJ endpoint type to which this limit applies. + * + * For OBJ limits only. */ - quota_name: string; + endpoint_type?: ObjectStorageEndpointTypes; /** - * Longer explanatory description for the quota. + * A unique identifier for the quota. */ - description: string; + quota_id: number; /** * The account-wide limit for this service, measured in units @@ -28,18 +30,9 @@ export interface Quota { quota_limit: number; /** - * The unit of measurement for this service limit. + * Customer facing label describing the quota. */ - resource_metric: - | 'instance' - | 'CPU' - | 'GPU' - | 'VPU' - | 'cluster' - | 'node' - | 'bucket' - | 'object' - | 'byte'; + quota_name: string; /** * The region slug to which this limit applies. @@ -47,14 +40,12 @@ export interface Quota { * OBJ limits are applied by endpoint, not region. * This below really just is a `string` type but being verbose helps with reading comprehension. */ - region_applied?: Region['id'] | 'global'; + region_applied?: 'global' | Region['id']; /** - * The OBJ endpoint type to which this limit applies. - * - * For OBJ limits only. + * The unit of measurement for this service limit. */ - endpoint_type?: ObjectStorageEndpointTypes; + resource_metric: StorageSymbol; /** * The S3 endpoint URL to which this limit applies. @@ -81,7 +72,7 @@ export interface QuotaUsage { * * This can be null if the user does not have resources for the given Quota Name. */ - used: number | null; + usage: null | number; } export const quotaTypes = { diff --git a/packages/manager/src/factories/quotas.ts b/packages/manager/src/factories/quotas.ts index 10f1767f74e..3b47cbf82b8 100644 --- a/packages/manager/src/factories/quotas.ts +++ b/packages/manager/src/factories/quotas.ts @@ -13,5 +13,5 @@ export const quotaFactory = Factory.Sync.makeFactory({ export const quotaUsageFactory = Factory.Sync.makeFactory({ quota_limit: 50, - used: 25, + usage: 25, }); diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index b5902920e12..dbe33c36644 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -83,7 +83,11 @@ export const Quotas = () => { This table shows quotas and usage. If you need to increase a quota, select Request Increase from the Actions menu. Usage can also be - found using the S3 APIs. + found using the{' '} + + S3 APIs + + . { it('should render', () => { const { getByRole, getByTestId, getByText } = renderWithTheme( ); expect( @@ -72,7 +72,7 @@ describe('QuotasTable', () => { ]; const quotaUsage = quotaUsageFactory.build({ quota_limit: 100, - used: 10, + usage: 10, }); queryMocks.useQueries.mockReturnValue([ { @@ -115,7 +115,7 @@ describe('QuotasTable', () => { expect(getByLabelText(quota.description)).toBeInTheDocument(); expect(getByTestId('linear-progress')).toBeInTheDocument(); expect( - getByText(`${quotaUsage.used} of ${quotaUsage.quota_limit} CPUs used`) + getByText(`${quotaUsage.usage} of ${quotaUsage.quota_limit} CPUs used`) ).toBeInTheDocument(); expect( getByLabelText(`Action menu for quota ${quota.quota_name}`) diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index 5f8f6a925bd..e1401f9348c 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -112,7 +112,7 @@ export const QuotasTable = (props: QuotasTableProps) => { {hasSelectedLocation && isFetchingQuotas ? ( ) : !selectedLocation ? ( @@ -129,7 +129,7 @@ export const QuotasTable = (props: QuotasTableProps) => { /> ) : ( quotasWithUsage.map((quota, index) => { - const hasQuotaUsage = quota.usage?.used !== null; + const hasQuotaUsage = quota.usage?.usage !== null; return ( { title: 'Request an Increase', }; + const convertResourceMetric = ({ + initialResourceMetric, + initialUsage, + initialLimit, + }: { + initialLimit: number; + initialResourceMetric: StorageSymbol; + initialUsage: number; + }) => { + if (initialResourceMetric === 'byte') { + // First determine the appropriate unit based on the larger number (limit) + const limitReadable = readableBytes(initialLimit); + // Then use that same unit for both values + return { + usage: readableBytes(initialUsage, { unit: limitReadable.unit }).value, + resourceMetric: limitReadable.unit, + limit: limitReadable.value, + }; + } + + return { + usage: initialUsage, + limit: initialLimit, + resourceMetric: initialResourceMetric, + }; + }; + + const { usage, limit, resourceMetric } = convertResourceMetric({ + initialResourceMetric: quota.resource_metric, + initialUsage: quota.usage?.usage ?? 0, + initialLimit: quota.quota_limit, + }); + return ( @@ -72,17 +107,20 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { {quota.quota_name} - {quota.quota_limit} + + {limit} {resourceMetric} + {quota.quota_limit > 1 ? 's' : ''} + {quotaUsageQueries[index]?.isLoading ? ( @@ -122,11 +160,11 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { max={quota.quota_limit} rounded sx={{ mb: 1, mt: 2, padding: '3px' }} - value={quota.usage?.used ?? 0} + value={quota.usage?.usage ?? 0} /> - {`${quota.usage?.used} of ${quota.quota_limit} ${ - quota.resource_metric + {`${usage} of ${limit} ${ + resourceMetric }${quota.quota_limit > 1 ? 's' : ''} used`} diff --git a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts index 52cc20c5fd6..95366a9c727 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/quotas.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/quotas.ts @@ -201,35 +201,35 @@ export const getQuotas = () => [ return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 45, + usage: 45, }) ); case 'GPU': return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 3, + usage: 3, }) ); case 'Shared CPU': return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 24, + usage: 24, }) ); case 'VPU': return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 7, + usage: 7, }) ); default: return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: null, + usage: null, }) ); } @@ -237,7 +237,7 @@ export const getQuotas = () => [ return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: pickRandom([2, 27, 5, 38, 49]), + usage: pickRandom([2, 27, 5, 38, 49]), }) ); case 'object-storage': @@ -246,28 +246,28 @@ export const getQuotas = () => [ return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 75, + usage: 75, }) ); case 'Number of Objects': return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 10_000_000, + usage: 10_000_000, }) ); case 'Total Capacity': return makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: 100_000_000_000_000, + usage: 100_000_000_000_000, }) ); default: makeResponse( quotaUsageFactory.build({ quota_limit: quota.quota_limit, - used: null, + usage: null, }) ); } From 5cb5d759fe1ec67c9d57d186f647ed6019050091 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 13:15:02 -0400 Subject: [PATCH 03/10] Conversions --- packages/api-v4/src/quotas/types.ts | 3 +- .../src/features/Account/Quotas/Quotas.tsx | 8 +- .../Quotas/QuotasIncreaseForm.test.tsx | 43 +++++----- .../Account/Quotas/QuotasIncreaseForm.tsx | 53 +++++++----- .../features/Account/Quotas/QuotasTable.tsx | 8 +- .../Account/Quotas/QuotasTableRow.tsx | 84 ++++++++++++------- 6 files changed, 119 insertions(+), 80 deletions(-) diff --git a/packages/api-v4/src/quotas/types.ts b/packages/api-v4/src/quotas/types.ts index a4d7a84e99c..93719ac53c3 100644 --- a/packages/api-v4/src/quotas/types.ts +++ b/packages/api-v4/src/quotas/types.ts @@ -1,4 +1,3 @@ -import type { StorageSymbol } from '../../../utilities/src/helpers/unitConversions'; import type { ObjectStorageEndpointTypes } from 'src/object-storage'; import type { Region } from 'src/regions'; /** @@ -45,7 +44,7 @@ export interface Quota { /** * The unit of measurement for this service limit. */ - resource_metric: StorageSymbol; + resource_metric: string; /** * The S3 endpoint URL to which this limit applies. diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index dbe33c36644..d46474c5869 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -36,7 +36,7 @@ export const Quotas = () => { Object Storage - + View your Object Storage quotas by applying the endpoint filter below.{' '} @@ -83,9 +83,9 @@ export const Quotas = () => { This table shows quotas and usage. If you need to increase a quota, select Request Increase from the Actions menu. Usage can also be - found using the{' '} - - S3 APIs + found using third-party tools like{' '} + + s3cmd . diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx index 20a8e92988d..586ba43b3a7 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx @@ -8,22 +8,22 @@ import { QuotasIncreaseForm } from './QuotasIncreaseForm'; describe('QuotasIncreaseForm', () => { it('should render with default values', async () => { - const { - getByLabelText, - getByRole, - getByTestId, - getByText, - } = renderWithTheme( - {}} - onSuccess={() => {}} - open={true} - /> - ); + const { getByLabelText, getByRole, getByTestId, getByText } = + renderWithTheme( + {}} + onSuccess={() => {}} + open={true} + quota={{ + ...quotaFactory.build(), + ...quotaUsageFactory.build(), + }} + /> + ); expect(getByLabelText('Title (required)')).toHaveValue('Increase Quota'); expect(getByLabelText('Quantity (required)')).toHaveValue(0); @@ -41,13 +41,17 @@ describe('QuotasIncreaseForm', () => { it('description should be updated as quantity is changed', async () => { const { getByLabelText, getByTestId } = renderWithTheme( {}} onSuccess={() => {}} open={true} + quota={{ + ...quotaFactory.build(), + ...quotaUsageFactory.build(), + }} /> ); @@ -65,7 +69,6 @@ describe('QuotasIncreaseForm', () => { }); await waitFor(() => { - // eslint-disable-next-line xss/no-mixed-html expect(previewContent).toHaveTextContent( 'Increase QuotaUser: mock-user Email: mock-user@linode.com Quota Name: Linode Dedicated vCPUs New Quantity Requested: 2 CPUs Region: us-east test!' ); diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx index c7c8f901d5e..c10a006a5e3 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx @@ -19,6 +19,12 @@ import { getQuotaIncreaseFormSchema, getQuotaIncreaseMessage } from './utils'; import type { APIError, Quota, TicketRequest } from '@linode/api-v4'; interface QuotasIncreaseFormProps { + convertedResourceMetrics: + | undefined + | { + limit: number; + metric: string; + }; onClose: () => void; onSuccess: (ticketId: number) => void; open: boolean; @@ -31,7 +37,7 @@ export interface QuotaIncreaseFormFields extends TicketRequest { } export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { - const { onClose, quota } = props; + const { onClose, quota, convertedResourceMetrics } = props; const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const formContainerRef = React.useRef(null); @@ -86,34 +92,43 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { {error && {error}} ( { - field.onChange(e); - }} errorText={fieldState.error?.message} label="Title" name="summary" + onChange={(e) => { + field.onChange(e); + }} placeholder="Enter a title for your ticket." required value={field.value} /> )} - control={form.control} - name="summary" /> ( { field.onChange(e); form.trigger('quantity'); }} + required slotProps={{ input: { endAdornment: ( ({ color: theme.tokens.alias.Content.Text, font: theme.font.bold, @@ -123,31 +138,29 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { userSelect: 'none', whiteSpace: 'nowrap', })} - component="span" > - {quota.resource_metric} + {convertedResourceMetrics?.metric ?? + quota.resource_metric} ), }, }} - errorText={fieldState.error?.message} - helperText={`In ${quota.region_applied} (initial limit of ${quota?.quota_limit})`} - label="Quantity" - min={1} - name="quantity" - required sx={{ width: 300 }} type="number" value={field.value} /> )} - control={form.control} - name="quantity" /> ( { field.onChange(e); }} @@ -158,15 +171,9 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { }, }, }} - errorText={fieldState.error?.message} - label="Notes" - multiline - name="notes" value={field.value} /> )} - control={form.control} - name="notes" /> { summaryProps={{ sx: { paddingX: 0.25 } }} > ({ backgroundColor: theme.tokens.alias.Background.Neutral, p: 2, })} - data-testid="quota-increase-form-preview-content" > ({ diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index e1401f9348c..ff05060704a 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -37,7 +37,11 @@ export const QuotasTable = (props: QuotasTableProps) => { const hasSelectedLocation = Boolean(selectedLocation); const [supportModalOpen, setSupportModalOpen] = React.useState(false); const [selectedQuota, setSelectedQuota] = React.useState(); - + const [convertedResourceMetrics, setConvertedResourceMetrics] = + React.useState<{ + limit: number; + metric: string; + }>(); const filters: Filter = getQuotasFilters({ location: selectedLocation, service: selectedService, @@ -138,6 +142,7 @@ export const QuotasTable = (props: QuotasTableProps) => { key={quota.quota_id} quota={quota} quotaUsageQueries={quotaUsageQueries} + setConvertedResourceMetrics={setConvertedResourceMetrics} setSelectedQuota={setSelectedQuota} setSupportModalOpen={setSupportModalOpen} /> @@ -170,6 +175,7 @@ export const QuotasTable = (props: QuotasTableProps) => { > {selectedQuota && ( setSupportModalOpen(false)} onSuccess={onIncreaseQuotaTicketCreated} open={supportModalOpen} diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx index 8125250370d..3029aae103b 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx @@ -14,7 +14,6 @@ import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; import { getQuotaError } from './utils'; import type { Quota, QuotaUsage } from '@linode/api-v4'; -import type { StorageSymbol } from '@linode/utilities'; import type { UseQueryResult } from '@tanstack/react-query'; import type { Action } from 'src/components/ActionMenu/ActionMenu'; @@ -27,6 +26,10 @@ interface QuotasTableRowProps { index: number; quota: QuotaWithUsage; quotaUsageQueries: UseQueryResult[]; + setConvertedResourceMetrics: (resourceMetric: { + limit: number; + metric: string; + }) => void; setSelectedQuota: (quota: Quota) => void; setSupportModalOpen: (open: boolean) => void; } @@ -41,6 +44,7 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { quotaUsageQueries, setSelectedQuota, setSupportModalOpen, + setConvertedResourceMetrics, } = props; const theme = useTheme(); const flags = useFlags(); @@ -53,47 +57,68 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { (flags.limitsEvolution?.requestForIncreaseDisabledForInternalAccountsOnly && isAkamaiAccount); - const requestIncreaseAction: Action = { - disabled: isRequestForQuotaButtonDisabled, - onClick: () => { - setSelectedQuota(quota); - setSupportModalOpen(true); - }, - title: 'Request an Increase', - }; - const convertResourceMetric = ({ initialResourceMetric, initialUsage, initialLimit, }: { initialLimit: number; - initialResourceMetric: StorageSymbol; + initialResourceMetric: string; initialUsage: number; - }) => { + }): { + convertedLimit: number; + convertedResourceMetric: string; + convertedUsage: number; + } => { if (initialResourceMetric === 'byte') { - // First determine the appropriate unit based on the larger number (limit) const limitReadable = readableBytes(initialLimit); - // Then use that same unit for both values + return { - usage: readableBytes(initialUsage, { unit: limitReadable.unit }).value, - resourceMetric: limitReadable.unit, - limit: limitReadable.value, + convertedUsage: readableBytes(initialUsage, { + unit: limitReadable.unit, + }).value, + convertedResourceMetric: limitReadable.unit, + convertedLimit: limitReadable.value, }; } return { - usage: initialUsage, - limit: initialLimit, - resourceMetric: initialResourceMetric, + convertedUsage: initialUsage, + convertedLimit: initialLimit, + convertedResourceMetric: initialResourceMetric, }; }; - const { usage, limit, resourceMetric } = convertResourceMetric({ - initialResourceMetric: quota.resource_metric, - initialUsage: quota.usage?.usage ?? 0, - initialLimit: quota.quota_limit, - }); + const pluralizeMetric = (value: number, unit: string) => { + if (unit !== 'byte') { + return value > 1 ? `${unit}s` : unit; + } + + return unit; + }; + + const { convertedUsage, convertedLimit, convertedResourceMetric } = + convertResourceMetric({ + initialResourceMetric: pluralizeMetric( + quota.quota_limit, + quota.resource_metric + ), + initialUsage: quota.usage?.usage ?? 0, + initialLimit: quota.quota_limit, + }); + + const requestIncreaseAction: Action = { + disabled: isRequestForQuotaButtonDisabled, + onClick: () => { + setSelectedQuota(quota); + setSupportModalOpen(true); + setConvertedResourceMetrics({ + limit: convertedLimit, + metric: convertedResourceMetric, + }); + }, + title: 'Request an Increase', + }; return ( @@ -118,8 +143,7 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { - {limit} {resourceMetric} - {quota.quota_limit > 1 ? 's' : ''} + {convertedLimit} {convertedResourceMetric} @@ -163,9 +187,9 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { value={quota.usage?.usage ?? 0} /> - {`${usage} of ${limit} ${ - resourceMetric - }${quota.quota_limit > 1 ? 's' : ''} used`} + {`${convertedUsage} of ${convertedLimit} ${ + convertedResourceMetric + } used`} ) : ( From 10ed747eddec762ee39d048816db690130a39616 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 15:22:18 -0400 Subject: [PATCH 04/10] Handling unit tests --- .../features/Account/Quotas/Quotas.test.tsx | 115 ++++++------------ .../src/features/Account/Quotas/Quotas.tsx | 1 - .../Quotas/QuotasIncreaseForm.test.tsx | 4 +- .../Account/Quotas/QuotasTable.test.tsx | 4 +- .../features/Account/Quotas/utils.test.tsx | 5 +- packages/manager/src/mocks/serverHandlers.ts | 15 +++ 6 files changed, 60 insertions(+), 84 deletions(-) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx index d90cef86c43..a144ac78239 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx @@ -1,4 +1,3 @@ -import { regionFactory } from '@linode/utilities'; import { QueryClient } from '@tanstack/react-query'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; @@ -12,12 +11,7 @@ const queryMocks = vi.hoisted(() => ({ getQuotasFilters: vi.fn().mockReturnValue({}), useFlags: vi.fn().mockReturnValue({}), useGetLocationsForQuotaService: vi.fn().mockReturnValue({}), - useGetRegionsQuery: vi.fn().mockReturnValue({}), -})); - -vi.mock('@linode/queries', async (importOriginal) => ({ - ...(await importOriginal()), - useRegionsQuery: queryMocks.useGetRegionsQuery, + useObjectStorageEndpoints: vi.fn().mockReturnValue({}), })); vi.mock('src/hooks/useFlags', () => { @@ -28,28 +22,20 @@ vi.mock('src/hooks/useFlags', () => { }; }); +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useObjectStorageEndpoints: queryMocks.useObjectStorageEndpoints, + }; +}); + vi.mock('./utils', () => ({ getQuotasFilters: queryMocks.getQuotasFilters, useGetLocationsForQuotaService: queryMocks.useGetLocationsForQuotaService, })); describe('Quotas', () => { - beforeEach(() => { - queryMocks.useGetLocationsForQuotaService.mockReturnValue({ - isFetchingRegions: false, - regions: [ - regionFactory.build({ id: 'global', label: 'Global (Account level)' }), - ], - }); - queryMocks.useGetRegionsQuery.mockReturnValue({ - data: [ - regionFactory.build({ id: 'global', label: 'Global (Account level)' }), - regionFactory.build({ id: 'us-east', label: 'Newark, NJ' }), - ], - isFetching: false, - }); - }); - const queryClient = new QueryClient({ defaultOptions: { queries: { @@ -64,89 +50,64 @@ describe('Quotas', () => { }); expect(getByText('Quotas')).toBeInTheDocument(); - expect(getByText('Learn More About Quotas')).toBeInTheDocument(); - expect(getByText('Select a Service')).toBeInTheDocument(); + expect(getByText('Learn more about quotas')).toBeInTheDocument(); + expect(getByText('Object Storage Endpoint')).toBeInTheDocument(); + expect( + screen.getByPlaceholderText('Select an Object Storage S3 endpoint') + ).toBeInTheDocument(); expect( - screen.getByPlaceholderText('Select a region for Linodes') + getByText('Apply filters above to see quotas and current usage.') ).toBeInTheDocument(); }); - it('allows service selection', async () => { + it('allows endpoint selection', async () => { + queryMocks.useGetLocationsForQuotaService.mockReturnValue({ + isFetchingS3Endpoints: false, + regions: null, + s3Endpoints: [{ label: 'endpoint1 (Standard E0)', value: 'endpoint1' }], + service: 'object-storage', + }); + const { getByPlaceholderText, getByRole } = renderWithTheme(, { queryClient, }); - const serviceSelect = getByPlaceholderText('Select a service'); - - await waitFor(() => { - expect(serviceSelect).toHaveValue('Linodes'); - expect( - getByPlaceholderText('Select a region for Linodes') - ).toBeInTheDocument(); - }); + const endpointSelect = getByPlaceholderText( + 'Select an Object Storage S3 endpoint' + ); - userEvent.click(serviceSelect); await waitFor(() => { - const kubernetesOption = getByRole('option', { name: 'Kubernetes' }); - userEvent.click(kubernetesOption); + expect(endpointSelect).not.toHaveValue(null); }); await waitFor(() => { - expect(serviceSelect).toHaveValue('Kubernetes'); - expect( - getByPlaceholderText('Select a region for Kubernetes') - ).toBeInTheDocument(); + expect(endpointSelect).toBeInTheDocument(); }); - userEvent.click(serviceSelect); - await waitFor(() => { - const objectStorageOption = getByRole('option', { - name: 'Object Storage', + await userEvent.click(endpointSelect); + await waitFor(async () => { + const endpointOption = getByRole('option', { + name: 'endpoint1 (Standard E0)', }); - userEvent.click(objectStorageOption); + await userEvent.click(endpointOption); }); await waitFor(() => { - expect(serviceSelect).toHaveValue('Object Storage'); - expect( - getByPlaceholderText('Select an Object Storage S3 endpoint') - ).toBeInTheDocument(); + expect(endpointSelect).toHaveValue('endpoint1 (Standard E0)'); }); }); it('shows loading state when fetching data', () => { queryMocks.useGetLocationsForQuotaService.mockReturnValue({ - isFetchingRegions: true, - regions: [], + isFetchingS3Endpoints: true, + s3Endpoints: null, + service: 'object-storage', }); const { getByPlaceholderText } = renderWithTheme(, { queryClient, }); - expect( - getByPlaceholderText('Loading Linodes regions...') - ).toBeInTheDocument(); - }); - - it('shows a global option for regions', async () => { - const { getByPlaceholderText, getByRole } = renderWithTheme(, { - queryClient, - }); - - const regionSelect = getByPlaceholderText('Select a region for Linodes'); - expect(regionSelect).toHaveValue(''); - - userEvent.click(regionSelect); - await waitFor(() => { - const globalOption = getByRole('option', { - name: 'Global (Account level) (global)', - }); - userEvent.click(globalOption); - }); - - await waitFor(() => { - expect(regionSelect).toHaveValue('Global (Account level) (global)'); - }); + expect(getByPlaceholderText('Loading S3 endpoints...')).toBeInTheDocument(); }); }); diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index d46474c5869..94ac3edd09a 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -17,7 +17,6 @@ export const Quotas = () => { const [selectedLocation, setSelectedLocation] = React.useState>(null); const locationData = useGetLocationsForQuotaService('object-storage'); - const { s3Endpoints } = locationData; const isFetchingLocations = 'isFetchingS3Endpoints' in locationData diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx index 586ba43b3a7..5959814ec00 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx @@ -27,7 +27,9 @@ describe('QuotasIncreaseForm', () => { expect(getByLabelText('Title (required)')).toHaveValue('Increase Quota'); expect(getByLabelText('Quantity (required)')).toHaveValue(0); - expect(getByText('In us-east (initial limit of 50)')).toBeInTheDocument(); + expect( + getByText('In us-east (initial limit of 100 GB)') + ).toBeInTheDocument(); expect(getByLabelText('Notes')).toHaveValue(''); expect(getByText('Ticket Preview')).toBeInTheDocument(); expect( diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx index 09514ff903b..d92b9b40e8b 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.test.tsx @@ -111,7 +111,9 @@ describe('QuotasTable', () => { await waitFor(() => { expect(getByText(quota.quota_name)).toBeInTheDocument(); - expect(getByText(quota.quota_limit)).toBeInTheDocument(); + expect( + getByText(`${quota.quota_limit} ${quota.resource_metric}s`) + ).toBeInTheDocument(); expect(getByLabelText(quota.description)).toBeInTheDocument(); expect(getByTestId('linear-progress')).toBeInTheDocument(); expect( diff --git a/packages/manager/src/features/Account/Quotas/utils.test.tsx b/packages/manager/src/features/Account/Quotas/utils.test.tsx index d405e13cedb..8f45efaf34e 100644 --- a/packages/manager/src/features/Account/Quotas/utils.test.tsx +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -49,9 +49,7 @@ describe('useGetLocationsForQuotaService', () => { } ); - expect(result.current.s3Endpoints).toEqual([ - { label: 'Global (Account level)', value: 'global' }, - ]); + expect(result.current.s3Endpoints).toEqual([]); }); it('should filter out endpoints with null s3_endpoint values', () => { @@ -76,7 +74,6 @@ describe('useGetLocationsForQuotaService', () => { ); expect(result.current.s3Endpoints).toEqual([ - { label: 'Global (Account level)', value: 'global' }, { label: 'endpoint1 (Standard E0)', value: 'endpoint1' }, ]); }); diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 2bd5fb950fa..aedb7e02c91 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -112,6 +112,7 @@ import { accountAgreementsFactory } from 'src/factories/accountAgreements'; import { accountLoginFactory } from 'src/factories/accountLogin'; import { accountUserFactory } from 'src/factories/accountUsers'; import { LinodeKernelFactory } from 'src/factories/linodeKernel'; +import { quotaFactory } from 'src/factories/quotas'; import { getStorage } from 'src/utilities/storage'; const getRandomWholeNumber = (min: number, max: number) => @@ -1085,6 +1086,20 @@ export const handlers = [ ]; return HttpResponse.json(makeResourcePage(endpoints)); }), + http.get('*/v4*/object-storage/quotas*', () => { + const quotas = [ + quotaFactory.build({ + description: 'The total capacity of your Object Storage account', + endpoint_type: 'E0', + quota_limit: 1_000_000_000_000_000, + quota_name: 'Total Capacity', + resource_metric: 'byte', + s3_endpoint: 'endpoint1', + }), + ]; + + return HttpResponse.json(makeResourcePage(quotas)); + }), http.get('*object-storage/buckets/*/*/access', async () => { await sleep(2000); return HttpResponse.json({ From c42b4bf9b1cc16be6961d33a3f0612b3823803e1 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 15:56:32 -0400 Subject: [PATCH 05/10] Cleanup and extra testing --- .../features/Account/Quotas/QuotasTable.tsx | 2 +- .../Account/Quotas/QuotasTableRow.tsx | 43 +-------------- .../features/Account/Quotas/utils.test.tsx | 51 ++++++++++++++++++ .../src/features/Account/Quotas/utils.ts | 53 +++++++++++++++++++ 4 files changed, 106 insertions(+), 43 deletions(-) diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index ff05060704a..2d4f99e046f 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -168,7 +168,7 @@ export const QuotasTable = (props: QuotasTableProps) => { open={supportModalOpen} sx={{ '& .MuiDialog-paper': { - width: '600px', + maxWidth: 600, }, }} title="Increase Quota" diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx index 3029aae103b..059e3a34707 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx @@ -1,5 +1,4 @@ import { Box, CircleProgress, TooltipIcon, Typography } from '@linode/ui'; -import { readableBytes } from '@linode/utilities'; import ErrorOutline from '@mui/icons-material/ErrorOutline'; import { useTheme } from '@mui/material/styles'; import * as React from 'react'; @@ -11,7 +10,7 @@ import { TableRow } from 'src/components/TableRow/TableRow'; import { useFlags } from 'src/hooks/useFlags'; import { useIsAkamaiAccount } from 'src/hooks/useIsAkamaiAccount'; -import { getQuotaError } from './utils'; +import { convertResourceMetric, getQuotaError, pluralizeMetric } from './utils'; import type { Quota, QuotaUsage } from '@linode/api-v4'; import type { UseQueryResult } from '@tanstack/react-query'; @@ -57,46 +56,6 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { (flags.limitsEvolution?.requestForIncreaseDisabledForInternalAccountsOnly && isAkamaiAccount); - const convertResourceMetric = ({ - initialResourceMetric, - initialUsage, - initialLimit, - }: { - initialLimit: number; - initialResourceMetric: string; - initialUsage: number; - }): { - convertedLimit: number; - convertedResourceMetric: string; - convertedUsage: number; - } => { - if (initialResourceMetric === 'byte') { - const limitReadable = readableBytes(initialLimit); - - return { - convertedUsage: readableBytes(initialUsage, { - unit: limitReadable.unit, - }).value, - convertedResourceMetric: limitReadable.unit, - convertedLimit: limitReadable.value, - }; - } - - return { - convertedUsage: initialUsage, - convertedLimit: initialLimit, - convertedResourceMetric: initialResourceMetric, - }; - }; - - const pluralizeMetric = (value: number, unit: string) => { - if (unit !== 'byte') { - return value > 1 ? `${unit}s` : unit; - } - - return unit; - }; - const { convertedUsage, convertedLimit, convertedResourceMetric } = convertResourceMetric({ initialResourceMetric: pluralizeMetric( diff --git a/packages/manager/src/features/Account/Quotas/utils.test.tsx b/packages/manager/src/features/Account/Quotas/utils.test.tsx index 8f45efaf34e..989a48eec16 100644 --- a/packages/manager/src/features/Account/Quotas/utils.test.tsx +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -6,8 +6,10 @@ import * as React from 'react'; import { quotaFactory, quotaUsageFactory } from 'src/factories/quotas'; import { + convertResourceMetric, getQuotaError, getQuotaIncreaseMessage, + pluralizeMetric, useGetLocationsForQuotaService, } from './utils'; @@ -117,3 +119,52 @@ describe('useGetLocationsForQuotaService', () => { ); }); }); + +describe('convertResourceMetric', () => { + it('should convert the resource metric to a human readable format', () => { + const resourceMetric = 'byte'; + const usage = 1e6; + const limit = 1e8; + + const result = convertResourceMetric({ + initialResourceMetric: resourceMetric, + initialUsage: usage, + initialLimit: limit, + }); + + expect(result).toEqual({ + convertedLimit: 95.4, + convertedResourceMetric: 'MB', + convertedUsage: 0.95, + }); + }); +}); + +describe('pluralizeMetric', () => { + it('should not pluralize if the value is 1', () => { + const value = 1; + const unit = 'CPU'; + + const result = pluralizeMetric(value, unit); + + expect(result).toEqual('CPU'); + }); + + it('should not pluralize the resource metric if the unit is byte', () => { + const value = 100; + const unit = 'byte'; + + const result = pluralizeMetric(value, unit); + + expect(result).toEqual('byte'); + }); + + it('should pluralize the resource metric if the unit is not byte', () => { + const value = 100; + const unit = 'CPU'; + + const result = pluralizeMetric(value, unit); + + expect(result).toEqual('CPUs'); + }); +}); diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index 6ed67b36ee3..ec34f8f3ec7 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -1,4 +1,5 @@ import { useRegionsQuery } from '@linode/queries'; +import { readableBytes } from '@linode/utilities'; import { object, string } from 'yup'; import { regionSelectGlobalOption } from 'src/components/RegionSelect/constants'; @@ -147,6 +148,58 @@ export const getQuotaIncreaseMessage = ({ }; }; +interface ConvertResourceMetricProps { + initialLimit: number; + initialResourceMetric: string; + initialUsage: number; +} + +/** + * Function to convert the resource metric to a human readable format + */ +export const convertResourceMetric = ({ + initialResourceMetric, + initialUsage, + initialLimit, +}: ConvertResourceMetricProps): { + convertedLimit: number; + convertedResourceMetric: string; + convertedUsage: number; +} => { + if (initialResourceMetric === 'byte') { + const limitReadable = readableBytes(initialLimit); + + return { + convertedUsage: readableBytes(initialUsage, { + unit: limitReadable.unit, + }).value, + convertedResourceMetric: limitReadable.unit, + convertedLimit: limitReadable.value, + }; + } + + return { + convertedUsage: initialUsage, + convertedLimit: initialLimit, + convertedResourceMetric: initialResourceMetric, + }; +}; + +/** + * Function to pluralize the resource metric + * If the unit is 'byte', we need to return the unit without an 's' (ex: 'GB', 'MB', 'TB') + * Otherwise, we need to return the unit with an 's' (ex: 'Buckets', 'Objects') + * + * Note: the value should be the raw values in bytes, not an existing conversion + */ +export const pluralizeMetric = (value: number, unit: string) => { + if (unit !== 'byte') { + return value > 1 ? `${unit}s` : unit; + } + + return unit; +}; + export const getQuotaIncreaseFormSchema = object({ description: string().required('Description is required.'), notes: string() From 48b61e7f58599dfb3f14ae50c809e543945f6d9b Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 16:06:11 -0400 Subject: [PATCH 06/10] Fix link --- packages/manager/src/features/Account/Quotas/Quotas.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index 94ac3edd09a..5e957b49d96 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -83,7 +83,7 @@ export const Quotas = () => { This table shows quotas and usage. If you need to increase a quota, select Request Increase from the Actions menu. Usage can also be found using third-party tools like{' '} - + s3cmd . From 1e2f7c3f840f1dada8a33934ff9e5ece6aefe019 Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 16:36:55 -0400 Subject: [PATCH 07/10] Fix CI --- packages/manager/src/features/Account/Quotas/Quotas.test.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx index a144ac78239..5be3c5f3c22 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.test.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.test.tsx @@ -12,6 +12,8 @@ const queryMocks = vi.hoisted(() => ({ useFlags: vi.fn().mockReturnValue({}), useGetLocationsForQuotaService: vi.fn().mockReturnValue({}), useObjectStorageEndpoints: vi.fn().mockReturnValue({}), + convertResourceMetric: vi.fn().mockReturnValue({}), + pluralizeMetric: vi.fn().mockReturnValue({}), })); vi.mock('src/hooks/useFlags', () => { @@ -33,6 +35,8 @@ vi.mock('@linode/queries', async () => { vi.mock('./utils', () => ({ getQuotasFilters: queryMocks.getQuotasFilters, useGetLocationsForQuotaService: queryMocks.useGetLocationsForQuotaService, + convertResourceMetric: queryMocks.convertResourceMetric, + pluralizeMetric: queryMocks.pluralizeMetric, })); describe('Quotas', () => { From 19d795393d267b9e1db928c69c820b7553dd054c Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Thu, 24 Apr 2025 22:52:29 -0400 Subject: [PATCH 08/10] first series of feedback --- .../src/features/Account/Quotas/Quotas.tsx | 11 ++-- .../Quotas/QuotasIncreaseForm.test.tsx | 22 ++++--- .../Account/Quotas/QuotasIncreaseForm.tsx | 47 ++++++++++----- .../features/Account/Quotas/QuotasTable.tsx | 8 ++- .../Account/Quotas/QuotasTableRow.tsx | 4 +- .../features/Account/Quotas/utils.test.tsx | 14 ++++- .../src/features/Account/Quotas/utils.ts | 58 ++++++++++++------- 7 files changed, 112 insertions(+), 52 deletions(-) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index 5e957b49d96..d2627e437c3 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -23,6 +23,10 @@ export const Quotas = () => { ? locationData.isFetchingS3Endpoints : locationData.isFetchingRegions; + const sortedS3Endpoints = React.useMemo(() => { + return s3Endpoints?.sort((a, b) => a.label.localeCompare(b.label)); + }, [s3Endpoints]); + return ( <> @@ -57,7 +61,7 @@ export const Quotas = () => { history.push('/account/quotas'); }} options={ - s3Endpoints?.map((location) => ({ + sortedS3Endpoints?.map((location) => ({ label: location.label, value: location.value, })) ?? [] @@ -80,9 +84,8 @@ export const Quotas = () => { Quotas - This table shows quotas and usage. If you need to increase a quota, - select Request Increase from the Actions menu. Usage can also be - found using third-party tools like{' '} + If you need to increase a quota, select Request Increase from the + Actions menu. Usage can also be found using third-party tools like{' '} s3cmd diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx index 5959814ec00..c83e2bd9827 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.test.tsx @@ -22,14 +22,18 @@ describe('QuotasIncreaseForm', () => { ...quotaFactory.build(), ...quotaUsageFactory.build(), }} + selectedService={{ + label: 'Object Storage', + value: 'object-storage', + }} /> ); - expect(getByLabelText('Title (required)')).toHaveValue('Increase Quota'); - expect(getByLabelText('Quantity (required)')).toHaveValue(0); - expect( - getByText('In us-east (initial limit of 100 GB)') - ).toBeInTheDocument(); + expect(getByLabelText('Title (required)')).toHaveValue( + 'Increase Object Storage Quota' + ); + expect(getByLabelText('New Quota (required)')).toHaveValue(0); + expect(getByText('In us-east - current quota: 100 GB')).toBeInTheDocument(); expect(getByLabelText('Notes')).toHaveValue(''); expect(getByText('Ticket Preview')).toBeInTheDocument(); expect( @@ -54,10 +58,14 @@ describe('QuotasIncreaseForm', () => { ...quotaFactory.build(), ...quotaUsageFactory.build(), }} + selectedService={{ + label: 'Object Storage', + value: 'object-storage', + }} /> ); - const quantityInput = getByLabelText('Quantity (required)'); + const quantityInput = getByLabelText('New Quota (required)'); const notesInput = getByLabelText('Notes'); const preview = getByTestId('quota-increase-form-preview'); const previewContent = getByTestId('quota-increase-form-preview-content'); @@ -72,7 +80,7 @@ describe('QuotasIncreaseForm', () => { await waitFor(() => { expect(previewContent).toHaveTextContent( - 'Increase QuotaUser: mock-user Email: mock-user@linode.com Quota Name: Linode Dedicated vCPUs New Quantity Requested: 2 CPUs Region: us-east test!' + 'Increase Object Storage QuotaUser: mock-user Email: mock-user@linode.com Quota Name: Linode Dedicated vCPUs Current Quota: 100 GB New Quota Requested: 2 GBs Region: us-east test!' ); }); }); diff --git a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx index c10a006a5e3..7f5c3bbad1d 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasIncreaseForm.tsx @@ -16,19 +16,19 @@ import { Markdown } from 'src/components/Markdown/Markdown'; import { getQuotaIncreaseFormSchema, getQuotaIncreaseMessage } from './utils'; -import type { APIError, Quota, TicketRequest } from '@linode/api-v4'; +import type { APIError, Quota, QuotaType, TicketRequest } from '@linode/api-v4'; +import type { SelectOption } from '@linode/ui'; interface QuotasIncreaseFormProps { - convertedResourceMetrics: - | undefined - | { - limit: number; - metric: string; - }; + convertedResourceMetrics: { + limit: number; + metric: string; + }; onClose: () => void; onSuccess: (ticketId: number) => void; open: boolean; quota: Quota; + selectedService: SelectOption; } export interface QuotaIncreaseFormFields extends TicketRequest { @@ -37,7 +37,7 @@ export interface QuotaIncreaseFormFields extends TicketRequest { } export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { - const { onClose, quota, convertedResourceMetrics } = props; + const { onClose, quota, convertedResourceMetrics, selectedService } = props; const [submitting, setSubmitting] = React.useState(false); const [error, setError] = React.useState(null); const formContainerRef = React.useRef(null); @@ -45,21 +45,38 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { const { mutateAsync: createSupportTicket } = useCreateSupportTicketMutation(); const defaultValues = React.useMemo( - () => getQuotaIncreaseMessage({ profile, quantity: 0, quota }), - [quota, profile] + () => + getQuotaIncreaseMessage({ + convertedMetrics: { + limit: convertedResourceMetrics.limit, + metric: convertedResourceMetrics.metric, + }, + profile, + quantity: convertedResourceMetrics?.limit ?? 0, + quota, + selectedService, + }), + [quota, profile, selectedService, convertedResourceMetrics] ); const form = useForm({ defaultValues, mode: 'onBlur', - resolver: yupResolver(getQuotaIncreaseFormSchema), + resolver: yupResolver( + getQuotaIncreaseFormSchema(convertedResourceMetrics?.limit ?? 0) + ), }); const { notes, quantity, summary } = form.watch(); const quotaIncreaseDescription = getQuotaIncreaseMessage({ + convertedMetrics: { + limit: convertedResourceMetrics.limit, + metric: convertedResourceMetrics.metric, + }, profile, quantity: Number(quantity), quota, + selectedService, }).description; const handleSubmit = form.handleSubmit(async (values) => { @@ -112,12 +129,12 @@ export const QuotasIncreaseForm = (props: QuotasIncreaseFormProps) => { control={form.control} name="quantity" render={({ field, fieldState }) => ( - + { field.onChange(e); diff --git a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx index 2d4f99e046f..cf4f73569a9 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTable.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTable.tsx @@ -41,7 +41,10 @@ export const QuotasTable = (props: QuotasTableProps) => { React.useState<{ limit: number; metric: string; - }>(); + }>({ + limit: 0, + metric: '', + }); const filters: Filter = getQuotasFilters({ location: selectedLocation, service: selectedService, @@ -171,7 +174,7 @@ export const QuotasTable = (props: QuotasTableProps) => { maxWidth: 600, }, }} - title="Increase Quota" + title={`Increase ${selectedService.label} Quota`} > {selectedQuota && ( { onSuccess={onIncreaseQuotaTicketCreated} open={supportModalOpen} quota={selectedQuota} + selectedService={selectedService} /> )} diff --git a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx index 059e3a34707..f122e61fefb 100644 --- a/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx +++ b/packages/manager/src/features/Account/Quotas/QuotasTableRow.tsx @@ -72,7 +72,7 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { setSelectedQuota(quota); setSupportModalOpen(true); setConvertedResourceMetrics({ - limit: convertedLimit, + limit: Number(convertedLimit), metric: convertedResourceMetric, }); }, @@ -145,7 +145,7 @@ export const QuotasTableRow = (props: QuotasTableRowProps) => { sx={{ mb: 1, mt: 2, padding: '3px' }} value={quota.usage?.usage ?? 0} /> - + {`${convertedUsage} of ${convertedLimit} ${ convertedResourceMetric } used`} diff --git a/packages/manager/src/features/Account/Quotas/utils.test.tsx b/packages/manager/src/features/Account/Quotas/utils.test.tsx index 989a48eec16..4d34a107600 100644 --- a/packages/manager/src/features/Account/Quotas/utils.test.tsx +++ b/packages/manager/src/features/Account/Quotas/utils.test.tsx @@ -106,6 +106,14 @@ describe('useGetLocationsForQuotaService', () => { profile, quantity, quota, + selectedService: { + label: 'Object Storage', + value: 'object-storage', + }, + convertedMetrics: { + limit: quota.quota_limit, + metric: quota.resource_metric, + }, }); expect(defaultValues.description).toEqual( @@ -113,7 +121,9 @@ describe('useGetLocationsForQuotaService', () => { profile.email }
\n**Quota Name**: ${ quota.quota_name - }
\n**New Quantity Requested**: ${quantity} ${quota.resource_metric}${ + }
\n**Current Quota**: ${quota.quota_limit} ${ + quota.resource_metric + }
\n**New Quota Requested**: ${quantity} ${quota.resource_metric}${ quantity > 1 ? 's' : '' }
\n**Region**: ${quota.region_applied}` ); @@ -133,7 +143,7 @@ describe('convertResourceMetric', () => { }); expect(result).toEqual({ - convertedLimit: 95.4, + convertedLimit: '95.4', convertedResourceMetric: 'MB', convertedUsage: 0.95, }); diff --git a/packages/manager/src/features/Account/Quotas/utils.ts b/packages/manager/src/features/Account/Quotas/utils.ts index ec34f8f3ec7..866d9427da9 100644 --- a/packages/manager/src/features/Account/Quotas/utils.ts +++ b/packages/manager/src/features/Account/Quotas/utils.ts @@ -1,5 +1,5 @@ import { useRegionsQuery } from '@linode/queries'; -import { readableBytes } from '@linode/utilities'; +import { capitalize, readableBytes } from '@linode/utilities'; import { object, string } from 'yup'; import { regionSelectGlobalOption } from 'src/components/RegionSelect/constants'; @@ -109,18 +109,25 @@ export const getQuotaError = ( }; interface GetQuotaIncreaseFormDefaultValuesProps { + convertedMetrics: { + limit: number; + metric: string; + }; profile: Profile | undefined; quantity: number; quota: Quota; + selectedService: SelectOption; } /** * Function to get the default values for the quota increase form */ export const getQuotaIncreaseMessage = ({ + convertedMetrics, profile, quantity, quota, + selectedService, }: GetQuotaIncreaseFormDefaultValuesProps): QuotaIncreaseFormFields => { const regionAppliedLabel = quota.s3_endpoint ? 'Endpoint' : 'Region'; const regionAppliedValue = quota.s3_endpoint ?? quota.region_applied; @@ -130,7 +137,7 @@ export const getQuotaIncreaseMessage = ({ description: '', notes: '', quantity: '0', - summary: 'Increase Quota', + summary: `Increase ${selectedService.label} Quota`, }; } @@ -139,12 +146,14 @@ export const getQuotaIncreaseMessage = ({ profile.email }
\n**Quota Name**: ${ quota.quota_name - }
\n**New Quantity Requested**: ${quantity} ${quota.resource_metric}${ + }
\n**Current Quota**: ${convertedMetrics.limit} ${ + convertedMetrics.metric + }
\n**New Quota Requested**: ${quantity} ${convertedMetrics.metric}${ quantity > 1 ? 's' : '' }
\n**${regionAppliedLabel}**: ${regionAppliedValue}`, notes: '', - quantity: '0', - summary: 'Increase Quota', + quantity: String(quantity), + summary: `Increase ${selectedService.label} Quota`, }; }; @@ -162,7 +171,7 @@ export const convertResourceMetric = ({ initialUsage, initialLimit, }: ConvertResourceMetricProps): { - convertedLimit: number; + convertedLimit: string; convertedResourceMetric: string; convertedUsage: number; } => { @@ -173,15 +182,15 @@ export const convertResourceMetric = ({ convertedUsage: readableBytes(initialUsage, { unit: limitReadable.unit, }).value, - convertedResourceMetric: limitReadable.unit, - convertedLimit: limitReadable.value, + convertedResourceMetric: capitalize(limitReadable.unit), + convertedLimit: String(limitReadable.value), }; } return { convertedUsage: initialUsage, - convertedLimit: initialLimit, - convertedResourceMetric: initialResourceMetric, + convertedLimit: initialLimit.toLocaleString(), + convertedResourceMetric: capitalize(initialResourceMetric), }; }; @@ -200,13 +209,22 @@ export const pluralizeMetric = (value: number, unit: string) => { return unit; }; -export const getQuotaIncreaseFormSchema = object({ - description: string().required('Description is required.'), - notes: string() - .optional() - .max(255, 'Notes must be less than 255 characters.'), - quantity: string() - .required('Quantity is required') - .matches(/^[1-9]\d*$/, 'Quantity must be a number greater than 0.'), - summary: string().required('Summary is required.'), -}); +export const getQuotaIncreaseFormSchema = (currentLimit: number) => + object({ + description: string().required('Description is required.'), + notes: string() + .optional() + .max(255, 'Notes must be less than 255 characters.'), + quantity: string() + .required('Quantity is required') + .test( + 'is-greater-than-limit', + `Quantity must be greater than the current quota of ${currentLimit.toLocaleString()}.`, + (value) => { + const num = parseFloat(value); + return !isNaN(num) && num > currentLimit; + } + ), + // .matches(/^\d*\.?\d*$/, 'Must be a valid number'), // allows decimals + summary: string().required('Summary is required.'), + }); From 683f78e846ecf10367b0b2d8fbf86103856f8d7e Mon Sep 17 00:00:00 2001 From: Alban Bailly Date: Tue, 29 Apr 2025 13:26:01 -0400 Subject: [PATCH 09/10] mooar feedback --- .../src/features/Account/Quotas/Quotas.tsx | 34 ++++++++++++------- .../Account/Quotas/QuotasIncreaseForm.tsx | 10 ++---- 2 files changed, 24 insertions(+), 20 deletions(-) diff --git a/packages/manager/src/features/Account/Quotas/Quotas.tsx b/packages/manager/src/features/Account/Quotas/Quotas.tsx index d2627e437c3..2376a9ee607 100644 --- a/packages/manager/src/features/Account/Quotas/Quotas.tsx +++ b/packages/manager/src/features/Account/Quotas/Quotas.tsx @@ -1,4 +1,12 @@ -import { Divider, Notice, Paper, Select, Stack, Typography } from '@linode/ui'; +import { + Box, + Divider, + Notice, + Paper, + Select, + Stack, + Typography, +} from '@linode/ui'; import * as React from 'react'; import { useHistory } from 'react-router-dom'; @@ -38,16 +46,18 @@ export const Quotas = () => { > Object Storage - - - View your Object Storage quotas by applying the endpoint filter - below.{' '} - - Learn more about quotas - - . - - + + + + View your Object Storage quotas by applying the endpoint filter + below.{' '} + + Learn more about quotas + + . + + +