Skip to content

Commit

Permalink
refactor: [M3-7831] - Use nodebalancers/types endpoint for pricing …
Browse files Browse the repository at this point in the history
…data (#10265)

* Replace nodebalancer constant pricing with /types endpoint pricing

* Add factory and MSW endpoint

* Remove price constant

* Clean up

* Disable Create button if pricing is unavailable

* Use query key factory for new types query

* Only disable Create button if region is valid but price is not

* Add changesets

* Fix typo

* Address feedback: types, unit test, move query

* Update the import statements I missed

* Gotta catch 'em all
  • Loading branch information
mjac0bs authored Apr 2, 2024
1 parent 0e0e5a5 commit 2a74280
Show file tree
Hide file tree
Showing 11 changed files with 204 additions and 18 deletions.
5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-10265-added-1711554883610.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Added
---

New endpoint and type for `nodebalancers/types` ([#10265](https://github.com/linode/manager/pull/10265))
17 changes: 15 additions & 2 deletions packages/api-v4/src/nodebalancers/nodebalancers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ import Request, {
setURL,
setXFilter,
} from '../request';
import { Filter, Params, ResourcePage as Page } from '../types';
import {
import type { Filter, Params, ResourcePage as Page, PriceType } from '../types';
import type {
CreateNodeBalancerPayload,
NodeBalancer,
NodeBalancerStats,
Expand Down Expand Up @@ -130,3 +130,16 @@ export const getNodeBalancerFirewalls = (
setXFilter(filter),
setParams(params)
);

/**
* getNodeBalancerTypes
*
* Return a paginated list of available NodeBalancer types; used for pricing.
* This endpoint does not require authentication.
*/
export const getNodeBalancerTypes = (params?: Params) =>
Request<Page<PriceType>>(
setURL(`${API_ROOT}/nodebalancers/types`),
setMethod('GET'),
setParams(params)
);
10 changes: 10 additions & 0 deletions packages/api-v4/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { PriceObject, RegionPriceObject } from './linodes/types';

export interface APIError {
field?: string;
reason: string;
Expand Down Expand Up @@ -115,3 +117,11 @@ export interface RequestHeaders {
'User-Agent'?: string;
'Content-Type'?: RequestContentType;
}

export interface PriceType {
id: string;
label: string;
price: PriceObject;
region_prices: RegionPriceObject[];
transfer: number;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
---

Price NodeBalancers dynamically with `nodebalancers/types` endpoint ([#10265](https://github.com/linode/manager/pull/10265))
25 changes: 24 additions & 1 deletion packages/manager/src/factories/types.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { LinodeType } from '@linode/api-v4/lib/linodes/types';
import * as Factory from 'factory.ts';

import type { LinodeType } from '@linode/api-v4/lib/linodes/types';
import type { PriceType } from '@linode/api-v4/src/types';
import type { PlanSelectionType } from 'src/features/components/PlansPanel/types';
import type { ExtendedType } from 'src/utilities/extendType';

Expand Down Expand Up @@ -114,3 +115,25 @@ export const extendedTypeFactory = Factory.Sync.makeFactory<ExtendedType>({
transfer: typeFactory.build().transfer,
vcpus: typeFactory.build().vcpus,
});

export const nodeBalancerTypeFactory = Factory.Sync.makeFactory<PriceType>({
id: 'nodebalancer',
label: 'NodeBalancer',
price: {
hourly: 0.015,
monthly: 10.0,
},
region_prices: [
{
hourly: 0.018,
id: 'id-cgk',
monthly: 12.0,
},
{
hourly: 0.021,
id: 'br-gru',
monthly: 14.0,
},
],
transfer: 0,
});
26 changes: 19 additions & 7 deletions packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,16 +38,19 @@ import {
useAccountAgreements,
useMutateAccountAgreements,
} from 'src/queries/account/agreements';
import { useNodebalancerCreateMutation } from 'src/queries/nodebalancers';
import {
useNodeBalancerTypesQuery,
useNodebalancerCreateMutation,
} from 'src/queries/nodebalancers';
import { useProfile } from 'src/queries/profile';
import { useRegionsQuery } from 'src/queries/regions/regions';
import { sendCreateNodeBalancerEvent } from 'src/utilities/analytics';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { getGDPRDetails } from 'src/utilities/formatRegion';
import { getAPIErrorFor } from 'src/utilities/getAPIErrorFor';
import { NODEBALANCER_PRICE } from 'src/utilities/pricing/constants';
import { PRICE_ERROR_TOOLTIP_TEXT } from 'src/utilities/pricing/constants';
import {
getDCSpecificPrice,
getDCSpecificPriceByType,
renderMonthlyPriceToCorrectDecimalPlace,
} from 'src/utilities/pricing/dynamicPricing';
import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView';
Expand Down Expand Up @@ -99,6 +102,7 @@ const NodeBalancerCreate = () => {
const { data: agreements } = useAccountAgreements();
const { data: profile } = useProfile();
const { data: regions } = useRegionsQuery();
const { data: types } = useNodeBalancerTypesQuery();

const {
error,
Expand Down Expand Up @@ -418,10 +422,11 @@ const NodeBalancerCreate = () => {
const regionLabel = regions?.find((r) => r.id === nodeBalancerFields.region)
?.label;

const price = getDCSpecificPrice({
basePrice: NODEBALANCER_PRICE,
const price = getDCSpecificPriceByType({
regionId: nodeBalancerFields.region,
type: types?.[0],
});
const isInvalidPrice = Boolean(nodeBalancerFields.region && !price);

const summaryItems = [];

Expand All @@ -448,7 +453,9 @@ const NodeBalancerCreate = () => {

if (nodeBalancerFields.region) {
summaryItems.unshift({
title: `$${renderMonthlyPriceToCorrectDecimalPlace(Number(price))}/month`,
title: `$${renderMonthlyPriceToCorrectDecimalPlace(
price ? Number(price) : undefined
)}/month`,
});
}

Expand Down Expand Up @@ -642,15 +649,20 @@ const NodeBalancerCreate = () => {
justifyContent={'flex-end'}
>
<Button
disabled={
(showGDPRCheckbox && !hasSignedAgreement) ||
isRestricted ||
isInvalidPrice
}
sx={{
flexShrink: 0,
mx: matchesSmDown ? theme.spacing(1) : null,
}}
buttonType="primary"
data-qa-deploy-nodebalancer
disabled={(showGDPRCheckbox && !hasSignedAgreement) || isRestricted}
loading={isLoading}
onClick={onCreate}
tooltipText={isInvalidPrice ? PRICE_ERROR_TOOLTIP_TEXT : ''}
>
Create NodeBalancer
</Button>
Expand Down
5 changes: 5 additions & 0 deletions packages/manager/src/mocks/serverHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ import {
nodeBalancerConfigFactory,
nodeBalancerConfigNodeFactory,
nodeBalancerFactory,
nodeBalancerTypeFactory,
nodePoolFactory,
notificationFactory,
objectStorageBucketFactory,
Expand Down Expand Up @@ -893,6 +894,10 @@ export const handlers = [
const nodeBalancers = nodeBalancerFactory.buildList(1);
return HttpResponse.json(makeResourcePage(nodeBalancers));
}),
http.get('*/v4/nodebalancers/types', () => {
const nodeBalancerTypes = nodeBalancerTypeFactory.buildList(1);
return HttpResponse.json(makeResourcePage(nodeBalancerTypes));
}),
http.get('*/v4/nodebalancers/:nodeBalancerID', ({ params }) => {
const nodeBalancer = nodeBalancerFactory.build({
id: Number(params.nodeBalancerID),
Expand Down
34 changes: 28 additions & 6 deletions packages/manager/src/queries/nodebalancers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,16 +13,12 @@ import {
getNodeBalancerConfigs,
getNodeBalancerFirewalls,
getNodeBalancerStats,
getNodeBalancerTypes,
getNodeBalancers,
updateNodeBalancer,
updateNodeBalancerConfig,
} from '@linode/api-v4';
import {
APIError,
Filter,
Params,
ResourcePage,
} from '@linode/api-v4/lib/types';
import { createQueryKeys } from '@lukemorales/query-key-factory';
import {
useInfiniteQuery,
useMutation,
Expand All @@ -40,11 +36,31 @@ import { queryPresets } from './base';
import { itemInListCreationHandler, itemInListMutationHandler } from './base';
import { profileQueries } from './profile';

import type {
APIError,
Filter,
Params,
PriceType,
ResourcePage,
} from '@linode/api-v4/lib/types';

export const queryKey = 'nodebalancers';

export const NODEBALANCER_STATS_NOT_READY_API_MESSAGE =
'Stats are unavailable at this time.';

const getAllNodeBalancerTypes = () =>
getAll<PriceType>((params) => getNodeBalancerTypes(params))().then(
(results) => results.data
);

export const typesQueries = createQueryKeys('types', {
nodebalancers: {
queryFn: getAllNodeBalancerTypes,
queryKey: null,
},
});

const getIsTooEarlyForStats = (created?: string) => {
if (!created) {
return false;
Expand Down Expand Up @@ -242,3 +258,9 @@ export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) =>
() => getNodeBalancerFirewalls(nodebalancerId),
queryPresets.oneTimeFetch
);

export const useNodeBalancerTypesQuery = () =>
useQuery<PriceType[], APIError[]>({
...queryPresets.oneTimeFetch,
...typesQueries.nodebalancers,
});
1 change: 0 additions & 1 deletion packages/manager/src/utilities/pricing/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ export interface ObjStoragePriceObject {

// These values will eventually come from the API, but for now they are hardcoded and
// used to generate the region based dynamic pricing.
export const NODEBALANCER_PRICE = 10;
export const LKE_HA_PRICE = 60;
export const OBJ_STORAGE_PRICE: ObjStoragePriceObject = {
monthly: 5.0,
Expand Down
49 changes: 49 additions & 0 deletions packages/manager/src/utilities/pricing/dynamicPricing.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { nodeBalancerTypeFactory } from 'src/factories';
import { UNKNOWN_PRICE } from 'src/utilities/pricing/constants';

import {
getDCSpecificPrice,
getDCSpecificPriceByType,
renderMonthlyPriceToCorrectDecimalPlace,
} from './dynamicPricing';
import { getDynamicVolumePrice } from './dynamicVolumePrice';
Expand Down Expand Up @@ -51,6 +53,53 @@ describe('getDCSpecificPricingDisplay', () => {
});
});

describe('getDCSpecificPricingByType', () => {
const mockNodeBalancerType = nodeBalancerTypeFactory.build();

it('calculates dynamic pricing for a region without an increase', () => {
expect(
getDCSpecificPriceByType({
regionId: 'us-east',
type: mockNodeBalancerType,
})
).toBe('10.00');
});

it('calculates dynamic pricing for a region with an increase', () => {
expect(
getDCSpecificPriceByType({
regionId: 'id-cgk',
type: mockNodeBalancerType,
})
).toBe('12.00');

expect(
getDCSpecificPriceByType({
regionId: 'br-gru',
type: mockNodeBalancerType,
})
).toBe('14.00');
});

it('handles an invalid price if region is not available', () => {
expect(
getDCSpecificPriceByType({
regionId: 'us-east',
type: undefined,
})
).toBe(undefined);
});

it('handles an invalid price if type is not available', () => {
expect(
getDCSpecificPriceByType({
regionId: undefined,
type: mockNodeBalancerType,
})
).toBe(undefined);
});
});

describe('renderMonthlyPriceToCorrectDecimalPlace', () => {
it('renders monthly price to two decimal places if the price includes a decimal', () => {
expect(renderMonthlyPriceToCorrectDecimalPlace(12.2)).toBe('12.20');
Expand Down
45 changes: 44 additions & 1 deletion packages/manager/src/utilities/pricing/dynamicPricing.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
import { UNKNOWN_PRICE } from './constants';

import type { Region } from '@linode/api-v4';
import type { PriceType, Region, RegionPriceObject } from '@linode/api-v4';

export interface RegionPrice extends RegionPriceObject {
id: string;
}

// TODO: Delete once all products are using /types endpoints.
export interface DataCenterPricingOptions {
/**
* The base price for an entity.
Expand All @@ -15,6 +20,18 @@ export interface DataCenterPricingOptions {
regionId: Region['id'] | undefined;
}

export interface DataCenterPricingByTypeOptions {
/**
* The `id` of the region we intended to get the price for.
* @example us-east
*/
regionId: Region['id'] | undefined;
/**
* The type data from a product's /types endpoint.
*/
type: PriceType | undefined;
}

// The key is a region id and the value is the percentage increase in price.
export const priceIncreaseMap = {
'br-gru': 0.4, // Sao Paulo
Expand Down Expand Up @@ -61,6 +78,32 @@ export const getDCSpecificPrice = ({
return basePrice.toFixed(2);
};

/**
* This function is used to calculate the dynamic pricing for a given entity, based on potential region increased costs.
* @example
* const price = getDCSpecificPrice({
* type: volumeType, // From the volumes/types endpoint
* regionId: 'us-east',
* });
* @returns a data center specific price or undefined if this cannot be calculated
*/
export const getDCSpecificPriceByType = ({
regionId,
type,
}: DataCenterPricingByTypeOptions): string | undefined => {
if (!regionId || !type) {
return undefined;
}

// Apply the DC-specific price if it exists; otherwise, use the base price.
const price =
type.region_prices.find((region_price: RegionPrice) => {
return region_price.id === regionId;
})?.monthly ?? type.price.monthly;

return price?.toFixed(2) ?? undefined;
};

export const renderMonthlyPriceToCorrectDecimalPlace = (
monthlyPrice: null | number | undefined
) => {
Expand Down

0 comments on commit 2a74280

Please sign in to comment.