Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking β€œSign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

refactor: [M3-7380] - Use volumes/types endpoint for pricing data #10376

Merged
merged 17 commits into from
Apr 15, 2024
Merged
Changes from 15 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/api-v4/.changeset/pr-10376-added-1712948652445.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/api-v4": Added
---

New endpoint for `volumes/types` ([#10376](https://github.com/linode/manager/pull/10376))
15 changes: 14 additions & 1 deletion packages/api-v4/src/volumes/volumes.ts
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@ import Request, {
setURL,
setXFilter,
} from '../request';
import { Filter, Params, ResourcePage as Page } from '../types';
import { Filter, Params, ResourcePage as Page, PriceType } from '../types';
import {
AttachVolumePayload,
CloneVolumePayload,
@@ -48,6 +48,19 @@ export const getVolumes = (params?: Params, filters?: Filter) =>
setXFilter(filters)
);

/**
* getVolumeTypes
*
* Return a paginated list of available Volume types, which contains pricing information.
* This endpoint does not require authentication.
*/
export const getVolumeTypes = (params?: Params) =>
Request<Page<PriceType>>(
setURL(`${API_ROOT}/volumes/types`),
setMethod('GET'),
setParams(params)
);

/**
* attachVolume
*
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Tech Stories
mjac0bs marked this conversation as resolved.
Show resolved Hide resolved
---

Add dynamic pricing with `volumes/types` endpoint ([#10376](https://github.com/linode/manager/pull/10376))
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
/* eslint-disable sonarjs/no-duplicate-string */
import { volumeFactory, linodeFactory } from '@src/factories';
import {
volumeFactory,
linodeFactory,
volumeTypeFactory,
} from '@src/factories';
import {
mockGetLinodes,
mockGetLinodeDetails,
@@ -9,10 +13,17 @@ import {
mockCreateVolume,
mockGetVolumes,
mockDetachVolume,
mockGetVolumeTypesError,
mockGetVolumeTypes,
} from 'support/intercepts/volumes';
import { randomLabel, randomNumber } from 'support/util/random';
import { ui } from 'support/ui';

import {
PRICES_RELOAD_ERROR_NOTICE_TEXT,
UNKNOWN_PRICE,
} from 'src/utilities/pricing/constants';

const region = 'Newark, NJ';

/**
@@ -70,9 +81,11 @@ const localStorageOverrides = {
describe('volumes', () => {
it('creates a volume without linode from volumes page', () => {
const mockVolume = volumeFactory.build({ label: randomLabel() });
const mockVolumeTypes = volumeTypeFactory.buildList(1);

mockGetVolumes([]).as('getVolumes');
mockCreateVolume(mockVolume).as('createVolume');
mockGetVolumeTypes(mockVolumeTypes).as('getVolumeTypes');

cy.visitWithLogin('/volumes', {
preferenceOverrides,
@@ -83,6 +96,8 @@ describe('volumes', () => {

cy.url().should('endWith', 'volumes/create');

cy.wait('@getVolumeTypes');

ui.button.findByTitle('Create Volume').should('be.visible').click();

cy.findByText('Label is required.').should('be.visible');
@@ -165,7 +180,7 @@ describe('volumes', () => {
cy.findByText('1 Volume').should('be.visible');
});

it('Detaches attached volume', () => {
it('detaches attached volume', () => {
const mockLinode = linodeFactory.build({ label: randomLabel() });
const mockAttachedVolume = volumeFactory.build({
label: randomLabel(),
@@ -210,4 +225,86 @@ describe('volumes', () => {
cy.wait('@detachVolume').its('response.statusCode').should('eq', 200);
ui.toast.assertMessage('Volume detachment started');
});

it('does not allow creation of a volume with invalid pricing from volumes landing', () => {
const mockVolume = volumeFactory.build({ label: randomLabel() });

mockGetVolumes([]).as('getVolumes');
mockCreateVolume(mockVolume).as('createVolume');
// Mock an error response to the /types endpoint so prices cannot be calculated.
mockGetVolumeTypesError().as('getVolumeTypesError');

cy.visitWithLogin('/volumes', {
preferenceOverrides,
localStorageOverrides,
});

ui.button.findByTitle('Create Volume').should('be.visible').click();

cy.url().should('endWith', 'volumes/create');

ui.regionSelect.find().click().type('newark{enter}');

cy.wait(['@getVolumeTypesError']);

// Confirm that unknown pricing placeholder text displays, create button is disabled, and error tooltip displays.
cy.findByText(`$${UNKNOWN_PRICE}/month`).should('be.visible');
ui.button
.findByTitle('Create Volume')
.should('be.visible')
.should('be.disabled')
.trigger('mouseover');
ui.tooltip.findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT).should('be.visible');
});

it('does not allow creation of a volume with invalid pricing from linode details', () => {
const mockLinode = linodeFactory.build({
label: randomLabel(),
id: randomNumber(),
});
const newVolume = volumeFactory.build({
label: randomLabel(),
});

mockCreateVolume(newVolume).as('createVolume');
mockGetLinodes([mockLinode]).as('getLinodes');
mockGetLinodeDetails(mockLinode.id, mockLinode).as('getLinodeDetail');
mockGetLinodeVolumes(mockLinode.id, []).as('getVolumes');
// Mock an error response to the /types endpoint so prices cannot be calculated.
mockGetVolumeTypesError().as('getVolumeTypesError');

cy.visitWithLogin('/linodes', {
preferenceOverrides,
localStorageOverrides,
});

// Visit a Linode's details page.
cy.wait('@getLinodes');
cy.findByText(mockLinode.label).should('be.visible').click();
cy.wait(['@getVolumes', '@getLinodeDetail']);

// Open the Create Volume drawer.
cy.findByText('Storage').should('be.visible').click();
ui.button.findByTitle('Create Volume').should('be.visible').click();
cy.wait(['@getVolumeTypesError']);

mockGetLinodeVolumes(mockLinode.id, [newVolume]).as('getVolumes');
ui.drawer
.findByTitle(`Create Volume for ${mockLinode.label}`)
.should('be.visible')
.within(() => {
cy.findByText('Create and Attach Volume').should('be.visible').click();

// Confirm that unknown pricing placeholder text displays, create button is disabled, and error tooltip displays.
cy.contains(`$${UNKNOWN_PRICE}/mo`).should('be.visible');
ui.button
.findByTitle('Create Volume')
.should('be.visible')
.should('be.disabled')
.trigger('mouseover');
ui.tooltip
.findByText(PRICES_RELOAD_ERROR_NOTICE_TEXT)
.should('be.visible');
});
});
});
34 changes: 33 additions & 1 deletion packages/manager/cypress/support/intercepts/volumes.ts
Original file line number Diff line number Diff line change
@@ -2,10 +2,12 @@
* @files Cypress intercepts and mocks for Volume API requests.
*/

import { makeErrorResponse } from 'support/util/errors';
import { apiMatcher } from 'support/util/intercepts';
import { paginateResponse } from 'support/util/paginate';
import { makeResponse } from 'support/util/response';

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

/**
* Intercepts GET request to fetch Volumes and mocks response.
@@ -122,3 +124,33 @@ export const interceptDeleteVolume = (
export const mockMigrateVolumes = (): Cypress.Chainable<null> => {
return cy.intercept('POST', apiMatcher(`volumes/migrate`), {});
};

/**
* Intercepts GET request to fetch Volumes Types and mocks response.
*
* @returns Cypress chainable.
*/
export const mockGetVolumeTypes = (
volumeTypes: PriceType[]
): Cypress.Chainable<null> => {
return cy.intercept(
'GET',
apiMatcher('volumes/types*'),
paginateResponse(volumeTypes)
);
};

/**
* Intercepts GET request to fetch Volumes Types and mocks an error response.
*
* @returns Cypress chainable.
*/
export const mockGetVolumeTypesError = (): Cypress.Chainable<null> => {
const errorResponse = makeErrorResponse('', 500);

return cy.intercept(
'GET',
apiMatcher('volumes/types*'),
makeResponse(errorResponse)
);
};
22 changes: 22 additions & 0 deletions packages/manager/src/factories/types.ts
Original file line number Diff line number Diff line change
@@ -137,3 +137,25 @@ export const nodeBalancerTypeFactory = Factory.Sync.makeFactory<PriceType>({
],
transfer: 0,
});

export const volumeTypeFactory = Factory.Sync.makeFactory<PriceType>({
id: 'volume',
label: 'Volume',
price: {
hourly: 0.00015,
monthly: 0.1,
},
region_prices: [
{
hourly: 0.00018,
id: 'id-cgk',
monthly: 0.12,
},
{
hourly: 0.00021,
id: 'br-gru',
monthly: 0.14,
},
],
transfer: 0,
});
16 changes: 13 additions & 3 deletions packages/manager/src/features/Volumes/CloneVolumeDrawer.tsx
Original file line number Diff line number Diff line change
@@ -10,14 +10,17 @@ import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
import { useEventsPollingActions } from 'src/queries/events/events';
import { useGrants } from 'src/queries/profile';
import { useCloneVolumeMutation } from 'src/queries/volumes';
import {
useCloneVolumeMutation,
useVolumeTypesQuery,
} from 'src/queries/volumes';
import {
handleFieldErrors,
handleGeneralErrors,
} from 'src/utilities/formikErrorUtils';
import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants';

import { PricePanel } from './VolumeDrawer/PricePanel';

interface Props {
onClose: () => void;
open: boolean;
@@ -34,6 +37,7 @@ export const CloneVolumeDrawer = (props: Props) => {
const { checkForNewEvents } = useEventsPollingActions();

const { data: grants } = useGrants();
const { data: types, isError, isLoading } = useVolumeTypesQuery();

// Even if a restricted user has the ability to create Volumes, they
// can't clone a Volume they only have read only permission on.
@@ -42,6 +46,8 @@ export const CloneVolumeDrawer = (props: Props) => {
grants.volume.find((grant) => grant.id === volume?.id)?.permissions ===
'read_only';

const isInvalidPrice = !types || isError;

const {
errors,
handleBlur,
@@ -109,9 +115,13 @@ export const CloneVolumeDrawer = (props: Props) => {
/>
<ActionsPanel
primaryButtonProps={{
disabled: isReadOnly,
disabled: isReadOnly || isInvalidPrice,
label: 'Clone Volume',
loading: isSubmitting,
tooltipText:
!isLoading && isInvalidPrice
? PRICES_RELOAD_ERROR_NOTICE_TEXT
: '',
type: 'submit',
}}
secondaryButtonProps={{
15 changes: 13 additions & 2 deletions packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx
Original file line number Diff line number Diff line change
@@ -9,11 +9,15 @@ import { Drawer } from 'src/components/Drawer';
import { Notice } from 'src/components/Notice/Notice';
import { useEventsPollingActions } from 'src/queries/events/events';
import { useGrants } from 'src/queries/profile';
import { useResizeVolumeMutation } from 'src/queries/volumes';
import {
useResizeVolumeMutation,
useVolumeTypesQuery,
} from 'src/queries/volumes';
import {
handleFieldErrors,
handleGeneralErrors,
} from 'src/utilities/formikErrorUtils';
import { PRICES_RELOAD_ERROR_NOTICE_TEXT } from 'src/utilities/pricing/constants';

import { PricePanel } from './VolumeDrawer/PricePanel';
import { SizeField } from './VolumeDrawer/SizeField';
@@ -36,12 +40,15 @@ export const ResizeVolumeDrawer = (props: Props) => {
const { enqueueSnackbar } = useSnackbar();

const { data: grants } = useGrants();
const { data: types, isError, isLoading } = useVolumeTypesQuery();

const isReadOnly =
grants !== undefined &&
grants.volume.find((grant) => grant.id === volume?.id)?.permissions ===
'read_only';

const isInvalidPrice = !types || isError;

const {
dirty,
errors,
@@ -113,9 +120,13 @@ export const ResizeVolumeDrawer = (props: Props) => {
/>
<ActionsPanel
primaryButtonProps={{
disabled: isReadOnly || !dirty,
disabled: isReadOnly || !dirty || isInvalidPrice,
label: 'Resize Volume',
loading: isSubmitting,
tooltipText:
!isLoading && isInvalidPrice
? PRICES_RELOAD_ERROR_NOTICE_TEXT
: '',
type: 'submit',
}}
secondaryButtonProps={{
Loading
Loading