From 2d8a834d7cad65a58b2fba80acb382c56e737000 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 20:02:33 +0530 Subject: [PATCH 01/24] Update Rules Edit/Add drawer to support PrefixLists --- .../MultipleIPInput/MultipleIPInput.tsx | 12 +- .../Rules/FirewallRuleDrawer.test.tsx | 165 +++++-- .../Rules/FirewallRuleDrawer.tsx | 50 +- .../Rules/FirewallRuleDrawer.types.ts | 4 +- .../Rules/FirewallRuleDrawer.utils.ts | 145 +++++- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 45 +- .../Rules/MutiplePrefixListInput.tsx | 455 ++++++++++++++++++ .../Firewalls/FirewallDetail/Rules/shared.ts | 10 + .../manager/src/features/Firewalls/shared.tsx | 2 +- packages/manager/src/utilities/ipUtils.ts | 4 + 10 files changed, 792 insertions(+), 100 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 6515ff3ee81..87fbf03eb50 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -70,6 +70,12 @@ export interface MultipeIPInputProps { */ buttonText?: string; + /** + * Whether the first input field can be removed. + * @default false + */ + canRemoveFirstInput?: boolean; + /** * Custom CSS class for additional styling. */ @@ -155,6 +161,7 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { const { adjustSpacingForVPCDualStack, buttonText, + canRemoveFirstInput, className, disabled, error, @@ -304,7 +311,10 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { * used in DBaaS or for Linode VPC interfaces */} - {(idx > 0 || forDatabaseAccessControls || forVPCIPRanges) && ( + {(idx > 0 || + forDatabaseAccessControls || + forVPCIPRanges || + canRemoveFirstInput) && ( { describe('utilities', () => { describe('formValueToIPs', () => { it('returns a complete set of IPs given a string form value', () => { - expect(formValueToIPs('all', [''].map(stringToExtendedIP))).toEqual( + expect(formValueToIPs('all', [''].map(stringToExtendedIP), [])).toEqual( allIPs ); - expect(formValueToIPs('allIPv4', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv4', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv4: ['0.0.0.0/0'], }); - expect(formValueToIPs('allIPv6', [''].map(stringToExtendedIP))).toEqual({ + expect( + formValueToIPs('allIPv6', [''].map(stringToExtendedIP), []) + ).toEqual({ ipv6: ['::/0'], }); expect( - formValueToIPs('ip/netmask', ['1.1.1.1'].map(stringToExtendedIP)) + formValueToIPs( + 'ip/netmask/prefixlist', + ['1.1.1.1'].map(stringToExtendedIP), + [] + ) ).toEqual({ ipv4: ['1.1.1.1'], }); @@ -303,21 +311,20 @@ describe('utilities', () => { describe('validateForm', () => { it('validates protocol', () => { - expect(validateForm({})).toHaveProperty( + expect(validateForm({}, [], [])).toHaveProperty( 'protocol', 'Protocol is required.' ); }); it('validates ports', () => { - expect(validateForm({ ports: '80', protocol: 'ICMP' })).toHaveProperty( - 'ports', - 'Ports are not allowed for ICMP protocols.' - ); expect( - validateForm({ ports: '443', protocol: 'IPENCAP' }) + validateForm({ ports: '80', protocol: 'ICMP' }, [], []) + ).toHaveProperty('ports', 'Ports are not allowed for ICMP protocols.'); + expect( + validateForm({ ports: '443', protocol: 'IPENCAP' }, [], []) ).toHaveProperty('ports', 'Ports are not allowed for IPENCAP protocols.'); expect( - validateForm({ ports: 'invalid-port', protocol: 'TCP' }) + validateForm({ ports: 'invalid-port', protocol: 'TCP' }, [], []) ).toHaveProperty('ports'); }); it('validates custom ports', () => { @@ -326,56 +333,68 @@ describe('utilities', () => { label: 'Firewalllabel', }; // SUCCESS CASES - expect(validateForm({ ports: '1234', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ ports: '1,2,3,4,5', protocol: 'TCP', ...rest }) + validateForm({ ports: '1234', protocol: 'TCP', ...rest }, [], []) ).toEqual({}); expect( - validateForm({ ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }) + validateForm({ ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, [], []) ).toEqual({}); - expect(validateForm({ ports: '1-20', protocol: 'TCP', ...rest })).toEqual( - {} - ); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', - protocol: 'TCP', - ...rest, - }) + validateForm( + { ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }, + [], + [] + ) ).toEqual({}); expect( - validateForm({ ports: '1-2,3-4', protocol: 'TCP', ...rest }) + validateForm({ ports: '1-20', protocol: 'TCP', ...rest }, [], []) ).toEqual({}); expect( - validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }) + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15', + protocol: 'TCP', + ...rest, + }, + [], + [] + ) + ).toEqual({}); + expect( + validateForm({ ports: '1-2,3-4', protocol: 'TCP', ...rest }, [], []) + ).toEqual({}); + expect( + validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }, [], []) ).toEqual({}); // FAILURE CASES expect( - validateForm({ ports: '1,21-12', protocol: 'TCP', ...rest }) + validateForm({ ports: '1,21-12', protocol: 'TCP', ...rest }, [], []) ).toHaveProperty( 'ports', 'Range must start with a smaller number and end with a larger number' ); expect( - validateForm({ ports: '1-21-45', protocol: 'TCP', ...rest }) + validateForm({ ports: '1-21-45', protocol: 'TCP', ...rest }, [], []) ).toHaveProperty('ports', 'Ranges must have 2 values'); expect( - validateForm({ ports: 'abc', protocol: 'TCP', ...rest }) + validateForm({ ports: 'abc', protocol: 'TCP', ...rest }, [], []) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '1--20', protocol: 'TCP', ...rest }) + validateForm({ ports: '1--20', protocol: 'TCP', ...rest }, [], []) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '-20', protocol: 'TCP', ...rest }) + validateForm({ ports: '-20', protocol: 'TCP', ...rest }, [], []) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ - ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', - protocol: 'TCP', - ...rest, - }) + validateForm( + { + ports: '1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16', + protocol: 'TCP', + ...rest, + }, + [], + [] + ) ).toHaveProperty( 'ports', 'Number of ports or port range endpoints exceeded. Max allowed is 15' @@ -430,11 +449,50 @@ describe('utilities', () => { ports: '80', protocol: 'TCP', }; - expect(validateForm({ label: value, ...rest })).toEqual(result); + expect(validateForm({ label: value, ...rest }, [], [])).toEqual(result); }); }); + + it('handles addresses field', () => { + // Invalid cases + expect(validateForm({}, [], [])).toHaveProperty( + 'addresses', + 'Sources is a required field.' + ); + + expect( + validateForm({ addresses: 'ip/netmask/prefixlist' }, [], []) + ).toHaveProperty( + 'addresses', + 'Add an IP address in IP/mask format, or reference a Prefix List name.' + ); + + // Valid cases + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + [{ address: '192.268.0.0' }, { address: '192.268.0.1' }], + [{ address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }] + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + [{ address: '192.268.0.0' }], + [] + ) + ).not.toHaveProperty('addresses'); + expect( + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + [], + [{ address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }] + ) + ).not.toHaveProperty('addresses'); + }); + it('handles required fields', () => { - expect(validateForm({})).toEqual({ + expect(validateForm({}, [], [])).toEqual({ addresses: 'Sources is a required field.', label: 'Label is required.', ports: 'Ports is a required field.', @@ -443,11 +501,11 @@ describe('utilities', () => { }); }); - describe('getInitialIPs', () => { + describe('getInitialIPsOrPLs', () => { const ruleToModify: ExtendedFirewallRule = { action: 'ACCEPT', addresses: { - ipv4: ['1.2.3.4'], + ipv4: ['1.2.3.4', 'pl:system:test'], ipv6: ['::0'], }, originalIndex: 0, @@ -456,10 +514,8 @@ describe('utilities', () => { status: 'NEW', }; it('parses the IPs when no errors', () => { - expect(getInitialIPs(ruleToModify)).toEqual([ - { address: '1.2.3.4' }, - { address: '::0' }, - ]); + const { ips: initalIPs } = getInitialIPsOrPLs(ruleToModify); + expect(initalIPs).toEqual([{ address: '1.2.3.4' }, { address: '::0' }]); }); it('parses the IPs with no errors', () => { const errors: FirewallRuleError[] = [ @@ -471,13 +527,17 @@ describe('utilities', () => { reason: 'Invalid IP', }, ]; - expect(getInitialIPs({ ...ruleToModify, errors })).toEqual([ + const { ips: initalIPs } = getInitialIPsOrPLs({ + ...ruleToModify, + errors, + }); + expect(initalIPs).toEqual([ { address: '1.2.3.4', error: IP_ERROR_MESSAGE }, { address: '::0' }, ]); }); it('offsets error indices correctly', () => { - const result = getInitialIPs({ + const { ips: initialIPs } = getInitialIPsOrPLs({ ...ruleToModify, addresses: { ipv4: ['1.2.3.4'], @@ -493,11 +553,17 @@ describe('utilities', () => { }, ], }); - expect(result).toEqual([ + expect(initialIPs).toEqual([ { address: '1.2.3.4' }, { address: 'INVALID_IP', error: IP_ERROR_MESSAGE }, ]); }); + it('parses the PLs when no errors', () => { + const { pls: initalPLs } = getInitialIPsOrPLs(ruleToModify); + expect(initalPLs).toEqual([ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: false }, + ]); + }); }); describe('classifyIPs', () => { @@ -526,12 +592,13 @@ describe('utilities', () => { }; it('correctly matches values to their representative type', () => { - const result = deriveTypeFromValuesAndIPs(formValues, []); + const result = deriveTypeFromValuesAndIPs(formValues, [], []); expect(result).toBe('https'); }); it('returns "custom" if there is no match', () => { const result = deriveTypeFromValuesAndIPs( { ...formValues, ports: '22-23' }, + [], [] ); expect(result).toBe('custom'); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 10c16eef62a..acb4d441ab1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -10,11 +10,12 @@ import { useIsFirewallRulesetsPrefixlistsEnabled } from '../../shared'; import { formValueToIPs, getInitialFormValues, - getInitialIPs, + getInitialIPsOrPLs, itemsToPortString, portStringToItems, validateForm, validateIPs, + validatePrefixLists, } from './FirewallRuleDrawer.utils'; import { FirewallRuleForm } from './FirewallRuleForm'; import { FirewallRuleSetDetailsView } from './FirewallRuleSetDetailsView'; @@ -32,7 +33,7 @@ import type { FirewallRuleProtocol, FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; // ============================================================================= // @@ -52,12 +53,16 @@ export const FirewallRuleDrawer = React.memo( const [createEntityType, setCreateEntityType] = React.useState('rule'); - // Custom IPs are tracked separately from the form. The + // Custom IPs or PLs are tracked separately from the form. The or // component consumes this state. We use this on form submission if the - // `addresses` form value is "ip/netmask", which indicates the user has - // intended to specify custom IPs. + // `addresses` form value is "ip/netmask/prefixlist", which indicates the user has + // intended to specify custom IPs or PLs. const [ips, setIPs] = React.useState([{ address: '' }]); + const [pls, setPLs] = React.useState([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + // Firewall Ports, like IPs, are tracked separately. The form.values state value // tracks the custom user input; the FirewallOptionItem[] array of port presets in the multi-select // is stored here. @@ -69,12 +74,15 @@ export const FirewallRuleDrawer = React.memo( // Reset state. If we're in EDIT mode, set IPs to the addresses of the rule we're modifying // (along with any errors we may have). if (mode === 'edit' && ruleToModifyOrView) { - setIPs(getInitialIPs(ruleToModifyOrView)); + const { ips, pls } = getInitialIPsOrPLs(ruleToModifyOrView); + setIPs(ips); + setPLs(pls); setPresetPorts(portStringToItems(ruleToModifyOrView.ports)[0]); } else if (isOpen) { setPresetPorts([]); } else { setIPs([{ address: '' }]); + setPLs([]); } // Reset the Create entity selection to 'rule' in two cases: @@ -109,31 +117,41 @@ export const FirewallRuleDrawer = React.memo( // The validated IPs may have errors, so set them to state so we see the errors. const validatedIPs = validateIPs(ips, { - allowEmptyAddress: addresses !== 'ip/netmask', + allowEmptyAddress: addresses !== 'ip/netmask/prefixlist', }); setIPs(validatedIPs); + // The validated PLs may have errors, so set them to state so we see the errors. + const validatedPLs = validatePrefixLists(pls); + setPLs(validatedPLs); + const _ports = itemsToPortString(presetPorts, ports!); return { - ...validateForm({ - addresses, - description, - label, - ports: _ports, - protocol, - }), + ...validateForm( + { + addresses, + description, + label, + ports: _ports, + protocol, + }, + validatedIPs, + validatedPLs + ), // This is a bit of a trick. If this function DOES NOT return an empty object, Formik will call // `onSubmit()`. If there are IP errors, we add them to the return object so Formik knows there // is an issue with the form. ...validatedIPs.filter((thisIP) => Boolean(thisIP.error)), + // For PrefixLists + ...validatedPLs.filter((thisPL) => Boolean(thisPL.error)), }; }; const onSubmitRule = (values: FormState) => { const ports = itemsToPortString(presetPorts, values.ports!); const protocol = values.protocol as FirewallRuleProtocol; - const addresses = formValueToIPs(values.addresses!, ips); + const addresses = formValueToIPs(values.addresses!, ips, pls); const payload: FirewallRuleType = { action: values.action, @@ -216,9 +234,11 @@ export const FirewallRuleDrawer = React.memo( closeDrawer={onClose} ips={ips} mode={mode} + pls={pls} presetPorts={presetPorts} ruleErrors={ruleToModifyOrView?.errors} setIPs={setIPs} + setPLs={setPLs} setPresetPorts={setPresetPorts} {...formikProps} /> diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts index c48ff4a91a6..c5caa94ec0b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.types.ts @@ -6,7 +6,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FormikProps } from 'formik'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; export type FirewallRuleDrawerMode = 'create' | 'edit' | 'view'; @@ -41,9 +41,11 @@ export interface FirewallRuleFormProps extends FormikProps { closeDrawer: () => void; ips: ExtendedIP[]; mode: FirewallRuleDrawerMode; + pls: ExtendedPL[]; presetPorts: FirewallOptionItem[]; ruleErrors?: FirewallRuleError[]; setIPs: (ips: ExtendedIP[]) => void; + setPLs: (pls: ExtendedPL[]) => void; setPresetPorts: (selected: FirewallOptionItem[]) => void; } diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index 63e046e57a5..e3c7626d612 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -13,6 +13,7 @@ import { allowNoneIPv4, allowNoneIPv6, allowsAllIPs, + buildPrefixListReferenceMap, predefinedFirewallFromRule, } from 'src/features/Firewalls/shared'; import { stringToExtendedIP } from 'src/utilities/ipUtils'; @@ -26,7 +27,7 @@ import type { FirewallRuleType, } from '@linode/api-v4/lib/firewalls'; import type { FirewallOptionItem } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; @@ -42,7 +43,8 @@ export const IP_ERROR_MESSAGE = 'Must be a valid IPv4 or IPv6 range.'; */ export const deriveTypeFromValuesAndIPs = ( values: FormState, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ) => { if (values.type === 'custom') { return 'custom'; @@ -52,7 +54,7 @@ export const deriveTypeFromValuesAndIPs = ( const predefinedFirewall = predefinedFirewallFromRule({ action: 'ACCEPT', - addresses: formValueToIPs(values.addresses!, ips), + addresses: formValueToIPs(values.addresses!, ips, pls), ports: values.ports, protocol, }); @@ -74,7 +76,8 @@ export const deriveTypeFromValuesAndIPs = ( */ export const formValueToIPs = ( formValue: string, - ips: ExtendedIP[] + ips: ExtendedIP[], + pls: ExtendedPL[] ): FirewallRuleType['addresses'] => { switch (formValue) { case 'all': @@ -83,10 +86,34 @@ export const formValueToIPs = ( return { ipv4: [allIPv4] }; case 'allIPv6': return { ipv6: [allIPv6] }; - default: - // The user has selected "IP / Netmask" and entered custom IPs, so we need + default: { + // The user has selected "IP / Netmask / Prefix List" and entered custom IPs or selected PLs, so we need // to separate those into v4 and v6 addresses. - return classifyIPs(ips); + const classifiedIPs = classifyIPs(ips); + const classifiedPLs = classifyPLs(pls); + + const ruleIPv4 = [ + ...(classifiedIPs.ipv4 ?? []), + ...(classifiedPLs.ipv4 ?? []), + ]; + + const ruleIPv6 = [ + ...(classifiedIPs.ipv6 ?? []), + ...(classifiedPLs.ipv6 ?? []), + ]; + + const result: FirewallRuleType['addresses'] = {}; + + if (ruleIPv4.length > 0) { + result.ipv4 = ruleIPv4; + } + + if (ruleIPv6.length > 0) { + result.ipv6 = ruleIPv6; + } + + return result; + } } }; @@ -112,6 +139,34 @@ export const validateIPs = ( }); }; +export const validatePrefixLists = (pls: ExtendedPL[]): ExtendedPL[] => { + const seen = new Set(); + return pls.map((pl) => { + const { address, inIPv4Rule, inIPv6Rule } = pl; + + if (!pl.address) { + return { ...pl, error: 'Please select the Prefix List.' }; + } + + if (pl.inIPv4Rule === false && pl.inIPv6Rule === false) { + return { + ...pl, + error: 'At least one IPv4 or IPv6 option must be selected.', + }; + } + + if (seen.has(pl.address)) { + return { + ...pl, + error: 'This Prefix List is already selected.', + }; + } + + seen.add(pl.address); + return { address, inIPv4Rule, inIPv6Rule }; + }); +}; + /** * Given an array of IP addresses, filter out invalid addresses and categorize * them by "ipv4" and "ipv6." @@ -138,6 +193,28 @@ export const classifyIPs = (ips: ExtendedIP[]) => { ); }; +/** + * Given an array of Firewall Rule IP addresses, categorize + * Prefix List by "ipv4" and "ipv6." + */ +export const classifyPLs = (pls: ExtendedPL[]) => { + return pls.reduce<{ ipv4?: string[]; ipv6?: string[] }>((acc, pl) => { + if (pl.inIPv4Rule) { + if (!acc.ipv4) { + acc.ipv4 = []; + } + acc.ipv4.push(pl.address); + } + if (pl.inIPv6Rule) { + if (!acc.ipv6) { + acc.ipv6 = []; + } + acc.ipv6.push(pl.address); + } + return acc; + }, {}); +}; + const initialValues: FormState = { action: 'ACCEPT', addresses: '', @@ -181,21 +258,42 @@ export const getInitialAddressFormValue = ( return 'allIPv6'; } - return 'ip/netmask'; + return 'ip/netmask/prefixlist'; }; -// Get a list of Extended IP from an existing Firewall rule. This is necessary when opening the +// Get a list of Extended IP or Extended PL from an existing Firewall rule. This is necessary when opening the // drawer/form to modify an existing rule. -export const getInitialIPs = ( +export const getInitialIPsOrPLs = ( ruleToModify: ExtendedFirewallRule -): ExtendedIP[] => { +): { + ips: ExtendedIP[]; + pls: ExtendedPL[]; +} => { const { addresses } = ruleToModify; - const extendedIPv4 = (addresses?.ipv4 ?? []).map(stringToExtendedIP); - const extendedIPv6 = (addresses?.ipv6 ?? []).map(stringToExtendedIP); + // Exclude all prefix list entries (pl:*) from the FW Rule addresses when building extendedIPv4/extendedIPv6 + const extendedIPv4 = (addresses?.ipv4 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); + const extendedIPv6 = (addresses?.ipv6 ?? []) + .filter((ip) => !ip.startsWith('pl:')) + .map(stringToExtendedIP); const ips: ExtendedIP[] = [...extendedIPv4, ...extendedIPv6]; + // Build ExtendedPL from the FW Rule addresses + const prefixListMap = buildPrefixListReferenceMap({ + ipv4: addresses?.ipv4 ?? [], + ipv6: addresses?.ipv6 ?? [], + }); + const extendedPL = Object.entries(prefixListMap).map(([pl, reference]) => ({ + address: pl, + inIPv4Rule: reference.inIPv4Rule, + inIPv6Rule: reference.inIPv6Rule, + })); + const pls: ExtendedPL[] = extendedPL; + + // Errors ruleToModify.errors?.forEach((thisError) => { const { formField, ip } = thisError; @@ -223,7 +321,7 @@ export const getInitialIPs = ( ips[index].error = IP_ERROR_MESSAGE; }); - return ips; + return { ips, pls }; }; /** @@ -305,13 +403,11 @@ export const portStringToItems = ( return [items, customInput.join(', ')]; }; -export const validateForm = ({ - addresses, - description, - label, - ports, - protocol, -}: Partial) => { +export const validateForm = ( + { addresses, description, label, ports, protocol }: Partial, + validatedIPs: ExtendedIP[], + validatedPLs: ExtendedPL[] +) => { const errors: Partial = {}; if (label) { @@ -337,6 +433,13 @@ export const validateForm = ({ if (!addresses) { errors.addresses = 'Sources is a required field.'; + } else if ( + addresses === 'ip/netmask/prefixlist' && + validatedIPs.length === 0 && + validatedPLs.length === 0 + ) { + errors.addresses = + 'Add an IP address in IP/mask format, or reference a Prefix List name.'; } if (!ports && protocol !== 'ICMP' && protocol !== 'IPENCAP') { diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index c94082dd44b..d86f5253e43 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -22,6 +22,7 @@ import { import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; +import { MultiplePrefixListInput } from './MutiplePrefixListInput'; import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; @@ -29,7 +30,7 @@ import type { FirewallOptionItem, FirewallPreset, } from 'src/features/Firewalls/shared'; -import type { ExtendedIP } from 'src/utilities/ipUtils'; +import type { ExtendedIP, ExtendedPL } from 'src/utilities/ipUtils'; const ipNetmaskTooltipText = 'If you do not specify a mask, /32 will be assumed for IPv4 addresses and /128 will be assumed for IPv6 addresses.'; @@ -44,12 +45,14 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { handleChange, handleSubmit, ips, + pls, mode, presetPorts, ruleErrors, setFieldError, setFieldValue, setIPs, + setPLs, setPresetPorts, touched, values, @@ -147,7 +150,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { (item: string) => { setFieldValue('addresses', item); // Reset custom IPs - setIPs([{ address: '' }]); + setIPs([]); }, [setFieldValue, setIPs] ); @@ -172,6 +175,13 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { setIPs(_ipsWithMasks); }; + const handlePrefixListChange = React.useCallback( + (_pls: ExtendedPL[]) => { + setPLs(_pls); + }, + [setPLs] + ); + const handlePortPresetChange = React.useCallback( (items: FirewallOptionItem[]) => { // If the user is selecting "ALL", it doesn't make sense @@ -305,16 +315,27 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { value={addressesValue} /> {/* Show this field only if "IP / Netmask has been selected." */} - {values.addresses === 'ip/netmask' && ( - + {values.addresses === 'ip/netmask/prefixlist' && ( + <> + 0 ? 'IP / Netmask' : ''} + tooltip={ipNetmaskTooltipText} + /> + + 0 ? 'Prefix List' : ''} + /> + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx new file mode 100644 index 00000000000..9eb160231da --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -0,0 +1,455 @@ +import { useAllFirewallPrefixListsQuery } from '@linode/queries'; +import { + Autocomplete, + Box, + Button, + Checkbox, + CloseIcon, + IconButton, + InputLabel, + LinkButton, + Notice, + Stack, + TooltipIcon, + Typography, +} from '@linode/ui'; +import Grid from '@mui/material/Grid'; +import * as React from 'react'; +import { makeStyles } from 'tss-react/mui'; + +import { Link } from 'src/components/Link'; +import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; +import { useIsFirewallRulesetsPrefixlistsEnabled } from 'src/features/Firewalls/shared'; + +import { getPrefixListType } from './shared'; + +import type { InputBaseProps } from '@mui/material/InputBase'; +import type { Theme } from '@mui/material/styles'; +import type { ExtendedPL } from 'src/utilities/ipUtils'; + +const useStyles = makeStyles()((theme: Theme) => ({ + addIP: { + '& span:first-of-type': { + justifyContent: 'flex-start', + }, + paddingLeft: 0, + paddingTop: theme.spacing(1.5), + }, + button: { + '& > span': { + padding: 2, + }, + marginLeft: `-${theme.spacing()}`, + marginTop: 4, + minHeight: 'auto', + minWidth: 'auto', + padding: 0, + }, + helperText: { + marginBottom: theme.spacing(), + }, + input: { + 'nth-child(n+2)': { + marginTop: theme.spacing(), + }, + }, + ipNetmaskTooltipSection: { + display: 'flex', + flexDirection: 'row', + }, + required: { + font: theme.font.normal, + }, + root: { + marginTop: theme.spacing(), + }, +})); + +export interface MultiplePrefixListInputProps { + /** + * Text displayed on the button. + */ + buttonText?: string; + + /** + * Custom CSS class for additional styling. + */ + className?: string; + + /** + * Disables the component (non-interactive). + * @default false + */ + disabled?: boolean; + + /** + * Error message for invalid input. + */ + error?: string; + + /** + * Helper text for additional guidance. + */ + helperText?: string; + + /** + * Custom input properties passed to the underlying input component. + */ + inputProps?: InputBaseProps; + + /** + * Styles the button as a link. + * @default false + */ + isLinkStyled?: boolean; + + // /** + // * Callback triggered when the input loses focus, passing updated `ips`. + // */ + // onBlur?: (ips: ExtendedIP[]) => void; + + /** + * Callback triggered when IPs change, passing updated `ips`. + */ + onChange: (ips: ExtendedPL[]) => void; + + /** + * Placeholder text for an empty input field. + */ + placeholder?: string; + + /** + * Array of `ExtendedPL` objects representing managed PLs. + */ + pls: ExtendedPL[]; + + /** + * Indicates if the input is required for form submission. + * @default false + */ + required?: boolean; + + /** + * Title or label for the input field. + */ + title: string; + + /** + * Tooltip text for extra info on hover. + */ + tooltip?: string; +} + +export const MultiplePrefixListInput = React.memo( + (props: MultiplePrefixListInputProps) => { + const { + buttonText, + className, + disabled, + error, + // forPLs, + helperText, + pls, + isLinkStyled, + // onBlur, + onChange, + // placeholder, + required, + title, + tooltip, + } = props; + const { classes, cx } = useStyles(); + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + const { data, isLoading } = useAllFirewallPrefixListsQuery( + isFirewallRulesetsPrefixlistsFeatureEnabled + ); + + const prefixLists = data ?? []; + + // const prefixLists: Partial[] = [ + // { id: 1, name: 'pl::subnets:325584', ipv6: ['asdas'] }, + // { id: 2, name: 'pl::vpcs:298694', ipv4: [], ipv6: [] }, + // { + // id: 3, + // name: 'pl:system:test-3', + // ipv4: ['192.168.0'], + // ipv6: null, + // }, + // { id: 4, name: 'pl:system:test-4', ipv4: null, ipv6: ['124.4124.124'] }, + // { id: 5, name: 'pl:system:test-5', ipv4: null, ipv6: null }, + // ]; + + const prefixListDropdownOptions = React.useMemo( + () => + prefixLists + .filter((pl) => { + const isUnsupported = + (pl.ipv4 === null || pl.ipv4 === undefined) && + (pl.ipv6 === null || pl.ipv6 === undefined); + return !isUnsupported; + }) + .map((pl) => ({ + label: pl.name, + value: pl.id, + notSupportedDetails: { + isPLIPv4NotSupported: pl.ipv4 === null || pl.ipv4 === undefined, + isPLIPv6NotSupported: pl.ipv6 === null || pl.ipv6 === undefined, + }, + })), + [prefixLists] + ); + + const getAvailableOptions = (idx: number, address: string) => + prefixListDropdownOptions.filter( + (o) => + o.label === address || // allow current + !pls.some((p, i) => i !== idx && p.address === o.label) + ); + + const handleChange = (pl: string, idx: number) => { + const newPLs = [...pls]; + + newPLs[idx].address = pl; + + const plNotSupportedDetails = prefixListDropdownOptions.find( + (o) => o.label === newPLs[idx].address + )?.notSupportedDetails; + + const bothIPv4AndIPv6Supported = + !plNotSupportedDetails?.isPLIPv4NotSupported && + !plNotSupportedDetails?.isPLIPv6NotSupported; + + const onlyIPv4Supported = + !plNotSupportedDetails?.isPLIPv4NotSupported && + plNotSupportedDetails?.isPLIPv6NotSupported; + + const onlyIPv6Supported = + !plNotSupportedDetails?.isPLIPv6NotSupported && + plNotSupportedDetails?.isPLIPv4NotSupported; + + if (bothIPv4AndIPv6Supported || onlyIPv4Supported) { + newPLs[idx].inIPv4Rule = true; + newPLs[idx].inIPv6Rule = false; + } + + if (onlyIPv6Supported) { + newPLs[idx].inIPv4Rule = false; + newPLs[idx].inIPv6Rule = true; + } + + onChange(newPLs); + }; + + const handleChangeIPv4 = (hasIPv4: boolean, idx: number) => { + const newPLs = [...pls]; + + const details = prefixListDropdownOptions.find( + (o) => o.label === newPLs[idx].address + )?.notSupportedDetails; + + const plSupportsIPv6 = details && !details.isPLIPv6NotSupported; + + newPLs[idx].inIPv4Rule = hasIPv4; + + // If Ipv4 is unchecked then check IpV6 by default if IPv6 is supported by this PL. + if (!hasIPv4 && !newPLs[idx].inIPv6Rule) { + if (plSupportsIPv6) { + newPLs[idx].inIPv6Rule = true; + } + } + onChange(newPLs); + }; + + const handleChangeIPv6 = (hasIPv6: boolean, idx: number) => { + const newPLs = [...pls]; + + const details = prefixListDropdownOptions.find( + (o) => o.label === newPLs[idx].address + )?.notSupportedDetails; + + const plSupportsIPv4 = details && !details.isPLIPv4NotSupported; + + newPLs[idx].inIPv6Rule = hasIPv6; + + // If Ipv6 is unchecked then check IpV4 by default if IPv4 is supported by this PL. + if (!hasIPv6 && !newPLs[idx].inIPv4Rule) { + if (plSupportsIPv4) { + newPLs[idx].inIPv4Rule = true; + } + } + onChange(newPLs); + }; + + const addNewInput = () => { + onChange([...pls, { address: '', inIPv4Rule: false, inIPv6Rule: false }]); + }; + + const removeInput = (idx: number) => { + const _pls = [...pls]; + _pls.splice(idx, 1); + onChange(_pls); + }; + + if (!pls) { + return null; + } + + const addPrefixListButton = isLinkStyled ? ( + + + {buttonText} + + + ) : ( + + ); + + const renderRow = (thisPL: ExtendedPL, idx: number) => { + const availableOptions = getAvailableOptions(idx, thisPL.address); + + const selectedOption = availableOptions.find( + (o) => o.label === thisPL.address + ); + + const ipv4Unsupported = + selectedOption?.notSupportedDetails.isPLIPv4NotSupported === true; + const ipv6Unsupported = + selectedOption?.notSupportedDetails.isPLIPv6NotSupported === true; + + // Prevent both being unchecked + const ipv4Forced = + thisPL.inIPv4Rule === true && thisPL.inIPv6Rule === false; + const ipv6Forced = + thisPL.inIPv6Rule === true && thisPL.inIPv4Rule === false; + + const disableIPv4 = ipv4Unsupported === true || ipv4Forced === true; + const disableIPv6 = ipv6Unsupported === true || ipv6Forced === true; + + return ( + + + 0} + disabled={disabled} + errorText={thisPL.error} + getOptionLabel={(option) => option.label} + groupBy={(option) => getPrefixListType(option.label)} + label="" + loading={isLoading} + noMarginTop + onChange={(_, selectedPrefixList) => { + handleChange(selectedPrefixList?.label ?? '', idx); + }} + options={availableOptions} + placeholder="Type to search or select a Rule Set" + value={ + availableOptions.find((o) => o.label === thisPL.address) ?? null + } + /> + {thisPL.address.length !== 0 && ( + + + handleChangeIPv4(!thisPL.inIPv4Rule, idx)} + text="IPv4" + /> + handleChangeIPv6(!thisPL.inIPv6Rule, idx)} + text="IPv6" + /> + + + {}}>View Details + + + )} + + + removeInput(idx)} + sx={(theme) => ({ + height: 20, + width: 20, + marginTop: `${theme.spacingFunction(16)} !important`, + })} + > + + + + + ); + }; + + return ( +
+ {tooltip && title ? ( +
+ {title} + +
+ ) : ( + // There are a couple of instances in the codebase where an empty string is passed as the title so a title isn't displayed. + // Having this check ensures we don't render an empty label element (which can still impact spacing) in those cases. + title && ( + + {title} + {required ? ( + (required) + ) : null} + + ) + )} + {helperText && ( + {helperText} + )} + {error && } + + {pls.map((thisPL, idx) => renderRow(thisPL, idx))} + + {addPrefixListButton} +
+ ); + } +); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 58cfe9f0a0c..4b75ecec14b 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -129,3 +129,13 @@ export const firewallRuleCreateOptions = [ value: 'ruleset', }, ] as const; + +export const getPrefixListType = (name: string) => { + if (name.startsWith('pl::')) { + return 'Account'; + } + if (name.startsWith('pl:system:')) { + return 'System'; + } + return 'Other'; +}; diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index c119735f991..14834247a31 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -76,7 +76,7 @@ export const addressOptions = [ { label: 'All IPv4, All IPv6', value: 'all' }, { label: 'All IPv4', value: 'allIPv4' }, { label: 'All IPv6', value: 'allIPv6' }, - { label: 'IP / Netmask', value: 'ip/netmask' }, + { label: 'IP / Netmask / Prefix List', value: 'ip/netmask/prefixlist' }, ]; export const portPresets: Record = { diff --git a/packages/manager/src/utilities/ipUtils.ts b/packages/manager/src/utilities/ipUtils.ts index e95a8fc3b9f..5b4cc8a97bf 100644 --- a/packages/manager/src/utilities/ipUtils.ts +++ b/packages/manager/src/utilities/ipUtils.ts @@ -1,6 +1,8 @@ import { PRIVATE_IPV4_REGEX } from '@linode/validation'; import { parseCIDR, parse as parseIP } from 'ipaddr.js'; +import type { PrefixListReference } from 'src/features/Firewalls/shared'; + /** * Removes the prefix length from the end of an IPv6 address. * @@ -21,6 +23,8 @@ export interface ExtendedIP { error?: string; } +export interface ExtendedPL extends ExtendedIP, PrefixListReference {} + export const stringToExtendedIP = (ip: string): ExtendedIP => ({ address: ip }); export const extendedIPToString = (ip: ExtendedIP): string => ip.address; export const ipFieldPlaceholder = '192.0.2.1/32'; From 9a1cfd8f69b67bc6ef98e73bf209472fcaceee37 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 20:17:48 +0530 Subject: [PATCH 02/24] Update comment --- .../Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index d86f5253e43..e99034ac586 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -314,7 +314,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { }} value={addressesValue} /> - {/* Show this field only if "IP / Netmask has been selected." */} + {/* Show this field only if "IP / Netmask / Prefix List has been selected." */} {values.addresses === 'ip/netmask/prefixlist' && ( <> Date: Wed, 26 Nov 2025 20:24:17 +0530 Subject: [PATCH 03/24] Add some mocks for prefixlists --- packages/manager/src/mocks/serverHandlers.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 4d1b36dae66..33afbc4f360 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1362,7 +1362,13 @@ export const handlers = [ return HttpResponse.json(makeResourcePage(rulesets)); }), http.get('*/v4beta/networking/prefixlists', () => { - const prefixlists = firewallPrefixListFactory.buildList(10); + const prefixlists = [ + firewallPrefixListFactory.build({ name: 'pl:system:test-1' }), + ...Array.from({ length: 5 }, (_, i) => + firewallPrefixListFactory.build({ name: `pl::vpcs:test-${i + 1}` }) + ), + ...firewallPrefixListFactory.buildList(10), + ]; return HttpResponse.json(makeResourcePage(prefixlists)); }), http.get( From 490712ccc2b514304252e3bdd2bd37d59447dceb Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 20:57:14 +0530 Subject: [PATCH 04/24] Update mock data order --- packages/manager/src/mocks/serverHandlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 33afbc4f360..b278e1fe88b 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1363,10 +1363,10 @@ export const handlers = [ }), http.get('*/v4beta/networking/prefixlists', () => { const prefixlists = [ - firewallPrefixListFactory.build({ name: 'pl:system:test-1' }), ...Array.from({ length: 5 }, (_, i) => firewallPrefixListFactory.build({ name: `pl::vpcs:test-${i + 1}` }) ), + firewallPrefixListFactory.build({ name: 'pl:system:test-1' }), ...firewallPrefixListFactory.buildList(10), ]; return HttpResponse.json(makeResourcePage(prefixlists)); From 755ef354d651c0cd988631a90aabbd493ad08c73 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 21:12:31 +0530 Subject: [PATCH 05/24] Reset pls state on address change --- .../Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index e99034ac586..695a957c6f8 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -149,10 +149,11 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { const handleAddressesChange = React.useCallback( (item: string) => { setFieldValue('addresses', item); - // Reset custom IPs + // Reset custom IPs & PLs setIPs([]); + setPLs([]); }, - [setFieldValue, setIPs] + [setFieldValue, setIPs, setPLs] ); const handleActionChange = React.useCallback( From cdd979b3b3a25b942925d94f000b4df80692401b Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 22:17:44 +0530 Subject: [PATCH 06/24] Feature-flagged add/edit prefixlists --- .../Rules/FirewallRuleDrawer.test.tsx | 227 +++++++++++++++--- .../Rules/FirewallRuleDrawer.tsx | 10 +- .../Rules/FirewallRuleDrawer.utils.ts | 14 +- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 39 ++- .../manager/src/features/Firewalls/shared.tsx | 26 +- 5 files changed, 256 insertions(+), 60 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx index d6b2b29d893..1c2d7a2382f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -311,20 +311,47 @@ describe('utilities', () => { describe('validateForm', () => { it('validates protocol', () => { - expect(validateForm({}, [], [])).toHaveProperty( - 'protocol', - 'Protocol is required.' - ); + expect( + validateForm( + {}, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) + ).toHaveProperty('protocol', 'Protocol is required.'); }); it('validates ports', () => { expect( - validateForm({ ports: '80', protocol: 'ICMP' }, [], []) + validateForm( + { ports: '80', protocol: 'ICMP' }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Ports are not allowed for ICMP protocols.'); expect( - validateForm({ ports: '443', protocol: 'IPENCAP' }, [], []) + validateForm( + { ports: '443', protocol: 'IPENCAP' }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Ports are not allowed for IPENCAP protocols.'); expect( - validateForm({ ports: 'invalid-port', protocol: 'TCP' }, [], []) + validateForm( + { ports: 'invalid-port', protocol: 'TCP' }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports'); }); it('validates custom ports', () => { @@ -334,20 +361,44 @@ describe('utilities', () => { }; // SUCCESS CASES expect( - validateForm({ ports: '1234', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1234', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toEqual({}); expect( - validateForm({ ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toEqual({}); expect( validateForm( { ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }, - [], - [] + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } ) ).toEqual({}); expect( - validateForm({ ports: '1-20', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1-20', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toEqual({}); expect( validateForm( @@ -356,34 +407,86 @@ describe('utilities', () => { protocol: 'TCP', ...rest, }, - [], - [] + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } ) ).toEqual({}); expect( - validateForm({ ports: '1-2,3-4', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1-2,3-4', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toEqual({}); expect( - validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1,5-12', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toEqual({}); // FAILURE CASES expect( - validateForm({ ports: '1,21-12', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1,21-12', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty( 'ports', 'Range must start with a smaller number and end with a larger number' ); expect( - validateForm({ ports: '1-21-45', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1-21-45', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Ranges must have 2 values'); expect( - validateForm({ ports: 'abc', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: 'abc', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '1--20', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '1--20', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm({ ports: '-20', protocol: 'TCP', ...rest }, [], []) + validateForm( + { ports: '-20', protocol: 'TCP', ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) ).toHaveProperty('ports', 'Must be 1-65535'); expect( validateForm( @@ -392,8 +495,11 @@ describe('utilities', () => { protocol: 'TCP', ...rest, }, - [], - [] + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } ) ).toHaveProperty( 'ports', @@ -449,19 +555,41 @@ describe('utilities', () => { ports: '80', protocol: 'TCP', }; - expect(validateForm({ label: value, ...rest }, [], [])).toEqual(result); + expect( + validateForm( + { label: value, ...rest }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) + ).toEqual(result); }); }); - it('handles addresses field', () => { + it('handles addresses field when isFirewallRulesetsPrefixlistsFeatureEnabled is true', () => { // Invalid cases - expect(validateForm({}, [], [])).toHaveProperty( - 'addresses', - 'Sources is a required field.' - ); + expect( + validateForm( + {}, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) + ).toHaveProperty('addresses', 'Sources is a required field.'); expect( - validateForm({ addresses: 'ip/netmask/prefixlist' }, [], []) + validateForm( + { addresses: 'ip/netmask/prefixlist' }, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } + ) ).toHaveProperty( 'addresses', 'Add an IP address in IP/mask format, or reference a Prefix List name.' @@ -471,28 +599,53 @@ describe('utilities', () => { expect( validateForm( { addresses: 'ip/netmask/prefixlist' }, - [{ address: '192.268.0.0' }, { address: '192.268.0.1' }], - [{ address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }] + { + validatedIPs: [ + { address: '192.268.0.0' }, + { address: '192.268.0.1' }, + ], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } ) ).not.toHaveProperty('addresses'); expect( validateForm( { addresses: 'ip/netmask/prefixlist' }, - [{ address: '192.268.0.0' }], - [] + { + validatedIPs: [{ address: '192.268.0.0' }], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } ) ).not.toHaveProperty('addresses'); expect( validateForm( { addresses: 'ip/netmask/prefixlist' }, - [], - [{ address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }] + { + validatedIPs: [], + validatedPLs: [ + { address: 'pl:system:test', inIPv4Rule: true, inIPv6Rule: true }, + ], + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + } ) ).not.toHaveProperty('addresses'); }); it('handles required fields', () => { - expect(validateForm({}, [], [])).toEqual({ + expect( + validateForm( + {}, + { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + } + ) + ).toEqual({ addresses: 'Sources is a required field.', label: 'Label is required.', ports: 'Ports is a required field.', diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index acb4d441ab1..99ffc564598 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -14,6 +14,7 @@ import { itemsToPortString, portStringToItems, validateForm, + ValidateFormOptions, validateIPs, validatePrefixLists, } from './FirewallRuleDrawer.utils'; @@ -127,6 +128,12 @@ export const FirewallRuleDrawer = React.memo( const _ports = itemsToPortString(presetPorts, ports!); + const validateFormOptions: ValidateFormOptions = { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }; + return { ...validateForm( { @@ -136,8 +143,7 @@ export const FirewallRuleDrawer = React.memo( ports: _ports, protocol, }, - validatedIPs, - validatedPLs + validateFormOptions ), // This is a bit of a trick. If this function DOES NOT return an empty object, Formik will call // `onSubmit()`. If there are IP errors, we add them to the return object so Formik knows there diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts index e3c7626d612..0f8aa88f3dd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.utils.ts @@ -403,10 +403,19 @@ export const portStringToItems = ( return [items, customInput.join(', ')]; }; +export interface ValidateFormOptions { + isFirewallRulesetsPrefixlistsFeatureEnabled: boolean; + validatedIPs: ExtendedIP[]; + validatedPLs: ExtendedPL[]; +} + export const validateForm = ( { addresses, description, label, ports, protocol }: Partial, - validatedIPs: ExtendedIP[], - validatedPLs: ExtendedPL[] + { + validatedIPs, + validatedPLs, + isFirewallRulesetsPrefixlistsFeatureEnabled, + }: ValidateFormOptions ) => { const errors: Partial = {}; @@ -434,6 +443,7 @@ export const validateForm = ( if (!addresses) { errors.addresses = 'Sources is a required field.'; } else if ( + isFirewallRulesetsPrefixlistsFeatureEnabled && addresses === 'ip/netmask/prefixlist' && validatedIPs.length === 0 && validatedPLs.length === 0 diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 695a957c6f8..f8ec7d6a608 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -14,10 +14,11 @@ import * as React from 'react'; import { MultipleIPInput } from 'src/components/MultipleIPInput/MultipleIPInput'; import { - addressOptions, firewallOptionItemsShort, portPresets, protocolOptions, + useAddressOptions, + useIsFirewallRulesetsPrefixlistsEnabled, } from 'src/features/Firewalls/shared'; import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; @@ -58,6 +59,11 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { values, } = props; + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const addressOptions = useAddressOptions(); + const hasCustomInput = presetPorts.some( (thisPort) => thisPort.value === PORT_PRESETS['CUSTOM'].value ); @@ -150,10 +156,16 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { (item: string) => { setFieldValue('addresses', item); // Reset custom IPs & PLs - setIPs([]); - setPLs([]); + if (isFirewallRulesetsPrefixlistsFeatureEnabled) { + // For "IP / Netmask / Prefix List": reset both custom IPs and PLs + setIPs([]); + setPLs([]); + } else { + // For "IP / Netmask": reset IPs to at least one empty input + setIPs([{ address: '' }]); + } }, - [setFieldValue, setIPs, setPLs] + [setFieldValue, setIPs, setPLs, isFirewallRulesetsPrefixlistsFeatureEnabled] ); const handleActionChange = React.useCallback( @@ -320,7 +332,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { <> { title={ips.length > 0 ? 'IP / Netmask' : ''} tooltip={ipNetmaskTooltipText} /> - - 0 ? 'Prefix List' : ''} - /> + {isFirewallRulesetsPrefixlistsFeatureEnabled && ( + 0 ? 'Prefix List' : ''} + /> + )} )} diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index 14834247a31..cba5a20c053 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -72,12 +72,26 @@ export const protocolOptions: FirewallOptionItem[] = [ { label: 'IPENCAP', value: 'IPENCAP' }, ]; -export const addressOptions = [ - { label: 'All IPv4, All IPv6', value: 'all' }, - { label: 'All IPv4', value: 'allIPv4' }, - { label: 'All IPv6', value: 'allIPv6' }, - { label: 'IP / Netmask / Prefix List', value: 'ip/netmask/prefixlist' }, -]; +export const useAddressOptions = () => { + const { isFirewallRulesetsPrefixlistsFeatureEnabled } = + useIsFirewallRulesetsPrefixlistsEnabled(); + + const addressOptions = [ + { label: 'All IPv4, All IPv6', value: 'all' }, + { label: 'All IPv4', value: 'allIPv4' }, + { label: 'All IPv6', value: 'allIPv6' }, + { + label: isFirewallRulesetsPrefixlistsFeatureEnabled + ? 'IP / Netmask / Prefix List' + : 'IP / Netmask', + value: 'ip/netmask/prefixlist', // keep the same value + }, + ]; + + return React.useMemo(() => { + return addressOptions; + }, [isFirewallRulesetsPrefixlistsFeatureEnabled]); +}; export const portPresets: Record = { dns: '53', From 4a9fb19bd9a55653bd5a6d3ab001ce75d53f76d9 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 22:25:21 +0530 Subject: [PATCH 07/24] Some clean up --- packages/manager/src/features/Firewalls/shared.tsx | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Firewalls/shared.tsx b/packages/manager/src/features/Firewalls/shared.tsx index cba5a20c053..05e24bfe0dd 100644 --- a/packages/manager/src/features/Firewalls/shared.tsx +++ b/packages/manager/src/features/Firewalls/shared.tsx @@ -76,7 +76,7 @@ export const useAddressOptions = () => { const { isFirewallRulesetsPrefixlistsFeatureEnabled } = useIsFirewallRulesetsPrefixlistsEnabled(); - const addressOptions = [ + return [ { label: 'All IPv4, All IPv6', value: 'all' }, { label: 'All IPv4', value: 'allIPv4' }, { label: 'All IPv6', value: 'allIPv6' }, @@ -84,13 +84,11 @@ export const useAddressOptions = () => { label: isFirewallRulesetsPrefixlistsFeatureEnabled ? 'IP / Netmask / Prefix List' : 'IP / Netmask', - value: 'ip/netmask/prefixlist', // keep the same value + // We can keep this entire value even if the option is feature-flagged. + // Feature-flagging the label (without the "Prefix List" text) is sufficient. + value: 'ip/netmask/prefixlist', }, ]; - - return React.useMemo(() => { - return addressOptions; - }, [isFirewallRulesetsPrefixlistsFeatureEnabled]); }; export const portPresets: Record = { From d56293395bdb0ae333c8307492388389ba0f8600 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Wed, 26 Nov 2025 23:17:14 +0530 Subject: [PATCH 08/24] Some changes --- .../Rules/MutiplePrefixListInput.tsx | 51 +++++++------------ 1 file changed, 18 insertions(+), 33 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 9eb160231da..edd75feed90 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -23,6 +23,7 @@ import { useIsFirewallRulesetsPrefixlistsEnabled } from 'src/features/Firewalls/ import { getPrefixListType } from './shared'; +import type { FirewallPrefixList } from '@linode/api-v4'; import type { InputBaseProps } from '@mui/material/InputBase'; import type { Theme } from '@mui/material/styles'; import type { ExtendedPL } from 'src/utilities/ipUtils'; @@ -167,41 +168,25 @@ export const MultiplePrefixListInput = React.memo( const prefixLists = data ?? []; - // const prefixLists: Partial[] = [ - // { id: 1, name: 'pl::subnets:325584', ipv6: ['asdas'] }, - // { id: 2, name: 'pl::vpcs:298694', ipv4: [], ipv6: [] }, - // { - // id: 3, - // name: 'pl:system:test-3', - // ipv4: ['192.168.0'], - // ipv6: null, - // }, - // { id: 4, name: 'pl:system:test-4', ipv4: null, ipv6: ['124.4124.124'] }, - // { id: 5, name: 'pl:system:test-5', ipv4: null, ipv6: null }, - // ]; - - const prefixListDropdownOptions = React.useMemo( + const isPrefixListSupported = (pl: FirewallPrefixList) => + (pl.ipv4 !== null && pl.ipv4 !== undefined) || + (pl.ipv6 !== null && pl.ipv6 !== undefined); + + const supportedPrefixListOptions = React.useMemo( () => - prefixLists - .filter((pl) => { - const isUnsupported = - (pl.ipv4 === null || pl.ipv4 === undefined) && - (pl.ipv6 === null || pl.ipv6 === undefined); - return !isUnsupported; - }) - .map((pl) => ({ - label: pl.name, - value: pl.id, - notSupportedDetails: { - isPLIPv4NotSupported: pl.ipv4 === null || pl.ipv4 === undefined, - isPLIPv6NotSupported: pl.ipv6 === null || pl.ipv6 === undefined, - }, - })), + prefixLists.filter(isPrefixListSupported).map((pl) => ({ + label: pl.name, + value: pl.id, + notSupportedDetails: { + isPLIPv4NotSupported: pl.ipv4 === null || pl.ipv4 === undefined, + isPLIPv6NotSupported: pl.ipv6 === null || pl.ipv6 === undefined, + }, + })), [prefixLists] ); const getAvailableOptions = (idx: number, address: string) => - prefixListDropdownOptions.filter( + supportedPrefixListOptions.filter( (o) => o.label === address || // allow current !pls.some((p, i) => i !== idx && p.address === o.label) @@ -212,7 +197,7 @@ export const MultiplePrefixListInput = React.memo( newPLs[idx].address = pl; - const plNotSupportedDetails = prefixListDropdownOptions.find( + const plNotSupportedDetails = supportedPrefixListOptions.find( (o) => o.label === newPLs[idx].address )?.notSupportedDetails; @@ -244,7 +229,7 @@ export const MultiplePrefixListInput = React.memo( const handleChangeIPv4 = (hasIPv4: boolean, idx: number) => { const newPLs = [...pls]; - const details = prefixListDropdownOptions.find( + const details = supportedPrefixListOptions.find( (o) => o.label === newPLs[idx].address )?.notSupportedDetails; @@ -264,7 +249,7 @@ export const MultiplePrefixListInput = React.memo( const handleChangeIPv6 = (hasIPv6: boolean, idx: number) => { const newPLs = [...pls]; - const details = prefixListDropdownOptions.find( + const details = supportedPrefixListOptions.find( (o) => o.label === newPLs[idx].address )?.notSupportedDetails; From 06943135e7ff578380432cb4dc7b48ed9600a229 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 14:39:35 +0530 Subject: [PATCH 09/24] More clean up --- .../MultipleIPInput/MultipleIPInput.tsx | 6 +- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 5 +- .../Rules/MutiplePrefixListInput.tsx | 162 +++--------------- 3 files changed, 29 insertions(+), 144 deletions(-) diff --git a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx index 87fbf03eb50..f8ca08ba0e9 100644 --- a/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx +++ b/packages/manager/src/components/MultipleIPInput/MultipleIPInput.tsx @@ -26,7 +26,7 @@ const useStyles = makeStyles()((theme: Theme) => ({ justifyContent: 'flex-start', }, paddingLeft: 0, - paddingTop: theme.spacing(1.5), + paddingTop: theme.spacingFunction(12), }, button: { '& > span': { @@ -251,8 +251,8 @@ export const MultipleIPInput = React.memo((props: MultipeIPInputProps) => { { {isFirewallRulesetsPrefixlistsFeatureEnabled && ( 0 ? 'Prefix List' : ''} @@ -392,6 +391,6 @@ const StyledDiv = styled('div', { label: 'StyledDiv' })(({ theme }) => ({ const StyledMultipleIPInput = styled(MultipleIPInput, { label: 'StyledMultipleIPInput', -})(({ theme }) => ({ - marginTop: theme.spacingFunction(16), +})(({ theme, ips }) => ({ + ...(ips.length !== 0 ? { marginTop: theme.spacingFunction(16) } : {}), })); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index edd75feed90..8d4f35c21bb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -1,77 +1,52 @@ import { useAllFirewallPrefixListsQuery } from '@linode/queries'; import { Autocomplete, + BetaChip, Box, Button, Checkbox, CloseIcon, IconButton, InputLabel, - LinkButton, - Notice, Stack, - TooltipIcon, - Typography, } from '@linode/ui'; import Grid from '@mui/material/Grid'; import * as React from 'react'; import { makeStyles } from 'tss-react/mui'; import { Link } from 'src/components/Link'; -import { StyledLinkButtonBox } from 'src/components/SelectFirewallPanel/SelectFirewallPanel'; import { useIsFirewallRulesetsPrefixlistsEnabled } from 'src/features/Firewalls/shared'; import { getPrefixListType } from './shared'; import type { FirewallPrefixList } from '@linode/api-v4'; -import type { InputBaseProps } from '@mui/material/InputBase'; import type { Theme } from '@mui/material/styles'; import type { ExtendedPL } from 'src/utilities/ipUtils'; const useStyles = makeStyles()((theme: Theme) => ({ - addIP: { + addPL: { '& span:first-of-type': { justifyContent: 'flex-start', }, paddingLeft: 0, - paddingTop: theme.spacing(1.5), + paddingTop: theme.spacingFunction(12), }, button: { '& > span': { padding: 2, }, - marginLeft: `-${theme.spacing()}`, + marginLeft: `-${theme.spacingFunction(8)}`, marginTop: 4, minHeight: 'auto', minWidth: 'auto', padding: 0, }, - helperText: { - marginBottom: theme.spacing(), - }, - input: { - 'nth-child(n+2)': { - marginTop: theme.spacing(), - }, - }, - ipNetmaskTooltipSection: { - display: 'flex', - flexDirection: 'row', - }, - required: { - font: theme.font.normal, - }, root: { - marginTop: theme.spacing(), + marginTop: theme.spacingFunction(8), }, })); export interface MultiplePrefixListInputProps { - /** - * Text displayed on the button. - */ - buttonText?: string; - /** * Custom CSS class for additional styling. */ @@ -83,32 +58,6 @@ export interface MultiplePrefixListInputProps { */ disabled?: boolean; - /** - * Error message for invalid input. - */ - error?: string; - - /** - * Helper text for additional guidance. - */ - helperText?: string; - - /** - * Custom input properties passed to the underlying input component. - */ - inputProps?: InputBaseProps; - - /** - * Styles the button as a link. - * @default false - */ - isLinkStyled?: boolean; - - // /** - // * Callback triggered when the input loses focus, passing updated `ips`. - // */ - // onBlur?: (ips: ExtendedIP[]) => void; - /** * Callback triggered when IPs change, passing updated `ips`. */ @@ -124,44 +73,20 @@ export interface MultiplePrefixListInputProps { */ pls: ExtendedPL[]; - /** - * Indicates if the input is required for form submission. - * @default false - */ - required?: boolean; - /** * Title or label for the input field. */ title: string; - - /** - * Tooltip text for extra info on hover. - */ - tooltip?: string; } export const MultiplePrefixListInput = React.memo( (props: MultiplePrefixListInputProps) => { - const { - buttonText, - className, - disabled, - error, - // forPLs, - helperText, - pls, - isLinkStyled, - // onBlur, - onChange, - // placeholder, - required, - title, - tooltip, - } = props; + const { className, disabled, pls, onChange, title } = props; const { classes, cx } = useStyles(); - const { isFirewallRulesetsPrefixlistsFeatureEnabled } = - useIsFirewallRulesetsPrefixlistsEnabled(); + const { + isFirewallRulesetsPrefixlistsFeatureEnabled, + isFirewallRulesetsPrefixListsBetaEnabled, + } = useIsFirewallRulesetsPrefixlistsEnabled(); const { data, isLoading } = useAllFirewallPrefixListsQuery( isFirewallRulesetsPrefixlistsFeatureEnabled ); @@ -280,28 +205,6 @@ export const MultiplePrefixListInput = React.memo( return null; } - const addPrefixListButton = isLinkStyled ? ( - - - {buttonText} - - - ) : ( - - ); - const renderRow = (thisPL: ExtendedPL, idx: number) => { const availableOptions = getAvailableOptions(idx, thisPL.address); @@ -326,9 +229,7 @@ export const MultiplePrefixListInput = React.memo( return ( - {tooltip && title ? ( -
- {title} - -
- ) : ( - // There are a couple of instances in the codebase where an empty string is passed as the title so a title isn't displayed. - // Having this check ensures we don't render an empty label element (which can still impact spacing) in those cases. - title && ( - - {title} - {required ? ( - (required) - ) : null} - - ) + {title && ( + + {title} + {isFirewallRulesetsPrefixListsBetaEnabled && } + )} - {helperText && ( - {helperText} - )} - {error && } {pls.map((thisPL, idx) => renderRow(thisPL, idx))} - {addPrefixListButton} + ); } From fc12b09b71ca7fb4e40fe1602b517b6c8c238528 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 17:10:34 +0530 Subject: [PATCH 10/24] More clean up --- .../Rules/MutiplePrefixListInput.tsx | 180 +++++++++--------- 1 file changed, 88 insertions(+), 92 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 8d4f35c21bb..7aff715471a 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -46,6 +46,36 @@ const useStyles = makeStyles()((theme: Theme) => ({ }, })); +const isPrefixListSupported = (pl: FirewallPrefixList) => + (pl.ipv4 !== null && pl.ipv4 !== undefined) || + (pl.ipv6 !== null && pl.ipv6 !== undefined); + +const getSupportDetails = (pl: FirewallPrefixList) => ({ + isPLIPv4Unsupported: pl.ipv4 === null || pl.ipv4 === undefined, + isPLIPv6Unsupported: pl.ipv6 === null || pl.ipv6 === undefined, +}); + +/** + * Default selection state for a newly chosen Prefix List + */ +const getDefaultPLReferenceState = ( + support: ReturnType +): { inIPv4Rule: boolean; inIPv6Rule: boolean } => { + const { isPLIPv4Unsupported, isPLIPv6Unsupported } = support; + + if (!isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (!isPLIPv4Unsupported && isPLIPv6Unsupported) + return { inIPv4Rule: true, inIPv6Rule: false }; + + if (isPLIPv4Unsupported && !isPLIPv6Unsupported) + return { inIPv4Rule: false, inIPv6Rule: true }; + + // Should not happen but safe fallback + return { inIPv4Rule: false, inIPv6Rule: false }; +}; + export interface MultiplePrefixListInputProps { /** * Custom CSS class for additional styling. @@ -59,9 +89,9 @@ export interface MultiplePrefixListInputProps { disabled?: boolean; /** - * Callback triggered when IPs change, passing updated `ips`. + * Callback triggered when PLs change, passing updated `pls`. */ - onChange: (ips: ExtendedPL[]) => void; + onChange: (pls: ExtendedPL[]) => void; /** * Placeholder text for an empty input field. @@ -92,103 +122,70 @@ export const MultiplePrefixListInput = React.memo( ); const prefixLists = data ?? []; - - const isPrefixListSupported = (pl: FirewallPrefixList) => - (pl.ipv4 !== null && pl.ipv4 !== undefined) || - (pl.ipv6 !== null && pl.ipv6 !== undefined); - - const supportedPrefixListOptions = React.useMemo( + // const prefixLists: Partial[] = [ + // { id: 1, name: 'pl::subnets:325584', ipv6: ['asdas'] }, + // { id: 2, name: 'pl::vpcs:298694', ipv4: [], ipv6: [] }, + // { + // id: 3, + // name: 'pl:system:test-3', + // ipv4: ['192.168.0'], + // ipv6: null, + // }, + // { id: 4, name: 'pl:system:test-4', ipv4: null, ipv6: ['124.4124.124'] }, + // { id: 5, name: 'pl:system:test-5', ipv4: null, ipv6: null }, + // ]; + + const supportedOptions = React.useMemo( () => prefixLists.filter(isPrefixListSupported).map((pl) => ({ label: pl.name, value: pl.id, - notSupportedDetails: { - isPLIPv4NotSupported: pl.ipv4 === null || pl.ipv4 === undefined, - isPLIPv6NotSupported: pl.ipv6 === null || pl.ipv6 === undefined, - }, + support: getSupportDetails(pl), })), [prefixLists] ); - const getAvailableOptions = (idx: number, address: string) => - supportedPrefixListOptions.filter( - (o) => - o.label === address || // allow current - !pls.some((p, i) => i !== idx && p.address === o.label) - ); + /** + * Returns available prefix list options for a PL selection row. + * Includes the current selection and excludes options used in other PL rows. + */ + const getAvailableOptions = React.useCallback( + (idx: number, address: string) => + supportedOptions.filter( + (o) => + o.label === address || // allow current + !pls.some((p, i) => i !== idx && p.address === o.label) + ), + [supportedOptions, pls] + ); - const handleChange = (pl: string, idx: number) => { + const updatePL = (idx: number, updated: Partial) => { const newPLs = [...pls]; - - newPLs[idx].address = pl; - - const plNotSupportedDetails = supportedPrefixListOptions.find( - (o) => o.label === newPLs[idx].address - )?.notSupportedDetails; - - const bothIPv4AndIPv6Supported = - !plNotSupportedDetails?.isPLIPv4NotSupported && - !plNotSupportedDetails?.isPLIPv6NotSupported; - - const onlyIPv4Supported = - !plNotSupportedDetails?.isPLIPv4NotSupported && - plNotSupportedDetails?.isPLIPv6NotSupported; - - const onlyIPv6Supported = - !plNotSupportedDetails?.isPLIPv6NotSupported && - plNotSupportedDetails?.isPLIPv4NotSupported; - - if (bothIPv4AndIPv6Supported || onlyIPv4Supported) { - newPLs[idx].inIPv4Rule = true; - newPLs[idx].inIPv6Rule = false; - } - - if (onlyIPv6Supported) { - newPLs[idx].inIPv4Rule = false; - newPLs[idx].inIPv6Rule = true; - } - + newPLs[idx] = { ...newPLs[idx], ...updated }; onChange(newPLs); }; - const handleChangeIPv4 = (hasIPv4: boolean, idx: number) => { - const newPLs = [...pls]; - - const details = supportedPrefixListOptions.find( - (o) => o.label === newPLs[idx].address - )?.notSupportedDetails; - - const plSupportsIPv6 = details && !details.isPLIPv6NotSupported; - - newPLs[idx].inIPv4Rule = hasIPv4; + // Handlers + const handleSelectPL = (label: string, idx: number) => { + const match = supportedOptions.find((o) => o.label === label); + if (!match) return; - // If Ipv4 is unchecked then check IpV6 by default if IPv6 is supported by this PL. - if (!hasIPv4 && !newPLs[idx].inIPv6Rule) { - if (plSupportsIPv6) { - newPLs[idx].inIPv6Rule = true; - } - } - onChange(newPLs); + updatePL(idx, { + address: label, + ...getDefaultPLReferenceState(match.support), + }); }; - const handleChangeIPv6 = (hasIPv6: boolean, idx: number) => { - const newPLs = [...pls]; - - const details = supportedPrefixListOptions.find( - (o) => o.label === newPLs[idx].address - )?.notSupportedDetails; - - const plSupportsIPv4 = details && !details.isPLIPv4NotSupported; - - newPLs[idx].inIPv6Rule = hasIPv6; + const handleToggleIPv4 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv4Rule: checked, + }); + }; - // If Ipv6 is unchecked then check IpV4 by default if IPv4 is supported by this PL. - if (!hasIPv6 && !newPLs[idx].inIPv4Rule) { - if (plSupportsIPv4) { - newPLs[idx].inIPv4Rule = true; - } - } - onChange(newPLs); + const handleToggleIPv6 = (checked: boolean, idx: number) => { + updatePL(idx, { + inIPv6Rule: checked, + }); }; const addNewInput = () => { @@ -213,18 +210,17 @@ export const MultiplePrefixListInput = React.memo( ); const ipv4Unsupported = - selectedOption?.notSupportedDetails.isPLIPv4NotSupported === true; + selectedOption?.support.isPLIPv4Unsupported === true; const ipv6Unsupported = - selectedOption?.notSupportedDetails.isPLIPv6NotSupported === true; + selectedOption?.support.isPLIPv6Unsupported === true; - // Prevent both being unchecked const ipv4Forced = thisPL.inIPv4Rule === true && thisPL.inIPv6Rule === false; const ipv6Forced = thisPL.inIPv6Rule === true && thisPL.inIPv4Rule === false; - const disableIPv4 = ipv4Unsupported === true || ipv4Forced === true; - const disableIPv6 = ipv6Unsupported === true || ipv6Forced === true; + const disableIPv4 = ipv4Unsupported || ipv4Forced; + const disableIPv6 = ipv6Unsupported || ipv6Forced; return ( { - handleChange(selectedPrefixList?.label ?? '', idx); + handleSelectPL(selectedPrefixList?.label ?? '', idx); }} options={availableOptions} placeholder="Type to search or select a Rule Set" @@ -263,14 +259,14 @@ export const MultiplePrefixListInput = React.memo( handleChangeIPv4(!thisPL.inIPv4Rule, idx)} + disabled={disableIPv4 || disabled} + onChange={() => handleToggleIPv4(!thisPL.inIPv4Rule, idx)} text="IPv4" /> handleChangeIPv6(!thisPL.inIPv6Rule, idx)} + disabled={disableIPv6 || disabled} + onChange={() => handleToggleIPv6(!thisPL.inIPv6Rule, idx)} text="IPv6" /> From 8ba0548c11c47d79711cdebcd82f07c98f626369 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 17:16:36 +0530 Subject: [PATCH 11/24] Add code comments and some clean up --- .../Rules/MutiplePrefixListInput.tsx | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 7aff715471a..65bb1979ee1 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -122,19 +122,11 @@ export const MultiplePrefixListInput = React.memo( ); const prefixLists = data ?? []; - // const prefixLists: Partial[] = [ - // { id: 1, name: 'pl::subnets:325584', ipv6: ['asdas'] }, - // { id: 2, name: 'pl::vpcs:298694', ipv4: [], ipv6: [] }, - // { - // id: 3, - // name: 'pl:system:test-3', - // ipv4: ['192.168.0'], - // ipv6: null, - // }, - // { id: 4, name: 'pl:system:test-4', ipv4: null, ipv6: ['124.4124.124'] }, - // { id: 5, name: 'pl:system:test-5', ipv4: null, ipv6: null }, - // ]; + /** + * Filter prefix lists to include those that support IPv4, IPv6, or both, + * and map them to options with label, value, and PL IP support details. + */ const supportedOptions = React.useMemo( () => prefixLists.filter(isPrefixListSupported).map((pl) => ({ From e8c5181d070d3fbb699ea7af5b19f47d6bcb852e Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 17:31:13 +0530 Subject: [PATCH 12/24] Add key and test ids to the pl rows --- .../Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 65bb1979ee1..97df58996db 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -217,7 +217,9 @@ export const MultiplePrefixListInput = React.memo( return ( Date: Thu, 27 Nov 2025 17:39:10 +0530 Subject: [PATCH 13/24] A small fix --- .../Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index e6cb6b880ca..d07e2a568dd 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -218,7 +218,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { return ( addressOptions.find( (thisOption) => thisOption.value === values.addresses - ) || undefined + ) ?? null ); }, [values]); From 28192134838e7171f64616d6167c3ce92ad4e9f5 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 23:29:54 +0530 Subject: [PATCH 14/24] Add unit tests --- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 1 - .../Rules/MutiplePrefixListInput.test.tsx | 388 ++++++++++++++++++ .../Rules/MutiplePrefixListInput.tsx | 17 +- 3 files changed, 396 insertions(+), 10 deletions(-) create mode 100644 packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.test.tsx diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index d07e2a568dd..dc6b8d58aeb 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -345,7 +345,6 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { aria-label="Prefix List for Firewall rule" onChange={handlePrefixListChange} pls={pls} - title={pls.length > 0 ? 'Prefix List' : ''} /> )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.test.tsx new file mode 100644 index 00000000000..8259678adf3 --- /dev/null +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.test.tsx @@ -0,0 +1,388 @@ +import { within } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { renderWithTheme } from 'src/utilities/testHelpers'; + +import * as shared from '../../shared'; +import { MultiplePrefixListInput } from './MutiplePrefixListInput'; + +const queryMocks = vi.hoisted(() => ({ + useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), +})); + +vi.mock('@linode/queries', async () => { + const actual = await vi.importActual('@linode/queries'); + return { + ...actual, + useAllFirewallPrefixListsQuery: queryMocks.useAllFirewallPrefixListsQuery, + }; +}); + +const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); +describe('MultiplePrefixListInput', () => { + beforeEach(() => { + spy.mockReturnValue({ + isFirewallRulesetsPrefixlistsFeatureEnabled: true, + isFirewallRulesetsPrefixListsBetaEnabled: false, + isFirewallRulesetsPrefixListsLAEnabled: false, + isFirewallRulesetsPrefixListsGAEnabled: false, + }); + }); + + const onChange = vi.fn(); + + const mockPrefixLists = [ + { + name: 'pl::supports-both', + ipv4: ['192.168.0.0/24'], + ipv6: ['2001:db8::/128'], + }, // PL supported (supports both) + { + name: 'pl::supports-only-ipv6', + ipv4: null, + ipv6: ['2001:db8:1::/128'], + }, // supported (supports only ipv6) + { + name: 'pl:system:supports-only-ipv4', + ipv4: ['10.0.0.0/16'], + ipv6: null, + }, // supported (supports only ipv4) + { name: 'pl:system:supports-both', ipv4: [], ipv6: [] }, // supported (supports both) + { name: 'pl:system:not-supported', ipv4: null, ipv6: null }, // unsupported + ]; + + queryMocks.useAllFirewallPrefixListsQuery.mockReturnValue({ + data: mockPrefixLists, + isFetching: false, + error: null, + }); + + it('should render the title only when at least one PL row is added', () => { + const { getByText } = renderWithTheme( + + ); + expect(getByText('Prefix List')).toBeVisible(); + }); + + it('should not render the title when no PL row is added', () => { + const { queryByText } = renderWithTheme( + + ); + expect(queryByText('Prefix List')).not.toBeInTheDocument(); + }); + + it('should add a new PL row (empty state) when clicking "Add a Prefix List"', async () => { + const { getByText } = renderWithTheme( + + ); + + await userEvent.click(getByText('Add a Prefix List')); + expect(onChange).toHaveBeenCalledWith([ + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]); + }); + + it('should remove a PL row when clicking delete (X)', async () => { + const onChange = vi.fn(); + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { getByTestId } = renderWithTheme( + + ); + + await userEvent.click(getByTestId('delete-pl-0')); + expect(onChange).toHaveBeenCalledWith([]); + }); + + it('filters out unsupported PLs from dropdown', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { getByRole, queryByText } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + await userEvent.type(input, 'pl:supports-both'); + + expect(queryByText('pl:supports-both')).not.toBeInTheDocument(); + }); + + it('prevents duplicate selection of PLs', async () => { + const selectedPLs = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { address: '', inIPv4Rule: false, inIPv6Rule: false }, + ]; + const { getAllByRole, findByText } = renderWithTheme( + + ); + + const inputs = getAllByRole('combobox'); + const lastEmptyInput = inputs[inputs.length - 1]; + + // Try to search already selected Prefix List + await userEvent.type(lastEmptyInput, 'pl::supports-only-ipv6'); + + // Display no option available message for already selected Prefix List in dropdown + const noOptionsMessage = await findByText( + 'You have no options to choose from' + ); + expect(noOptionsMessage).toBeInTheDocument(); + }); + + it('should render a PL select field for each string in PLs', () => { + const pls = [ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + { + address: 'pl:system:supports-only-ipv4', + inIPv6Rule: false, + inIPv4Rule: true, + }, + ]; + const { getByDisplayValue, queryAllByTestId } = renderWithTheme( + + ); + + expect(queryAllByTestId('prefixlist-select')).toHaveLength(3); + getByDisplayValue('pl::supports-both'); + getByDisplayValue('pl::supports-only-ipv6'); + getByDisplayValue('pl:system:supports-only-ipv4'); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-both'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-both'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-both', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports only IPv4', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl:system:supports-only-ipv4'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl:system:supports-only-ipv4'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]); + }); + + it('defaults to IPv4 unselected and IPv6 selected when choosing a PL that supports only IPv6', async () => { + const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; + const { findByText, getByRole } = renderWithTheme( + + ); + + const input = getByRole('combobox'); + + // Type the PL name to filter the dropdown + await userEvent.type(input, 'pl::supports-only-ipv6'); + + // Select the option from the autocomplete dropdown + const option = await findByText('pl::supports-only-ipv6'); + await userEvent.click(option); + + expect(onChange).toHaveBeenCalledWith([ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]); + }); + + it('renders IPv4 checked + disabled, and IPv6 unchecked + enabled when a prefix list supports both but is only referenced in IPv4 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPv6 Unchecked and enabled (User can check/select IPv6 since this prefix list supports both IPv4 and IPv6) + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 checked + disabled, and IPv4 unchecked + enabled when a prefix list supports both but is only referenced in IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: false, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Enabled (User can check/select IPv4 since this prefix list supports both IPv4 and IPv6) + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders both IPv4 and IPv6 as checked and enabled when the prefix list supports both and is referenced in both IPv4 & IPv6 Rule', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Enabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeEnabled(); + + // IPv6 Checked and Enabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeEnabled(); + }); + + it('renders IPv6 unchecked + disabled, and IPv4 checked + disabled when PL only supports IPv4', async () => { + const pls = [ + { + address: 'pl:system:supports-only-ipv4', + inIPv4Rule: true, + inIPv6Rule: false, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Checked and Disabled + expect(ipv4Checkbox).toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Unchecked and Disabled + expect(ipv6Checkbox).not.toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + it('renders IPv4 checkbox unchecked + disabled, and IPv6 checked + disabled when PL only supports IPv6', async () => { + const pls = [ + { + address: 'pl::supports-only-ipv6', + inIPv4Rule: false, + inIPv6Rule: true, + }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); + const ipv6CheckboxWrapper = await findByTestId('ipv6-checkbox-0'); + + const ipv4Checkbox = within(ipv4CheckboxWrapper).getByRole('checkbox'); + const ipv6Checkbox = within(ipv6CheckboxWrapper).getByRole('checkbox'); + + // IPv4 Unchecked and Disabled + expect(ipv4Checkbox).not.toBeChecked(); + expect(ipv4Checkbox).toBeDisabled(); + + // IPV6 Checked and Disabled + expect(ipv6Checkbox).toBeChecked(); + expect(ipv6Checkbox).toBeDisabled(); + }); + + // Toggling of Checkbox is allowed only when PL supports both IPv4 & IPv6 + it('calls onChange with updated values when toggling checkboxes', async () => { + const pls = [ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, + ]; + const { findByTestId } = renderWithTheme( + + ); + + const ipv6Checkbox = await findByTestId('ipv6-checkbox-0'); + await userEvent.click(ipv6Checkbox); + + expect(onChange).toHaveBeenCalledWith([ + { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, + ]); + }); +}); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 97df58996db..88da9453451 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -102,16 +102,11 @@ export interface MultiplePrefixListInputProps { * Array of `ExtendedPL` objects representing managed PLs. */ pls: ExtendedPL[]; - - /** - * Title or label for the input field. - */ - title: string; } export const MultiplePrefixListInput = React.memo( (props: MultiplePrefixListInputProps) => { - const { className, disabled, pls, onChange, title } = props; + const { className, disabled, pls, onChange } = props; const { classes, cx } = useStyles(); const { isFirewallRulesetsPrefixlistsFeatureEnabled, @@ -201,6 +196,7 @@ export const MultiplePrefixListInput = React.memo( (o) => o.label === thisPL.address ); + // Disabling a checkbox ensures that at least one option (IPv4 or IPv6) remains checked const ipv4Unsupported = selectedOption?.support.isPLIPv4Unsupported === true; const ipv6Unsupported = @@ -217,7 +213,7 @@ export const MultiplePrefixListInput = React.memo( return ( handleToggleIPv4(!thisPL.inIPv4Rule, idx)} text="IPv4" /> handleToggleIPv6(!thisPL.inIPv6Rule, idx)} text="IPv6" @@ -292,9 +290,10 @@ export const MultiplePrefixListInput = React.memo( return (
- {title && ( + {/* Display the title only when pls.length > 0 (i.e., at least one PL row is added) */} + {pls.length > 0 && ( - {title} + Prefix List {isFirewallRulesetsPrefixListsBetaEnabled && } )} From eaf77fc9cb455496901b32f998783f6e57fda67f Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Thu, 27 Nov 2025 23:53:41 +0530 Subject: [PATCH 15/24] More clean up --- .../Rules/FirewallRuleDrawer.test.tsx | 173 ++++-------------- .../Rules/MutiplePrefixListInput.tsx | 6 +- 2 files changed, 36 insertions(+), 143 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx index 1c2d7a2382f..f4c76775b7e 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.test.tsx @@ -310,48 +310,27 @@ describe('utilities', () => { }); describe('validateForm', () => { + const baseOptions = { + validatedIPs: [], + validatedPLs: [], + isFirewallRulesetsPrefixlistsFeatureEnabled: false, + }; + it('validates protocol', () => { - expect( - validateForm( - {}, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) - ).toHaveProperty('protocol', 'Protocol is required.'); + expect(validateForm({}, baseOptions)).toHaveProperty( + 'protocol', + 'Protocol is required.' + ); }); it('validates ports', () => { expect( - validateForm( - { ports: '80', protocol: 'ICMP' }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '80', protocol: 'ICMP' }, baseOptions) ).toHaveProperty('ports', 'Ports are not allowed for ICMP protocols.'); expect( - validateForm( - { ports: '443', protocol: 'IPENCAP' }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '443', protocol: 'IPENCAP' }, baseOptions) ).toHaveProperty('ports', 'Ports are not allowed for IPENCAP protocols.'); expect( - validateForm( - { ports: 'invalid-port', protocol: 'TCP' }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: 'invalid-port', protocol: 'TCP' }, baseOptions) ).toHaveProperty('ports'); }); it('validates custom ports', () => { @@ -361,44 +340,22 @@ describe('utilities', () => { }; // SUCCESS CASES expect( - validateForm( - { ports: '1234', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '1234', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); expect( validateForm( { ports: '1,2,3,4,5', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toEqual({}); expect( validateForm( { ports: '1, 2, 3, 4, 5', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toEqual({}); expect( - validateForm( - { ports: '1-20', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '1-20', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); expect( validateForm( @@ -407,42 +364,23 @@ describe('utilities', () => { protocol: 'TCP', ...rest, }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toEqual({}); expect( validateForm( { ports: '1-2,3-4', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toEqual({}); expect( - validateForm( - { ports: '1,5-12', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '1,5-12', protocol: 'TCP', ...rest }, baseOptions) ).toEqual({}); // FAILURE CASES expect( validateForm( { ports: '1,21-12', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toHaveProperty( 'ports', @@ -451,42 +389,17 @@ describe('utilities', () => { expect( validateForm( { ports: '1-21-45', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toHaveProperty('ports', 'Ranges must have 2 values'); expect( - validateForm( - { ports: 'abc', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: 'abc', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm( - { ports: '1--20', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '1--20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( - validateForm( - { ports: '-20', protocol: 'TCP', ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) + validateForm({ ports: '-20', protocol: 'TCP', ...rest }, baseOptions) ).toHaveProperty('ports', 'Must be 1-65535'); expect( validateForm( @@ -495,11 +408,7 @@ describe('utilities', () => { protocol: 'TCP', ...rest, }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } + baseOptions ) ).toHaveProperty( 'ports', @@ -555,16 +464,9 @@ describe('utilities', () => { ports: '80', protocol: 'TCP', }; - expect( - validateForm( - { label: value, ...rest }, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) - ).toEqual(result); + expect(validateForm({ label: value, ...rest }, baseOptions)).toEqual( + result + ); }); }); @@ -574,8 +476,7 @@ describe('utilities', () => { validateForm( {}, { - validatedIPs: [], - validatedPLs: [], + ...baseOptions, isFirewallRulesetsPrefixlistsFeatureEnabled: true, } ) @@ -585,8 +486,7 @@ describe('utilities', () => { validateForm( { addresses: 'ip/netmask/prefixlist' }, { - validatedIPs: [], - validatedPLs: [], + ...baseOptions, isFirewallRulesetsPrefixlistsFeatureEnabled: true, } ) @@ -636,16 +536,7 @@ describe('utilities', () => { }); it('handles required fields', () => { - expect( - validateForm( - {}, - { - validatedIPs: [], - validatedPLs: [], - isFirewallRulesetsPrefixlistsFeatureEnabled: false, - } - ) - ).toEqual({ + expect(validateForm({}, baseOptions)).toEqual({ addresses: 'Sources is a required field.', label: 'Label is required.', ports: 'Ports is a required field.', diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx index 88da9453451..9afcb456ae6 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx @@ -133,8 +133,10 @@ export const MultiplePrefixListInput = React.memo( ); /** - * Returns available prefix list options for a PL selection row. - * Includes the current selection and excludes options used in other PL rows. + * Returns the list of prefix list options available for a specific row. + * Always includes the currently selected option, and excludes any options + * that are already selected in other rows. This prevents duplicate prefix + * list selection across rows. */ const getAvailableOptions = React.useCallback( (idx: number, address: string) => From c1a640f2b63e158d2d6bce065baf84660ce5e820 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 00:03:15 +0530 Subject: [PATCH 16/24] Rename component --- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 4 +-- ...t.tsx => MutiplePrefixListSelect.test.tsx} | 36 +++++++++---------- ...tInput.tsx => MutiplePrefixListSelect.tsx} | 6 ++-- 3 files changed, 23 insertions(+), 23 deletions(-) rename packages/manager/src/features/Firewalls/FirewallDetail/Rules/{MutiplePrefixListInput.test.tsx => MutiplePrefixListSelect.test.tsx} (91%) rename packages/manager/src/features/Firewalls/FirewallDetail/Rules/{MutiplePrefixListInput.tsx => MutiplePrefixListSelect.tsx} (98%) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index dc6b8d58aeb..80695818798 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -23,7 +23,7 @@ import { import { ipFieldPlaceholder } from 'src/utilities/ipUtils'; import { enforceIPMasks } from './FirewallRuleDrawer.utils'; -import { MultiplePrefixListInput } from './MutiplePrefixListInput'; +import { MultiplePrefixListSelect } from './MutiplePrefixListSelect'; import { PORT_PRESETS, PORT_PRESETS_ITEMS } from './shared'; import type { FirewallRuleFormProps } from './FirewallRuleDrawer.types'; @@ -341,7 +341,7 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { tooltip={ipNetmaskTooltipText} /> {isFirewallRulesetsPrefixlistsFeatureEnabled && ( - ({ useAllFirewallPrefixListsQuery: vi.fn().mockReturnValue({}), @@ -20,7 +20,7 @@ vi.mock('@linode/queries', async () => { }); const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); -describe('MultiplePrefixListInput', () => { +describe('MultiplePrefixListSelect', () => { beforeEach(() => { spy.mockReturnValue({ isFirewallRulesetsPrefixlistsFeatureEnabled: true, @@ -60,7 +60,7 @@ describe('MultiplePrefixListInput', () => { it('should render the title only when at least one PL row is added', () => { const { getByText } = renderWithTheme( - @@ -70,14 +70,14 @@ describe('MultiplePrefixListInput', () => { it('should not render the title when no PL row is added', () => { const { queryByText } = renderWithTheme( - + ); expect(queryByText('Prefix List')).not.toBeInTheDocument(); }); it('should add a new PL row (empty state) when clicking "Add a Prefix List"', async () => { const { getByText } = renderWithTheme( - + ); await userEvent.click(getByText('Add a Prefix List')); @@ -96,7 +96,7 @@ describe('MultiplePrefixListInput', () => { }, ]; const { getByTestId } = renderWithTheme( - + ); await userEvent.click(getByTestId('delete-pl-0')); @@ -106,7 +106,7 @@ describe('MultiplePrefixListInput', () => { it('filters out unsupported PLs from dropdown', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { getByRole, queryByText } = renderWithTheme( - + ); const input = getByRole('combobox'); @@ -130,7 +130,7 @@ describe('MultiplePrefixListInput', () => { { address: '', inIPv4Rule: false, inIPv6Rule: false }, ]; const { getAllByRole, findByText } = renderWithTheme( - + ); const inputs = getAllByRole('combobox'); @@ -165,7 +165,7 @@ describe('MultiplePrefixListInput', () => { }, ]; const { getByDisplayValue, queryAllByTestId } = renderWithTheme( - + ); expect(queryAllByTestId('prefixlist-select')).toHaveLength(3); @@ -177,7 +177,7 @@ describe('MultiplePrefixListInput', () => { it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports both', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { findByText, getByRole } = renderWithTheme( - + ); const input = getByRole('combobox'); @@ -201,7 +201,7 @@ describe('MultiplePrefixListInput', () => { it('defaults to IPv4 selected and IPv6 unselected when choosing a PL that supports only IPv4', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { findByText, getByRole } = renderWithTheme( - + ); const input = getByRole('combobox'); @@ -225,7 +225,7 @@ describe('MultiplePrefixListInput', () => { it('defaults to IPv4 unselected and IPv6 selected when choosing a PL that supports only IPv6', async () => { const pls = [{ address: '', inIPv4Rule: false, inIPv6Rule: false }]; const { findByText, getByRole } = renderWithTheme( - + ); const input = getByRole('combobox'); @@ -251,7 +251,7 @@ describe('MultiplePrefixListInput', () => { { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); @@ -274,7 +274,7 @@ describe('MultiplePrefixListInput', () => { { address: 'pl::supports-both', inIPv4Rule: false, inIPv6Rule: true }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); @@ -297,7 +297,7 @@ describe('MultiplePrefixListInput', () => { { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: true }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); @@ -324,7 +324,7 @@ describe('MultiplePrefixListInput', () => { }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); @@ -351,7 +351,7 @@ describe('MultiplePrefixListInput', () => { }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv4CheckboxWrapper = await findByTestId('ipv4-checkbox-0'); @@ -375,7 +375,7 @@ describe('MultiplePrefixListInput', () => { { address: 'pl::supports-both', inIPv4Rule: true, inIPv6Rule: false }, ]; const { findByTestId } = renderWithTheme( - + ); const ipv6Checkbox = await findByTestId('ipv6-checkbox-0'); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx similarity index 98% rename from packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx rename to packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx index 9afcb456ae6..c87f32c0df0 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListInput.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx @@ -76,7 +76,7 @@ const getDefaultPLReferenceState = ( return { inIPv4Rule: false, inIPv6Rule: false }; }; -export interface MultiplePrefixListInputProps { +export interface MultiplePrefixListSelectProps { /** * Custom CSS class for additional styling. */ @@ -104,8 +104,8 @@ export interface MultiplePrefixListInputProps { pls: ExtendedPL[]; } -export const MultiplePrefixListInput = React.memo( - (props: MultiplePrefixListInputProps) => { +export const MultiplePrefixListSelect = React.memo( + (props: MultiplePrefixListSelectProps) => { const { className, disabled, pls, onChange } = props; const { classes, cx } = useStyles(); const { From dfd3ea258c1746d368dc78fa8d28f32760b55ed2 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 00:10:11 +0530 Subject: [PATCH 17/24] Fix one test case --- .../FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx index d9fb8fd76cc..580f8cc70ab 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx @@ -110,9 +110,9 @@ describe('MultiplePrefixListSelect', () => { ); const input = getByRole('combobox'); - await userEvent.type(input, 'pl:supports-both'); + await userEvent.type(input, 'pl:system:not-supported'); - expect(queryByText('pl:supports-both')).not.toBeInTheDocument(); + expect(queryByText('pl:system:not-supported')).not.toBeInTheDocument(); }); it('prevents duplicate selection of PLs', async () => { From 37dcd7e0ab2c6a30eb3eea8fcc0985b94d881505 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 02:31:37 +0530 Subject: [PATCH 18/24] Few fixes --- .../FirewallDetail/Rules/FirewallRuleForm.tsx | 15 ++++++++++----- .../Rules/MutiplePrefixListSelect.test.tsx | 1 + .../Rules/MutiplePrefixListSelect.tsx | 2 +- 3 files changed, 12 insertions(+), 6 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx index 80695818798..6de74c88677 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleForm.tsx @@ -1,6 +1,7 @@ import { ActionsPanel, Autocomplete, + Box, FormControlLabel, Radio, RadioGroup, @@ -317,10 +318,8 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { }} options={addressOptions} placeholder={`Select ${addressesLabel}s...`} + required textFieldProps={{ - InputProps: { - required: true, - }, dataAttrs: { 'data-qa-address-source-select': true, }, @@ -329,7 +328,13 @@ export const FirewallRuleForm = React.memo((props: FirewallRuleFormProps) => { /> {/* Show this field only if "IP / Netmask / Prefix List has been selected." */} {values.addresses === 'ip/netmask/prefixlist' && ( - <> + + isFirewallRulesetsPrefixlistsFeatureEnabled + ? theme.spacingFunction(24) + : 0 + } + > { pls={pls} /> )} - + )} diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx index 580f8cc70ab..8df198514e4 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.test.tsx @@ -20,6 +20,7 @@ vi.mock('@linode/queries', async () => { }); const spy = vi.spyOn(shared, 'useIsFirewallRulesetsPrefixlistsEnabled'); + describe('MultiplePrefixListSelect', () => { beforeEach(() => { spy.mockReturnValue({ diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx index c87f32c0df0..a9e4d124e02 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx @@ -237,7 +237,7 @@ export const MultiplePrefixListSelect = React.memo( handleSelectPL(selectedPrefixList?.label ?? '', idx); }} options={availableOptions} - placeholder="Type to search or select a Rule Set" + placeholder="Type to search or select Prefix List" value={ availableOptions.find((o) => o.label === thisPL.address) ?? null } From ab80ec075ce4de45f150f332e8bbe6f1ae744028 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 04:43:50 +0530 Subject: [PATCH 19/24] Fix type import --- .../Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx index 99ffc564598..e11d9ff598f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleDrawer.tsx @@ -14,7 +14,6 @@ import { itemsToPortString, portStringToItems, validateForm, - ValidateFormOptions, validateIPs, validatePrefixLists, } from './FirewallRuleDrawer.utils'; @@ -30,6 +29,7 @@ import type { FormRuleSetState, FormState, } from './FirewallRuleDrawer.types'; +import type { ValidateFormOptions } from './FirewallRuleDrawer.utils'; import type { FirewallRuleProtocol, FirewallRuleType, From c6e6afd52b7f12884f02350ef0008d4c3bc1241f Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 14:16:04 +0530 Subject: [PATCH 20/24] Update mocks for PLs with more possible cases --- packages/manager/src/mocks/serverHandlers.ts | 47 +++++++++++++------- 1 file changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index b278e1fe88b..b5e89cbe16c 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1323,8 +1323,8 @@ export const handlers = [ label: 'firewall with rule and ruleset reference', rules: firewallRulesFactory.build({ inbound: [ - firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall (ID 123) - firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) + { ruleset: 123 }, // Referenced Ruleset to the Firewall (ID 123) + { ruleset: 123456789 }, // Referenced Ruleset to the Firewall (ID 123456789) ...firewallRuleFactory.buildList(1), ], }), @@ -1363,10 +1363,25 @@ export const handlers = [ }), http.get('*/v4beta/networking/prefixlists', () => { const prefixlists = [ - ...Array.from({ length: 5 }, (_, i) => - firewallPrefixListFactory.build({ name: `pl::vpcs:test-${i + 1}` }) + ...Array.from({ length: 3 }, (_, i) => + firewallPrefixListFactory.build({ + name: `pl::vpcs:supports-both-${i + 1}`, + }) ), - firewallPrefixListFactory.build({ name: 'pl:system:test-1' }), + firewallPrefixListFactory.build({ + name: 'pl::supports-only-ipv4', + ipv6: null, + }), + firewallPrefixListFactory.build({ + name: 'pl::supports-only-ipv6', + ipv4: null, + }), + firewallPrefixListFactory.build({ + name: 'pl::not-supported', + ipv4: null, + ipv6: null, + }), + firewallPrefixListFactory.build({ name: 'pl:system:supports-both' }), ...firewallPrefixListFactory.buildList(10), ]; return HttpResponse.json(makeResourcePage(prefixlists)); @@ -1422,28 +1437,30 @@ export const handlers = [ label: 'firewall with rule and ruleset reference', rules: firewallRulesFactory.build({ inbound: [ - firewallRuleFactory.build({ ruleset: 123 }), // Referenced Ruleset to the Firewall (ID 123) - firewallRuleFactory.build({ ruleset: 123456789 }), // Referenced Ruleset to the Firewall (ID 123456789) + { ruleset: 123 }, // Referenced Ruleset to the Firewall (ID 123) + { ruleset: 123456789 }, // Referenced Ruleset to the Firewall (ID 123456789) ...firewallRuleFactory.buildList(1, { addresses: { ipv4: [ - 'pl:system:test-1', - 'pl::vpcs:test-1', + 'pl:system:supports-both', + 'pl::supports-only-ipv4', '192.168.1.213', '192.168.1.214', '192.168.1.215', '192.168.1.216', - 'pl::vpcs:test-2', + 'pl::vpcs:supports-both-1', + 'pl::vpcs:supports-both-2', '172.31.255.255', ], ipv6: [ - 'pl:system:test-1', - 'pl::vpcs:test-3', + 'pl:system:supports-both', + 'pl::supports-only-ipv6', + 'pl::vpcs:supports-both-3', '2001:db8:85a3::8a2e:370:7334/128', '2001:db8:85a3::8a2e:371:7335/128', - 'pl::vpcs:test-3', - 'pl::vpcs:test-4', - 'pl::vpcs:test-5', + // Duplicate PrefixList entries like the below one, may not appear, but if they do, + // our logic will treat them as a single entity within the ipv4 or ipv6 array. + 'pl::vpcs:supports-both-3', '2001:db8:85a3::8a2e:372:7336/128', ], }, From f110e65426ede39597dfec8e8b5d4cc977946949 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Fri, 28 Nov 2025 18:06:01 +0530 Subject: [PATCH 21/24] Some more changes related to sorting --- .../Rules/MutiplePrefixListSelect.tsx | 23 ++++++++++++++----- .../Firewalls/FirewallDetail/Rules/shared.ts | 12 ++++++++-- packages/manager/src/mocks/serverHandlers.ts | 4 ++-- 3 files changed, 29 insertions(+), 10 deletions(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx index a9e4d124e02..b153790692f 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx @@ -17,7 +17,7 @@ import { makeStyles } from 'tss-react/mui'; import { Link } from 'src/components/Link'; import { useIsFirewallRulesetsPrefixlistsEnabled } from 'src/features/Firewalls/shared'; -import { getPrefixListType } from './shared'; +import { getPrefixListType, groupPriority } from './shared'; import type { FirewallPrefixList } from '@linode/api-v4'; import type { Theme } from '@mui/material/styles'; @@ -124,11 +124,22 @@ export const MultiplePrefixListSelect = React.memo( */ const supportedOptions = React.useMemo( () => - prefixLists.filter(isPrefixListSupported).map((pl) => ({ - label: pl.name, - value: pl.id, - support: getSupportDetails(pl), - })), + prefixLists + .filter(isPrefixListSupported) + .map((pl) => ({ + label: pl.name, + value: pl.id, + support: getSupportDetails(pl), + })) + // The API does not seem to have the capability to sort prefix lists by "name" to prioritize certain types. + // This sort ensures that Autocomplete's groupBy displays groups correctly without duplicates + // and that the dropdown shows groups in the desired order. + .sort((a, b) => { + const groupA = getPrefixListType(a.label); + const groupB = getPrefixListType(b.label); + + return groupPriority[groupA] - groupPriority[groupB]; + }), [prefixLists] ); diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts index 4b75ecec14b..c19244caf51 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/shared.ts @@ -130,12 +130,20 @@ export const firewallRuleCreateOptions = [ }, ] as const; -export const getPrefixListType = (name: string) => { +type PrefixListGroup = 'Account' | 'Other' | 'System'; + +export const groupPriority: Record = { + Account: 1, + System: 2, + Other: 3, +}; + +export const getPrefixListType = (name: string): PrefixListGroup => { if (name.startsWith('pl::')) { return 'Account'; } if (name.startsWith('pl:system:')) { return 'System'; } - return 'Other'; + return 'Other'; // Safe fallback }; diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index 6711b68720b..ef038a82db1 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1363,6 +1363,8 @@ export const handlers = [ }), http.get('*/v4beta/networking/prefixlists', () => { const prefixlists = [ + firewallPrefixListFactory.build({ name: 'pl:system:supports-both' }), + ...firewallPrefixListFactory.buildList(10), ...Array.from({ length: 3 }, (_, i) => firewallPrefixListFactory.build({ name: `pl::vpcs:supports-both-${i + 1}`, @@ -1381,8 +1383,6 @@ export const handlers = [ ipv4: null, ipv6: null, }), - firewallPrefixListFactory.build({ name: 'pl:system:supports-both' }), - ...firewallPrefixListFactory.buildList(10), ]; return HttpResponse.json(makeResourcePage(prefixlists)); }), From c01df713e2d72f6d6066a8ec67f91bc06233881a Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Mon, 1 Dec 2025 12:52:31 +0530 Subject: [PATCH 22/24] Update mocks --- packages/manager/src/mocks/serverHandlers.ts | 46 ++++++++++---------- 1 file changed, 23 insertions(+), 23 deletions(-) diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts index ef038a82db1..24ff6dea266 100644 --- a/packages/manager/src/mocks/serverHandlers.ts +++ b/packages/manager/src/mocks/serverHandlers.ts @@ -1363,26 +1363,27 @@ export const handlers = [ }), http.get('*/v4beta/networking/prefixlists', () => { const prefixlists = [ - firewallPrefixListFactory.build({ name: 'pl:system:supports-both' }), ...firewallPrefixListFactory.buildList(10), - ...Array.from({ length: 3 }, (_, i) => + ...Array.from({ length: 2 }, (_, i) => firewallPrefixListFactory.build({ name: `pl::vpcs:supports-both-${i + 1}`, + description: `pl::vpcs:supports-both-${i + 1} description`, + }) + ), + // Prefix List variants / cases + ...[ + { name: 'pl::supports-both' }, + { name: 'pl:system:supports-only-ipv4', ipv6: null }, + { name: 'pl::supports-only-ipv6', ipv4: null }, + { name: 'pl::supports-both-but-ipv6-empty', ipv6: [] }, + { name: 'pl::supports-both-but-empty-both', ipv4: [], ipv6: [] }, + { name: 'pl::not-supported', ipv4: null, ipv6: null }, + ].map((variant) => + firewallPrefixListFactory.build({ + ...variant, + description: `${variant.name} description`, }) ), - firewallPrefixListFactory.build({ - name: 'pl::supports-only-ipv4', - ipv6: null, - }), - firewallPrefixListFactory.build({ - name: 'pl::supports-only-ipv6', - ipv4: null, - }), - firewallPrefixListFactory.build({ - name: 'pl::not-supported', - ipv4: null, - ipv6: null, - }), ]; return HttpResponse.json(makeResourcePage(prefixlists)); }), @@ -1442,25 +1443,24 @@ export const handlers = [ ...firewallRuleFactory.buildList(1, { addresses: { ipv4: [ - 'pl:system:supports-both', - 'pl::supports-only-ipv4', + 'pl::supports-both', + 'pl:system:supports-only-ipv4', '192.168.1.213', '192.168.1.214', - '192.168.1.215', - '192.168.1.216', 'pl::vpcs:supports-both-1', - 'pl::vpcs:supports-both-2', + 'pl::supports-both-but-empty-both', '172.31.255.255', ], ipv6: [ - 'pl:system:supports-both', + 'pl::supports-both', 'pl::supports-only-ipv6', - 'pl::vpcs:supports-both-3', + 'pl::supports-both-but-ipv6-empty', + 'pl::vpcs:supports-both-2', '2001:db8:85a3::8a2e:370:7334/128', '2001:db8:85a3::8a2e:371:7335/128', // Duplicate PrefixList entries like the below one, may not appear, but if they do, // our logic will treat them as a single entity within the ipv4 or ipv6 array. - 'pl::vpcs:supports-both-3', + 'pl::vpcs:supports-both-2', '2001:db8:85a3::8a2e:372:7336/128', ], }, From b7b3194106cf70f692327f75661f772d1b406c77 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 2 Dec 2025 18:47:19 +0530 Subject: [PATCH 23/24] Added changeset: Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection --- .../.changeset/pr-13138-upcoming-features-1764681439890.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md diff --git a/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md b/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md new file mode 100644 index 00000000000..35a420a4964 --- /dev/null +++ b/packages/manager/.changeset/pr-13138-upcoming-features-1764681439890.md @@ -0,0 +1,5 @@ +--- +"@linode/manager": Upcoming Features +--- + +Update Firewall Rules Edit & Add Drawer to Support Prefix List Selection ([#13138](https://github.com/linode/manager/pull/13138)) From e770826c787bd33cb700876300c63d91e14f8917 Mon Sep 17 00:00:00 2001 From: pmakode-akamai Date: Tue, 2 Dec 2025 19:40:19 +0530 Subject: [PATCH 24/24] Add/update comments --- .../Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx | 1 + .../Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx index eda6435e4f0..a07f89d38cc 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/FirewallRuleSetForm.tsx @@ -58,6 +58,7 @@ export const FirewallRuleSetForm = React.memo( const ruleSetDropdownOptions = React.useMemo( () => ruleSets + // TODO: Firewall RuleSets: Remove this client-side filter once the API supports filtering by the 'type' field .filter((ruleSet) => ruleSet.type === category) // Display only rule sets applicable to the given category .map((ruleSet) => ({ label: ruleSet.label, diff --git a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx index b153790692f..b956e1c4075 100644 --- a/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx +++ b/packages/manager/src/features/Firewalls/FirewallDetail/Rules/MutiplePrefixListSelect.tsx @@ -131,7 +131,7 @@ export const MultiplePrefixListSelect = React.memo( value: pl.id, support: getSupportDetails(pl), })) - // The API does not seem to have the capability to sort prefix lists by "name" to prioritize certain types. + // The API does not seem to sort prefix lists by "name" to prioritize certain types. // This sort ensures that Autocomplete's groupBy displays groups correctly without duplicates // and that the dropdown shows groups in the desired order. .sort((a, b) => {