Skip to content

Commit 095c904

Browse files
upcoming: [UIE-9514] - Update Firewall Rule Drawer to support referencing Rule Set (#13094)
* Save progress * Update tests * Few more changes * Add more changes * Clean up tests * Few changes * Layout updates * Update tests * Add ruleset loading state * Clean up mocks * Fix mocks * Add comments to the type * Added changeset: Update FirewallRuleType to support ruleset * Added changeset: Update FirewallRuleTypeSchema to support ruleset * Added changeset: Add new Firewall RuleSet row layout * Update ruleset action text - Delete to Remove * Save progress... * Update comment * Exclude 'addresses' from rulesets reference payloads * Some fixes * Add more details to the drawer for rulset * More changes... * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Add more changes to the drawer for rulesets * Update gap & fontsize of firwall add rules selection card * Fix Chip shrink issue * Revert Action column movement since its not yet confirmed * More Refactoring + better formstate typesaftey * Add renderOptions for Add ruleset Autocomplete * Fix typos * Few updates * Few fixes * Update cypress component tests * More changes * Update Add rulesets button copy * More Updates * Feature flag create entity selection for ruleset * More refactoring - separating form states * Some clean up... * Show only rulsets in dropdown applicable to the given catergory * Update date format * Update badge color tokens * Capitalize action label in chip * Update Chip width * Added changeset: Update Firewall Rule Drawer to support referencing Rule Set * Update placeholder for Select Rule Set * Few updates and clean up * Make cy test work * Clean up: remove duplicate validation * Add cancel btn for rules form + some design tokens for dropdown options * Add unit tests for Add Rule Set Drawer * Update test title * Mock useIsFirewallRulesetsPrefixlistsEnabled instead of feature flag * Fix styling and a bit of clean up
1 parent f76c020 commit 095c904

File tree

11 files changed

+546
-69
lines changed

11 files changed

+546
-69
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
Update Firewall Rule Drawer to support referencing Rule Set ([#13094](https://github.com/linode/manager/pull/13094))

packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { allIPs } from 'src/features/Firewalls/shared';
55
import { stringToExtendedIP } from 'src/utilities/ipUtils';
66
import { renderWithTheme } from 'src/utilities/testHelpers';
77

8+
import * as shared from '../../shared';
89
import { FirewallRuleDrawer } from './FirewallRuleDrawer';
910
import {
1011
classifyIPs,
@@ -37,8 +38,12 @@ const props: FirewallRuleDrawerProps = {
3738
onSubmit: mockOnSubmit,
3839
};
3940

41+
const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled');
42+
4043
describe('AddRuleDrawer', () => {
4144
it('renders the title', () => {
45+
spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: false });
46+
4247
const { getByText } = renderWithTheme(
4348
<FirewallRuleDrawer {...props} category="inbound" mode="create" />
4449
);
@@ -66,6 +71,79 @@ describe('AddRuleDrawer', () => {
6671
});
6772
});
6873

74+
describe('AddRuleSetDrawer', () => {
75+
beforeEach(() => {
76+
spy.mockReturnValue({ isFirewallRulesetsPrefixlistsEnabled: true });
77+
});
78+
79+
it('renders the drawer title', () => {
80+
const { getByText } = renderWithTheme(
81+
<FirewallRuleDrawer {...props} category="inbound" mode="create" />
82+
);
83+
84+
expect(getByText('Add an Inbound Rule or Rule Set')).toBeVisible();
85+
});
86+
87+
it('renders the selection cards', () => {
88+
const { getByText } = renderWithTheme(
89+
<FirewallRuleDrawer {...props} category="inbound" mode="create" />
90+
);
91+
92+
expect(getByText(/Create a Rule/i)).toBeVisible();
93+
expect(getByText(/Reference Rule Set/i)).toBeVisible();
94+
});
95+
96+
it('renders the Rule Set form and its elements when selection card is clicked', async () => {
97+
const { getByText, getByPlaceholderText, getByRole } = renderWithTheme(
98+
<FirewallRuleDrawer {...props} category="inbound" mode="create" />
99+
);
100+
101+
const ruleSetCard = getByText(/Reference Rule Set/i);
102+
await userEvent.click(ruleSetCard);
103+
104+
// Description
105+
expect(
106+
getByText(
107+
'RuleSets are reusable collections of Cloud Firewall rules that use the same fields as individual rules. They let you manage and update multiple rules as a group. You can then apply them across different firewalls by reference.'
108+
)
109+
).toBeVisible();
110+
111+
// Autocomplete field
112+
expect(getByText('Rule Set')).toBeVisible();
113+
expect(
114+
getByPlaceholderText('Type to search or select a Rule Set')
115+
).toBeVisible();
116+
117+
// Action buttons
118+
expect(getByRole('button', { name: 'Add Rule' })).toBeVisible();
119+
expect(getByRole('button', { name: 'Cancel' })).toBeVisible();
120+
121+
// Footer text
122+
expect(
123+
getByText(
124+
'Rule changes don’t take effect immediately. You can add or delete rules before saving all your changes to this Firewall.'
125+
)
126+
).toBeVisible();
127+
});
128+
129+
it('shows validation message when Rule Set form is submitted without selecting a value', async () => {
130+
const { getByText, getByRole } = renderWithTheme(
131+
<FirewallRuleDrawer {...props} category="inbound" mode="create" />
132+
);
133+
134+
// Click the Rule Set Selection card to open the Rule Set form
135+
const ruleSetCard = getByText(/Reference Rule Set/i);
136+
await userEvent.click(ruleSetCard);
137+
138+
// Click the "Add Rule" button without selecting the Autocomplete field
139+
const addRuleButton = getByRole('button', { name: 'Add Rule' });
140+
await userEvent.click(addRuleButton);
141+
142+
// Expect the validation message to appear
143+
getByText('Rule Set is required.');
144+
});
145+
});
146+
69147
describe('utilities', () => {
70148
describe('formValueToIPs', () => {
71149
it('returns a complete set of IPs given a string form value', () => {

packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx

Lines changed: 156 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,15 @@
1-
import { Drawer, Typography } from '@linode/ui';
1+
import { Drawer, Notice, Radio, Typography } from '@linode/ui';
22
import { capitalize } from '@linode/utilities';
3+
import { Grid } from '@mui/material';
34
import { Formik } from 'formik';
45
import * as React from 'react';
56

7+
import { SelectionCard } from 'src/components/SelectionCard/SelectionCard';
8+
9+
import {
10+
type FirewallOptionItem,
11+
useIsFirewallRulesetsPrefixlistsEnabled,
12+
} from '../../shared';
613
import {
714
formValueToIPs,
815
getInitialFormValues,
@@ -13,10 +20,13 @@ import {
1320
validateIPs,
1421
} from './FirewallRuleDrawer.utils';
1522
import { FirewallRuleForm } from './FirewallRuleForm';
23+
import { FirewallRuleSetForm } from './FirewallRuleSetForm';
24+
import { firewallRuleCreateOptions } from './shared';
1625

17-
import type { FirewallOptionItem } from '../../shared';
1826
import type {
27+
FirewallCreateEntityType,
1928
FirewallRuleDrawerProps,
29+
FormRuleSetState,
2030
FormState,
2131
} from './FirewallRuleDrawer.types';
2232
import type {
@@ -32,6 +42,17 @@ export const FirewallRuleDrawer = React.memo(
3242
(props: FirewallRuleDrawerProps) => {
3343
const { category, isOpen, mode, onClose, ruleToModify } = props;
3444

45+
const { isFirewallRulesetsPrefixlistsEnabled } =
46+
useIsFirewallRulesetsPrefixlistsEnabled();
47+
48+
/**
49+
* State for the type of entity being created: either a firewall 'rule' or
50+
* referencing an existing 'ruleset' in the firewall.
51+
* Only relevant when `mode === 'create'`.
52+
*/
53+
const [createEntityType, setCreateEntityType] =
54+
React.useState<FirewallCreateEntityType>('rule');
55+
3556
// Custom IPs are tracked separately from the form. The <MultipleIPs />
3657
// component consumes this state. We use this on form submission if the
3758
// `addresses` form value is "ip/netmask", which indicates the user has
@@ -45,9 +66,9 @@ export const FirewallRuleDrawer = React.memo(
4566
FirewallOptionItem<string>[]
4667
>([]);
4768

48-
// Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying
49-
// (along with any errors we may have).
5069
React.useEffect(() => {
70+
// Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying
71+
// (along with any errors we may have).
5172
if (mode === 'edit' && ruleToModify) {
5273
setIPs(getInitialIPs(ruleToModify));
5374
setPresetPorts(portStringToItems(ruleToModify.ports)[0]);
@@ -56,20 +77,30 @@ export const FirewallRuleDrawer = React.memo(
5677
} else {
5778
setIPs([{ address: '' }]);
5879
}
59-
}, [mode, isOpen, ruleToModify]);
80+
81+
// Reset the Create entity selection to 'rule' in two cases:
82+
// 1. The ruleset feature flag is disabled - 'ruleset' is not allowed.
83+
// 2. The drawer is closed - ensures the next time it opens, it starts with the default 'rule' selection.
84+
if (
85+
mode === 'create' &&
86+
(!isFirewallRulesetsPrefixlistsEnabled || !isOpen)
87+
) {
88+
setCreateEntityType('rule');
89+
}
90+
}, [mode, isOpen, ruleToModify, isFirewallRulesetsPrefixlistsEnabled]);
6091

6192
const title =
62-
mode === 'create' ? `Add an ${capitalize(category)} Rule` : 'Edit Rule';
93+
mode === 'create'
94+
? `Add an ${capitalize(category)} Rule${
95+
isFirewallRulesetsPrefixlistsEnabled ? ' or Rule Set' : ''
96+
}`
97+
: 'Edit Rule';
6398

6499
const addressesLabel = category === 'inbound' ? 'source' : 'destination';
65100

66-
const onValidate = ({
67-
addresses,
68-
description,
69-
label,
70-
ports,
71-
protocol,
72-
}: FormState) => {
101+
const onValidateRule = (values: FormState) => {
102+
const { addresses, description, label, ports, protocol } = values;
103+
73104
// The validated IPs may have errors, so set them to state so we see the errors.
74105
const validatedIPs = validateIPs(ips, {
75106
allowEmptyAddress: addresses !== 'ip/netmask',
@@ -93,7 +124,7 @@ export const FirewallRuleDrawer = React.memo(
93124
};
94125
};
95126

96-
const onSubmit = (values: FormState) => {
127+
const onSubmitRule = (values: FormState) => {
97128
const ports = itemsToPortString(presetPorts, values.ports!);
98129
const protocol = values.protocol as FirewallRuleProtocol;
99130
const addresses = formValueToIPs(values.addresses!, ips);
@@ -103,41 +134,125 @@ export const FirewallRuleDrawer = React.memo(
103134
addresses,
104135
ports,
105136
protocol,
137+
label: values.label || null,
138+
description: values.description || null,
106139
};
107-
108-
payload.label = values.label === '' ? null : values.label;
109-
payload.description =
110-
values.description === '' ? null : values.description;
111-
112140
props.onSubmit(category, payload);
113141
onClose();
114142
};
115143

144+
const onValidateRuleSet = (values: FormRuleSetState) => {
145+
const errors: Record<string, string> = {};
146+
if (!values.ruleset || values.ruleset === -1) {
147+
errors.ruleset = 'Rule Set is required.';
148+
}
149+
if (typeof values.ruleset !== 'number') {
150+
errors.ruleset = 'Rule Set should be a number.';
151+
}
152+
return errors;
153+
};
154+
116155
return (
117156
<Drawer onClose={onClose} open={isOpen} title={title}>
118-
<Formik
119-
initialValues={getInitialFormValues(ruleToModify)}
120-
onSubmit={onSubmit}
121-
validate={onValidate}
122-
validateOnBlur={false}
123-
validateOnChange={false}
124-
>
125-
{(formikProps) => {
126-
return (
127-
<FirewallRuleForm
128-
addressesLabel={addressesLabel}
129-
category={category}
130-
ips={ips}
131-
mode={mode}
132-
presetPorts={presetPorts}
133-
ruleErrors={ruleToModify?.errors}
134-
setIPs={setIPs}
135-
setPresetPorts={setPresetPorts}
136-
{...formikProps}
157+
{mode === 'create' && isFirewallRulesetsPrefixlistsEnabled && (
158+
<Grid container spacing={2}>
159+
{firewallRuleCreateOptions.map((option) => (
160+
<SelectionCard
161+
checked={createEntityType === option.value}
162+
gridSize={{
163+
md: 6,
164+
sm: 12,
165+
xs: 12,
166+
}}
167+
heading={option.label}
168+
key={option.value}
169+
onClick={() => setCreateEntityType(option.value)}
170+
renderIcon={() => (
171+
<Radio checked={createEntityType === option.value} />
172+
)}
173+
subheadings={[]}
174+
sxCardBase={(theme) => ({
175+
gap: 0,
176+
'& .cardSubheadingTitle': {
177+
fontSize: theme.tokens.font.FontSize.Xs,
178+
},
179+
})}
180+
sxCardBaseIcon={(theme) => ({
181+
svg: { fontSize: theme.tokens.font.FontSize.L },
182+
})}
137183
/>
138-
);
139-
}}
140-
</Formik>
184+
))}
185+
</Grid>
186+
)}
187+
188+
{(mode === 'edit' || createEntityType === 'rule') && (
189+
<Formik<FormState>
190+
initialValues={getInitialFormValues(ruleToModify)}
191+
onSubmit={onSubmitRule}
192+
validate={onValidateRule}
193+
validateOnBlur={false}
194+
validateOnChange={false}
195+
>
196+
{(formikProps) => (
197+
<>
198+
{formikProps.status && (
199+
<Notice
200+
data-qa-error
201+
key={formikProps.status}
202+
text={formikProps.status.generalError}
203+
variant="error"
204+
/>
205+
)}
206+
<FirewallRuleForm
207+
addressesLabel={addressesLabel}
208+
category={category}
209+
closeDrawer={onClose}
210+
ips={ips}
211+
mode={mode}
212+
presetPorts={presetPorts}
213+
ruleErrors={ruleToModify?.errors}
214+
setIPs={setIPs}
215+
setPresetPorts={setPresetPorts}
216+
{...formikProps}
217+
/>
218+
</>
219+
)}
220+
</Formik>
221+
)}
222+
223+
{mode === 'create' &&
224+
createEntityType === 'ruleset' &&
225+
isFirewallRulesetsPrefixlistsEnabled && (
226+
<Formik<FormRuleSetState>
227+
initialValues={{ ruleset: -1 }}
228+
onSubmit={(values) => {
229+
props.onSubmit(category, values);
230+
onClose();
231+
}}
232+
validate={onValidateRuleSet}
233+
validateOnBlur={true}
234+
validateOnChange={true}
235+
>
236+
{(formikProps) => (
237+
<>
238+
{formikProps.status && (
239+
<Notice
240+
data-qa-error
241+
key={formikProps.status}
242+
text={formikProps.status.generalError}
243+
variant="error"
244+
/>
245+
)}
246+
<FirewallRuleSetForm
247+
category={category}
248+
closeDrawer={onClose}
249+
ruleErrors={ruleToModify?.errors}
250+
{...formikProps}
251+
/>
252+
</>
253+
)}
254+
</Formik>
255+
)}
141256
<Typography variant="body1">
142257
Rule changes don&rsquo;t take effect immediately. You can add or
143258
delete rules before saving all your changes to this Firewall.

packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,13 +29,27 @@ export interface FormState {
2929
type: string;
3030
}
3131

32+
export interface FormRuleSetState {
33+
ruleset: number;
34+
}
35+
36+
export type FirewallCreateEntityType = 'rule' | 'ruleset';
37+
3238
export interface FirewallRuleFormProps extends FormikProps<FormState> {
3339
addressesLabel: string;
3440
category: Category;
41+
closeDrawer: () => void;
3542
ips: ExtendedIP[];
3643
mode: FirewallRuleDrawerMode;
3744
presetPorts: FirewallOptionItem<string>[];
3845
ruleErrors?: FirewallRuleError[];
3946
setIPs: (ips: ExtendedIP[]) => void;
4047
setPresetPorts: (selected: FirewallOptionItem<string>[]) => void;
4148
}
49+
50+
export interface FirewallRuleSetFormProps
51+
extends FormikProps<FormRuleSetState> {
52+
category: Category;
53+
closeDrawer: () => void;
54+
ruleErrors?: FirewallRuleError[];
55+
}

0 commit comments

Comments
 (0)