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
Show file tree
Hide file tree
Changes from 14 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
Expand Up @@ -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,
Expand Down Expand Up @@ -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
*
Expand Down
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,
Expand All @@ -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';

/**
Expand Down Expand Up @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Up @@ -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
Expand Up @@ -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;
Expand All @@ -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.
Expand All @@ -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,
Expand Down Expand Up @@ -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={{
Expand Down
15 changes: 13 additions & 2 deletions packages/manager/src/features/Volumes/ResizeVolumeDrawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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,
Expand Down Expand Up @@ -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={{
Expand Down
Loading
Loading