diff --git a/packages/api-v4/src/firewalls/types.ts b/packages/api-v4/src/firewalls/types.ts index e54f3718d3e..5d5148b6f3b 100644 --- a/packages/api-v4/src/firewalls/types.ts +++ b/packages/api-v4/src/firewalls/types.ts @@ -2,7 +2,7 @@ export type FirewallStatus = 'enabled' | 'disabled' | 'deleted'; export type FirewallRuleProtocol = 'ALL' | 'TCP' | 'UDP' | 'ICMP' | 'IPENCAP'; -export type FirewallDeviceEntityType = 'linode'; +export type FirewallDeviceEntityType = 'linode' | 'nodebalancer'; export type FirewallPolicyType = 'ACCEPT' | 'DROP'; diff --git a/packages/api-v4/src/nodebalancers/nodebalancers.ts b/packages/api-v4/src/nodebalancers/nodebalancers.ts index 1173a16ad1f..42401c3fb4c 100644 --- a/packages/api-v4/src/nodebalancers/nodebalancers.ts +++ b/packages/api-v4/src/nodebalancers/nodebalancers.ts @@ -17,6 +17,7 @@ import { NodeBalancerStats, } from './types'; import { combineNodeBalancerConfigNodeAddressAndPort } from './utils'; +import type { Firewall } from '../firewalls/types'; /** * getNodeBalancers @@ -107,3 +108,25 @@ export const getNodeBalancerStats = (nodeBalancerId: number) => { setMethod('GET') ); }; + +/** + * getNodeBalancerFirewalls + * + * View Firewall information for Firewalls associated with this NodeBalancer + */ + +export const getNodeBalancerFirewalls = ( + nodeBalancerId: number, + params?: Params, + filter?: Filter +) => + Request>( + setURL( + `${API_ROOT}/nodebalancers/${encodeURIComponent( + nodeBalancerId + )}/firewalls` + ), + setMethod('GET'), + setXFilter(filter), + setParams(params) + ); diff --git a/packages/api-v4/src/nodebalancers/types.ts b/packages/api-v4/src/nodebalancers/types.ts index c3d9aab28d9..f8e93154b0b 100644 --- a/packages/api-v4/src/nodebalancers/types.ts +++ b/packages/api-v4/src/nodebalancers/types.ts @@ -106,9 +106,7 @@ export interface CreateNodeBalancerConfigNode { weight?: number; } -export type UpdateNodeBalancerConfigNode = Partial< - CreateNodeBalancerConfigNode ->; +export type UpdateNodeBalancerConfigNode = Partial; export interface NodeBalancerConfigNode { address: string; @@ -130,4 +128,6 @@ export interface CreateNodeBalancerPayload { label?: string; client_conn_throttle?: number; configs: any; + firewall_id?: number; + tags?: string[]; } diff --git a/packages/manager/.changeset/pr-9886-upcoming-features-1699461580236.md b/packages/manager/.changeset/pr-9886-upcoming-features-1699461580236.md new file mode 100644 index 00000000000..5120a3ce7e8 --- /dev/null +++ b/packages/manager/.changeset/pr-9886-upcoming-features-1699461580236.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Dropdowns in Create Firewall Drawer using Autocomplete instead of updated LinodeSelect and NodeBalancerSelect ([#9886](https://github.com/linode/manager/pull/9886)) diff --git a/packages/manager/.changeset/pr-9926-upcoming-features-1700672014523.md b/packages/manager/.changeset/pr-9926-upcoming-features-1700672014523.md new file mode 100644 index 00000000000..83479822079 --- /dev/null +++ b/packages/manager/.changeset/pr-9926-upcoming-features-1700672014523.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add Firewall documentation links in the Create flows for Linodes, NodeBalancers, and Firewalls and in the Add NodeBalancer to Firewall drawer ([#9926](https://github.com/linode/manager/pull/9926)) diff --git a/packages/manager/.changeset/pr-9931-upcoming-features-1700686098973.md b/packages/manager/.changeset/pr-9931-upcoming-features-1700686098973.md new file mode 100644 index 00000000000..be77bd37fcf --- /dev/null +++ b/packages/manager/.changeset/pr-9931-upcoming-features-1700686098973.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Add feature flag to Firewall-NodeBalancer ([#9931](https://github.com/linode/manager/pull/9931)) diff --git a/packages/manager/.changeset/pr-9974-changed-1701965338643.md b/packages/manager/.changeset/pr-9974-changed-1701965338643.md new file mode 100644 index 00000000000..d7484964b4f --- /dev/null +++ b/packages/manager/.changeset/pr-9974-changed-1701965338643.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Changed +--- + +Revert FW table changes and removed clamp-js dependancy ([#9974](https://github.com/linode/manager/pull/9974)) diff --git a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts index a763eb5a833..0e8d2885fed 100644 --- a/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts +++ b/packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts @@ -124,7 +124,10 @@ const removeFirewallRules = (ruleLabel: string) => { */ const addLinodesToFirewall = (firewall: Firewall, linode: Linode) => { // Go to Linodes tab - ui.tabList.findTabByTitle('Linodes').should('be.visible').click(); + ui.tabList + .findTabByTitle('Linodes', { exact: false }) + .should('be.visible') + .click(); ui.button.findByTitle('Add Linodes to Firewall').should('be.visible').click(); diff --git a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts index f1805d4b738..97be417a554 100644 --- a/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts +++ b/packages/manager/cypress/e2e/core/linodes/linode-config.spec.ts @@ -491,7 +491,7 @@ describe('Linode Config', () => { ui.button.findByTitle('Clone').should('be.disabled'); cy.findByRole('combobox').should('be.visible').click(); ui.select - .findLinodeItemByText('cy-test-clone-destination-linode') + .findItemByText('cy-test-clone-destination-linode') .click(); ui.button.findByTitle('Clone').should('be.enabled').click(); }); diff --git a/packages/manager/cypress/support/ui/tab-list.ts b/packages/manager/cypress/support/ui/tab-list.ts index ae3ac1bbd1a..dd4816696dd 100644 --- a/packages/manager/cypress/support/ui/tab-list.ts +++ b/packages/manager/cypress/support/ui/tab-list.ts @@ -1,3 +1,5 @@ +import type { SelectorMatcherOptions } from '@testing-library/cypress'; + /** * Tab list UI element. */ @@ -15,10 +17,14 @@ export const tabList = { * Finds a tab within a tab list by its title. * * @param tabTitle - Title of tab to find. + * @param options - Selector matcher options. * * @returns Cypress chainable. */ - findTabByTitle: (tabTitle: string): Cypress.Chainable => { - return cy.get('[data-reach-tab-list]').findByText(tabTitle); + findTabByTitle: ( + tabTitle: string, + options?: SelectorMatcherOptions + ): Cypress.Chainable => { + return cy.get('[data-reach-tab-list]').findByText(tabTitle, options); }, }; diff --git a/packages/manager/src/components/ActionsPanel/ActionsPanel.stories.tsx b/packages/manager/src/components/ActionsPanel/ActionsPanel.stories.tsx new file mode 100644 index 00000000000..6b9163beb31 --- /dev/null +++ b/packages/manager/src/components/ActionsPanel/ActionsPanel.stories.tsx @@ -0,0 +1,31 @@ +import { Meta, StoryObj } from '@storybook/react'; +import React from 'react'; + +import { ActionPanelProps, ActionsPanel } from './ActionsPanel'; + +const meta: Meta = { + component: ActionsPanel, + title: 'Components/ActionsPanel', +}; + +type Story = StoryObj; + +const primaryButtonProps = { + label: 'Confirm', +}; + +const secondaryButtonProps = { + label: 'Cancel', +}; + +export const StandardActions: Story = { + args: { + primaryButtonProps, + secondaryButtonProps, + }, + render: (args: ActionPanelProps) => { + return ; + }, +}; + +export default meta; diff --git a/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx b/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx index e5437dfbebb..8b11b9b3042 100644 --- a/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx +++ b/packages/manager/src/components/ActionsPanel/ActionsPanel.tsx @@ -3,7 +3,6 @@ import cx from 'classnames'; import * as React from 'react'; import { Button, ButtonProps } from 'src/components/Button/Button'; -import { RenderGuard } from 'src/components/RenderGuard'; import { Box, BoxProps } from '../Box'; @@ -14,19 +13,22 @@ interface ActionButtonsProps extends ButtonProps { label: string; } -interface ActionPanelProps extends BoxProps { +export interface ActionPanelProps extends BoxProps { /** * primary type actionable button custom aria descripton. */ primaryButtonProps?: ActionButtonsProps; - /** * secondary type actionable button custom aria descripton. */ secondaryButtonProps?: ActionButtonsProps; } -const ActionsPanelComponent = (props: ActionPanelProps) => { +/** + * `ActionPanel` is a container for primary and secondary actions (ex: "Cancel" & "Save") + * It can also be used to render a single action within modals or drawers for styling and layout consistency. + */ +export const ActionsPanel = (props: ActionPanelProps) => { const { className, primaryButtonProps, @@ -83,7 +85,3 @@ const StyledBox = styled(Box)(({ theme: { spacing } }) => ({ paddingBottom: spacing(1), paddingTop: spacing(1), })); - -const ActionsPanel = RenderGuard(ActionsPanelComponent); - -export { ActionsPanel }; diff --git a/packages/manager/src/components/Autocomplete/Autocomplete.tsx b/packages/manager/src/components/Autocomplete/Autocomplete.tsx index 359b89cc8ec..923efc6d703 100644 --- a/packages/manager/src/components/Autocomplete/Autocomplete.tsx +++ b/packages/manager/src/components/Autocomplete/Autocomplete.tsx @@ -121,7 +121,7 @@ export const Autocomplete = < return renderOption ? ( renderOption(props, option, state, ownerState) ) : ( - + <> { it('should render', async () => { const wrapper = renderWithTheme( Testing} selectedFirewallId={-1} @@ -34,14 +38,16 @@ describe('SelectFirewallPanel', () => { }); }); - it('should open a Create Firewall drawer when the link is clicked', async () => { + it('should open a Create Firewall drawer when the link is clicked in Linode Create', async () => { const wrapper = renderWithTheme( Testing} selectedFirewallId={-1} />, { + flags: { firewallNodebalancer: true }, queryClient, } ); @@ -51,7 +57,34 @@ describe('SelectFirewallPanel', () => { fireEvent.click(createFirewallLink); await waitFor(() => { - expect(wrapper.getByLabelText(createFirewallLabel)).toBeInTheDocument(); + expect( + wrapper.getByLabelText(LINODE_CREATE_FLOW_TEXT) + ).toBeInTheDocument(); + }); + }); + + it('should open a Create Firewall drawer when the link is clicked in NodeBalancer Create', async () => { + const wrapper = renderWithTheme( + Testing} + selectedFirewallId={-1} + />, + { + flags: { firewallNodebalancer: true }, + queryClient, + } + ); + + const createFirewallLink = wrapper.getByText('Create Firewall'); + + fireEvent.click(createFirewallLink); + + await waitFor(() => { + expect( + wrapper.getByLabelText(NODEBALANCER_CREATE_FLOW_TEXT) + ).toBeInTheDocument(); }); }); }); diff --git a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx index 9d69c29f55b..9ef30ea4ff5 100644 --- a/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx +++ b/packages/manager/src/components/SelectFirewallPanel/SelectFirewallPanel.tsx @@ -1,4 +1,4 @@ -import { Firewall } from '@linode/api-v4'; +import { Firewall, FirewallDeviceEntityType } from '@linode/api-v4'; import { styled } from '@mui/material/styles'; import * as React from 'react'; @@ -13,15 +13,19 @@ import { Autocomplete } from '../Autocomplete/Autocomplete'; import { LinkButton } from '../LinkButton'; interface Props { + entityType: FirewallDeviceEntityType | undefined; handleFirewallChange: (firewallID: number) => void; helperText: JSX.Element; selectedFirewallId: number; } -export const createFirewallLabel = 'Additional Linodes (Optional)'; - export const SelectFirewallPanel = (props: Props) => { - const { handleFirewallChange, helperText, selectedFirewallId } = props; + const { + entityType, + handleFirewallChange, + helperText, + selectedFirewallId, + } = props; const [isDrawerOpen, setIsDrawerOpen] = React.useState(false); @@ -79,7 +83,7 @@ export const SelectFirewallPanel = (props: Props) => { setIsDrawerOpen(false)} onFirewallCreated={handleFirewallCreated} open={isDrawerOpen} diff --git a/packages/manager/src/components/Tag/Tag.styles.ts b/packages/manager/src/components/Tag/Tag.styles.ts index 3010a22d869..a54f9b67755 100644 --- a/packages/manager/src/components/Tag/Tag.styles.ts +++ b/packages/manager/src/components/Tag/Tag.styles.ts @@ -17,7 +17,7 @@ export const StyledChip = styled(Chip, { }, borderRadius: 4, color: theme.name === 'light' ? '#3a3f46' : '#fff', - fontWeight: 'normal', + fontFamily: theme.font.normal, maxWidth: 350, padding: '7px 10px', }, diff --git a/packages/manager/src/constants.ts b/packages/manager/src/constants.ts index 67e3c6f2869..73620853c25 100644 --- a/packages/manager/src/constants.ts +++ b/packages/manager/src/constants.ts @@ -267,3 +267,11 @@ export const DEVELOPERS_LINK = 'https://developers.linode.com'; // URL validators export const OFFSITE_URL_REGEX = /(?=.{1,2000}$)((\s)*((ht|f)tp(s?):\/\/|mailto:)[A-Za-z0-9]+[~a-zA-Z0-9-_\.@\#\$%&;:,\?=/\+!\(\)]*(\s)*)/; export const ONSITE_URL_REGEX = /^([A-Za-z0-9/\.\?=&\-~]){1,2000}$/; + +// Firewall links +export const CREATE_FIREWALL_LINK = + 'https://www.linode.com/docs/products/networking/cloud-firewall/guides/create-a-cloud-firewall/'; +export const FIREWALL_GET_STARTED_LINK = + 'https://www.linode.com/docs/products/networking/cloud-firewall/get-started/'; +export const FIREWALL_LIMITS_CONSIDERATIONS_LINK = + 'https://www.linode.com/docs/products/networking/cloud-firewall/#limits-and-considerations'; diff --git a/packages/manager/src/dev-tools/FeatureFlagTool.tsx b/packages/manager/src/dev-tools/FeatureFlagTool.tsx index 35ef73c8b83..1b0a672705a 100644 --- a/packages/manager/src/dev-tools/FeatureFlagTool.tsx +++ b/packages/manager/src/dev-tools/FeatureFlagTool.tsx @@ -20,6 +20,7 @@ const options: { flag: keyof Flags; label: string }[] = [ { flag: 'selfServeBetas', label: 'Self Serve Betas' }, { flag: 'unifiedMigrations', label: 'Unified Migrations' }, { flag: 'vpc', label: 'VPC' }, + { flag: 'firewallNodebalancer', label: 'Firewall NodeBalancer' }, { flag: 'recharts', label: 'Recharts' }, { flag: 'objMultiCluster', label: 'OBJ Multi-Cluster' }, ]; diff --git a/packages/manager/src/featureFlags.ts b/packages/manager/src/featureFlags.ts index f1cdd037a32..2a59a479cf9 100644 --- a/packages/manager/src/featureFlags.ts +++ b/packages/manager/src/featureFlags.ts @@ -46,6 +46,7 @@ export interface Flags { databaseBeta: boolean; databases: boolean; dcGetWell: boolean; + firewallNodebalancer: boolean; ipv6Sharing: boolean; kubernetesDashboardAvailability: boolean; linodeCreateWithFirewall: boolean; diff --git a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx index fa4b2a4fca2..860c1223cb0 100644 --- a/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx +++ b/packages/manager/src/features/Domains/CreateDomain/CreateDomain.tsx @@ -413,12 +413,12 @@ export const CreateDomain = () => { defaultRecordsSetting.value === 'nodebalancer' && ( - setSelectedDefaultNodeBalancer(nodebalancer) + onSelectionChange={(value) => + setSelectedDefaultNodeBalancer(value ?? undefined) } disabled={disabled} - error={errorMap.defaultNodeBalancer} - value={selectedDefaultNodeBalancer?.id} + errorText={errorMap.defaultNodeBalancer} + value={selectedDefaultNodeBalancer?.id ?? null} /> {!errorMap.defaultNodeBalancer && ( diff --git a/packages/manager/src/features/Events/eventMessageGenerator.ts b/packages/manager/src/features/Events/eventMessageGenerator.ts index 31f5d012476..7f2f3c97c69 100644 --- a/packages/manager/src/features/Events/eventMessageGenerator.ts +++ b/packages/manager/src/features/Events/eventMessageGenerator.ts @@ -10,6 +10,8 @@ import { import { escapeRegExp } from 'src/utilities/escapeRegExp'; import { getLinkForEvent } from 'src/utilities/getEventsActionLink'; +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + export type EventMessageCreator = (e: Event) => string; export interface CreatorsForStatus { @@ -29,6 +31,14 @@ export const safeSecondaryEntityLabel = ( return label ? `${text} ${label}` : fallback; }; +const secondaryFirewallEntityNameMap: Record< + FirewallDeviceEntityType, + string +> = { + linode: 'Linode', + nodebalancer: 'NodeBalancer', +}; + export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { account_agreement_eu_model: { notification: () => 'The EU Model Contract has been signed.', @@ -262,12 +272,30 @@ export const eventMessageCreators: { [index: string]: CreatorsForStatus } = { notification: (e) => `Firewall ${e.entity?.label ?? ''} has been deleted.`, }, firewall_device_add: { - notification: (e) => - `A device has been added to Firewall ${e.entity?.label ?? ''}.`, + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return `${secondaryEntityName} ${ + e.secondary_entity?.label + } has been added to Firewall ${e.entity?.label ?? ''}.`; + } + return `A device has been added to Firewall ${e.entity?.label ?? ''}.`; + }, }, firewall_device_remove: { - notification: (e) => - `A device has been removed from Firewall ${e.entity?.label ?? ''}.`, + notification: (e) => { + if (e.secondary_entity?.type) { + const secondaryEntityName = + secondaryFirewallEntityNameMap[e.secondary_entity.type]; + return `${secondaryEntityName} ${ + e.secondary_entity?.label + } has been removed from Firewall ${e.entity?.label ?? ''}.`; + } + return `A device has been removed from Firewall ${ + e.entity?.label ?? '' + }.`; + }, }, firewall_disable: { notification: (e) => `Firewall ${e.entity?.label ?? ''} has been disabled.`, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx deleted file mode 100644 index 0099a36eb4a..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddDeviceDrawer.tsx +++ /dev/null @@ -1,163 +0,0 @@ -import { useTheme } from '@mui/material/styles'; -import * as React from 'react'; -import { useParams } from 'react-router-dom'; - -import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; -import { Drawer } from 'src/components/Drawer'; -import { Link } from 'src/components/Link'; -import { Notice } from 'src/components/Notice/Notice'; -import { SupportLink } from 'src/components/SupportLink'; -import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; -import { - useAddFirewallDeviceMutation, - useAllFirewallDevicesQuery, - useFirewallQuery, -} from 'src/queries/firewalls'; -import { useGrants, useProfile } from 'src/queries/profile'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; -import { getEntityIdsByPermission } from 'src/utilities/grants'; - -import { READ_ONLY_LINODES_HIDDEN_MESSAGE } from '../../FirewallLanding/CreateFirewallDrawer'; - -interface Props { - label?: string; - onClose: () => void; - open: boolean; -} - -export const AddDeviceDrawer = (props: Props) => { - const { label, onClose, open } = props; - - const { id } = useParams<{ id: string }>(); - - const { data: grants } = useGrants(); - const { data: profile } = useProfile(); - const isRestrictedUser = Boolean(profile?.restricted); - - const { data: firewall } = useFirewallQuery(Number(id)); - const { - data: currentDevices, - isLoading: currentDevicesLoading, - } = useAllFirewallDevicesQuery(Number(id)); - - const currentLinodeIds = - currentDevices - ?.filter((device) => device.entity.type === 'linode') - .map((device) => device.entity.id) ?? []; - - const { - error, - isLoading, - mutateAsync: addDevice, - } = useAddFirewallDeviceMutation(Number(id)); - const theme = useTheme(); - - const [selectedLinodeIds, setSelectedLinodeIds] = React.useState( - [] - ); - - const handleSubmit = async () => { - await Promise.all( - selectedLinodeIds.map((id) => addDevice({ id, type: 'linode' })) - ); - onClose(); - setSelectedLinodeIds([]); - }; - - // @todo title and error messaging will update to "Device" once NodeBalancers are allowed - const errorMessage = error - ? getAPIErrorOrDefault(error, 'Error adding Linode')[0].reason - : undefined; - - // @todo update regex once error messaging updates - const errorNotice = (errorMsg: string) => { - // match something like: Linode (ID ) - const linode = /linode (.+?) \(id ([^()]*)\)/i.exec(errorMsg); - const openTicket = errorMsg.match(/open a support ticket\./i); - if (openTicket) { - errorMsg = errorMsg.replace(/open a support ticket\./i, ''); - } - if (linode) { - const [, label, id] = linode; - const labelIndex = errorMsg.indexOf(label); - errorMsg = errorMsg.replace(/\(id ([^()]*)\)/i, ''); - return ( - - {errorMsg.substring(0, labelIndex)} - {label} - {errorMsg.substring(labelIndex + label.length)} - {openTicket ? ( - <> - . - - ) : null} - - ); - } else { - return ; - } - }; - - // If a user is restricted, they can not add a read-only Linode to a firewall. - const readOnlyLinodeIds = isRestrictedUser - ? getEntityIdsByPermission(grants, 'linode', 'read_only') - : []; - - const linodeSelectGuidance = - readOnlyLinodeIds.length > 0 ? READ_ONLY_LINODES_HIDDEN_MESSAGE : undefined; - - return ( - -
) => { - e.preventDefault(); - handleSubmit(); - }} - > - {errorMessage ? errorNotice(errorMessage) : null} - - setSelectedLinodeIds(linodes.map((linode) => linode.id)) - } - optionsFilter={(linode) => - ![...readOnlyLinodeIds, ...currentLinodeIds].includes(linode.id) - } - disabled={currentDevicesLoading} - label={label} - loading={currentDevicesLoading} - multiple - noOptionsMessage="No Linodes available to add" - value={selectedLinodeIds} - /> - - -
- ); -}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx new file mode 100644 index 00000000000..2e14b866959 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.test.tsx @@ -0,0 +1,35 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AddLinodeDrawer } from './AddLinodeDrawer'; + +const helperText = 'helper text'; +const onClose = vi.fn(); + +const props = { + helperText, + onClose, + open: true, +}; +describe('AddLinodeDrawer', () => { + it('should contain helper text', () => { + const { getByText } = renderWithTheme(); + expect(getByText(helperText)).toBeInTheDocument(); + }); + + it('should contain a Linodes label', () => { + const { getByText } = renderWithTheme(); + expect(getByText('Linodes')).toBeInTheDocument(); + }); + + it('should contain a Linodes input dropdown', () => { + const { getByTestId } = renderWithTheme(); + expect(getByTestId('add-linode-autocomplete')).toBeInTheDocument(); + }); + + it('should contain an Add button', () => { + const { getByText } = renderWithTheme(); + expect(getByText('Add')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx new file mode 100644 index 00000000000..617e44b54ce --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddLinodeDrawer.tsx @@ -0,0 +1,219 @@ +import { Linode } from '@linode/api-v4'; +import { useTheme } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useParams } from 'react-router-dom'; +import sanitize from 'sanitize-html'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { SupportLink } from 'src/components/SupportLink'; +import { + useAddFirewallDeviceMutation, + useAllFirewallDevicesQuery, + useFirewallQuery, +} from 'src/queries/firewalls'; +import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getEntityIdsByPermission } from 'src/utilities/grants'; + +interface Props { + helperText: string; + onClose: () => void; + open: boolean; +} + +export const AddLinodeDrawer = (props: Props) => { + const { helperText, onClose, open } = props; + + const { id } = useParams<{ id: string }>(); + + const { enqueueSnackbar } = useSnackbar(); + + const { data: grants } = useGrants(); + const { data: profile } = useProfile(); + const isRestrictedUser = Boolean(profile?.restricted); + + const { data: firewall } = useFirewallQuery(Number(id)); + const { + data: currentDevices, + isLoading: currentDevicesLoading, + } = useAllFirewallDevicesQuery(Number(id)); + + const theme = useTheme(); + + const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( + Number(id) + ); + + const [selectedLinodes, setSelectedLinodes] = React.useState([]); + + const [localError, setLocalError] = React.useState( + undefined + ); + + const handleSubmit = async () => { + let firstError: string | undefined = undefined; + const failedLinodes: Linode[] = []; + + const results = await Promise.allSettled( + selectedLinodes.map((linode) => + addDevice({ id: linode.id, type: 'linode' }) + ) + ); + + results.forEach((result, index) => { + const label = selectedLinodes[index].label; + const id = selectedLinodes[index].id; + if (result.status === 'fulfilled') { + enqueueSnackbar(`Linode ${label} successfully added.`, { + variant: 'success', + }); + return; + } + failedLinodes?.push(selectedLinodes[index]); + const errorReason = getAPIErrorOrDefault( + result.reason, + `Failed to add Linode ${label} (ID ${id}).` + )[0].reason; + + if (!firstError) { + firstError = errorReason; + } + }); + + setLocalError(firstError); + setSelectedLinodes(failedLinodes); + + if (!firstError) { + onClose(); + } + }; + + const errorNotice = () => { + let errorMsg = sanitize(localError || '', { + allowedAttributes: {}, + allowedTags: [], // Disallow all HTML tags, + }); + // match something like: Linode (ID ) + + const linode = /Linode (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); + const openTicket = errorMsg.match(/open a support ticket\./i); + + if (openTicket) { + errorMsg = errorMsg.replace(/open a support ticket\./i, ''); + } + + if (linode) { + const [, label, id] = linode; + + // Break the errorMsg into two parts: before and after the linode pattern + const startMsg = errorMsg.substring( + 0, + errorMsg.indexOf(`Linode ${label}`) + ); + const endMsg = errorMsg.substring( + errorMsg.indexOf(`(ID ${id})`) + `(ID ${id})`.length + ); + + return ( + + {startMsg} + {label} + {endMsg} + {openTicket ? ( + <> + . + + ) : null} + + ); + } else { + return ; + } + }; + + const currentLinodeIds = + currentDevices + ?.filter((device) => device.entity.type === 'linode') + .map((device) => device.entity.id) ?? []; + + // If a user is restricted, they can not add a read-only Linode to a firewall. + const readOnlyLinodeIds = isRestrictedUser + ? getEntityIdsByPermission(grants, 'linode', 'read_only') + : []; + + const optionsFilter = (linode: Linode) => { + return ![...currentLinodeIds, ...readOnlyLinodeIds].includes(linode.id); + }; + + const { + data, + error: linodeError, + isLoading: linodeIsLoading, + } = useAllLinodesQuery(); + + React.useEffect(() => { + if (linodeError) { + setLocalError('Could not load Linode Data'); + } + }, [linodeError]); + + const linodes = data?.filter(optionsFilter); + + return ( + { + setSelectedLinodes([]); + setLocalError(undefined); + onClose(); + }} + open={open} + title={`Add Linode to Firewall: ${firewall?.label}`} + > +
) => { + e.preventDefault(); + handleSubmit(); + }} + > + {localError ? errorNotice() : null} + setSelectedLinodes(linodes)} + options={linodes || []} + value={selectedLinodes} + /> + + +
+ ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx new file mode 100644 index 00000000000..36d229be748 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.test.tsx @@ -0,0 +1,38 @@ +import * as React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { AddNodebalancerDrawer } from './AddNodebalancerDrawer'; + +const helperText = 'helper text'; +const onClose = vi.fn(); + +const props = { + helperText, + onClose, + open: true, +}; + +describe('AddNodeBalancerDrawer', () => { + it('should contain helper text', () => { + const { getByText } = renderWithTheme(); + expect(getByText(helperText)).toBeInTheDocument(); + }); + + it('should contain a NodeBalancers label', () => { + const { getByText } = renderWithTheme(); + expect(getByText('NodeBalancers')).toBeInTheDocument(); + }); + + it('should contain a Nodebalancer input dropdown', () => { + const { getByTestId } = renderWithTheme( + + ); + expect(getByTestId('add-nodebalancer-autocomplete')).toBeInTheDocument(); + }); + + it('should contain an Add button', () => { + const { getByText } = renderWithTheme(); + expect(getByText('Add')).toBeInTheDocument(); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx new file mode 100644 index 00000000000..31479ea02f3 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/AddNodebalancerDrawer.tsx @@ -0,0 +1,236 @@ +import { NodeBalancer } from '@linode/api-v4'; +import { useTheme } from '@mui/material'; +import { useSnackbar } from 'notistack'; +import * as React from 'react'; +import { useQueryClient } from 'react-query'; +import { useParams } from 'react-router-dom'; +import sanitize from 'sanitize-html'; + +import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; +import { Notice } from 'src/components/Notice/Notice'; +import { SupportLink } from 'src/components/SupportLink'; +import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; +import { + useAddFirewallDeviceMutation, + useAllFirewallDevicesQuery, + useFirewallQuery, +} from 'src/queries/firewalls'; +import { queryKey } from 'src/queries/nodebalancers'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { useGrants, useProfile } from 'src/queries/profile'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; +import { getEntityIdsByPermission } from 'src/utilities/grants'; + +interface Props { + helperText: string; + onClose: () => void; + open: boolean; +} + +export const AddNodebalancerDrawer = (props: Props) => { + const { helperText, onClose, open } = props; + const { enqueueSnackbar } = useSnackbar(); + const { id } = useParams<{ id: string }>(); + const { data: grants } = useGrants(); + const { data: profile } = useProfile(); + const isRestrictedUser = Boolean(profile?.restricted); + const queryClient = useQueryClient(); + const { data: firewall } = useFirewallQuery(Number(id)); + const { + data: currentDevices, + isLoading: currentDevicesLoading, + } = useAllFirewallDevicesQuery(Number(id)); + + const theme = useTheme(); + + const { isLoading, mutateAsync: addDevice } = useAddFirewallDeviceMutation( + Number(id) + ); + + const [selectedNodebalancers, setSelectedNodebalancers] = React.useState< + NodeBalancer[] + >([]); + + const [localError, setLocalError] = React.useState( + undefined + ); + + const handleSubmit = async () => { + let firstError: string | undefined = undefined; + const failedNodebalancers: NodeBalancer[] = []; + + const results = await Promise.allSettled( + selectedNodebalancers.map((nodebalancer) => + addDevice({ id: nodebalancer.id, type: 'nodebalancer' }) + ) + ); + + results.forEach((result, index) => { + const label = selectedNodebalancers[index].label; + const id = selectedNodebalancers[index].id; + if (result.status === 'fulfilled') { + enqueueSnackbar(`NodeBalancer ${label} successfully added`, { + variant: 'success', + }); + queryClient.invalidateQueries([ + queryKey, + 'nodebalancer', + id, + 'firewalls', + ]); + return; + } + failedNodebalancers.push(selectedNodebalancers[index]); + const errorReason = getAPIErrorOrDefault( + result.reason, + `Failed to add NodeBalancer ${label} (ID ${id}).` + )[0].reason; + + if (!firstError) { + firstError = errorReason; + } + }); + + setLocalError(firstError); + setSelectedNodebalancers(failedNodebalancers); + + if (!firstError) { + onClose(); + } + }; + + const errorNotice = () => { + let errorMsg = sanitize(localError || '', { + allowedAttributes: {}, + allowedTags: [], // Disallow all HTML tags, + }); + // match something like: NodeBalancer (ID ) + + const nodebalancer = /NodeBalancer (.+?) \(ID ([^\)]+)\)/i.exec(errorMsg); + const openTicket = errorMsg.match(/open a support ticket\./i); + + if (openTicket) { + errorMsg = errorMsg.replace(/open a support ticket\./i, ''); + } + + if (nodebalancer) { + const [, label, id] = nodebalancer; + + // Break the errorMsg into two parts: before and after the NodeBalancer pattern + const startMsg = errorMsg.substring( + 0, + errorMsg.indexOf(`NodeBalancer ${label}`) + ); + const endMsg = errorMsg.substring( + errorMsg.indexOf(`(ID ${id})`) + `(ID ${id})`.length + ); + + return ( + + {startMsg} + {label} + {endMsg} + {openTicket ? ( + <> + . + + ) : null} + + ); + } else { + return ; + } + }; + + const currentNodebalancerIds = + currentDevices + ?.filter((device) => device.entity.type === 'nodebalancer') + .map((device) => device.entity.id) ?? []; + + // If a user is restricted, they can not add a read-only Nodebalancer to a firewall. + const readOnlyNodebalancerIds = isRestrictedUser + ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') + : []; + + const optionsFilter = (nodebalancer: NodeBalancer) => { + return ![...currentNodebalancerIds, ...readOnlyNodebalancerIds].includes( + nodebalancer.id + ); + }; + + const { + data, + error: nodebalancerError, + isLoading: nodebalancerIsLoading, + } = useAllNodeBalancersQuery(); + + React.useEffect(() => { + if (nodebalancerError) { + setLocalError('Could not load NodeBalancer Data'); + } + }, [nodebalancerError]); + + const nodebalancers = data?.filter(optionsFilter); + + return ( + { + setSelectedNodebalancers([]); + setLocalError(undefined); + onClose(); + }} + open={open} + title={`Add Nodebalancer to Firewall: ${firewall?.label}`} + > + + Only the Firewall's inbound rules apply to NodeBalancers. Any existing + outbound rules won't be applied.{' '} + Learn more. + +
) => { + e.preventDefault(); + handleSubmit(); + }} + > + {localError ? errorNotice() : null} + + setSelectedNodebalancers(nodebalancers) + } + data-testid="add-nodebalancer-autocomplete" + disabled={currentDevicesLoading || nodebalancerIsLoading} + helperText={helperText} + label="NodeBalancers" + loading={currentDevicesLoading || nodebalancerIsLoading} + multiple + noOptionsText="No NodeBalancers available to add" + options={nodebalancers || []} + value={selectedNodebalancers} + /> + + +
+ ); +}; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx index ab0099d945f..ff489aae8f8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceActionMenu.tsx @@ -6,10 +6,13 @@ export interface ActionHandlers { triggerRemoveDevice: (deviceID: number, label: string) => void; } +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + export interface FirewallDeviceActionMenuProps extends ActionHandlers { deviceEntityID: number; deviceID: number; deviceLabel: string; + deviceType: FirewallDeviceEntityType; disabled: boolean; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx new file mode 100644 index 00000000000..c65298ee2e3 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.test.tsx @@ -0,0 +1,98 @@ +import { fireEvent } from '@testing-library/react'; +import { createMemoryHistory } from 'history'; +import * as React from 'react'; +import { Router } from 'react-router-dom'; + +import { firewallDeviceFactory } from 'src/factories'; +import { rest, server } from 'src/mocks/testServer'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { + FirewallDeviceLanding, + FirewallDeviceLandingProps, +} from './FirewallDeviceLanding'; + +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +const baseProps = ( + type: FirewallDeviceEntityType +): FirewallDeviceLandingProps => ({ + disabled: false, + firewallId: 1, + firewallLabel: 'test', + type, +}); + +const disabledProps = (type: FirewallDeviceEntityType) => ({ + ...baseProps(type), + disabled: true, +}); + +const services = ['linode', 'nodebalancer']; + +services.forEach((service: FirewallDeviceEntityType) => { + const serviceName = service === 'linode' ? 'Linode' : 'NodeBalancer'; + + describe(`Firewall ${serviceName} landing page`, () => { + const props = [baseProps(service), disabledProps(service)]; + + props.forEach((prop) => { + it('should render the component', () => { + server.use( + rest.get('*/firewalls/*', (req, res, ctx) => { + return res(ctx.json(firewallDeviceFactory.buildList(1))); + }) + ); + const { getByRole, getByTestId } = renderWithTheme( + + ); + const addButton = getByTestId('add-device-button'); + const table = getByRole('table'); + + expect(addButton).toBeInTheDocument(); + expect(table).toBeInTheDocument(); + }); + + if (prop.disabled) { + it(`should contain a disabled Add ${serviceName} button`, () => { + const { getByTestId } = renderWithTheme( + + ); + const addButton = getByTestId('add-device-button'); + + expect(addButton).toBeDisabled(); + }); + it('should contain permission notice when disabled', () => { + const { getByRole } = renderWithTheme( + + ); + const permissionNotice = getByRole('alert'); + expect(permissionNotice).toBeInTheDocument(); + }); + } + + if (!prop.disabled) { + it(`should contain an enabled Add ${serviceName} button`, () => { + const { getByTestId } = renderWithTheme( + + ); + const addButton = getByTestId('add-device-button'); + + expect(addButton).toBeEnabled(); + }); + it(`should navigate to Add ${serviceName} To Firewall drawer when enabled`, () => { + const history = createMemoryHistory(); + const { getByTestId } = renderWithTheme( + + + + ); + const addButton = getByTestId('add-device-button'); + fireEvent.click(addButton); + const baseUrl = '/'; + expect(history.location.pathname).toBe(baseUrl + '/add'); + }); + } + }); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx new file mode 100644 index 00000000000..1e1dbaa9f86 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceLanding.tsx @@ -0,0 +1,199 @@ +import Grid from '@mui/material/Unstable_Grid2'; +import { styled } from '@mui/material/styles'; +import { useTheme } from '@mui/material/styles'; +import * as React from 'react'; +import { useHistory, useLocation, useRouteMatch } from 'react-router-dom'; + +import { Button } from 'src/components/Button/Button'; +import { DebouncedSearchTextField } from 'src/components/DebouncedSearchTextField'; +import { Notice } from 'src/components/Notice/Notice'; +import { Typography } from 'src/components/Typography'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; + +import { AddLinodeDrawer } from './AddLinodeDrawer'; +import { AddNodebalancerDrawer } from './AddNodebalancerDrawer'; +import { FirewallDeviceTable } from './FirewallDeviceTable'; +import { RemoveDeviceDialog } from './RemoveDeviceDialog'; + +import type { FirewallDevice, FirewallDeviceEntityType } from '@linode/api-v4'; + +export interface FirewallDeviceLandingProps { + disabled: boolean; + firewallId: number; + firewallLabel: string; + type: FirewallDeviceEntityType; +} + +export const formattedTypes = { + linode: 'Linode', + nodebalancer: 'NodeBalancer', +}; + +export const FirewallDeviceLanding = React.memo( + (props: FirewallDeviceLandingProps) => { + const { disabled, firewallId, firewallLabel, type } = props; + + const { data: allDevices, error, isLoading } = useAllFirewallDevicesQuery( + firewallId + ); + + const theme = useTheme(); + + const history = useHistory(); + const routeMatch = useRouteMatch(); + const location = useLocation(); + + const flags = useFlags(); + const entityName = flags.firewallNodebalancer ? 'services' : 'Linodes'; + const helperText = `Assign one or more ${entityName} to this firewall. You can add ${entityName} later if you want to customize your rules first.`; + + React.useEffect(() => { + if (location.pathname.endsWith('add')) { + setDeviceDrawerOpen(true); + } + }, [location.pathname]); + + const devices = + allDevices?.filter((device) => device.entity.type === type) || []; + + const [filteredDevices, setFilteredDevices] = React.useState< + FirewallDevice[] + >([]); + + React.useEffect(() => { + setFilteredDevices(devices); + }, [allDevices]); + + const [ + isRemoveDeviceDialogOpen, + setIsRemoveDeviceDialogOpen, + ] = React.useState(false); + + const [selectedDeviceId, setSelectedDeviceId] = React.useState(-1); + + const selectedDevice = filteredDevices?.find( + (device) => device.id === selectedDeviceId + ); + + const [addDeviceDrawerOpen, setDeviceDrawerOpen] = React.useState( + false + ); + + const handleClose = () => { + setDeviceDrawerOpen(false); + history.push(routeMatch.url); + }; + + const handleOpen = () => { + setDeviceDrawerOpen(true); + history.push(routeMatch.url + '/add'); + }; + + const [searchText, setSearchText] = React.useState(''); + + const filter = (value: string) => { + setSearchText(value); + const filtered = devices?.filter((device) => { + return device.entity.label.toLowerCase().includes(value.toLowerCase()); + }); + setFilteredDevices(filtered ?? []); + }; + + const formattedType = formattedTypes[type]; + + return ( + <> + {disabled ? ( + + ) : null} + + + The following {formattedType}s have been assigned to this Firewall. + A {formattedType} can only be assigned to a single Firewall. + + + + { + filter(val); + }} + debounceTime={250} + expand={true} + hideLabel + label="" + placeholder={`Search ${formattedType}s`} + value={searchText} + /> + + + + + + + { + setSelectedDeviceId(id); + setIsRemoveDeviceDialogOpen(true); + }} + deviceType={type} + devices={filteredDevices ?? []} + disabled={disabled} + error={error ?? undefined} + loading={isLoading} + /> + {type === 'linode' ? ( + + ) : ( + + )} + setIsRemoveDeviceDialogOpen(false)} + onService={undefined} + open={isRemoveDeviceDialogOpen} + /> + + ); + } +); + +const StyledTypography = styled(Typography, { label: 'StyledTypography' })( + ({ theme }) => ({ + fontSize: '0.875rem', + marginBottom: theme.spacing(2), + marginTop: theme.spacing(), + }) +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx index 0c8384e3e94..fe17defa93e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceRow.tsx @@ -11,7 +11,7 @@ import { export const FirewallDeviceRow = React.memo( (props: FirewallDeviceActionMenuProps) => { - const { deviceEntityID, deviceID, deviceLabel } = props; + const { deviceEntityID, deviceID, deviceLabel, deviceType } = props; return ( - + {deviceLabel} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx new file mode 100644 index 00000000000..f56aa9160f1 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.test.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; + +import { firewallDeviceFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { FirewallDeviceTable } from './FirewallDeviceTable'; + +import type { FirewallDeviceTableProps } from './FirewallDeviceTable'; +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +const devices = ['linode', 'nodebalancer']; + +const props = (type: FirewallDeviceEntityType): FirewallDeviceTableProps => ({ + deviceType: type, + devices: firewallDeviceFactory.buildList(2), + disabled: false, + error: undefined, + loading: false, + triggerRemoveDevice: vi.fn(), +}); + +devices.forEach((device: FirewallDeviceEntityType) => { + describe(`Firewall ${device} table`, () => { + it('should render', () => { + const { getByRole } = renderWithTheme( + + ); + const table = getByRole('table'); + expect(table).toBeInTheDocument(); + }); + }); + + it('should contain two rows', () => { + const { getAllByRole } = renderWithTheme( + + ); + const rows = getAllByRole('row'); + expect(rows.length - 1).toBe(2); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx new file mode 100644 index 00000000000..6856e083328 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDeviceTable.tsx @@ -0,0 +1,113 @@ +import { FirewallDevice } from '@linode/api-v4/lib/firewalls/types'; +import { APIError } from '@linode/api-v4/lib/types'; +import * as React from 'react'; + +import OrderBy from 'src/components/OrderBy'; +import Paginate from 'src/components/Paginate'; +import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableSortCell } from 'src/components/TableSortCell'; +import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; + +import { formattedTypes } from './FirewallDeviceLanding'; +import { FirewallDeviceRow } from './FirewallDeviceRow'; + +import type { FirewallDeviceEntityType } from '@linode/api-v4'; + +export interface FirewallDeviceTableProps { + deviceType: FirewallDeviceEntityType; + devices: FirewallDevice[]; + disabled: boolean; + error?: APIError[]; + loading: boolean; + triggerRemoveDevice: (deviceID: number) => void; +} + +export const FirewallDeviceTable = React.memo( + (props: FirewallDeviceTableProps) => { + const { + deviceType, + devices, + disabled, + error, + loading, + triggerRemoveDevice, + } = props; + + const _error = error + ? getAPIErrorOrDefault( + error, + `Unable to retrieve ${formattedTypes[deviceType]}s` + ) + : undefined; + + const ariaLabel = `List of ${formattedTypes[deviceType]}s attached to this firewall`; + + return ( + + {({ data: orderedData, handleOrderChange, order, orderBy }) => ( + + {({ + count, + data: paginatedAndOrderedData, + handlePageChange, + handlePageSizeChange, + page, + pageSize, + }) => ( + <> + + + + + {formattedTypes[deviceType]} + + + + + + {paginatedAndOrderedData.map((thisDevice) => ( + + ))} + + +
+ + + )} +
+ )} +
+ ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDevicesTable.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDevicesTable.tsx deleted file mode 100644 index 5c71683b62c..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallDevicesTable.tsx +++ /dev/null @@ -1,95 +0,0 @@ -import { FirewallDevice } from '@linode/api-v4/lib/firewalls/types'; -import { APIError } from '@linode/api-v4/lib/types'; -import * as React from 'react'; - -import OrderBy from 'src/components/OrderBy'; -import Paginate from 'src/components/Paginate'; -import { PaginationFooter } from 'src/components/PaginationFooter/PaginationFooter'; -import { Table } from 'src/components/Table'; -import { TableBody } from 'src/components/TableBody'; -import { TableContentWrapper } from 'src/components/TableContentWrapper/TableContentWrapper'; -import { TableHead } from 'src/components/TableHead'; -import { TableRow } from 'src/components/TableRow'; -import { TableSortCell } from 'src/components/TableSortCell'; -import { getAPIErrorOrDefault } from 'src/utilities/errorUtils'; - -import { FirewallDeviceRow } from './FirewallDeviceRow'; - -interface Props { - devices: FirewallDevice[]; - disabled: boolean; - error?: APIError[]; - loading: boolean; - triggerRemoveDevice: (deviceID: number) => void; -} - -export const FirewallDevicesTable = React.memo((props: Props) => { - const { devices, disabled, error, loading, triggerRemoveDevice } = props; - - const _error = error - ? // @todo change to Devices or make dynamic when NBs are possible as Devices - getAPIErrorOrDefault(error, 'Unable to retrieve Linodes') - : undefined; - - return ( - - {({ data: orderedData, handleOrderChange, order, orderBy }) => ( - - {({ - count, - data: paginatedAndOrderedData, - handlePageChange, - handlePageSizeChange, - page, - pageSize, - }) => ( - <> - - - - - Linode - - - - - - {paginatedAndOrderedData.map((thisDevice) => ( - - ))} - - -
- - - )} -
- )} -
- ); -}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallLinodesLanding.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallLinodesLanding.tsx deleted file mode 100644 index fe78d6e3664..00000000000 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/FirewallLinodesLanding.tsx +++ /dev/null @@ -1,120 +0,0 @@ -import Grid from '@mui/material/Unstable_Grid2'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; - -import { Button } from 'src/components/Button/Button'; -import { Notice } from 'src/components/Notice/Notice'; -import { Typography } from 'src/components/Typography'; -import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; - -import { AddDeviceDrawer } from './AddDeviceDrawer'; -import { FirewallDevicesTable } from './FirewallDevicesTable'; -import { RemoveDeviceDialog } from './RemoveDeviceDialog'; - -interface Props { - disabled: boolean; - firewallID: number; - firewallLabel: string; -} - -const FirewallLinodesLanding = (props: Props) => { - const { disabled, firewallID, firewallLabel } = props; - - const { data: devices, error, isLoading } = useAllFirewallDevicesQuery( - firewallID - ); - - const [ - isRemoveDeviceDialogOpen, - setIsRemoveDeviceDialogOpen, - ] = React.useState(false); - - const [selectedDeviceId, setSelectedDeviceId] = React.useState(-1); - - const selectedDevice = devices?.find( - (device) => device.id === selectedDeviceId - ); - - const [addDeviceDrawerOpen, setDeviceDrawerOpen] = React.useState( - false - ); - - const handleClose = () => { - setDeviceDrawerOpen(false); - }; - - return ( - <> - {disabled ? ( - - ) : null} - - - - The following Linodes have been assigned to this Firewall. A Linode - can only be assigned to a single Firewall. - - - - - - - { - setSelectedDeviceId(id); - setIsRemoveDeviceDialogOpen(true); - }} - devices={devices ?? []} - disabled={disabled} - error={error ?? undefined} - loading={isLoading} - /> - - setIsRemoveDeviceDialogOpen(false)} - open={isRemoveDeviceDialogOpen} - /> - - ); -}; - -const StyledTypography = styled(Typography, { label: 'StyledTypography' })( - ({ theme }) => ({ - fontSize: '0.875rem', - marginTop: theme.spacing(), - [theme.breakpoints.down('lg')]: { - marginLeft: theme.spacing(), - marginRight: theme.spacing(), - }, - }) -); - -const StyledGrid = styled(Grid, { label: 'StyledGrid' })(({ theme }) => ({ - '&.MuiGrid-item': { - paddingTop: 0, - }, - display: 'flex', - justifyContent: 'flex-end', - marginBottom: theme.spacing(), - [theme.breakpoints.only('sm')]: { - marginRight: theme.spacing(), - }, -})); - -export default React.memo(FirewallLinodesLanding); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx index 055a555386e..3694e78fb6c 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog.tsx @@ -8,29 +8,22 @@ import { ConfirmationDialog } from 'src/components/ConfirmationDialog/Confirmati import { Typography } from 'src/components/Typography'; import { useRemoveFirewallDeviceMutation } from 'src/queries/firewalls'; import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { queryKey as nodeBalancerQueryKey } from 'src/queries/nodebalancers'; export interface Props { device: FirewallDevice | undefined; firewallId: number; firewallLabel: string; - linodeId?: number; onClose: () => void; - onLinodeNetworkTab?: boolean; + onService: boolean | undefined; open: boolean; } export const RemoveDeviceDialog = React.memo((props: Props) => { - const { - device, - firewallId, - firewallLabel, - linodeId, - onClose, - onLinodeNetworkTab, - open, - } = props; + const { device, firewallId, firewallLabel, onClose, onService, open } = props; const { enqueueSnackbar } = useSnackbar(); + const deviceType = device?.entity.type; const { error, isLoading, mutateAsync } = useRemoveFirewallDeviceMutation( firewallId, @@ -39,50 +32,60 @@ export const RemoveDeviceDialog = React.memo((props: Props) => { const queryClient = useQueryClient(); + const deviceDialog = deviceType === 'linode' ? 'Linode' : 'NodeBalancer'; + const onDelete = async () => { await mutateAsync(); - enqueueSnackbar(`Device ${device?.entity.label} successfully removed`, { + const toastMessage = onService + ? `Firewall ${firewallLabel} successfully unassigned` + : `${deviceDialog} ${device?.entity.label} successfully removed`; + enqueueSnackbar(toastMessage, { variant: 'success', }); + if (error) { + enqueueSnackbar(error[0].reason, { variant: 'error' }); + } + + const querykey = + deviceType === 'linode' ? linodesQueryKey : nodeBalancerQueryKey; + // Since the linode was removed as a device, invalidate the linode-specific firewall query queryClient.invalidateQueries([ - linodesQueryKey, - 'linode', - linodeId, + querykey, + deviceType, + device?.entity.id, 'firewalls', ]); onClose(); }; - const dialogTitle = onLinodeNetworkTab + const dialogTitle = onService ? `Unassign Firewall ${firewallLabel}?` - : `Remove ${device?.entity.label} from ${firewallLabel}?`; + : `Remove ${deviceDialog} ${device?.entity.label}?`; const confirmationText = ( Are you sure you want to{' '} - {onLinodeNetworkTab - ? 'unassign this Firewall?' - : `remove ${device?.entity.label} from ${firewallLabel}?`} + {onService + ? `unassign Firewall ${firewallLabel} from ${deviceDialog} ${device?.entity.label}?` + : `remove ${deviceDialog} ${device?.entity.label} from Firewall ${firewallLabel}?`} ); - const primaryButtonText = onLinodeNetworkTab ? 'Unassign Firewall' : 'Remove'; + const primaryButtonText = onService ? 'Unassign Firewall' : 'Remove'; return ( { +// TODO: Refactor this code - Becoming too large and hard to maintain + +export const FirewallRulesLanding = React.memo((props: Props) => { const { disabled, firewallID, rules } = props; const { mutateAsync: updateFirewallRules } = useUpdateFirewallRulesMutation( firewallID @@ -383,7 +384,7 @@ const FirewallRulesLanding = (props: Props) => { /> ); -}; +}); const StyledActionsPanel = styled(ActionsPanel, { label: 'StyledActionsPanel', @@ -396,8 +397,6 @@ const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ marginTop: theme.spacing(2), })); -export default React.memo(FirewallRulesLanding); - interface DiscardChangesDialogProps { handleClose: () => void; handleDiscard: () => void; diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx index d637eb76871..e5407edc9f4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/index.tsx @@ -10,20 +10,28 @@ import { SafeTabPanel } from 'src/components/Tabs/SafeTabPanel'; import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; +import { useFlags } from 'src/hooks/useFlags'; +import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { useFirewallQuery, useMutateFirewall } from 'src/queries/firewalls'; import { useGrants, useProfile } from 'src/queries/profile'; import { getErrorStringOrDefault } from 'src/utilities/errorUtils'; import { checkIfUserCanModifyFirewall } from '../shared'; -const FirewallLinodesLanding = React.lazy( - () => import('./Devices/FirewallLinodesLanding') +const FirewallRulesLanding = React.lazy(() => + import('./Rules/FirewallRulesLanding').then((module) => ({ + default: module.FirewallRulesLanding, + })) ); -const FirewallRulesLanding = React.lazy( - () => import('./Rules/FirewallRulesLanding') + +const FirewallDeviceLanding = React.lazy(() => + import('./Devices/FirewallDeviceLanding').then((module) => ({ + default: module.FirewallDeviceLanding, + })) ); export const FirewallDetail = () => { + const flags = useFlags(); const { id, tab } = useParams<{ id: string; tab?: string }>(); const history = useHistory(); const { data: profile } = useProfile(); @@ -37,6 +45,20 @@ export const FirewallDetail = () => { grants ); + const { data: allDevices } = useAllFirewallDevicesQuery(firewallId); + + const { linodeCount, nodebalancerCount } = allDevices?.reduce( + (acc, device) => { + if (device.entity.type === 'linode') { + acc.linodeCount += 1; + } else if (device.entity.type === 'nodebalancer') { + acc.nodebalancerCount += 1; + } + return acc; + }, + { linodeCount: 0, nodebalancerCount: 0 } + ) || { linodeCount: 0, nodebalancerCount: 0 }; + const tabs = [ { routeName: `/firewalls/${id}/rules`, @@ -44,10 +66,17 @@ export const FirewallDetail = () => { }, { routeName: `/firewalls/${id}/linodes`, - title: 'Linodes', + title: `Linodes (${linodeCount})`, }, ]; + if (flags.firewallNodebalancer) { + tabs.push({ + routeName: `/firewalls/${id}/nodebalancers`, + title: `NodeBalancers (${nodebalancerCount})`, + }); + } + const tabIndex = tab ? tabs.findIndex((t) => t.routeName.endsWith(tab)) : -1; const { data: firewall, error, isLoading } = useFirewallQuery(firewallId); @@ -91,7 +120,7 @@ export const FirewallDetail = () => { { /> - + + + diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx index 2c0e224ec8c..c742b662b04 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.test.tsx @@ -7,11 +7,19 @@ import { renderWithTheme } from 'src/utilities/testHelpers'; import { CreateFirewallDrawer } from './CreateFirewallDrawer'; const props = { + createFlow: undefined, onClose: vi.fn(), + onFirewallCreated: vi.fn(), open: true, }; describe('Create Firewall Drawer', () => { + it('should close the drawer on cancel', () => { + renderWithTheme(); + userEvent.click(screen.getByTestId('cancel')); + expect(props.onClose).toHaveBeenCalledTimes(1); + }); + it('should render a title', () => { renderWithTheme(); const title = within(screen.getByTestId('drawer-title')).getByText( @@ -22,7 +30,7 @@ describe('Create Firewall Drawer', () => { it('should validate the form on submit', async () => { renderWithTheme(); - userEvent.type(screen.getByLabelText('Label'), 'a'); + userEvent.type(screen.getByLabelText('Label (required)'), 'a'); userEvent.click(screen.getByTestId('submit')); const error = await screen.findByText( /Label must be between 3 and 32 characters./i diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx index ee0d011d542..490ae26bcd1 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/CreateFirewallDrawer.tsx @@ -1,15 +1,32 @@ -import { CreateFirewallPayload, Firewall } from '@linode/api-v4/lib/firewalls'; +/* eslint-disable jsx-a11y/anchor-is-valid */ +import { Linode } from '@linode/api-v4'; +import { + CreateFirewallPayload, + Firewall, + FirewallDeviceEntityType, +} from '@linode/api-v4/lib/firewalls'; +import { NodeBalancer } from '@linode/api-v4/lib/nodebalancers'; import { CreateFirewallSchema } from '@linode/validation/lib/firewalls.schema'; import { useFormik } from 'formik'; +import { useSnackbar } from 'notistack'; import * as React from 'react'; +import { useQueryClient } from 'react-query'; import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel'; +import { Box } from 'src/components/Box'; import { Drawer } from 'src/components/Drawer'; +import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { TextField } from 'src/components/TextField'; +import { Typography } from 'src/components/Typography'; +import { FIREWALL_LIMITS_CONSIDERATIONS_LINK } from 'src/constants'; import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect'; +import { NodeBalancerSelect } from 'src/features/NodeBalancers/NodeBalancerSelect'; import { useAccountManagement } from 'src/hooks/useAccountManagement'; +import { useFlags } from 'src/hooks/useFlags'; import { useCreateFirewall } from 'src/queries/firewalls'; +import { queryKey as linodesQueryKey } from 'src/queries/linodes/linodes'; +import { queryKey as nodebalancerQueryKey } from 'src/queries/nodebalancers'; import { useGrants } from 'src/queries/profile'; import { getErrorMap } from 'src/utilities/errorUtils'; import { @@ -18,11 +35,17 @@ import { } from 'src/utilities/formikErrorUtils'; import { getEntityIdsByPermission } from 'src/utilities/grants'; -export const READ_ONLY_LINODES_HIDDEN_MESSAGE = - 'Only Linodes you have permission to modify are shown.'; +import { + LINODE_CREATE_FLOW_TEXT, + NODEBALANCER_CREATE_FLOW_TEXT, +} from './constants'; + +export const READ_ONLY_DEVICES_HIDDEN_MESSAGE = + 'Only services you have permission to modify are shown.'; +const NODEBALANCER_HELPER_TEXT = `Only the firewall's inbound rules apply to NodeBalancers.`; export interface CreateFirewallDrawerProps { - label?: string; + createFlow: FirewallDeviceEntityType | undefined; onClose: () => void; onFirewallCreated?: (firewall: Firewall) => void; open: boolean; @@ -31,6 +54,7 @@ export interface CreateFirewallDrawerProps { const initialValues: CreateFirewallPayload = { devices: { linodes: [], + nodebalancers: [], }, label: '', rules: { @@ -42,11 +66,15 @@ const initialValues: CreateFirewallPayload = { export const CreateFirewallDrawer = React.memo( (props: CreateFirewallDrawerProps) => { // TODO: NBFW - We'll eventually want to check the read_write firewall grant here too, but it doesn't exist yet. - const { label, onClose, onFirewallCreated, open } = props; + const flags = useFlags(); + const { createFlow, onClose, onFirewallCreated, open } = props; const { _hasGrant, _isRestrictedUser } = useAccountManagement(); const { data: grants } = useGrants(); const { mutateAsync } = useCreateFirewall(); + const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); + const { errors, handleBlur, @@ -89,6 +117,34 @@ export const CreateFirewallDrawer = React.memo( mutateAsync(payload) .then((response) => { setSubmitting(false); + enqueueSnackbar(`Firewall ${payload.label} successfully created`, { + variant: 'success', + }); + + // Invalidate for Linodes + if (payload.devices?.linodes) { + payload.devices.linodes.forEach((linodeId) => { + queryClient.invalidateQueries([ + linodesQueryKey, + 'linode', + linodeId, + 'firewalls', + ]); + }); + } + + // Invalidate for NodeBalancers + if (payload.devices?.nodebalancers) { + payload.devices.nodebalancers.forEach((nodebalancerId) => { + queryClient.invalidateQueries([ + nodebalancerQueryKey, + 'nodebalancer', + nodebalancerId, + 'firewalls', + ]); + }); + } + if (onFirewallCreated) { onFirewallCreated(response); } @@ -112,11 +168,16 @@ export const CreateFirewallDrawer = React.memo( validationSchema: CreateFirewallSchema, }); + const entityName = flags.firewallNodebalancer ? 'services' : 'Linodes'; + + const FirewallLabelText = `Assign ${entityName} to the Firewall`; + const FirewallHelperText = `Assign one or more ${entityName} to this firewall. You can add ${entityName} later if you want to customize your rules first.`; + React.useEffect(() => { if (open) { resetForm(); } - }, [open]); + }, [open, resetForm]); const userCannotAddFirewall = _isRestrictedUser && !_hasGrant('add_firewalls'); @@ -126,14 +187,27 @@ export const CreateFirewallDrawer = React.memo( ? getEntityIdsByPermission(grants, 'linode', 'read_only') : []; - const linodeSelectGuidance = - readOnlyLinodeIds.length > 0 - ? READ_ONLY_LINODES_HIDDEN_MESSAGE + // If a user is restricted, they can not add a read-only NodeBalancer to a firewall. + const readOnlyNodebalancerIds = _isRestrictedUser + ? getEntityIdsByPermission(grants, 'nodebalancer', 'read_only') + : []; + + const deviceSelectGuidance = + readOnlyLinodeIds.length > 0 || readOnlyNodebalancerIds.length > 0 + ? READ_ONLY_DEVICES_HIDDEN_MESSAGE : undefined; - const firewallHelperText = `Assign one or more Linodes to this firewall. You can add Linodes later if you want to customize your rules first. ${ - linodeSelectGuidance ? linodeSelectGuidance : '' - }`; + const linodeOptionsFilter = (linode: Linode) => { + return !readOnlyLinodeIds.includes(linode.id); + }; + + const nodebalancerOptionsFilter = (nodebalancer: NodeBalancer) => { + return !readOnlyNodebalancerIds.includes(nodebalancer.id); + }; + + const learnMoreLink = ( + Learn more + ); const generalError = status?.generalError || @@ -169,24 +243,70 @@ export const CreateFirewallDrawer = React.memo( name="label" onBlur={handleBlur} onChange={handleChange} + required value={values.label} /> + + ({ + margin: `${theme.spacing(2)} ${theme.spacing(0)}`, + })} + variant="h3" + > + {FirewallLabelText} + + + {FirewallHelperText} + {deviceSelectGuidance ? ` ${deviceSelectGuidance}` : null} + + {flags.firewallNodebalancer && ( + ({ + margin: `${theme.spacing(2)} ${theme.spacing(0)}`, + })} + > + {NODEBALANCER_HELPER_TEXT} +
+ {learnMoreLink}. +
+ )} +
+ label={ + createFlow === 'linode' ? LINODE_CREATE_FLOW_TEXT : 'Linodes' + } + onSelectionChange={(linodes) => { setFieldValue( 'devices.linodes', - selected.map((linode) => linode.id) - ) - } - disabled={userCannotAddFirewall} + linodes.map((linode) => linode.id) + ); + }} errorText={errors['devices.linodes']} - helperText={firewallHelperText} - label={label} + helperText={deviceSelectGuidance} multiple - onBlur={handleBlur} - optionsFilter={(linode) => !readOnlyLinodeIds.includes(linode.id)} - value={values.devices?.linodes ?? []} + optionsFilter={linodeOptionsFilter} + value={values.devices?.linodes ?? null} /> + {flags.firewallNodebalancer && ( + { + setFieldValue( + 'devices.nodebalancers', + nodebalancers.map((nodebalancer) => nodebalancer.id) + ); + }} + errorText={errors['devices.nodebalancers']} + helperText={deviceSelectGuidance} + multiple + optionsFilter={nodebalancerOptionsFilter} + value={values.devices?.nodebalancers ?? null} + /> + )} void; open: boolean; - selectedFirewallID?: number; + selectedFirewallId?: number; selectedFirewallLabel: string; } export const FirewallDialog = React.memo((props: Props) => { const { enqueueSnackbar } = useSnackbar(); + const queryClient = useQueryClient(); const { mode, onClose, open, - selectedFirewallID, + selectedFirewallId, selectedFirewallLabel: label, } = props; + const { data: devices } = useAllFirewallDevicesQuery( + selectedFirewallId ?? -1 + ); + const { error: updateError, isLoading: isUpdating, mutateAsync: updateFirewall, - } = useMutateFirewall(selectedFirewallID ?? -1); + } = useMutateFirewall(selectedFirewallId ?? -1); const { error: deleteError, isLoading: isDeleting, mutateAsync: deleteFirewall, - } = useDeleteFirewall(selectedFirewallID ?? -1); + } = useDeleteFirewall(selectedFirewallId ?? -1); const requestMap = { delete: () => deleteFirewall(), @@ -58,6 +67,17 @@ export const FirewallDialog = React.memo((props: Props) => { const onSubmit = async () => { await requestMap[mode](); + if (mode === 'delete') { + devices?.forEach((device) => { + const deviceType = device.entity.type; + queryClient.invalidateQueries([ + deviceType === 'linode' ? linodesQueryKey : nodebalancerQueryKey, + deviceType, + device.entity.id, + 'firewalls', + ]); + }); + } enqueueSnackbar(`Firewall ${label} successfully ${mode}d`, { variant: 'success', }); @@ -81,7 +101,7 @@ export const FirewallDialog = React.memo((props: Props) => { open={open} title={`${capitalize(mode)} Firewall ${label}?`} > - Are you sure you want to {mode} this Firewall? + Are you sure you want to {mode} this firewall?
); }); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx index 4967f73ff4b..f6bb41ee2cb 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallLanding.tsx @@ -12,6 +12,7 @@ import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableSortCell } from 'src/components/TableSortCell/TableSortCell'; +import { useFlags } from 'src/hooks/useFlags'; import { useOrder } from 'src/hooks/useOrder'; import { usePagination } from 'src/hooks/usePagination'; import { useFirewallsQuery } from 'src/queries/firewalls'; @@ -26,6 +27,7 @@ import { FirewallRow } from './FirewallRow'; const preferenceKey = 'firewalls'; const FirewallLanding = () => { + const flags = useFlags(); const location = useLocation(); const history = useHistory(); const pagination = usePagination(1, preferenceKey); @@ -103,6 +105,7 @@ const FirewallLanding = () => { <> @@ -129,7 +132,7 @@ const FirewallLanding = () => { onButtonClick={onOpenCreateDrawer} title="Firewalls" /> - +
{ Rules - Linodes + + {flags.firewallNodebalancer ? 'Services' : 'Linodes'} + @@ -170,6 +175,7 @@ const FirewallLanding = () => { pageSize={pagination.pageSize} /> @@ -177,7 +183,7 @@ const FirewallLanding = () => { mode={dialogMode} onClose={() => setIsModalOpen(false)} open={isModalOpen} - selectedFirewallID={selectedFirewallId} + selectedFirewallId={selectedFirewallId} selectedFirewallLabel={selectedFirewall?.label ?? ''} /> diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts new file mode 100644 index 00000000000..6ac8ff5514f --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.styles.ts @@ -0,0 +1,23 @@ +import { styled } from '@mui/material/styles'; +import { Link } from 'react-router-dom'; + +export const StyledDevicesLink = styled(Link, { + label: 'StyledDevicesLink', +})(() => ({ + display: 'inline-block', +})); + +export const StyledDivWrapper = styled('div', { + label: 'StyledDivWrapper', +})(({ theme }) => ({ + marginBottom: theme.spacing(0.5), + marginTop: theme.spacing(1), +})); + +export const StyledSpan = styled('span', { + label: 'StyledSpan', +})(({ theme }) => ({ + display: 'inline-block', + marginBottom: theme.spacing(0.5), + marginRight: theme.spacing(0.5), +})); diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx index c48df24a5fc..8962cfbebc7 100644 --- a/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx +++ b/packages/manager/src/features/Firewalls/FirewallLanding/FirewallRow.tsx @@ -1,25 +1,34 @@ import { Firewall, FirewallDevice } from '@linode/api-v4/lib/firewalls'; import { APIError } from '@linode/api-v4/lib/types'; -import { styled } from '@mui/material/styles'; -import * as React from 'react'; +import React from 'react'; import { Link } from 'react-router-dom'; import { Hidden } from 'src/components/Hidden'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; +import { useFlags } from 'src/hooks/useFlags'; import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; import { capitalize } from 'src/utilities/capitalize'; import { ActionHandlers, FirewallActionMenu } from './FirewallActionMenu'; -type CombinedProps = Firewall & ActionHandlers; +export type Props = Firewall & ActionHandlers; -export const FirewallRow = React.memo((props: CombinedProps) => { +export const FirewallRow = React.memo((props: Props) => { + const flags = useFlags(); const { id, label, rules, status, ...actionHandlers } = props; const { data: devices, error, isLoading } = useAllFirewallDevicesQuery(id); + let featureFlaggedDevices: FirewallDevice[] = devices ?? []; + if (!flags.firewallNodebalancer) { + featureFlaggedDevices = + devices?.filter((thisDevice) => { + return thisDevice.entity.type === 'linode'; + }) ?? []; + } + const count = getCountOfRules(rules); return ( @@ -28,9 +37,9 @@ export const FirewallRow = React.memo((props: CombinedProps) => { data-testid={`firewall-row-${id}`} > - + {label} - + @@ -39,10 +48,14 @@ export const FirewallRow = React.memo((props: CombinedProps) => { {getRuleString(count)} - {getLinodesCellString(devices ?? [], isLoading, error ?? undefined)} + {getDevicesCellString( + featureFlaggedDevices ?? [], + isLoading, + error ?? undefined + )} - + { ); }); -export const StyledLink = styled(Link, { label: 'StyledLink' })(() => ({ - '&:hover, &:focus': { - textDecoration: 'underline', - }, - display: 'block', - fontSize: '.875rem', - lineHeight: '1.125rem', -})); - /** * * outputs either @@ -92,7 +96,7 @@ export const getCountOfRules = (rules: Firewall['rules']): [number, number] => { return [(rules.inbound || []).length, (rules.outbound || []).length]; }; -const getLinodesCellString = ( +const getDevicesCellString = ( data: FirewallDevice[], loading: boolean, error?: APIError[] @@ -114,6 +118,7 @@ const getLinodesCellString = ( export const getDeviceLinks = (data: FirewallDevice[]): JSX.Element => { const firstThree = data.slice(0, 3); + return ( <> {firstThree.map((thisDevice, idx) => ( @@ -121,7 +126,7 @@ export const getDeviceLinks = (data: FirewallDevice[]): JSX.Element => { className="link secondaryLink" data-testid="firewall-row-link" key={thisDevice.id} - to={`/linodes/${thisDevice.entity.id}`} + to={`/${thisDevice.entity.type}s/${thisDevice.entity.id}`} > {idx > 0 && `, `} {thisDevice.entity.label} diff --git a/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts b/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts new file mode 100644 index 00000000000..6e1825920b7 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallLanding/constants.ts @@ -0,0 +1,2 @@ +export const LINODE_CREATE_FLOW_TEXT = 'Additional Linodes'; +export const NODEBALANCER_CREATE_FLOW_TEXT = 'Additional NodeBalancers'; diff --git a/packages/manager/src/features/Images/ImagesDrawer.tsx b/packages/manager/src/features/Images/ImagesDrawer.tsx index 6e6132d77f1..542a7c26782 100644 --- a/packages/manager/src/features/Images/ImagesDrawer.tsx +++ b/packages/manager/src/features/Images/ImagesDrawer.tsx @@ -397,14 +397,6 @@ export const ImagesDrawer = (props: CombinedProps) => { label: 'Cancel', onClick: close, }} - updateFor={[ - requirementsMet, - classes, - submitting, - mode, - label, - description, - ]} style={{ marginTop: 16 }} /> diff --git a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx index 3d1bb417298..c9416fcf5d7 100644 --- a/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx +++ b/packages/manager/src/features/Linodes/LinodeSelect/LinodeSelect.tsx @@ -1,18 +1,13 @@ import { APIError, Filter, Linode } from '@linode/api-v4'; import CloseIcon from '@mui/icons-material/Close'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; -import { Autocomplete } from '@mui/material'; import { SxProps } from '@mui/system'; import React from 'react'; -import { - CustomPopper, - SelectedIcon, -} from 'src/components/Autocomplete/Autocomplete.styles'; -import { Box } from 'src/components/Box'; -import { TextField } from 'src/components/TextField'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { CustomPopper } from 'src/components/Autocomplete/Autocomplete.styles'; import { useAllLinodesQuery } from 'src/queries/linodes/linodes'; -import { mapIdsToLinodes } from 'src/utilities/mapIdsToLinodes'; +import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; interface LinodeSelectProps { /** Whether to display the clear icon. Defaults to `true`. */ @@ -122,59 +117,37 @@ export const LinodeSelect = ( renderOptionLabel ? renderOptionLabel(linode) : linode.label } noOptionsText={ - noOptionsMessage ?? ( - {getDefaultNoOptionsMessage(error, isLoading, linodes)} - ) + noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) } onChange={(_, value) => multiple && Array.isArray(value) ? onSelectionChange(value) : !multiple && !Array.isArray(value) && onSelectionChange(value) } - renderInput={(params) => ( - - )} - renderOption={(props, option, { selected }) => { - return ( -
  • - {renderOption ? ( - renderOption(option, selected) - ) : ( - <> - - {option.label} - - - - )} -
  • - ); - }} + placeholder={ + placeholder + ? placeholder + : multiple + ? 'Select Linodes' + : 'Select a Linode' + } + renderOption={ + renderOption + ? (props, option, { selected }) => { + return ( +
  • + {renderOption(option, selected)} +
  • + ); + } + : undefined + } value={ typeof value === 'function' ? multiple && Array.isArray(value) ? linodes?.filter(value) ?? null : linodes?.find(value) ?? null - : mapIdsToLinodes(value, linodes) + : mapIdsToDevices(value, linodes) } ChipProps={{ deleteIcon: }} PopperComponent={CustomPopper} @@ -183,10 +156,14 @@ export const LinodeSelect = ( disableCloseOnSelect={multiple} disablePortal={true} disabled={disabled} + errorText={error?.[0].reason ?? errorText} + helperText={helperText} id={id} inputValue={inputValue} + label={label ? label : multiple ? 'Linodes' : 'Linode'} loading={isLoading || loading} multiple={multiple} + noMarginTop={noMarginTop} onBlur={onBlur} onInputChange={(_, value) => setInputValue(value)} options={options || (linodes ?? [])} @@ -198,15 +175,12 @@ export const LinodeSelect = ( const getDefaultNoOptionsMessage = ( error: APIError[] | null, - loading: boolean, - filteredLinodes: Linode[] | undefined + loading: boolean ) => { if (error) { - return 'An error occured while fetching your Linodes'; + return 'An error occurred while fetching your Linodes'; } else if (loading) { return 'Loading your Linodes...'; - } else if (!filteredLinodes?.length) { - return 'You have no Linodes to choose from'; } else { return 'No options'; } diff --git a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx index 93072745856..265172aa298 100644 --- a/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx +++ b/packages/manager/src/features/Linodes/LinodesCreate/LinodeCreate.tsx @@ -27,6 +27,7 @@ import { TabLinkList } from 'src/components/Tabs/TabLinkList'; import { TabPanels } from 'src/components/Tabs/TabPanels'; import { Tabs } from 'src/components/Tabs/Tabs'; import { Typography } from 'src/components/Typography'; +import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; import { WithAccountProps, withAccount, @@ -637,10 +638,11 @@ export class LinodeCreate extends React.PureComponent< helperText={ Assign an existing Firewall to this Linode to control inbound - and outbound network traffic. Learn more. + and outbound network traffic.{' '} + Learn more. - // @TODO VPC: Update "Learn More" link } + entityType="linode" handleFirewallChange={this.props.handleFirewallChange} selectedFirewallId={this.props.firewallId || -1} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx index f2c81ed362c..d89d9805b5d 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewalls.tsx @@ -3,13 +3,13 @@ import { Stack } from '@mui/material'; import * as React from 'react'; import { Box } from 'src/components/Box'; -import { ErrorState } from 'src/components/ErrorState/ErrorState'; import { Table } from 'src/components/Table'; import { TableBody } from 'src/components/TableBody'; import { TableCell } from 'src/components/TableCell'; import { TableHead } from 'src/components/TableHead'; import { TableRow } from 'src/components/TableRow'; import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; import { Typography } from 'src/components/Typography'; import { RemoveDeviceDialog } from 'src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog'; @@ -50,11 +50,11 @@ export const LinodeFirewalls = (props: LinodeFirewallsProps) => { const renderTableContent = () => { if (isLoading) { - return ; + return ; } if (error) { - return ; + return ; } if (attachedFirewalls?.length === 0 || attachedFirewalls === undefined) { @@ -102,9 +102,8 @@ export const LinodeFirewalls = (props: LinodeFirewallsProps) => { device={deviceToBeRemoved} firewallId={selectedFirewall?.id ?? -1} firewallLabel={selectedFirewall?.label ?? ''} - linodeId={linodeID} onClose={() => setIsRemoveDeviceDialogOpen(false)} - onLinodeNetworkTab + onService open={isRemoveDeviceDialogOpen} /> diff --git a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx index 9e11f07bc80..301a71df7ea 100644 --- a/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx +++ b/packages/manager/src/features/Linodes/LinodesDetail/LinodeNetworking/LinodeFirewalls/LinodeFirewallsRow.tsx @@ -1,11 +1,11 @@ import { Firewall, FirewallDevice } from '@linode/api-v4'; import * as React from 'react'; +import { Link } from 'react-router-dom'; import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; import { TableCell } from 'src/components/TableCell'; import { TableRow } from 'src/components/TableRow'; import { - StyledLink, getCountOfRules, getRuleString, } from 'src/features/Firewalls/FirewallLanding/FirewallRow'; @@ -43,9 +43,9 @@ export const LinodeFirewallsRow = (props: LinodeFirewallsRowProps) => { key={`firewall-${firewallID}`} > - + {label} - + diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx index d0b02bc6a13..bf464f0d5b7 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.tsx @@ -21,12 +21,16 @@ import { CheckoutSummary } from 'src/components/CheckoutSummary/CheckoutSummary' import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; import { LandingHeader } from 'src/components/LandingHeader'; +import { Link } from 'src/components/Link'; import { Notice } from 'src/components/Notice/Notice'; import { Paper } from 'src/components/Paper'; +import { SelectFirewallPanel } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { SelectRegionPanel } from 'src/components/SelectRegionPanel/SelectRegionPanel'; import { Tag, TagsInput } from 'src/components/TagsInput/TagsInput'; import { TextField } from 'src/components/TextField'; import { Typography } from 'src/components/Typography'; +import { FIREWALL_GET_STARTED_LINK } from 'src/constants'; +import { useFlags } from 'src/hooks/useFlags'; import { reportAgreementSigningError, useAccountAgreements, @@ -59,6 +63,7 @@ import type { APIError } from '@linode/api-v4/lib/types'; interface NodeBalancerFieldsState { configs: (NodeBalancerConfigFieldsWithStatus & { errors?: any })[]; + firewall_id?: number; label?: string; region?: string; tags?: string[]; @@ -83,6 +88,7 @@ const defaultFieldsStates = { }; const NodeBalancerCreate = () => { + const flags = useFlags(); const { data: agreements } = useAccountAgreements(); const { data: grants } = useGrants(); const { data: profile } = useProfile(); @@ -410,17 +416,28 @@ const NodeBalancerCreate = () => { regionId: nodeBalancerFields.region, }); - const summaryItems = [ - { title: regionLabel }, - { details: nodeBalancerFields.configs.length, title: 'Configs' }, - { - details: nodeBalancerFields.configs.reduce( - (acc, config) => acc + config.nodes.length, - 0 - ), - title: 'Nodes', - }, - ].filter((item) => Boolean(item.title)); + const summaryItems = []; + + if (regionLabel) { + summaryItems.push({ title: regionLabel }); + } + + if (nodeBalancerFields.firewall_id) { + summaryItems.push({ title: 'Firewall Assigned' }); + } + + summaryItems.push({ + details: nodeBalancerFields.configs.length, + title: 'Configs', + }); + + summaryItems.push({ + details: nodeBalancerFields.configs.reduce( + (acc, config) => acc + config.nodes.length, + 0 + ), + title: 'Nodes', + }); if (nodeBalancerFields.region) { summaryItems.unshift({ @@ -486,6 +503,25 @@ const NodeBalancerCreate = () => { regions={regions ?? []} selectedId={nodeBalancerFields.region} /> + {flags.firewallNodebalancer && ( + { + setNodeBalancerFields((prev) => ({ + ...prev, + firewall_id: firewallId > 0 ? firewallId : undefined, + })); + }} + helperText={ + + Assign an existing Firewall to this NodeBalancer to control + inbound network traffic.{' '} + Learn more. + + } + entityType="nodebalancer" + selectedFirewallId={nodeBalancerFields.firewall_id ?? -1} + /> + )} {nodeBalancerFields.configs.map((nodeBalancerConfig, idx) => { const onChange = (key: keyof NodeBalancerConfigFieldsWithStatus) => ( diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx new file mode 100644 index 00000000000..5b962fd53a6 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx @@ -0,0 +1,123 @@ +/* eslint-disable jsx-a11y/anchor-is-valid */ +import { Firewall, FirewallDevice } from '@linode/api-v4'; +import { Stack } from '@mui/material'; +import * as React from 'react'; + +import { Box } from 'src/components/Box'; +import { Link } from 'src/components/Link'; +import { Table } from 'src/components/Table'; +import { TableBody } from 'src/components/TableBody'; +import { TableCell } from 'src/components/TableCell'; +import { TableHead } from 'src/components/TableHead'; +import { TableRow } from 'src/components/TableRow'; +import { TableRowEmpty } from 'src/components/TableRowEmpty/TableRowEmpty'; +import { TableRowError } from 'src/components/TableRowError/TableRowError'; +import { TableRowLoading } from 'src/components/TableRowLoading/TableRowLoading'; +import { Typography } from 'src/components/Typography'; +import { CREATE_FIREWALL_LINK } from 'src/constants'; +import { RemoveDeviceDialog } from 'src/features/Firewalls/FirewallDetail/Devices/RemoveDeviceDialog'; +import { useNodeBalancersFirewallsQuery } from 'src/queries/nodebalancers'; + +import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow'; + +interface Props { + displayFirewallInfoText: boolean; + nodeBalancerId: number; +} + +export const NodeBalancerFirewalls = (props: Props) => { + const { displayFirewallInfoText, nodeBalancerId } = props; + + const { + data: attachedFirewallData, + error, + isLoading, + } = useNodeBalancersFirewallsQuery(nodeBalancerId); + + const attachedFirewalls = attachedFirewallData?.data; + + const [selectedFirewall, setSelectedFirewall] = React.useState(); + + const [ + deviceToBeRemoved, + setDeviceToBeRemoved, + ] = React.useState(); + + const [ + isRemoveDeviceDialogOpen, + setIsRemoveDeviceDialogOpen, + ] = React.useState(false); + + const handleClickUnassign = (device: FirewallDevice, firewall: Firewall) => { + setDeviceToBeRemoved(device); + setSelectedFirewall(firewall); + setIsRemoveDeviceDialogOpen(true); + }; + + const renderTableContent = () => { + if (isLoading) { + return ; + } + + if (error) { + return ; + } + + if (attachedFirewalls?.length === 0 || attachedFirewalls === undefined) { + return ; + } + + return attachedFirewalls.map((attachedFirewall) => ( + + )); + }; + + const learnMoreLink = ( + Learn more about creating Firewalls. + ); + const firewallLink = Firewalls; + + return ( + + + {displayFirewallInfoText ? ( + ({ + marginBottom: theme.spacing(), + })} + data-testid="nodebalancer-firewalls-table-header" + > + If you want to assign a new Firewall to this NodeBalancer, go to{' '} + {firewallLink}. +
    + {learnMoreLink} +
    + ) : null} +
    +
    + + + Firewall + Status + Rules + + + + {renderTableContent()} +
    + setIsRemoveDeviceDialogOpen(false)} + onService + open={isRemoveDeviceDialogOpen} + /> + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx new file mode 100644 index 00000000000..9ecf185236c --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; + +import { Action } from 'src/components/ActionMenu/ActionMenu'; +import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction'; +import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu'; +import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared'; +import { useGrants, useProfile } from 'src/queries/profile'; + +interface Props { + firewallID: number; + onUnassign: () => void; +} + +export const NodeBalancerFirewallsActionMenu = (props: Props) => { + const { firewallID, onUnassign } = props; + + const { data: profile } = useProfile(); + const { data: grants } = useGrants(); + + const userCanModifyFirewall = checkIfUserCanModifyFirewall( + firewallID, + profile, + grants + ); + + const disabledProps = !userCanModifyFirewall + ? { + disabled: true, + tooltip: noPermissionTooltipText, + } + : {}; + + const action: Action = { + onClick: () => { + onUnassign(); + }, + title: 'Unassign', + ...disabledProps, + }; + + return ( + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx new file mode 100644 index 00000000000..b66cde88106 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx @@ -0,0 +1,65 @@ +import { Firewall, FirewallDevice } from '@linode/api-v4'; +import * as React from 'react'; +import { Link } from 'react-router-dom'; + +import { StatusIcon } from 'src/components/StatusIcon/StatusIcon'; +import { TableCell } from 'src/components/TableCell'; +import { TableRow } from 'src/components/TableRow'; +import { + getCountOfRules, + getRuleString, +} from 'src/features/Firewalls/FirewallLanding/FirewallRow'; +import { useAllFirewallDevicesQuery } from 'src/queries/firewalls'; +import { capitalize } from 'src/utilities/capitalize'; + +import { NodeBalancerFirewallsActionMenu } from './NodeBalancerFirewallsActionMenu'; + +interface Props { + firewall: Firewall; + nodeBalancerID: number; + onClickUnassign: ( + device: FirewallDevice | undefined, + firewall: Firewall + ) => void; +} + +export const NodeBalancerFirewallsRow = (props: Props) => { + const { firewall, nodeBalancerID, onClickUnassign } = props; + + const { id: firewallID, label, rules, status } = firewall; + + const { data: devices } = useAllFirewallDevicesQuery(firewallID); + + const firewallDevice = devices?.find( + (device) => + device.entity.type === 'nodebalancer' && + device.entity.id === nodeBalancerID + ); + + const count = getCountOfRules(rules); + + return ( + + + + {label} + + + + + {capitalize(status)} + + {getRuleString(count)} + + onClickUnassign(firewallDevice, firewall)} + /> + + + ); +}; diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx index b166af4ba49..d9fb9dcee6f 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx @@ -5,22 +5,27 @@ import { useParams } from 'react-router-dom'; import { Accordion } from 'src/components/Accordion'; import { Button } from 'src/components/Button/Button'; import { DocumentTitleSegment } from 'src/components/DocumentTitle'; -import { TextField } from 'src/components/TextField'; import { FormHelperText } from 'src/components/FormHelperText'; import { InputAdornment } from 'src/components/InputAdornment'; +import { TextField } from 'src/components/TextField'; +import { useFlags } from 'src/hooks/useFlags'; +import { useNodeBalancersFirewallsQuery } from 'src/queries/nodebalancers'; import { useNodeBalancerQuery, useNodebalancerUpdateMutation, } from 'src/queries/nodebalancers'; import { NodeBalancerDeleteDialog } from '../NodeBalancerDeleteDialog'; +import { NodeBalancerFirewalls } from './NodeBalancerFirewalls'; export const NodeBalancerSettings = () => { + const flags = useFlags(); const theme = useTheme(); const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); const id = Number(nodeBalancerId); - const { data: nodebalancer } = useNodeBalancerQuery(id); + const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery(id); + const displayFirewallInfoText = Boolean(attachedFirewallData?.data?.length); const { error: labelError, @@ -85,6 +90,14 @@ export const NodeBalancerSettings = () => { Save + {flags.firewallNodebalancer && ( + + + + )} { + const flags = useFlags(); const { nodeBalancerId } = useParams<{ nodeBalancerId: string }>(); const id = Number(nodeBalancerId); const { data: nodebalancer } = useNodeBalancerQuery(id); const { data: configs } = useAllNodeBalancerConfigsQuery(id); const { data: regions } = useRegionsQuery(); - + const { data: attachedFirewallData } = useNodeBalancersFirewallsQuery(id); + const linkText = attachedFirewallData?.data[0]?.label; + const linkID = attachedFirewallData?.data[0]?.id; const region = regions?.find((r) => r.id === nodebalancer?.region); - const { mutateAsync: updateNodeBalancer } = useNodebalancerUpdateMutation(id); + const displayFirewallLink = !!attachedFirewallData?.data?.length; const configPorts = configs?.reduce((acc, config) => { return [...acc, { configId: config.id, port: config.port }]; @@ -90,6 +95,18 @@ export const SummaryPanel = () => { + {displayFirewallLink && flags.firewallNodebalancer && ( + + + Firewall + + + + {linkText} + + + + )} IP Addresses diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx new file mode 100644 index 00000000000..6d59d3a05b6 --- /dev/null +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx @@ -0,0 +1,188 @@ +import { NodeBalancer } from '@linode/api-v4'; +import { screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { nodeBalancerFactory } from 'src/factories'; +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import { NodeBalancerSelect } from './NodeBalancerSelect'; + +const fakeNodeBalancerData = nodeBalancerFactory.build({ + id: 1, + label: 'metadata-test-region', + region: 'eu-west', +}); + +const TEXTFIELD_ID = 'textfield-input'; + +describe('NodeBalancerSelect', () => { + it('should render custom options using renderOption', async () => { + // Create a mock renderOption function + const mockRenderOption = ( + nodebalancer: NodeBalancer, + selected: boolean + ) => ( + + {`${nodebalancer.label} - ${selected ? 'Selected' : 'Not Selected'}`} + + ); + + // Render the component with the custom renderOption function + renderWithTheme( + + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + + // Open the dropdown + userEvent.click(input); + + // Wait for the options to load (use some unique identifier for the options) + await waitFor(() => { + const customOption = screen.getByTestId('custom-option-1'); + expect(customOption).toBeInTheDocument(); + expect(customOption).toHaveTextContent( + 'metadata-test-region - Not Selected' + ); + }); + }); + it('should display custom no options message if one is passed in', async () => { + const customNoOptionsMessage = 'Custom No Options Message'; + const options: NodeBalancer[] = []; // Assuming no options are available + const onSelectionChange = vi.fn(); + + renderWithTheme( + + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + + // Open the dropdown + userEvent.click(input); + + await waitFor(() => { + // The custom no options message should be displayed when there are no options available + expect(screen.getByText(customNoOptionsMessage)).toBeInTheDocument(); + }); + }); + + it('should display default no options message if no custom message is passed', async () => { + // Mock the props + const option: NodeBalancer[] = []; // Assuming no options are available + const onSelectionChange = vi.fn(); + + renderWithTheme( + + ); + + // Open the dropdown + const input = screen.getByTestId(TEXTFIELD_ID); + userEvent.click(input); + + await waitFor(() => { + // The default no options message should be displayed when noOptionsMessage prop is not provided + expect(screen.getByText('No options')).toBeInTheDocument(); + }); + }); + + it('should display no options message when user input does not match', async () => { + // Mock the props + const customNoOptionsMessage = 'Custom No Options Message'; + const option: NodeBalancer[] = []; // Assuming no options are available + const onSelectionChange = vi.fn(); + + renderWithTheme( + + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + + // Open the dropdown + userEvent.click(input); + + await waitFor(() => { + expect(screen.getByText(customNoOptionsMessage)).toBeInTheDocument(); + }); + }); + + it('should display no options message when user input does not match an option', async () => { + const customNoOptionsMessage = 'Custom No Options Message'; + const option = nodeBalancerFactory.build({ + id: 1, + label: 'NodeBalancer 1', + }); + const onSelectionChange = vi.fn(); + + renderWithTheme( + + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + + userEvent.type(input, 'NodeBalancer 2'); + + await waitFor(() => { + expect(screen.getByText(customNoOptionsMessage)).toBeInTheDocument(); + }); + }); + + it('should not display no options message when user input matches an option', async () => { + const customNoOptionsMessage = 'Custom No Options Message'; + const option = nodeBalancerFactory.build({ + id: 1, + label: 'NodeBalancer 1', + }); + const onSelectionChange = vi.fn(); + + renderWithTheme( + + ); + + const input = screen.getByTestId(TEXTFIELD_ID); + + // The custom no options message should not be displayed when user input matches an option + userEvent.type(input, 'NodeBalancer 1'); + + await waitFor(() => { + expect( + screen.queryByText(customNoOptionsMessage) + ).not.toBeInTheDocument(); + }); + }); +}); diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx index 9cf65b06c48..0fc3edd6a6d 100644 --- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx +++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.tsx @@ -1,88 +1,185 @@ import { NodeBalancer } from '@linode/api-v4'; -import Autocomplete from '@mui/material/Autocomplete'; +import { APIError } from '@linode/api-v4/lib/types'; +import CloseIcon from '@mui/icons-material/Close'; +import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; +import { SxProps } from '@mui/system'; import * as React from 'react'; -import { TextField, TextFieldProps } from 'src/components/TextField'; -import { useInfiniteNodebalancersQuery } from 'src/queries/nodebalancers'; +import { Autocomplete } from 'src/components/Autocomplete/Autocomplete'; +import { CustomPopper } from 'src/components/Autocomplete/Autocomplete.styles'; +import { useAllNodeBalancersQuery } from 'src/queries/nodebalancers'; +import { mapIdsToDevices } from 'src/utilities/mapIdsToDevices'; -interface Props { +interface NodeBalancerSelectProps { + /** Whether to display the clear icon. Defaults to `true`. */ + clearable?: boolean; + /** Disable editing the input value. */ disabled?: boolean; - error?: string; - onChange: (id: number, nodebalancer: NodeBalancer | undefined) => void; - region?: string; - textFieldProps?: TextFieldProps; - value?: number; + /** Hint displayed with error styling. */ + errorText?: string; + /** Hint displayed in normal styling. */ + helperText?: string; + /** The ID of the input. */ + id?: string; + /** Override the default "NodeBalancer" or "NodeBalancers" label */ + label?: string; + /** Adds styling to indicate a loading state. */ + loading?: boolean; + /** Optionally disable top margin for input label */ + noMarginTop?: boolean; + /** Message displayed when no options match the user's search. */ + noOptionsMessage?: string; + /** Called when the input loses focus. */ + onBlur?: (e: React.FocusEvent) => void; + /* The options to display in the select. */ + options?: NodeBalancer[]; + /** Determine which NodeBalancers should be available as options. */ + optionsFilter?: (nodebalancer: NodeBalancer) => boolean; + /* Displayed when the input is blank. */ + placeholder?: string; + /* Render a custom option. */ + renderOption?: (nodebalancer: NodeBalancer, selected: boolean) => JSX.Element; + /* Render a custom option label. */ + renderOptionLabel?: (nodebalancer: NodeBalancer) => string; + /* Displays an indication that the input is required. */ + required?: boolean; + /* Adds custom styles to the component. */ + sx?: SxProps; } -export const NodeBalancerSelect = (props: Props) => { - const { disabled, error, onChange, region, value } = props; - const [inputValue, setInputValue] = React.useState(''); +export interface NodeBalancerMultiSelectProps extends NodeBalancerSelectProps { + /* Enable multi-select. */ + multiple: true; + /* Called when the value changes */ + onSelectionChange: (selected: NodeBalancer[]) => void; + /* An array of `id`s of NodeBalancers that should be selected or a function that should return `true` if the NodeBalancer should be selected. */ + value: ((nodebalancer: NodeBalancer) => boolean) | null | number[]; +} - const searchFilter = inputValue - ? { - '+or': [ - { label: { '+contains': inputValue } }, - { tags: { '+contains': inputValue } }, - ], - } - : {}; +export interface NodeBalancerSingleSelectProps extends NodeBalancerSelectProps { + /* Enable single-select. */ + multiple?: false; + /* Called when the value changes */ + onSelectionChange: (selected: NodeBalancer | null) => void; + /* The `id` of the selected NodeBalancers or a function that should return `true` if the NodeBalancer should be selected. */ + value: ((nodebalancer: NodeBalancer) => boolean) | null | number; +} +/** + * A select input allowing selection between account NodeBalancers. + */ +export const NodeBalancerSelect = ( + props: NodeBalancerMultiSelectProps | NodeBalancerSingleSelectProps +) => { const { - data, - fetchNextPage, - hasNextPage, - isLoading, - } = useInfiniteNodebalancersQuery({ - ...searchFilter, - ...(region ? { region } : {}), - '+order': 'asc', - '+order_by': 'label', - }); - const nodebalancers = data?.pages.flatMap((page) => page.data); + clearable = true, + disabled, + errorText, + helperText, + id, + label, + loading, + multiple, + noMarginTop, + noOptionsMessage, + onBlur, + onSelectionChange, + options, + optionsFilter, + placeholder, + renderOption, + renderOptionLabel, + sx, + value, + } = props; - const options = nodebalancers?.map(({ id, label }) => ({ id, label })); + const [inputValue, setInputValue] = React.useState(''); - const selectedNodebalancer = - options?.find((option) => option.id === value) ?? null; + const { data, error, isLoading } = useAllNodeBalancersQuery(); + + const nodebalancers = optionsFilter ? data?.filter(optionsFilter) : data; + + React.useEffect(() => { + /** We want to clear the input value when the value prop changes to null. + * This is for use cases where a user changes their region and the Linode + * they had selected is no longer available. + */ + if (value === null) { + setInputValue(''); + } + }, [value]); return ( { - const listboxNode = event.currentTarget; - if ( - listboxNode.scrollTop + listboxNode.clientHeight >= - listboxNode.scrollHeight && - hasNextPage - ) { - fetchNextPage(); - } - }, - }} - onChange={(event, value) => - onChange( - value?.id ?? -1, - nodebalancers?.find((n) => n.id === value?.id) - ) + getOptionLabel={(nodebalancer: NodeBalancer) => + renderOptionLabel ? renderOptionLabel(nodebalancer) : nodebalancer.label + } + noOptionsText={ + noOptionsMessage ?? getDefaultNoOptionsMessage(error, isLoading) } - onInputChange={(event, value) => { - setInputValue(value); - }} - renderInput={(params) => ( - - )} + onChange={(_, value) => + multiple && Array.isArray(value) + ? onSelectionChange(value) + : !multiple && !Array.isArray(value) && onSelectionChange(value) + } + placeholder={ + placeholder + ? placeholder + : multiple + ? 'Select NodeBalancers' + : 'Select a NodeBalancer' + } + renderOption={ + renderOption + ? (props, option, { selected }) => { + return ( +
  • + {renderOption(option, selected)} +
  • + ); + } + : undefined + } + value={ + typeof value === 'function' + ? multiple && Array.isArray(value) + ? nodebalancers?.filter(value) ?? null + : nodebalancers?.find(value) ?? null + : mapIdsToDevices(value, nodebalancers) + } + ChipProps={{ deleteIcon: }} + PopperComponent={CustomPopper} + clearOnBlur={false} + disableClearable={!clearable} + disableCloseOnSelect={multiple} + disablePortal={true} disabled={disabled} + errorText={error?.[0].reason ?? errorText} + helperText={helperText} + id={id} inputValue={inputValue} - isOptionEqualToValue={(option) => option.id === selectedNodebalancer?.id} - loading={isLoading} - options={options ?? []} - value={selectedNodebalancer} + label={label ? label : multiple ? 'NodeBalancers' : 'NodeBalancer'} + loading={isLoading || loading} + multiple={multiple} + noMarginTop={noMarginTop} + onBlur={onBlur} + onInputChange={(_, value) => setInputValue(value)} + options={options || (nodebalancers ?? [])} + popupIcon={} + sx={sx} /> ); }; + +const getDefaultNoOptionsMessage = ( + error: APIError[] | null, + loading: boolean +) => { + if (error) { + return 'An error occurred while fetching your NodeBalancers'; + } else if (loading) { + return 'Loading your NodeBalancers...'; + } else { + return 'No options'; + } +}; diff --git a/packages/manager/src/queries/nodebalancers.ts b/packages/manager/src/queries/nodebalancers.ts index 089a818abef..cae57fa1ff7 100644 --- a/packages/manager/src/queries/nodebalancers.ts +++ b/packages/manager/src/queries/nodebalancers.ts @@ -1,6 +1,7 @@ import { CreateNodeBalancerConfig, CreateNodeBalancerPayload, + Firewall, NodeBalancer, NodeBalancerConfig, NodeBalancerStats, @@ -10,6 +11,7 @@ import { deleteNodeBalancerConfig, getNodeBalancer, getNodeBalancerConfigs, + getNodeBalancerFirewalls, getNodeBalancerStats, getNodeBalancers, updateNodeBalancer, @@ -33,6 +35,7 @@ import { EventWithStore } from 'src/events'; import { parseAPIDate } from 'src/utilities/date'; import { getAll } from 'src/utilities/getAll'; +import { queryPresets } from './base'; import { itemInListCreationHandler, itemInListMutationHandler } from './base'; import { queryKey as PROFILE_QUERY_KEY } from './profile'; @@ -224,3 +227,10 @@ export const nodebalanacerEventHandler = ({ } } }; + +export const useNodeBalancersFirewallsQuery = (nodebalancerId: number) => + useQuery, APIError[]>( + [queryKey, 'nodebalancer', nodebalancerId, 'firewalls'], + () => getNodeBalancerFirewalls(nodebalancerId), + queryPresets.oneTimeFetch + ); diff --git a/packages/manager/src/utilities/mapIdsToDevices.test.ts b/packages/manager/src/utilities/mapIdsToDevices.test.ts new file mode 100644 index 00000000000..1f8a43091a4 --- /dev/null +++ b/packages/manager/src/utilities/mapIdsToDevices.test.ts @@ -0,0 +1,46 @@ +import { Linode } from '@linode/api-v4'; +import { NodeBalancer } from '@linode/api-v4'; + +import { linodeFactory } from 'src/factories'; +import { nodeBalancerFactory } from 'src/factories'; + +import { mapIdsToDevices } from './mapIdsToDevices'; + +describe('mapIdsToDevices', () => { + const linodes = linodeFactory.buildList(5); + const nodebalancers = nodeBalancerFactory.buildList(5); + it('works with a single Linode ID', () => { + expect(mapIdsToDevices(1, linodes)).toBe(linodes[1]); + }); + it('works with a single NodeBalancer ID', () => { + expect(mapIdsToDevices(1, nodebalancers)).toBe( + nodebalancers[1] + ); + }); + it('works with a multiple Linode IDs', () => { + expect(mapIdsToDevices([0, 1, 2], linodes)).toEqual([ + linodes[0], + linodes[1], + linodes[2], + ]); + }); + it('works with a multiple NodeBalancer IDs', () => { + expect(mapIdsToDevices([0, 1, 2], nodebalancers)).toEqual([ + nodebalancers[0], + nodebalancers[1], + nodebalancers[2], + ]); + }); + it('omits missing IDs', () => { + expect(mapIdsToDevices(99, linodes)).toBe(null); + expect(mapIdsToDevices(99, nodebalancers)).toBe(null); + expect(mapIdsToDevices([0, 99, 2], linodes)).toEqual([ + linodes[0], + linodes[2], + ]); + expect(mapIdsToDevices([0, 99, 2], nodebalancers)).toEqual([ + nodebalancers[0], + nodebalancers[2], + ]); + }); +}); diff --git a/packages/manager/src/utilities/mapIdsToDevices.ts b/packages/manager/src/utilities/mapIdsToDevices.ts new file mode 100644 index 00000000000..326016995e5 --- /dev/null +++ b/packages/manager/src/utilities/mapIdsToDevices.ts @@ -0,0 +1,22 @@ +import { Linode, NodeBalancer } from '@linode/api-v4'; + +import { isNotNullOrUndefined } from './nullOrUndefined'; + +type Device = Linode | NodeBalancer; + +export const mapIdsToDevices = ( + ids: null | number | number[], + devices: T[] = [] +): T | T[] | null => { + const deviceMap = new Map( + // Even though the types extend Device. type insertion is still required here + devices.map((device) => [device.id, device]) + ); + if (Array.isArray(ids)) { + return ids.map((id) => deviceMap.get(id)).filter(isNotNullOrUndefined); + } else if (ids !== null) { + return deviceMap.get(ids) ?? null; + } else { + return null; + } +}; diff --git a/packages/manager/src/utilities/mapIdsToLinodes.test.ts b/packages/manager/src/utilities/mapIdsToLinodes.test.ts deleted file mode 100644 index 5ba41fa2b42..00000000000 --- a/packages/manager/src/utilities/mapIdsToLinodes.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { linodeFactory } from 'src/factories'; - -import { mapIdsToLinodes } from './mapIdsToLinodes'; - -describe('mapIdsToLinodes', () => { - const linodes = linodeFactory.buildList(5); - it('works with a single id', () => { - expect(mapIdsToLinodes(1, linodes)).toBe(linodes[1]); - }); - it('works with a multiple ids', () => { - expect(mapIdsToLinodes([0, 1, 2], linodes)).toEqual([ - linodes[0], - linodes[1], - linodes[2], - ]); - }); - it('omits missing ids', () => { - expect(mapIdsToLinodes(99, linodes)).toBe(null); - expect(mapIdsToLinodes([0, 99, 2], linodes)).toEqual([ - linodes[0], - linodes[2], - ]); - }); -}); diff --git a/packages/manager/src/utilities/mapIdsToLinodes.ts b/packages/manager/src/utilities/mapIdsToLinodes.ts deleted file mode 100644 index 37bec20d2ff..00000000000 --- a/packages/manager/src/utilities/mapIdsToLinodes.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { Linode } from '@linode/api-v4'; - -import { isNotNullOrUndefined } from './nullOrUndefined'; - -export const mapIdsToLinodes = ( - ids: null | number | number[], - linodes: Linode[] = [] -): Linode | Linode[] | null => { - const linodeMap = new Map(linodes.map((linode) => [linode.id, linode])); - if (Array.isArray(ids)) { - return ids.map((id) => linodeMap.get(id)).filter(isNotNullOrUndefined); - } else if (ids !== null) { - return linodeMap.get(ids) ?? null; - } else { - return null; - } -};