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..6e49dbe17bc 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 '../account/types'; 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/.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/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..5327e0f983d --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.test.tsx @@ -0,0 +1,440 @@ +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(), + useUpdateIPMutation: vi.fn(), +})); + +vi.mock('@linode/queries', async (importOriginal) => ({ + ...(await importOriginal()), + useRegionsQuery: queryMocks.useRegionsQuery, + useReserveIPMutation: queryMocks.useReserveIPMutation, + useReservedIPTypesQuery: queryMocks.useReservedIPTypesQuery, + useUpdateReservedIPMutation: queryMocks.useUpdateReservedIPMutation, + useUpdateIPMutation: queryMocks.useUpdateIPMutation, +})); + +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 mockUpdateIP = 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, + }); + queryMocks.useUpdateIPMutation.mockReturnValue({ + mutateAsync: mockUpdateIP, + }); +}; + +beforeEach(() => { + defaultMocks(); + mockOnClose.mockClear(); + mockReserveIP.mockClear(); + mockUpdateReservedIP.mockClear(); + mockUpdateIP.mockClear(); +}); + +const RESERVE_IP_TITLE = 'Reserve an IP Address'; +const REGION_SELECT_TEST_ID = 'region-select'; +const RESERVE_BUTTON_LABEL = 'Reserve IP Address'; +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 updatedIP mutation and closes on submit', async () => { + renderWithTheme( + + ); + + await userEvent.click( + screen.getByRole('button', { name: RESERVE_BUTTON_LABEL }) + ); + + await waitFor(() => { + expect(mockUpdateIP).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..feb55bc3497 --- /dev/null +++ b/packages/manager/src/features/ReservedIps/ReserveIPDrawer.tsx @@ -0,0 +1,303 @@ +/* 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, + useUpdateIPMutation, + 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 { APIError, 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; + 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 { + 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 { mutateAsync: updateIP } = useUpdateIPMutation( + ipAddress?.address ?? '' + ); + const { enqueueSnackbar } = useSnackbar(); + + const isRegionDisabled = + mode === 'edit' || mode === 'reserve' || Boolean(props.region); + + const { + control, + formState: { errors, isDirty, isSubmitting, isValid }, + handleSubmit, + reset, + setError, + } = useForm({ + mode: 'onChange', + values: { + region: props.region ?? ipAddress?.region ?? '', + tags: (ipAddress?.tags ?? []).map((t) => ({ label: t, value: t })), + }, + }); + + const isSubmitDisabled = + isSubmitting || + (mode === 'create' ? !isValid : mode === 'edit' ? !isDirty : false); + + const selectedRegion = useWatch({ control, name: 'region' }); + + const reservedIPPrice = getDCSpecificPriceByType({ + regionId: selectedRegion, + type: reservedIPTypes?.[0], + }); + + const handleClose = () => { + onClose(); + }; + + const onSubmit = async (values: ReserveIPFormValues) => { + try { + const tags = values.tags.map((tag) => tag.value); + + 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 APIError[]) { + 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 ? ( + + ) : ( +
+ + {mode !== 'edit' && ( + + {RESERVE_IP_DESCRIPTION} +
+ Learn more. +
+ )} + + {errors.root?.message && ( + + {errors.root.message} + + )} + + {(mode === 'reserve' || mode === 'edit') && ipAddress?.address && ( + + + IP Address + + ({ + color: theme.palette.text.primary, + display: 'block', + marginTop: theme.spacingFunction(8), + })} + > + {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( + Number(reservedIPPrice) + )} / 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..b00f6e680b4 --- /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/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..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'; @@ -18,7 +20,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 +34,7 @@ import type { IPRange, IPRangeInformation, Params, + PriceType, ReserveIPPayload, ResourcePage, } from '@linode/api-v4'; @@ -50,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], @@ -58,6 +69,10 @@ export const networkingQueries = createQueryKeys('networking', { queryFn: () => getReservedIP(address), queryKey: [address], }), + reservedIPTypes: { + queryFn: getAllReservedIPsTypes, + queryKey: null, + }, }); export const useAllIPsQuery = ( @@ -139,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, @@ -207,3 +242,9 @@ export const useUnReserveIPMutation = (address: string) => { }, }); }; + +export const useReservedIPTypesQuery = () => { + return useQuery({ + ...networkingQueries.reservedIPTypes, + }); +};