diff --git a/packages/manager/.changeset/pr-9660-added-1697473425862.md b/packages/manager/.changeset/pr-9660-added-1697473425862.md new file mode 100644 index 00000000000..05c869fb8eb --- /dev/null +++ b/packages/manager/.changeset/pr-9660-added-1697473425862.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Added +--- + +Dynamic price error handling for DC-specific pricing ([#9660](https://github.com/linode/manager/pull/9660)) diff --git a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts index 0b03f8c57d7..ba6218e195e 100644 --- a/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/backup-linode.spec.ts @@ -29,7 +29,7 @@ import { ui } from 'support/ui'; import { cleanUp } from 'support/util/cleanup'; import { makeFeatureFlagData } from 'support/util/feature-flags'; import { randomLabel } from 'support/util/random'; -import { dcPricingMockLinodeTypes } from 'support/constants/dc-specific-pricing'; +import { dcPricingMockLinodeTypesForBackups } from 'support/constants/dc-specific-pricing'; import { chooseRegion } from 'support/util/regions'; authenticate(); @@ -374,13 +374,13 @@ describe('"Enable Linode Backups" banner', () => { label: randomLabel(), region: 'us-east', backups: { enabled: false }, - type: dcPricingMockLinodeTypes[0].id, + type: dcPricingMockLinodeTypesForBackups[0].id, }), linodeFactory.build({ label: randomLabel(), region: 'us-west', backups: { enabled: false }, - type: dcPricingMockLinodeTypes[1].id, + type: dcPricingMockLinodeTypesForBackups[1].id, }), linodeFactory.build({ label: randomLabel(), @@ -398,11 +398,11 @@ describe('"Enable Linode Backups" banner', () => { ]; // The expected total cost of enabling backups, as shown in backups drawer. - const expectedTotal = '$7.74/mo'; + const expectedTotal = '$9.74/mo'; - mockGetLinodeType(dcPricingMockLinodeTypes[0]); - mockGetLinodeType(dcPricingMockLinodeTypes[1]); - mockGetLinodeTypes(dcPricingMockLinodeTypes); + mockGetLinodeType(dcPricingMockLinodeTypesForBackups[0]); + mockGetLinodeType(dcPricingMockLinodeTypesForBackups[1]); + mockGetLinodeTypes(dcPricingMockLinodeTypesForBackups); mockAppendFeatureFlags({ dcSpecificPricing: makeFeatureFlagData(true), diff --git a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts index f2ed69a74ad..b7fa804dc69 100644 --- a/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts +++ b/packages/manager/cypress/e2e/core/nodebalancers/smoke-create-nodebal.spec.ts @@ -44,7 +44,7 @@ const createNodeBalancerWithUI = (nodeBal, isDcPricingTest = false) => { // Confirms that the price will show up when the region is selected containsClick(selectRegionString).type(`${regionName}{enter}`); cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$10.00/month`).should('be.visible'); + cy.findByText(`$10/month`).should('be.visible'); }); // TODO: DC Pricing - M3-7086: Uncomment docs link assertion when docs links are added. @@ -55,7 +55,7 @@ const createNodeBalancerWithUI = (nodeBal, isDcPricingTest = false) => { // Confirms that the summary updates to reflect price changes if the user changes their region. cy.get(`[value="${regionName}"]`).click().type(`${newRegion.label}{enter}`); cy.get('[data-qa-summary="true"]').within(() => { - cy.findByText(`$14.00/month`).should('be.visible'); + cy.findByText(`$14/month`).should('be.visible'); }); // Confirms that a notice is shown in the "Region" section of the NodeBalancer Create form informing the user of DC-specific pricing diff --git a/packages/manager/cypress/support/constants/dc-specific-pricing.ts b/packages/manager/cypress/support/constants/dc-specific-pricing.ts index f0cc868327c..62c236de134 100644 --- a/packages/manager/cypress/support/constants/dc-specific-pricing.ts +++ b/packages/manager/cypress/support/constants/dc-specific-pricing.ts @@ -41,7 +41,7 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { backups: { price: { hourly: 0.004, - monthly: 2.5, + monthly: 2.0, }, region_prices: [ { @@ -75,6 +75,33 @@ export const dcPricingMockLinodeTypes = linodeTypeFactory.buildList(3, { ], }); +export const dcPricingMockLinodeTypesForBackups = linodeTypeFactory.buildList( + 3, + { + addons: { + backups: { + price: { + hourly: 0.004, + monthly: 2.0, + }, + region_prices: [ + { + hourly: 0.0048, + id: 'us-east', + monthly: 3.57, + }, + { + hourly: 0.0056, + id: 'us-west', + monthly: 4.17, + }, + ], + }, + }, + id: 'g6-nanode-1', + } +); + /** * Subset of LKE cluster plans as shown on Cloud Manager, mapped from DC-specific pricing mock linode * types to ensure size is consistent with ids in the types factory. diff --git a/packages/manager/src/MainContent.tsx b/packages/manager/src/MainContent.tsx index bf6ccaf5901..7e205ac114e 100644 --- a/packages/manager/src/MainContent.tsx +++ b/packages/manager/src/MainContent.tsx @@ -155,7 +155,9 @@ const LoadBalancers = React.lazy(() => import('src/features/LoadBalancers')); const NodeBalancers = React.lazy( () => import('src/features/NodeBalancers/NodeBalancers') ); -const StackScripts = React.lazy(() => import('src/features/StackScripts/StackScripts')); +const StackScripts = React.lazy( + () => import('src/features/StackScripts/StackScripts') +); const SupportTickets = React.lazy( () => import('src/features/Support/SupportTickets') ); diff --git a/packages/manager/src/components/Currency/Currency.test.tsx b/packages/manager/src/components/Currency/Currency.test.tsx index e1386da69bf..806407dc601 100644 --- a/packages/manager/src/components/Currency/Currency.test.tsx +++ b/packages/manager/src/components/Currency/Currency.test.tsx @@ -1,5 +1,6 @@ import * as React from 'react'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { renderWithTheme } from 'src/utilities/testHelpers'; import { Currency } from './Currency'; @@ -45,6 +46,13 @@ describe('Currency Component', () => { getByText('-$5.000'); }); + it('handles custom default values', () => { + const { getByText } = renderWithTheme( + + ); + getByText(`$${UNKNOWN_PRICE}`); + }); + it('groups by comma', () => { const { getByText, rerender } = renderWithTheme( diff --git a/packages/manager/src/components/Currency/Currency.tsx b/packages/manager/src/components/Currency/Currency.tsx index ea9b718b200..f57cb03d511 100644 --- a/packages/manager/src/components/Currency/Currency.tsx +++ b/packages/manager/src/components/Currency/Currency.tsx @@ -1,9 +1,10 @@ +import { isNumber } from 'lodash'; import * as React from 'react'; interface CurrencyFormatterProps { dataAttrs?: Record; decimalPlaces?: number; - quantity: number; + quantity: '--.--' | number; wrapInParentheses?: boolean; } @@ -16,8 +17,10 @@ export const Currency = (props: CurrencyFormatterProps) => { style: 'currency', }); - const formattedQuantity = formatter.format(Math.abs(quantity)); - const isNegative = quantity < 0; + const formattedQuantity = isNumber(quantity) + ? formatter.format(Math.abs(quantity)) + : `$${quantity}`; + const isNegative = isNumber(quantity) ? quantity < 0 : false; let output; diff --git a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx index 96684e8e182..07bacae6467 100644 --- a/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx +++ b/packages/manager/src/components/DisplayPrice/DisplayPrice.tsx @@ -8,7 +8,7 @@ export interface DisplayPriceProps { decimalPlaces?: number; fontSize?: string; interval?: string; - price: number; + price: '--.--' | number; } export const displayPrice = (price: number) => `$${price.toFixed(2)}`; diff --git a/packages/manager/src/components/TableCell/TableCell.tsx b/packages/manager/src/components/TableCell/TableCell.tsx index 959f7839daf..16b29c884d9 100644 --- a/packages/manager/src/components/TableCell/TableCell.tsx +++ b/packages/manager/src/components/TableCell/TableCell.tsx @@ -6,6 +6,8 @@ import { Theme } from '@mui/material/styles'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { TooltipIcon } from 'src/components/TooltipIcon'; + const useStyles = makeStyles()((theme: Theme) => ({ actionCell: { // Prevents Safari from adding margins to the ActionMenu button @@ -72,6 +74,8 @@ export interface TableCellProps extends _TableCellProps { center?: boolean; className?: string; compact?: boolean; + errorCell?: boolean; + errorText?: string; noWrap?: boolean; /* * parent column will either be the name of the column this @@ -90,6 +94,8 @@ export const TableCell = (props: TableCellProps) => { center, className, compact, + errorCell, + errorText, noWrap, parentColumn, sortable, @@ -116,6 +122,15 @@ export const TableCell = (props: TableCellProps) => { > {statusCell ? (
{props.children}
+ ) : errorCell && errorText ? ( + <> + {props.children} + + ) : ( props.children )} diff --git a/packages/manager/src/features/Backups/BackupDrawer.tsx b/packages/manager/src/features/Backups/BackupDrawer.tsx index 5e7859898d8..b5b96857e84 100644 --- a/packages/manager/src/features/Backups/BackupDrawer.tsx +++ b/packages/manager/src/features/Backups/BackupDrawer.tsx @@ -1,5 +1,6 @@ import { styled } from '@mui/material'; import Stack from '@mui/material/Stack'; +import { isNumber } from 'lodash'; import { useSnackbar } from 'notistack'; import * as React from 'react'; @@ -26,6 +27,7 @@ import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; import { useAllTypes } from 'src/queries/types'; import { pluralize } from 'src/utilities/pluralize'; import { getTotalBackupsPrice } from 'src/utilities/pricing/backups'; +import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants'; import { AutoEnroll } from './AutoEnroll'; import { BackupLinodeRow } from './BackupLinodeRow'; @@ -147,6 +149,12 @@ all new Linodes will automatically be backed up.` onClose(); }; + const totalBackupsPrice = getTotalBackupsPrice({ + flags, + linodes: linodesWithoutBackups, + types: types ?? [], + }); + return ( diff --git a/packages/manager/src/features/Backups/BackupLinodeRow.tsx b/packages/manager/src/features/Backups/BackupLinodeRow.tsx index 2257c6ebd79..1452af3bfd9 100644 --- a/packages/manager/src/features/Backups/BackupLinodeRow.tsx +++ b/packages/manager/src/features/Backups/BackupLinodeRow.tsx @@ -8,6 +8,10 @@ import { useFlags } from 'src/hooks/useFlags'; import { useRegionsQuery } from 'src/queries/regions'; import { useTypeQuery } from 'src/queries/types'; import { getMonthlyBackupsPrice } from 'src/utilities/pricing/backups'; +import { + PRICE_ERROR_TOOLTIP_TEXT, + UNKNOWN_PRICE, +} from 'src/utilities/pricing/constants'; interface Props { error?: string; @@ -20,7 +24,9 @@ export const BackupLinodeRow = (props: Props) => { const { data: regions } = useRegionsQuery(); const flags = useFlags(); - const backupsMonthlyPrice: PriceObject['monthly'] = getMonthlyBackupsPrice({ + const backupsMonthlyPrice: + | PriceObject['monthly'] + | undefined = getMonthlyBackupsPrice({ flags, region: linode.region, type, @@ -51,10 +57,12 @@ export const BackupLinodeRow = (props: Props) => { {flags.dcSpecificPricing && ( {regionLabel ?? 'Unknown'} )} - - {backupsMonthlyPrice !== 0 - ? `$${backupsMonthlyPrice?.toFixed(2)}/mo` - : '$Unknown/mo'} + + {`$${backupsMonthlyPrice?.toFixed(2) ?? UNKNOWN_PRICE}/mo`} ); diff --git a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx index 11fcdb8ebd2..df3966049c9 100644 --- a/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx +++ b/packages/manager/src/features/Kubernetes/CreateCluster/CreateCluster.tsx @@ -247,17 +247,10 @@ export const CreateCluster = () => { * @returns dynamically calculated high availability price by region */ const getHighAvailabilityPrice = (regionId: Region['id'] | null) => { - if (!regionId) { - return undefined; - } else { - return parseFloat( - getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - flags, - regionId, - }) - ); - } + const dcSpecificPrice = regionId + ? getDCSpecificPrice({ basePrice: LKE_HA_PRICE, flags, regionId }) + : undefined; + return dcSpecificPrice ? parseFloat(dcSpecificPrice) : undefined; }; const showPricingNotice = diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx index a6588c3e9de..d233cc60063 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/KubeCheckoutBar.tsx @@ -10,10 +10,6 @@ import { Divider } from 'src/components/Divider'; import { Notice } from 'src/components/Notice/Notice'; import { RenderGuard } from 'src/components/RenderGuard'; import EUAgreementCheckbox from 'src/features/Account/Agreements/EUAgreementCheckbox'; -import { - getKubernetesMonthlyPrice, - getTotalClusterPrice, -} from 'src/utilities/pricing/kubernetes'; import { useFlags } from 'src/hooks/useFlags'; import { useAccountAgreements } from 'src/queries/accountAgreements'; import { useProfile } from 'src/queries/profile'; @@ -21,6 +17,10 @@ import { useSpecificTypes } from 'src/queries/types'; import { extendTypesQueryResult } from 'src/utilities/extendType'; import { isEURegion } from 'src/utilities/formatRegion'; import { LKE_CREATE_CLUSTER_CHECKOUT_MESSAGE } from 'src/utilities/pricing/constants'; +import { + getKubernetesMonthlyPrice, + getTotalClusterPrice, +} from 'src/utilities/pricing/kubernetes'; import { nodeWarning } from '../kubeUtils'; import NodePoolSummary from './NodePoolSummary'; diff --git a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx index 1a0961086e3..cd7c47c0ecf 100644 --- a/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx +++ b/packages/manager/src/features/Kubernetes/KubeCheckoutBar/NodePoolSummary.tsx @@ -51,7 +51,7 @@ export interface Props { nodeCount: number; onRemove: () => void; poolType: ExtendedType | null; - price?: number; // Can be undefined until a Region is selected. + price?: null | number; // Can be undefined until a Region is selected. updateNodeCount: (count: number) => void; } diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx index e4094fa55af..ac193e2dff9 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/KubeClusterSpecs.tsx @@ -69,14 +69,16 @@ export const KubeClusterSpecs = (props: Props) => { const displayRegion = region?.label ?? cluster.region; - const highAvailabilityPrice = cluster.control_plane.high_availability - ? parseFloat( - getDCSpecificPrice({ - basePrice: LKE_HA_PRICE, - flags, - regionId: region?.id, - }) - ) + const dcSpecificPrice = cluster.control_plane.high_availability + ? getDCSpecificPrice({ + basePrice: LKE_HA_PRICE, + flags, + regionId: region?.id, + }) + : undefined; + + const highAvailabilityPrice = dcSpecificPrice + ? parseFloat(dcSpecificPrice) : undefined; const kubeSpecsLeft = [ diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx index 5bb6bc36b0e..c152790c0e0 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/AddNodePoolDrawer.tsx @@ -14,8 +14,9 @@ import { extendType } from 'src/utilities/extendType'; import { filterCurrentTypes } from 'src/utilities/filterCurrentLinodeTypes'; import { plansNoticesUtils } from 'src/utilities/planNotices'; import { pluralize } from 'src/utilities/pluralize'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; +import { getPrice } from 'src/utilities/pricing/linodes'; import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView'; import { KubernetesPlansPanel } from '../../KubernetesPlansPanel/KubernetesPlansPanel'; @@ -111,15 +112,16 @@ export const AddNodePoolDrawer = (props: Props) => { ? extendedTypes.find((thisType) => thisType.id === selectedTypeInfo.planId) : undefined; - const pricePerNode = - flags.dcSpecificPricing && selectedType - ? getLinodeRegionPrice(selectedType, clusterRegionId).monthly - : selectedType?.price?.monthly; + const pricePerNode = getPrice( + selectedType, + clusterRegionId, + flags.dcSpecificPricing + )?.monthly; const totalPrice = selectedTypeInfo && pricePerNode ? selectedTypeInfo.count * pricePerNode - : 0; + : undefined; React.useEffect(() => { if (open) { @@ -207,10 +209,19 @@ export const AddNodePoolDrawer = (props: Props) => { spacingBottom={16} spacingTop={8} text={nodeWarning} - variant="error" + variant="warning" /> )} + {selectedTypeInfo && !totalPrice && !pricePerNode && ( + + )} + { ${renderMonthlyPriceToCorrectDecimalPlace(totalPrice)}/month ( {pluralize('node', 'nodes', selectedTypeInfo.count)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode) ?? 0} + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} /month) {' '} to this cluster. diff --git a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx index bd3b8642f0a..558410c68f1 100644 --- a/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx +++ b/packages/manager/src/features/Kubernetes/KubernetesClusterDetail/NodePoolsDisplay/ResizeNodePoolDrawer.tsx @@ -14,9 +14,10 @@ import { useUpdateNodePoolMutation } from 'src/queries/kubernetes'; import { useSpecificTypes } from 'src/queries/types'; import { extendType } from 'src/utilities/extendType'; import { pluralize } from 'src/utilities/pluralize'; +import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants'; import { renderMonthlyPriceToCorrectDecimalPlace } from 'src/utilities/pricing/dynamicPricing'; import { getKubernetesMonthlyPrice } from 'src/utilities/pricing/kubernetes'; -import { getLinodeRegionPrice } from 'src/utilities/pricing/linodes'; +import { getPrice } from 'src/utilities/pricing/linodes'; import { nodeWarning } from '../../kubeUtils'; @@ -98,10 +99,11 @@ export const ResizeNodePoolDrawer = (props: Props) => { }); }; - const pricePerNode = - (flags.dcSpecificPricing && planType - ? getLinodeRegionPrice(planType, kubernetesRegionId)?.monthly - : planType?.price.monthly) || 0; + const pricePerNode = getPrice( + planType, + kubernetesRegionId, + flags.dcSpecificPricing + )?.monthly; const totalMonthlyPrice = planType && @@ -127,13 +129,15 @@ export const ResizeNodePoolDrawer = (props: Props) => { }} >
- - Current pool: $ - {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)}/month ( - {pluralize('node', 'nodes', nodePool.count)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} - /month) - + {totalMonthlyPrice && ( + + Current pool: $ + {renderMonthlyPriceToCorrectDecimalPlace(totalMonthlyPrice)}/month{' '} + ({pluralize('node', 'nodes', nodePool.count)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} + /month) + + )}
{error && } @@ -150,14 +154,17 @@ export const ResizeNodePoolDrawer = (props: Props) => {
- - Resized pool: $ - {renderMonthlyPriceToCorrectDecimalPlace( - updatedCount * pricePerNode - )} - /month ({pluralize('node', 'nodes', updatedCount)} at $ - {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)}/month) - + {/* Renders total pool price/month for N nodes at price per node/month. */} + {pricePerNode && ( + + {`Resized pool: $${renderMonthlyPriceToCorrectDecimalPlace( + updatedCount * pricePerNode + )}/month`}{' '} + ({pluralize('node', 'nodes', updatedCount)} at $ + {renderMonthlyPriceToCorrectDecimalPlace(pricePerNode)} + /month) + + )}
{updatedCount < nodePool.count && ( @@ -168,6 +175,15 @@ export const ResizeNodePoolDrawer = (props: Props) => { )} + {nodePool.count && (!pricePerNode || !totalMonthlyPrice) && ( + + )} + {type.heading} - - {' '} - ${renderMonthlyPriceToCorrectDecimalPlace(price.monthly)} + + ${renderMonthlyPriceToCorrectDecimalPlace(price?.monthly)} + + + ${price?.hourly ?? UNKNOWN_PRICE} - ${price.hourly} {convertMegabytesTo(type.memory, true)} @@ -110,9 +124,11 @@ export const KubernetesPlanSelection = ( updatePlanCount(type.id, newCount) @@ -123,7 +139,7 @@ export const KubernetesPlanSelection = ( {onAdd && (