From daacadfff24caecc24308ba04e93bb7fbad6d73d Mon Sep 17 00:00:00 2001 From: Ganesh Revanakar Date: Fri, 27 Mar 2026 19:09:10 +0530 Subject: [PATCH 1/3] upcoming: [UIE-10432] - Implement drawer to reserve an IP address --- packages/api-v4/src/networking/networking.ts | 2 +- packages/api-v4/src/networking/types.ts | 9 +- .../src/components/TagsInput/TagsInput.tsx | 18 +- packages/manager/src/factories/types.ts | 17 + .../ReservedIps/ReserveIPDrawer.test.tsx | 431 ++++++++++++++++++ .../features/ReservedIps/ReserveIPDrawer.tsx | 266 +++++++++++ .../ReservedIpsLandingEmptyState.tsx | 12 +- .../src/features/ReservedIps/constants.ts | 5 + .../src/mocks/presets/baseline/crud.ts | 2 + .../mocks/presets/crud/handlers/networking.ts | 41 +- .../src/mocks/presets/crud/networking.ts | 4 +- packages/queries/src/networking/networking.ts | 17 +- 12 files changed, 806 insertions(+), 18 deletions(-) create mode 100644 packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx create mode 100644 packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx create mode 100644 packages/manager/src/features/ReservedIps/constants.ts diff --git a/packages/api-v4/src/networking/networking.ts b/packages/api-v4/src/networking/networking.ts index 71f4a415b5c..7fa3db1ce7a 100644 --- a/packages/api-v4/src/networking/networking.ts +++ b/packages/api-v4/src/networking/networking.ts @@ -294,7 +294,7 @@ export const unReserveIP = (ipAddress: string) => */ export const getReservedIPsTypes = (params?: Params) => Request>( - setURL(`${API_ROOT}/networking/reserved/ips/types`), + setURL(`${BETA_API_ROOT}/networking/reserved/ips/types`), setMethod('GET'), setParams(params), ); diff --git a/packages/api-v4/src/networking/types.ts b/packages/api-v4/src/networking/types.ts index 45252fb763b..51b440ae850 100644 --- a/packages/api-v4/src/networking/types.ts +++ b/packages/api-v4/src/networking/types.ts @@ -1,13 +1,8 @@ -export interface AssignedEntity { - id: number; - label: string; - type: string; - url: string; -} +import type { Entity } from '@linode/api-v4'; export interface IPAddress { address: string; - assigned_entity: AssignedEntity | null; + assigned_entity: Entity | null; gateway: null | string; interface_id: null | number; linode_id: null | number; diff --git a/packages/manager/src/components/TagsInput/TagsInput.tsx b/packages/manager/src/components/TagsInput/TagsInput.tsx index 12a921d2886..c0cb3931bfa 100644 --- a/packages/manager/src/components/TagsInput/TagsInput.tsx +++ b/packages/manager/src/components/TagsInput/TagsInput.tsx @@ -47,6 +47,10 @@ export interface TagsInputProps { * Callback fired when the value changes. */ onChange: (selected: TagOption[]) => void; + /** + * If true, displays "(optional)" after the label. + */ + optional?: boolean; /** * An error to display beneath the input. */ @@ -58,8 +62,16 @@ export interface TagsInputProps { } export const TagsInput = (props: TagsInputProps) => { - const { disabled, hideLabel, label, noMarginTop, onChange, tagError, value } = - props; + const { + disabled, + hideLabel, + label, + noMarginTop, + onChange, + optional, + tagError, + value, + } = props; const [errors, setErrors] = React.useState([]); @@ -185,7 +197,7 @@ export const TagsInput = (props: TagsInputProps) => { /> )); }} - textFieldProps={{ hideLabel, noMarginTop }} + textFieldProps={{ hideLabel, noMarginTop, optional }} value={value} /> ); diff --git a/packages/manager/src/factories/types.ts b/packages/manager/src/factories/types.ts index e4d82346194..f389401737a 100644 --- a/packages/manager/src/factories/types.ts +++ b/packages/manager/src/factories/types.ts @@ -298,3 +298,20 @@ export const networkTransferPriceTypeFactory = ], transfer: 0, }); + +export const reservedIPsTypeFactory = Factory.Sync.makeFactory({ + id: 'reserved-ipv4', + label: 'Reserved IPv4 Address', + price: { + hourly: 0.007, + monthly: 5, + }, + region_prices: [ + { + hourly: 0.014, + id: 'pl-labkrk-2', + monthly: 10, + }, + ], + transfer: 0, +}); diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx new file mode 100644 index 00000000000..16ad3eba4e1 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx @@ -0,0 +1,431 @@ +import { regionFactory } from '@linode/utilities'; +import { screen, waitFor, within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ipAddressFactory, reservedIPsTypeFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { ReserveIPDrawer } from './ReserveIPDrawer'; + +const queryMocks = vi.hoisted(() => ({ + useFlags: vi.fn().mockReturnValue({}), + useIsGeckoEnabled: vi.fn().mockReturnValue({ isGeckoLAEnabled: false }), + useRegionsQuery: vi.fn(), + useReserveIPMutation: vi.fn(), + useReservedIPTypesQuery: vi.fn(), + useUpdateReservedIPMutation: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery, + useReserveIPMutation: queryMocks.useReserveIPMutation, + useReservedIPTypesQuery: queryMocks.useReservedIPTypesQuery, + useUpdateReservedIPMutation: queryMocks.useUpdateReservedIPMutation, +})); + +vi.mock('@linode/shared', async (importOriginal) => ({ + ...(await importOriginal()), + useIsGeckoEnabled: queryMocks.useIsGeckoEnabled, +})); + +vi.mock('src/hooks/useFlags', async (importOriginal) => ({ + ...(await importOriginal()), + useFlags: queryMocks.useFlags, +})); + +const regions = regionFactory.buildList(2); +const reservedIPType = reservedIPsTypeFactory.build(); +const mockReserveIP = vi + .fn() + .mockResolvedValue( + ipAddressFactory.build({ address: '192.0.2.1', reserved: true }) + ); +const mockUpdateReservedIP = vi.fn().mockResolvedValue({}); +const mockOnClose = vi.fn(); + +const defaultMocks = () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: regions, + isLoading: false, + }); + queryMocks.useReservedIPTypesQuery.mockReturnValue({ + data: [reservedIPType], + isLoading: false, + }); + queryMocks.useReserveIPMutation.mockReturnValue({ + mutateAsync: mockReserveIP, + }); + queryMocks.useUpdateReservedIPMutation.mockReturnValue({ + mutateAsync: mockUpdateReservedIP, + }); +}; + +beforeEach(() => { + defaultMocks(); + mockOnClose.mockClear(); + mockReserveIP.mockClear(); + mockUpdateReservedIP.mockClear(); +}); + +const RESERVE_IP_TITLE = 'Reserve an IP Address'; +const REGION_SELECT_TEST_ID = 'region-select'; +const RESERVE_BUTTON_LABEL = 'Reserve'; +const REGION_OPEN_BUTTON_LABEL = 'Open'; + +describe('ReserveIPDrawer - loading state', () => { + it('shows a loading spinner while data is loading', () => { + queryMocks.useRegionsQuery.mockReturnValue({ + data: null, + isLoading: true, + }); + + renderWithTheme( + + ); + + expect(screen.getByRole('progressbar')).toBeVisible(); + expect( + screen.queryByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeNull(); + }); + + it('shows the form once all data has loaded', () => { + renderWithTheme( + + ); + + expect(screen.queryByRole('progressbar')).toBeNull(); + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeVisible(); + }); +}); + +describe('ReserveIPDrawer - create mode', () => { + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText(RESERVE_IP_TITLE)).toBeVisible(); + }); + + it('renders the description and docs link', () => { + renderWithTheme( + + ); + + expect(screen.getByText(/Reserve a public IPv4 address/i)).toBeVisible(); + expect(screen.getByText('Learn more')).toBeVisible(); + }); + + it('does not show the IP address field', () => { + renderWithTheme( + + ); + + expect(screen.queryByLabelText('IP Address')).toBeNull(); + }); + + it('Reserve button is disabled when no region is selected', () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeDisabled(); + }); + + it('calls reserveIP mutation and closes on successful submit', async () => { + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + const reserveButton = screen.getByRole('button', { + name: RESERVE_BUTTON_LABEL, + }); + await waitFor(() => expect(reserveButton).toBeEnabled()); + + await userEvent.click(reserveButton); + + await waitFor(() => { + expect(mockReserveIP).toHaveBeenCalledWith({ + region: regions[0].id, + tags: [], + }); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); + + it('shows root error notice when API returns an error', async () => { + mockReserveIP.mockRejectedValueOnce([{ reason: 'Region is unavailable.' }]); + + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + const reserveButton = screen.getByRole('button', { + name: RESERVE_BUTTON_LABEL, + }); + await waitFor(() => expect(reserveButton).toBeEnabled()); + await userEvent.click(reserveButton); + + await waitFor(() => { + expect(screen.getByText('Region is unavailable.')).toBeVisible(); + }); + }); +}); + +describe('ReserveIPDrawer - edit mode', () => { + const existingIP = ipAddressFactory.build({ + address: '198.51.100.5', + region: regions[0].id, + reserved: true, + tags: ['prod'], + }); + + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText('Edit Reserved IP')).toBeVisible(); + }); + + it('shows the IP address field', () => { + renderWithTheme( + + ); + + expect(screen.getByText('IP Address')).toBeVisible(); + }); + + it('region select is disabled', async () => { + renderWithTheme( + + ); + + const regionCombobox = screen.getByRole('combobox', { name: /region/i }); + expect(regionCombobox).toBeDisabled(); + }); + + it('Save button is disabled until the form is dirtied', async () => { + renderWithTheme( + + ); + + expect(screen.getByRole('button', { name: 'Save' })).toBeDisabled(); + }); + + it('calls updateReservedIP mutation and closes on successful submit', async () => { + renderWithTheme( + + ); + + // Add a new tag to dirty the form + const tagsInput = screen.getByRole('combobox', { name: /tags/i }); + await userEvent.type(tagsInput, 'staging{enter}'); + + const saveButton = screen.getByRole('button', { name: 'Save' }); + await waitFor(() => expect(saveButton).toBeEnabled()); + await userEvent.click(saveButton); + + await waitFor(() => { + expect(mockUpdateReservedIP).toHaveBeenCalled(); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - reserve mode', () => { + const existingIP = ipAddressFactory.build({ + address: '203.0.113.10', + region: regions[1].id, + reserved: false, + tags: [], + }); + + it('renders the correct title', () => { + renderWithTheme( + + ); + + expect(screen.getByText(RESERVE_IP_TITLE)).toBeVisible(); + }); + + it('shows the IP address as a disabled field', () => { + renderWithTheme( + + ); + + expect(screen.getByText('203.0.113.10')).toBeVisible(); + }); + + it('region select is disabled', () => { + renderWithTheme( + + ); + + expect(screen.getByRole('combobox', { name: /region/i })).toBeDisabled(); + }); + + it('Reserve button is enabled without any user interaction', () => { + renderWithTheme( + + ); + + expect( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ).toBeEnabled(); + }); + + it('calls updateReservedIP mutation and closes on submit', async () => { + renderWithTheme( + + ); + + await userEvent.click( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ); + + await waitFor(() => { + expect(mockUpdateReservedIP).toHaveBeenCalled(); + }); + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - cancel button', () => { + it('calls onClose when Cancel is clicked', async () => { + renderWithTheme( + + ); + + await userEvent.click(screen.getByRole('button', { name: 'Cancel' })); + + expect(mockOnClose).toHaveBeenCalled(); + }); +}); + +describe('ReserveIPDrawer - pricing', () => { + it('shows the monthly price in create mode once a region is selected', async () => { + renderWithTheme( + + ); + + const regionSelect = screen.getByTestId(REGION_SELECT_TEST_ID); + await userEvent.click( + within(regionSelect).getByRole('button', { + name: REGION_OPEN_BUTTON_LABEL, + }) + ); + await userEvent.click( + await screen.findByRole('option', { + name: new RegExp(regions[0].label, 'i'), + }) + ); + + await waitFor(() => { + expect( + screen.getByText((text) => text.startsWith('$') && text.endsWith('/mo')) + ).toBeVisible(); + }); + }); + + it('does not show the price in edit mode', () => { + const editIP = ipAddressFactory.build({ + region: regions[0].id, + reserved: true, + }); + + renderWithTheme( + + ); + + expect( + screen.queryByText((text) => text.startsWith('$') && text.endsWith('/mo')) + ).toBeNull(); + }); +}); diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx new file mode 100644 index 00000000000..96b9e4bf147 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx @@ -0,0 +1,266 @@ +/* Reserve IP Drawer +* +* Usage: + + +// Edit reserved IP (allows editing tags, but not region or IP address) + + +// Reserve an existing IP (shows IP as readonly) + +*/ +import { + useRegionsQuery, + useReservedIPTypesQuery, + useReserveIPMutation, + useUpdateReservedIPMutation, +} from '@linode/queries'; +import { useIsGeckoEnabled } from '@linode/shared'; +import { + ActionsPanel, + Box, + CircleProgress, + Drawer, + Notice, + Stack, + Typography, +} from '@linode/ui'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { Controller, useForm, useWatch } from 'react-hook-form'; + +import { Link } from 'src/components/Link'; +import { RegionSelect } from 'src/components/RegionSelect/RegionSelect'; +import { TagsInput } from 'src/components/TagsInput/TagsInput'; +import { useFlags } from 'src/hooks/useFlags'; +import { + getDCSpecificPriceByType, + renderMonthlyPriceToCorrectDecimalPlace, +} from 'src/utilities/pricing/dynamicPricing'; + +import { RESERVE_IP_DESCRIPTION, RESERVED_IPS_DOCS_LINK } from './constants'; + +import type { IPAddress } from '@linode/api-v4'; +import type { TagOption } from 'src/components/TagsInput/TagsInput'; + +export type ReserveIPDrawerMode = 'create' | 'edit' | 'reserve'; + +export interface ReserveIPDrawerProps { + /** + * The IPAddress object to pre-populate the form. Provides the address (shown + * readonly in reserve mode), region, and tags. Required when mode is 'edit' or 'reserve'. + */ + ipAddress?: IPAddress; + mode: ReserveIPDrawerMode; + onClose: () => void; + open: boolean; +} + +interface ReserveIPFormValues { + region: string; + tags: TagOption[]; +} + +const reserveIPDrawerConfig: Record< + ReserveIPDrawerMode, + { submitLabel: string; title: string } +> = { + create: { + submitLabel: 'Reserve IP Address', + title: 'Reserve an IP Address', + }, + edit: { + submitLabel: 'Save', + title: 'Edit Reserved IP', + }, + reserve: { + submitLabel: 'Reserve IP Address', + title: 'Reserve an IP Address', + }, +}; + +export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { + const { ipAddress, mode, onClose, open } = props; + + const flags = useFlags(); + const { isGeckoLAEnabled } = useIsGeckoEnabled( + flags.gecko2?.enabled, + flags.gecko2?.la + ); + const { data: regions, isLoading: isRegionsLoading } = useRegionsQuery(); + const { data: reservedIPTypes, isLoading: isReservedIPTypesLoading } = + useReservedIPTypesQuery(); + + const isLoading = isRegionsLoading || isReservedIPTypesLoading; + const { mutateAsync: reserveIP } = useReserveIPMutation(); + const { mutateAsync: updateReservedIP } = useUpdateReservedIPMutation( + ipAddress?.address ?? '' + ); + const { enqueueSnackbar } = useSnackbar(); + + const isRegionDisabled = mode === 'edit' || mode === 'reserve'; + + const { + control, + formState: { errors, isDirty, isSubmitting, isValid }, + handleSubmit, + reset, + setError, + } = useForm({ + mode: 'onChange', + values: { + region: ipAddress?.region ?? '', + tags: (ipAddress?.tags ?? []).map((t) => ({ label: t, value: t })), + }, + }); + + const isSubmitDisabled = + mode === 'create' ? !isValid : mode === 'edit' ? !isDirty : false; + + const selectedRegion = useWatch({ control, name: 'region' }); + + const reservedIPPrice = getDCSpecificPriceByType({ + regionId: selectedRegion, + type: reservedIPTypes?.[0], + }); + + const handleClose = () => { + onClose(); + reset(); + }; + + const onSubmit = async (values: ReserveIPFormValues) => { + try { + const tags = values.tags.map((tag) => tag.value); + + if (mode === 'create') { + const created = await reserveIP({ region: values.region, tags }); + enqueueSnackbar(`${created.address} has been reserved`, { + variant: 'success', + }); + } else { + await updateReservedIP({ address: ipAddress?.address ?? '', tags }); + const verb = mode === 'reserve' ? 'reserved' : 'updated'; + enqueueSnackbar(`${ipAddress?.address} has been ${verb}`, { + variant: 'success', + }); + } + + handleClose(); + } catch (apiErrors) { + for (const error of apiErrors as { field?: string; reason: string }[]) { + if (error?.field === 'region' || error?.field === 'tags') { + setError(error.field, { message: error.reason }); + } else { + setError('root', { message: error.reason }); + } + } + } + }; + + return ( + reset()} + open={open} + title={reserveIPDrawerConfig[mode].title} + > + {isLoading ? ( + + ) : ( +
+ + + {RESERVE_IP_DESCRIPTION} +
+ Learn more. +
+ + {errors.root?.message && ( + + {errors.root.message} + + )} + + {(mode === 'reserve' || mode === 'edit') && ipAddress?.address && ( + + IP Address + ({ + color: theme.palette.text.primary, + marginTop: `${theme.spacingFunction(8)} !important`, + })} + > + {ipAddress.address} + + + )} + + ( + field.onChange(region?.id ?? '')} + regions={regions ?? []} + value={field.value} + /> + )} + rules={ + mode === 'create' ? { required: 'Region is required.' } : {} + } + /> + + ( + + )} + /> + {reservedIPPrice && mode !== 'edit' && ( + ({ color: theme.palette.text.secondary })} + variant="body1" + > + {`$${renderMonthlyPriceToCorrectDecimalPlace( + reservedIPPrice ? Number(reservedIPPrice) : undefined + )} / mo.`} + + )} +
+ + + + )} +
+ ); +}; diff --git a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx index c842234ce89..62f12de0ad9 100644 --- a/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx +++ b/packages/manager/src/features/ReservedIps/ReservedIpsLanding/ReservedIpsLandingEmptyState.tsx @@ -4,6 +4,7 @@ import NetworkingIcon from 'src/assets/icons/entityIcons/networking.svg'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { ResourcesSection } from 'src/components/EmptyLandingPageResources/ResourcesSection'; +import { ReserveIPDrawer } from '../ReserveIPDrawer'; import { gettingStartedGuides, headers, @@ -11,6 +12,8 @@ import { } from './ReservedIpsLandingEmptyStateData'; export const ReservedIpsLandingEmptyState = () => { + const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); + return ( @@ -18,9 +21,7 @@ export const ReservedIpsLandingEmptyState = () => { buttonProps={[ { children: 'Reserve an IP Address', - onClick: () => { - // TODO: Open Reserve IP create drawer once ready - }, + onClick: () => setIsDrawerOpen(true), }, ]} descriptionMaxWidth={500} @@ -30,6 +31,11 @@ export const ReservedIpsLandingEmptyState = () => { linkAnalyticsEvent={linkAnalyticsEvent} wide={true} /> + setIsDrawerOpen(false)} + open={isDrawerOpen} + /> ); }; diff --git a/packages/manager/src/features/ReservedIps/constants.ts b/packages/manager/src/features/ReservedIps/constants.ts new file mode 100644 index 00000000000..df868570c69 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/constants.ts @@ -0,0 +1,5 @@ +export const RESERVE_IP_DESCRIPTION = + 'Reserve a public IPv4 address from the selected region. It will be added to your reserved IPs and ready to assign to resources, such as Linodes or NodeBalancers. Reserved IPs and resources must be in the same region.'; + +export const RESERVED_IPS_DOCS_LINK = + 'https://techdocs.akamai.com/cloud-computing/update/docs/reserved-ips'; diff --git a/packages/manager/src/mocks/presets/baseline/crud.ts b/packages/manager/src/mocks/presets/baseline/crud.ts index 3347bb93695..35235c49976 100644 --- a/packages/manager/src/mocks/presets/baseline/crud.ts +++ b/packages/manager/src/mocks/presets/baseline/crud.ts @@ -14,6 +14,7 @@ import { firewallCrudPreset } from '../crud/firewalls'; import { imagesCrudPreset } from '../crud/images'; import { kubernetesCrudPreset } from '../crud/kubernetes'; import { locksCrudPreset } from '../crud/locks'; +import { networkingCrudPreset } from '../crud/networking'; import { nodeBalancerCrudPreset } from '../crud/nodebalancers'; import { permissionsCrudPreset } from '../crud/permissions'; import { placementGroupsCrudPreset } from '../crud/placementGroups'; @@ -46,6 +47,7 @@ export const baselineCrudPreset: MockPresetBaseline = { ...volumeCrudPreset.handlers, ...usersCrudPreset.handlers, ...vpcCrudPreset.handlers, + ...networkingCrudPreset.handlers, ...nodeBalancerCrudPreset.handlers, // Events. diff --git a/packages/manager/src/mocks/presets/crud/handlers/networking.ts b/packages/manager/src/mocks/presets/crud/handlers/networking.ts index 7f395f6b49e..c9997d084e7 100644 --- a/packages/manager/src/mocks/presets/crud/handlers/networking.ts +++ b/packages/manager/src/mocks/presets/crud/handlers/networking.ts @@ -1,6 +1,6 @@ import { http } from 'msw'; -import { ipAddressFactory } from 'src/factories'; +import { ipAddressFactory, reservedIPsTypeFactory } from 'src/factories'; import { mswDB } from 'src/mocks/indexedDB'; import { // makeErrorResponse, @@ -9,7 +9,7 @@ import { makeResponse, } from 'src/mocks/utilities/response'; -import type { IPAddress } from '@linode/api-v4'; +import type { IPAddress, PriceType } from '@linode/api-v4'; import type { StrictResponse } from 'msw'; import type { MockState } from 'src/mocks/types'; import type { @@ -80,4 +80,41 @@ export const allocateIP = (mockState: MockState) => [ ), ]; +export const reserveIP = (mockState: MockState) => [ + http.post( + '*/v4beta/networking/reserved/ips', + async ({ + request, + }): Promise> => { + const payload = await request.clone().json(); + + const ipAddress = ipAddressFactory.build({ + region: payload.region, + reserved: true, + tags: payload.tags ?? [], + type: 'ipv4', + }); + + await mswDB.add('ipAddresses', ipAddress, mockState); + + return makeResponse(ipAddress); + } + ), +]; + +export const getReservedIPsTypes = () => [ + http.get( + '*/v4beta/networking/reserved/ips/types', + ({ + request, + }): StrictResponse> => { + const templates = reservedIPsTypeFactory.buildList(1); + return makePaginatedResponse({ + data: templates, + request, + }); + } + ), +]; + // @TODO Linode Interfaces - add mocks for sharing/assigning IPs diff --git a/packages/manager/src/mocks/presets/crud/networking.ts b/packages/manager/src/mocks/presets/crud/networking.ts index ffe09c84032..115cb13e65e 100644 --- a/packages/manager/src/mocks/presets/crud/networking.ts +++ b/packages/manager/src/mocks/presets/crud/networking.ts @@ -1,13 +1,15 @@ import { allocateIP, getIPAddresses, + getReservedIPsTypes, + reserveIP, } from 'src/mocks/presets/crud/handlers/networking'; import type { MockPresetCrud } from 'src/mocks/types'; export const networkingCrudPreset: MockPresetCrud = { group: { id: 'IP Addresses' }, - handlers: [getIPAddresses, allocateIP], + handlers: [getIPAddresses, allocateIP, getReservedIPsTypes, reserveIP], id: 'ip-addresses:crud', label: 'IP Addresses CRUD', }; diff --git a/packages/queries/src/networking/networking.ts b/packages/queries/src/networking/networking.ts index 53d304786fe..1e4ed7f1d70 100644 --- a/packages/queries/src/networking/networking.ts +++ b/packages/queries/src/networking/networking.ts @@ -18,7 +18,11 @@ import { import { useMemo } from 'react'; import { linodeQueries } from '../linodes/linodes'; -import { getAllIps, getAllIPv6Ranges } from './requests'; +import { + getAllIps, + getAllIPv6Ranges, + getAllReservedIPsTypes, +} from './requests'; import type { APIError, @@ -28,6 +32,7 @@ import type { IPRange, IPRangeInformation, Params, + PriceType, ReserveIPPayload, ResourcePage, } from '@linode/api-v4'; @@ -58,6 +63,10 @@ export const networkingQueries = createQueryKeys('networking', { queryFn: () => getReservedIP(address), queryKey: [address], }), + reservedIPTypes: { + queryFn: getAllReservedIPsTypes, + queryKey: null, + }, }); export const useAllIPsQuery = ( @@ -207,3 +216,9 @@ export const useUnReserveIPMutation = (address: string) => { }, }); }; + +export const useReservedIPTypesQuery = () => { + return useQuery({ + ...networkingQueries.reservedIPTypes, + }); +}; From f67a647dad5c749e71cec6d2f5c8543ee5c0050f Mon Sep 17 00:00:00 2001 From: Ganesh Revanakar Date: Mon, 30 Mar 2026 12:14:17 +0530 Subject: [PATCH 2/3] Added changeset: Reserve IP - Implement drawer to reserve an IP address --- packages/api-v4/src/networking/types.ts | 2 +- .../.changeset/pr-13541-upcoming-features-1774844980662.md | 5 +++++ .../src/features/ReservedIps/ReserveIPDrawer.test.tsx | 6 ++++-- 3 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md diff --git a/packages/api-v4/src/networking/types.ts b/packages/api-v4/src/networking/types.ts index 51b440ae850..6e49dbe17bc 100644 --- a/packages/api-v4/src/networking/types.ts +++ b/packages/api-v4/src/networking/types.ts @@ -1,4 +1,4 @@ -import type { Entity } from '@linode/api-v4'; +import type { Entity } from '../account/types'; export interface IPAddress { address: string; diff --git a/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md b/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md new file mode 100644 index 00000000000..5f0d6574281 --- /dev/null +++ b/packages/manager/.changeset/pr-13541-upcoming-features-1774844980662.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Reserve IP - Implement drawer to reserve an IP address ([#13541](https://github.com/linode/manager/pull/13541)) diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx index 16ad3eba4e1..963fec81f2e 100644 --- a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx @@ -71,7 +71,7 @@ beforeEach(() => { const RESERVE_IP_TITLE = 'Reserve an IP Address'; const REGION_SELECT_TEST_ID = 'region-select'; -const RESERVE_BUTTON_LABEL = 'Reserve'; +const RESERVE_BUTTON_LABEL = 'Reserve IP Address'; const REGION_OPEN_BUTTON_LABEL = 'Open'; describe('ReserveIPDrawer - loading state', () => { @@ -404,7 +404,9 @@ describe('ReserveIPDrawer - pricing', () => { await waitFor(() => { expect( - screen.getByText((text) => text.startsWith('$') && text.endsWith('/mo')) + screen.getByText( + (text) => text.startsWith('$') && text.endsWith(' / mo.') + ) ).toBeVisible(); }); }); From 92fd3606cca4d2342857e2af58219e46d0e40691 Mon Sep 17 00:00:00 2001 From: Ganesh Revanakar Date: Thu, 2 Apr 2026 14:55:57 +0530 Subject: [PATCH 3/3] Addressed review comments --- .../ReservedIps/ReserveIPDrawer.test.tsx | 11 ++- .../features/ReservedIps/ReserveIPDrawer.tsx | 91 +++++++++++++------ .../src/features/ReservedIps/constants.ts | 2 +- packages/queries/src/networking/networking.ts | 26 ++++++ 4 files changed, 100 insertions(+), 30 deletions(-) diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx index 963fec81f2e..5327e0f983d 100644 --- a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx @@ -15,6 +15,7 @@ const queryMocks = vi.hoisted(() => ({ useReserveIPMutation: vi.fn(), useReservedIPTypesQuery: vi.fn(), useUpdateReservedIPMutation: vi.fn(), + useUpdateIPMutation: vi.fn(), })); vi.mock('@linode/queries', async (importOriginal) => ({ @@ -23,6 +24,7 @@ vi.mock('@linode/queries', async (importOriginal) => ({ useReserveIPMutation: queryMocks.useReserveIPMutation, useReservedIPTypesQuery: queryMocks.useReservedIPTypesQuery, useUpdateReservedIPMutation: queryMocks.useUpdateReservedIPMutation, + useUpdateIPMutation: queryMocks.useUpdateIPMutation, })); vi.mock('@linode/shared', async (importOriginal) => ({ @@ -43,6 +45,7 @@ const mockReserveIP = vi ipAddressFactory.build({ address: '192.0.2.1', reserved: true }) ); const mockUpdateReservedIP = vi.fn().mockResolvedValue({}); +const mockUpdateIP = vi.fn().mockResolvedValue({}); const mockOnClose = vi.fn(); const defaultMocks = () => { @@ -60,6 +63,9 @@ const defaultMocks = () => { queryMocks.useUpdateReservedIPMutation.mockReturnValue({ mutateAsync: mockUpdateReservedIP, }); + queryMocks.useUpdateIPMutation.mockReturnValue({ + mutateAsync: mockUpdateIP, + }); }; beforeEach(() => { @@ -67,6 +73,7 @@ beforeEach(() => { mockOnClose.mockClear(); mockReserveIP.mockClear(); mockUpdateReservedIP.mockClear(); + mockUpdateIP.mockClear(); }); const RESERVE_IP_TITLE = 'Reserve an IP Address'; @@ -351,7 +358,7 @@ describe('ReserveIPDrawer - reserve mode', () => { ).toBeEnabled(); }); - it('calls updateReservedIP mutation and closes on submit', async () => { + it('calls updatedIP mutation and closes on submit', async () => { renderWithTheme( { ); await waitFor(() => { - expect(mockUpdateReservedIP).toHaveBeenCalled(); + expect(mockUpdateIP).toHaveBeenCalled(); }); expect(mockOnClose).toHaveBeenCalled(); }); diff --git a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx index 96b9e4bf147..feb55bc3497 100644 --- a/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx @@ -15,6 +15,7 @@ import { useRegionsQuery, useReservedIPTypesQuery, useReserveIPMutation, + useUpdateIPMutation, useUpdateReservedIPMutation, } from '@linode/queries'; import { useIsGeckoEnabled } from '@linode/shared'; @@ -42,7 +43,7 @@ import { import { RESERVE_IP_DESCRIPTION, RESERVED_IPS_DOCS_LINK } from './constants'; -import type { IPAddress } from '@linode/api-v4'; +import type { APIError, IPAddress } from '@linode/api-v4'; import type { TagOption } from 'src/components/TagsInput/TagsInput'; export type ReserveIPDrawerMode = 'create' | 'edit' | 'reserve'; @@ -55,7 +56,11 @@ export interface ReserveIPDrawerProps { ipAddress?: IPAddress; mode: ReserveIPDrawerMode; onClose: () => void; + onSuccess?: (ip: IPAddress) => void; open: boolean; + // Optional region text to display when mode is 'create'. This is to show region + // selected and disabled while reserving an IP during create Linode flow. + region?: string; } interface ReserveIPFormValues { @@ -98,9 +103,13 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { const { mutateAsync: updateReservedIP } = useUpdateReservedIPMutation( ipAddress?.address ?? '' ); + const { mutateAsync: updateIP } = useUpdateIPMutation( + ipAddress?.address ?? '' + ); const { enqueueSnackbar } = useSnackbar(); - const isRegionDisabled = mode === 'edit' || mode === 'reserve'; + const isRegionDisabled = + mode === 'edit' || mode === 'reserve' || Boolean(props.region); const { control, @@ -111,13 +120,14 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { } = useForm({ mode: 'onChange', values: { - region: ipAddress?.region ?? '', + region: props.region ?? ipAddress?.region ?? '', tags: (ipAddress?.tags ?? []).map((t) => ({ label: t, value: t })), }, }); const isSubmitDisabled = - mode === 'create' ? !isValid : mode === 'edit' ? !isDirty : false; + isSubmitting || + (mode === 'create' ? !isValid : mode === 'edit' ? !isDirty : false); const selectedRegion = useWatch({ control, name: 'region' }); @@ -128,29 +138,47 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { const handleClose = () => { onClose(); - reset(); }; const onSubmit = async (values: ReserveIPFormValues) => { try { const tags = values.tags.map((tag) => tag.value); - if (mode === 'create') { - const created = await reserveIP({ region: values.region, tags }); - enqueueSnackbar(`${created.address} has been reserved`, { - variant: 'success', - }); - } else { - await updateReservedIP({ address: ipAddress?.address ?? '', tags }); - const verb = mode === 'reserve' ? 'reserved' : 'updated'; - enqueueSnackbar(`${ipAddress?.address} has been ${verb}`, { - variant: 'success', - }); + switch (mode) { + case 'create': + const created = await reserveIP({ region: values.region, tags }); + enqueueSnackbar(`${created.address} has been reserved`, { + variant: 'success', + }); + props.onSuccess?.(created); + break; + case 'edit': + const edited = await updateReservedIP({ + address: ipAddress?.address ?? '', + tags, + }); + enqueueSnackbar(`${ipAddress?.address} has been updated`, { + variant: 'success', + }); + props.onSuccess?.(edited); + break; + case 'reserve': + const reserved = await updateIP({ + address: ipAddress?.address ?? '', + rdns: ipAddress?.rdns ?? null, + reserved: true, + }); + enqueueSnackbar(`${ipAddress?.address} has been reserved`, { + variant: 'success', + }); + props.onSuccess?.(reserved); + break; + default: + return; } - handleClose(); } catch (apiErrors) { - for (const error of apiErrors as { field?: string; reason: string }[]) { + for (const error of apiErrors as APIError[]) { if (error?.field === 'region' || error?.field === 'tags') { setError(error.field, { message: error.reason }); } else { @@ -170,13 +198,18 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { {isLoading ? ( ) : ( -
+ - - {RESERVE_IP_DESCRIPTION} -
- Learn more. -
+ {mode !== 'edit' && ( + + {RESERVE_IP_DESCRIPTION} +
+ Learn more. +
+ )} {errors.root?.message && ( @@ -186,11 +219,15 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { {(mode === 'reserve' || mode === 'edit') && ipAddress?.address && ( - IP Address + + IP Address + ({ color: theme.palette.text.primary, - marginTop: `${theme.spacingFunction(8)} !important`, + display: 'block', + marginTop: theme.spacingFunction(8), })} > {ipAddress.address} @@ -238,7 +275,7 @@ export const ReserveIPDrawer = (props: ReserveIPDrawerProps) => { variant="body1" > {`$${renderMonthlyPriceToCorrectDecimalPlace( - reservedIPPrice ? Number(reservedIPPrice) : undefined + Number(reservedIPPrice) )} / mo.`} )} diff --git a/packages/manager/src/features/ReservedIps/constants.ts b/packages/manager/src/features/ReservedIps/constants.ts index df868570c69..b00f6e680b4 100644 --- a/packages/manager/src/features/ReservedIps/constants.ts +++ b/packages/manager/src/features/ReservedIps/constants.ts @@ -2,4 +2,4 @@ export const RESERVE_IP_DESCRIPTION = 'Reserve a public IPv4 address from the selected region. It will be added to your reserved IPs and ready to assign to resources, such as Linodes or NodeBalancers. Reserved IPs and resources must be in the same region.'; export const RESERVED_IPS_DOCS_LINK = - 'https://techdocs.akamai.com/cloud-computing/update/docs/reserved-ips'; + 'https://techdocs.akamai.com/cloud-computing/docs/reserved-ips'; diff --git a/packages/queries/src/networking/networking.ts b/packages/queries/src/networking/networking.ts index 1e4ed7f1d70..2ccbe1b62a2 100644 --- a/packages/queries/src/networking/networking.ts +++ b/packages/queries/src/networking/networking.ts @@ -1,10 +1,12 @@ import { createIPv6Range, + getIP, getIPv6RangeInfo, getReservedIP, getReservedIPs, reserveIP, unReserveIP, + updateIP, updateReservedIP, } from '@linode/api-v4'; import { createQueryKeys } from '@lukemorales/query-key-factory'; @@ -55,6 +57,10 @@ export const networkingQueries = createQueryKeys('networking', { }, queryKey: null, }, + ip: (address: string) => ({ + queryFn: () => getIP(address), + queryKey: [address], + }), reservedIPs: (params: Params = {}, filter: Filter = {}) => ({ queryFn: () => getReservedIPs(params, filter), queryKey: [params, filter], @@ -148,6 +154,26 @@ export const useCreateIPv6RangeMutation = () => { }); }; +export const useUpdateIPMutation = (address: string) => { + const queryClient = useQueryClient(); + return useMutation< + IPAddress, + APIError[], + { address: string; rdns: null | string; reserved: boolean } + >({ + mutationFn: (data) => updateIP(address, data.rdns, data.reserved), + onSuccess(ip) { + queryClient.invalidateQueries({ + queryKey: networkingQueries.ips._def, + }); + queryClient.setQueryData( + networkingQueries.ip(address).queryKey, + ip, + ); + }, + }); +}; + export const useReservedIPsQuery = ( params?: Params, filter?: Filter,