Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@linode/manager": Upcoming Features
---

Add `CloudPulseVPCSubnet` component ([#12489](https://github.com/linode/manager/pull/12489))
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';

import { subnetFactory, vpcFactory } from 'src/factories';
import { renderWithTheme } from 'src/utilities/testHelpers';

import { CloudPulseVPCSubnetSelect } from './CloudPulseVPCSubnetSelect';

const queryMock = vi.hoisted(() => ({
useAllVpcsQuery: vi.fn(),
}));

vi.mock('@linode/queries', async () => {
const actual = await vi.importActual('@linode/queries');
return {
...actual,
useAllVPCsQuery: queryMock.useAllVpcsQuery,
};
});

const onChange = vi.fn();

const vpcs = vpcFactory.build({
subnets: subnetFactory.buildList(2),
});

describe('CloudPulseVPCSubnet', () => {
const component = <CloudPulseVPCSubnetSelect multiple onChange={onChange} />;
beforeEach(() => {
vi.resetAllMocks();
queryMock.useAllVpcsQuery.mockReturnValue({
isLoading: false,
data: [vpcs],
});
});
it('should render vpc subnet filter', () => {
renderWithTheme(component);

const filter = screen.getByTestId('vpc-subnet-filter');

expect(filter).toBeInTheDocument();
});

it('Should render vpc subnet options', async () => {
renderWithTheme(component);

const openButton = await screen.findByRole('button', { name: 'Open' });

await userEvent.click(openButton);

const options = screen.getAllByRole('option');

// options[0] is Select All button
expect(options).toHaveLength(3);
expect(options[1]).toHaveTextContent(
`${vpcs.label}_${vpcs.subnets[0].label}`
);
});

it('Should click options', async () => {
renderWithTheme(component);

const openButton = await screen.findByRole('button', { name: 'Open' });

await userEvent.click(openButton);

const options = screen.getAllByRole('option');

await userEvent.click(options[1]);

expect(onChange).toHaveBeenCalledWith([vpcs.subnets[0].id]);

// click select all button
await userEvent.click(options[0]);

expect(onChange).toHaveBeenCalledWith([
vpcs.subnets[0].id,
vpcs.subnets[1].id,
]);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useAllVPCsQuery } from '@linode/queries';
import { Autocomplete } from '@linode/ui';
import React from 'react';

interface VPCSubnetOption {
/**
* Unique identifier for the subnet.
*/
id: number;
/**
* Display label for the subnet, typically includes VPC name.
*/
label: string;
}

interface CloudPulseVPCSubnetSelectProps {
/**
* Error text to display when there is an error.
*/
errorText?: string;
/**
* Label for the autocomplete field.
*/
label?: string;
/**
* Whether to allow multiple selections.
*/
multiple?: boolean;
/**
* This function is called when the input field loses focus.
*/
onBlur?: () => void;
/**
* Callback function when the value changes.
* @param value - The selected value(s).
*/
onChange: (value: null | number | number[]) => void;
/**
* Placeholder text for the autocomplete field.
*/
placeholder?: string;
/**
* The default selected value for the component.
*/
value?: number | number[];
}

export const CloudPulseVPCSubnetSelect = (
props: CloudPulseVPCSubnetSelectProps
) => {
const { errorText, onChange, value, onBlur, label, placeholder, multiple } =
props;

const [selectedValue, setSelectedValue] = React.useState<
null | number | number[]
>(value ?? null);
const { data, isLoading, error } = useAllVPCsQuery({ enabled: true });

// Creating a mapping of subnet id to options for constant time access to fetch selected options
const options: Record<number, VPCSubnetOption> = React.useMemo(() => {
if (!data) return {};

const options: Record<number, VPCSubnetOption> = [];

for (const { label: vpcLabel, subnets } of data) {
subnets.forEach(({ id: subnetId, label: subnetLabel }) => {
options[subnetId] = {
id: subnetId,
label: `${vpcLabel}_${subnetLabel}`,
};
});
}

return options;
}, [data]);

const isArray = selectedValue && Array.isArray(selectedValue);

const getSelectedOptions = (): null | VPCSubnetOption | VPCSubnetOption[] => {
if (selectedValue === null) {
return multiple ? [] : null;
}
if (isArray) {
const selectedOptions = selectedValue
.filter((value) => options[value] !== undefined)
.map((value) => options[value]);

return multiple ? selectedOptions : (selectedOptions[0] ?? null);
}

const selectedOption = options[selectedValue];

if (multiple) {
return selectedOption ? [selectedOption] : [];
}

return selectedOption ?? null;
};

return (
<Autocomplete
data-testid="vpc-subnet-filter"
errorText={errorText ?? error?.[0].reason}
fullWidth
isOptionEqualToValue={(option, value) => option.id === value.id}
label={label ?? 'VPC Subnet'}
limitTags={2}
loading={isLoading}
multiple={multiple}
onBlur={onBlur}
onChange={(_, newValue) => {
const newSelectedValue = Array.isArray(newValue)
? newValue.map(({ id }) => id)
: (newValue?.id ?? null);
setSelectedValue(newSelectedValue);
onChange?.(newSelectedValue);
}}
options={Object.values(options)}
placeholder={placeholder ?? 'Select VPC Subnets'}
value={getSelectedOptions()}
/>
);
};