Skip to content

Commit bb15097

Browse files
upcoming: [UIE-9507, UIE-9510] - Add New Firewall RuleSet Row Layout + Factories/Mocks (#13079)
* 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 * Move Action column and improve table responsiveness for long labels * Update Cypress component test * Revert Action column movement since its not yet confirmed * Few updates * Few fixes * Update cypress component tests
1 parent a69c687 commit bb15097

File tree

15 files changed

+361
-106
lines changed

15 files changed

+361
-106
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/api-v4": Upcoming Features
3+
---
4+
5+
Update FirewallRuleType to support ruleset ([#13079](https://github.com/linode/manager/pull/13079))

packages/api-v4/src/firewalls/types.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,26 @@ export type UpdateFirewallRules = Omit<
3636

3737
export type FirewallTemplateRules = UpdateFirewallRules;
3838

39+
/**
40+
* The API may return either a full firewall rule object or a ruleset reference
41+
* containing only the `ruleset` field. This interface supports both formats
42+
* to ensure backward compatibility with existing implementations and avoid
43+
* widespread refactoring.
44+
*/
3945
export interface FirewallRuleType {
40-
action: FirewallPolicyType;
46+
action?: FirewallPolicyType | null;
4147
addresses?: null | {
4248
ipv4?: null | string[];
4349
ipv6?: null | string[];
4450
};
4551
description?: null | string;
4652
label?: null | string;
47-
ports?: string;
48-
protocol: FirewallRuleProtocol;
53+
ports?: null | string;
54+
protocol?: FirewallRuleProtocol | null;
55+
/**
56+
* Present when the object represents a ruleset reference.
57+
*/
58+
ruleset?: null | number;
4959
}
5060

5161
export interface FirewallDeviceEntity {
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+
Add new Firewall RuleSet row layout ([#13079](https://github.com/linode/manager/pull/13079))

packages/manager/cypress/component/features/firewalls/firewall-rule-table.spec.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -122,13 +122,13 @@ const verifyFirewallWithRules = ({
122122
.within(() => {
123123
if (isSmallViewport) {
124124
// Column 'Protocol' is not visible for smaller screens.
125-
cy.findByText(rule.protocol).should('not.exist');
125+
cy.findByText(rule.protocol!).should('not.exist');
126126
} else {
127-
cy.findByText(rule.protocol).should('be.visible');
127+
cy.findByText(rule.protocol!).should('be.visible');
128128
}
129129

130130
cy.findByText(rule.ports!).should('be.visible');
131-
cy.findByText(getRuleActionLabel(rule.action)).should('be.visible');
131+
cy.findByText(getRuleActionLabel(rule.action!)).should('be.visible');
132132
});
133133
});
134134
};

packages/manager/cypress/e2e/core/firewalls/update-firewall.spec.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -227,9 +227,9 @@ describe('update firewall', () => {
227227
.should('be.visible')
228228
.closest('tr')
229229
.within(() => {
230-
cy.findByText(inboundRule.protocol).should('be.visible');
230+
cy.findByText(inboundRule.protocol!).should('be.visible');
231231
cy.findByText(inboundRule.ports!).should('be.visible');
232-
cy.findByText(getRuleActionLabel(inboundRule.action)).should(
232+
cy.findByText(getRuleActionLabel(inboundRule.action!)).should(
233233
'be.visible'
234234
);
235235
});
@@ -242,9 +242,9 @@ describe('update firewall', () => {
242242
.should('be.visible')
243243
.closest('tr')
244244
.within(() => {
245-
cy.findByText(outboundRule.protocol).should('be.visible');
245+
cy.findByText(outboundRule.protocol!).should('be.visible');
246246
cy.findByText(outboundRule.ports!).should('be.visible');
247-
cy.findByText(getRuleActionLabel(outboundRule.action)).should(
247+
cy.findByText(getRuleActionLabel(outboundRule.action!)).should(
248248
'be.visible'
249249
);
250250
});

packages/manager/src/factories/firewalls.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,11 @@ import {
1010
} from '@linode/api-v4/lib/firewalls/types';
1111
import { Factory } from '@linode/utilities';
1212

13-
import type { FirewallDeviceEntity } from '@linode/api-v4/lib/firewalls/types';
13+
import type {
14+
FirewallDeviceEntity,
15+
FirewallPrefixList,
16+
FirewallRuleSet,
17+
} from '@linode/api-v4/lib/firewalls/types';
1418

1519
export const firewallRuleFactory = Factory.Sync.makeFactory<FirewallRuleType>({
1620
action: 'DROP',
@@ -97,3 +101,35 @@ export const firewallSettingsFactory =
97101
vpc_interface: 1,
98102
},
99103
});
104+
105+
export const firewallRuleSetFactory = Factory.Sync.makeFactory<FirewallRuleSet>(
106+
{
107+
created: '2025-11-05T00:00:00',
108+
deleted: null,
109+
description: Factory.each((i) => `firewall-ruleset-${i} description`),
110+
label: Factory.each((i) => `firewall-ruleset-${i}`),
111+
is_service_defined: false,
112+
id: Factory.each((i) => i),
113+
type: 'inbound',
114+
rules: firewallRuleFactory.buildList(3),
115+
updated: '2025-11-05T00:00:00',
116+
version: 1,
117+
}
118+
);
119+
120+
export const firewallPrefixListFactory =
121+
Factory.Sync.makeFactory<FirewallPrefixList>({
122+
created: '2025-11-05T00:00:00',
123+
updated: '2025-11-05T00:00:00',
124+
description: Factory.each((i) => `firewall-prefixlist-${i} description`),
125+
id: Factory.each((i) => i),
126+
name: Factory.each((i) => `pl:system:resolvers:test-${i}`),
127+
version: 1,
128+
visibility: 'public',
129+
ipv4: Factory.each((i) =>
130+
Array.from({ length: 5 }, (_, j) => `139.144.${i}.${j}`)
131+
),
132+
ipv6: Factory.each((i) =>
133+
Array.from({ length: 5 }, (_, j) => `2600:3c05:e001:bc::${i}${j}`)
134+
),
135+
});

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ const props: FirewallRuleActionMenuProps = {
1212
handleCloneFirewallRule: vi.fn(),
1313
handleDeleteFirewallRule: vi.fn(),
1414
handleOpenRuleDrawerForEditing: vi.fn(),
15+
isRuleSetRowEnabled: false,
1516
idx: 1,
1617
};
1718

@@ -25,8 +26,37 @@ describe('Firewall rule action menu', () => {
2526

2627
await userEvent.click(actionMenuButton);
2728

29+
// "Edit", "Clone" and "Delete" are all visible and enabled
2830
for (const action of ['Edit', 'Clone', 'Delete']) {
2931
expect(getByText(action)).toBeVisible();
3032
}
3133
});
34+
35+
it('should include the correct actions when Firewall rules row is a RuleSet', async () => {
36+
const { getByText, queryByText, queryByLabelText, findByRole } =
37+
renderWithTheme(
38+
<FirewallRuleActionMenu {...props} isRuleSetRowEnabled={true} />
39+
);
40+
41+
const actionMenuButton = queryByLabelText(/^Action menu for/)!;
42+
43+
await userEvent.click(actionMenuButton);
44+
45+
// "Edit" is visible but disabled, "Clone" is not present, and "Remove" is visible and enabled
46+
for (const action of ['Edit', 'Remove']) {
47+
expect(getByText(action)).toBeVisible();
48+
}
49+
expect(queryByText('Clone')).toBeNull();
50+
51+
expect(getByText('Edit')).toBeDisabled();
52+
expect(getByText('Remove')).toBeEnabled();
53+
54+
// Hover over "Edit" and assert tooltip text
55+
const editButton = getByText('Edit');
56+
await userEvent.hover(editButton);
57+
const tooltip = await findByRole('tooltip');
58+
expect(tooltip).toHaveTextContent(
59+
'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.'
60+
);
61+
});
3262
});

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

Lines changed: 40 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { Box } from '@linode/ui';
12
import { useTheme } from '@mui/material/styles';
23
import useMediaQuery from '@mui/material/useMediaQuery';
34
import * as React from 'react';
@@ -13,64 +14,78 @@ import type {
1314

1415
export interface FirewallRuleActionMenuProps extends Partial<ActionMenuProps> {
1516
disabled: boolean;
16-
handleCloneFirewallRule: (idx: number) => void;
17+
handleCloneFirewallRule?: (idx: number) => void; // Cloning is NOT applicable in the case of ruleset
1718
handleDeleteFirewallRule: (idx: number) => void;
18-
handleOpenRuleDrawerForEditing: (idx: number) => void;
19+
handleOpenRuleDrawerForEditing?: (idx: number) => void; // Editing is NOT applicable in the case of ruleset
1920
idx: number;
21+
isRuleSetRowEnabled: boolean;
2022
}
2123

2224
export const FirewallRuleActionMenu = React.memo(
2325
(props: FirewallRuleActionMenuProps) => {
2426
const theme = useTheme<Theme>();
25-
const matchesSmDown = useMediaQuery(theme.breakpoints.down('md'));
27+
const matchesLgDown = useMediaQuery(theme.breakpoints.down('lg'));
28+
29+
const rulesetEditActionToolTipText =
30+
'Edit your custom Rule Set\u2019s label, description, or rules, using the API. Rule Sets that are defined by a managed-service can only be updated by service accounts.';
2631

2732
const {
2833
disabled,
2934
handleCloneFirewallRule,
3035
handleDeleteFirewallRule,
3136
handleOpenRuleDrawerForEditing,
3237
idx,
38+
isRuleSetRowEnabled,
3339
...actionMenuProps
3440
} = props;
3541

3642
const actions: Action[] = [
3743
{
38-
disabled,
44+
disabled: disabled || isRuleSetRowEnabled,
3945
onClick: () => {
40-
handleOpenRuleDrawerForEditing(idx);
46+
handleOpenRuleDrawerForEditing?.(idx);
4147
},
4248
title: 'Edit',
49+
tooltip: isRuleSetRowEnabled ? rulesetEditActionToolTipText : undefined,
4350
},
44-
{
45-
disabled,
46-
onClick: () => {
47-
handleCloneFirewallRule(idx);
48-
},
49-
title: 'Clone',
50-
},
51+
...(!isRuleSetRowEnabled
52+
? [
53+
{
54+
disabled,
55+
onClick: () => {
56+
handleCloneFirewallRule?.(idx);
57+
},
58+
title: 'Clone',
59+
},
60+
]
61+
: []),
5162
{
5263
disabled,
5364
onClick: () => {
5465
handleDeleteFirewallRule(idx);
5566
},
56-
title: 'Delete',
67+
title: isRuleSetRowEnabled ? 'Remove' : 'Delete',
5768
},
5869
];
5970

6071
return (
6172
<>
62-
{!matchesSmDown &&
63-
actions.map((action) => {
64-
return (
65-
<InlineMenuAction
66-
actionText={action.title}
67-
disabled={action.disabled}
68-
key={action.title}
69-
onClick={action.onClick}
70-
/>
71-
);
72-
})}
73-
{matchesSmDown && (
73+
{!matchesLgDown && (
74+
<Box sx={{ display: 'flex', alignItems: 'center' }}>
75+
{actions.map((action) => {
76+
return (
77+
<InlineMenuAction
78+
actionText={action.title}
79+
disabled={action.disabled}
80+
key={action.title}
81+
onClick={action.onClick}
82+
tooltip={action.tooltip}
83+
/>
84+
);
85+
})}
86+
</Box>
87+
)}
88+
{matchesLgDown && (
7489
<ActionMenu
7590
actionsList={actions}
7691
ariaLabel={`Action menu for Firewall Rule`}

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

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ export const FirewallRuleDrawer = React.memo(
7676
});
7777
setIPs(validatedIPs);
7878

79-
const _ports = itemsToPortString(presetPorts, ports);
79+
const _ports = itemsToPortString(presetPorts, ports!);
8080

8181
return {
8282
...validateForm({
@@ -94,9 +94,9 @@ export const FirewallRuleDrawer = React.memo(
9494
};
9595

9696
const onSubmit = (values: FormState) => {
97-
const ports = itemsToPortString(presetPorts, values.ports);
97+
const ports = itemsToPortString(presetPorts, values.ports!);
9898
const protocol = values.protocol as FirewallRuleProtocol;
99-
const addresses = formValueToIPs(values.addresses, ips);
99+
const addresses = formValueToIPs(values.addresses!, ips);
100100

101101
const payload: FirewallRuleType = {
102102
action: values.action,

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

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,17 +52,17 @@ export const deriveTypeFromValuesAndIPs = (
5252

5353
const predefinedFirewall = predefinedFirewallFromRule({
5454
action: 'ACCEPT',
55-
addresses: formValueToIPs(values.addresses, ips),
55+
addresses: formValueToIPs(values.addresses!, ips),
5656
ports: values.ports,
5757
protocol,
5858
});
5959

6060
if (predefinedFirewall) {
6161
return predefinedFirewall;
6262
} else if (
63-
values.protocol?.length > 0 ||
63+
(values.protocol && values.protocol?.length > 0) ||
6464
(values.ports && values.ports?.length > 0) ||
65-
values.addresses?.length > 0
65+
(values.addresses && values.addresses?.length > 0)
6666
) {
6767
return 'custom';
6868
}
@@ -163,7 +163,7 @@ export const getInitialFormValues = (
163163
ports: portStringToItems(ruleToModify.ports)[1],
164164
protocol: ruleToModify.protocol,
165165
type: predefinedFirewallFromRule(ruleToModify) || '',
166-
};
166+
} as FormState;
167167
};
168168

169169
export const getInitialAddressFormValue = (
@@ -264,7 +264,7 @@ export const itemsToPortString = (
264264
* and converts it to FirewallOptionItem<string>[] and a custom input string.
265265
*/
266266
export const portStringToItems = (
267-
portString?: string
267+
portString?: null | string
268268
): [FirewallOptionItem<string>[], string] => {
269269
// Handle empty input
270270
if (!portString) {

0 commit comments

Comments
 (0)