diff --git a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx
index 2a8933ff543..95df7aad844 100644
--- a/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx
+++ b/packages/manager/src/features/Managed/Monitors/MonitorTable.tsx
@@ -1,11 +1,8 @@
-import { ManagedServicePayload } from '@linode/api-v4/lib/managed';
-import { APIError } from '@linode/api-v4/lib/types';
import Grid from '@mui/material/Unstable_Grid2';
-import { FormikBag } from 'formik';
import { useSnackbar } from 'notistack';
import * as React from 'react';
-import AddNewLink from 'src/components/AddNewLink';
+import { Button } from 'src/components/Button/Button';
import { DeletionDialog } from 'src/components/DeletionDialog/DeletionDialog';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
import OrderBy from 'src/components/OrderBy';
@@ -41,6 +38,10 @@ import {
} from './MonitorTable.styles';
import MonitorTableContent from './MonitorTableContent';
+import type { ManagedServicePayload } from '@linode/api-v4/lib/managed';
+import type { APIError } from '@linode/api-v4/lib/types';
+import type { FormikBag } from 'formik';
+
export type Modes = 'create' | 'edit';
export type FormikProps = FormikBag<{}, ManagedServicePayload>;
@@ -180,10 +181,12 @@ export const MonitorTable = () => {
- setMonitorDrawerOpen(true)}
- />
+ >
+ Add Monitor
+
diff --git a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
index 1c971babe18..6630b7509c9 100644
--- a/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
+++ b/packages/manager/src/features/NodeBalancers/ConfigNodeIPSelect.tsx
@@ -1,4 +1,3 @@
-import { Linode } from '@linode/api-v4/lib/linodes';
import { Box } from '@mui/material';
import * as React from 'react';
@@ -6,6 +5,7 @@ import { SelectedIcon } from 'src/components/Autocomplete/Autocomplete.styles';
import { LinodeSelect } from 'src/features/Linodes/LinodeSelect/LinodeSelect';
import { privateIPRegex } from 'src/utilities/ipUtils';
+import type { Linode } from '@linode/api-v4/lib/linodes';
import type { TextFieldProps } from 'src/components/TextField';
interface ConfigNodeIPSelectProps {
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx
new file mode 100644
index 00000000000..6ea897d87fa
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.test.tsx
@@ -0,0 +1,76 @@
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { NodeBalancerConfigNode } from './NodeBalancerConfigNode';
+
+import type { NodeBalancerConfigNodeProps } from './NodeBalancerConfigNode';
+
+const node = {
+ address: 'some address',
+ label: 'some label',
+};
+
+const props: NodeBalancerConfigNodeProps = {
+ configIdx: 1,
+ disabled: false,
+ forEdit: true,
+ idx: 1,
+ node,
+ onNodeAddressChange: vi.fn(),
+ onNodeLabelChange: vi.fn(),
+ onNodeModeChange: vi.fn(),
+ onNodePortChange: vi.fn(),
+ onNodeWeightChange: vi.fn(),
+ removeNode: vi.fn(),
+};
+
+describe('NodeBalancerConfigNode', () => {
+ it('renders the NodeBalancerConfigNode', () => {
+ const { getByLabelText, getByText, queryByText } = renderWithTheme(
+
+ );
+
+ expect(getByLabelText('Label')).toBeVisible();
+ expect(getByLabelText('Port')).toBeVisible();
+ expect(getByLabelText('Weight')).toBeVisible();
+ expect(getByText('Mode')).toBeVisible();
+ expect(getByText('Remove')).toBeVisible();
+ expect(queryByText('Status')).not.toBeInTheDocument();
+ });
+
+ it('renders the node status', () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('DOWN')).toBeVisible();
+ });
+
+ it('cannot change the mode if the node is not for edit', () => {
+ const { queryByText } = renderWithTheme(
+
+ );
+
+ expect(queryByText('Mode')).not.toBeInTheDocument();
+ });
+
+ it('cannot remove the node if the node is not for edit or is the first node', () => {
+ const { queryByText } = renderWithTheme(
+
+ );
+
+ expect(queryByText('Remove')).not.toBeInTheDocument();
+ });
+
+ it('removes the node', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ await userEvent.click(getByText('Remove'));
+ expect(props.removeNode).toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
index af6e03a1118..05f47659c98 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigNode.tsx
@@ -1,5 +1,5 @@
-import Grid from '@mui/material/Unstable_Grid2';
import { styled } from '@mui/material/styles';
+import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
@@ -13,8 +13,8 @@ import { Typography } from 'src/components/Typography';
import { getErrorMap } from 'src/utilities/errorUtils';
import { ConfigNodeIPSelect } from './ConfigNodeIPSelect';
-import { NodeBalancerConfigNodeFields } from './types';
+import type { NodeBalancerConfigNodeFields } from './types';
import type { NodeBalancerConfigNodeMode } from '@linode/api-v4';
export interface NodeBalancerConfigNodeProps {
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx
index 7f2716c6f78..fa06be32953 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerConfigPanel.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -28,7 +28,7 @@ const node: NodeBalancerConfigNodeFields = {
weight: 100,
};
-const props: NodeBalancerConfigPanelProps = {
+export const nbConfigPanelMockPropsForTest: NodeBalancerConfigPanelProps = {
addNode: vi.fn(),
algorithm: 'roundrobin',
checkBody: '',
@@ -70,7 +70,17 @@ const props: NodeBalancerConfigPanelProps = {
sslCertificate: '',
};
-const activeHealthChecks = ['Interval', 'Timeout', 'Attempts'];
+const activeHealthChecksFormInputs = ['Interval', 'Timeout', 'Attempts'];
+
+const activeHealthChecksHelperText = [
+ 'Seconds between health check probes',
+ 'Seconds to wait before considering the probe a failure. 1-30. Must be less than check_interval.',
+ 'Number of failed probes before taking a node out of rotation. 1-30',
+];
+
+const sslCertificate = 'ssl-certificate';
+const privateKey = 'private-key';
+const proxyProtocol = 'Proxy Protocol';
describe('NodeBalancerConfigPanel', () => {
it('renders the NodeBalancerConfigPanel', () => {
@@ -79,7 +89,10 @@ describe('NodeBalancerConfigPanel', () => {
getByText,
queryByLabelText,
queryByTestId,
- } = renderWithTheme( );
+ queryByText,
+ } = renderWithTheme(
+
+ );
expect(getByLabelText('Protocol')).toBeVisible();
expect(getByLabelText('Algorithm')).toBeVisible();
@@ -109,67 +122,99 @@ describe('NodeBalancerConfigPanel', () => {
expect(getByText('Add a Node')).toBeVisible();
expect(getByText('Backend Nodes')).toBeVisible();
- activeHealthChecks.forEach((type) => {
- expect(queryByLabelText(type)).not.toBeInTheDocument();
+ activeHealthChecksFormInputs.forEach((formLabel) => {
+ expect(queryByLabelText(formLabel)).not.toBeInTheDocument();
});
- expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument();
- expect(queryByTestId('private-key')).not.toBeInTheDocument();
+ activeHealthChecksHelperText.forEach((helperText) => {
+ expect(queryByText(helperText)).not.toBeInTheDocument();
+ });
+ expect(queryByTestId(sslCertificate)).not.toBeInTheDocument();
+ expect(queryByTestId(privateKey)).not.toBeInTheDocument();
expect(queryByTestId('http-path')).not.toBeInTheDocument();
expect(queryByTestId('http-body')).not.toBeInTheDocument();
- expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument();
+ expect(queryByLabelText(proxyProtocol)).not.toBeInTheDocument();
});
it('renders form fields specific to the HTTPS protocol', () => {
const { getByTestId, queryByLabelText } = renderWithTheme(
-
+
);
- expect(getByTestId('ssl-certificate')).toBeVisible();
- expect(getByTestId('private-key')).toBeVisible();
- expect(queryByLabelText('Proxy Protocol')).not.toBeInTheDocument();
+ expect(getByTestId(sslCertificate)).toBeVisible();
+ expect(getByTestId(privateKey)).toBeVisible();
+ expect(queryByLabelText(proxyProtocol)).not.toBeInTheDocument();
});
it('renders form fields specific to the TCP protocol', () => {
const { getByLabelText, queryByTestId } = renderWithTheme(
-
+
);
- expect(getByLabelText('Proxy Protocol')).toBeVisible();
- expect(queryByTestId('ssl-certificate')).not.toBeInTheDocument();
- expect(queryByTestId('private-key')).not.toBeInTheDocument();
+ expect(getByLabelText(proxyProtocol)).toBeVisible();
+ expect(queryByTestId(sslCertificate)).not.toBeInTheDocument();
+ expect(queryByTestId(privateKey)).not.toBeInTheDocument();
});
it('renders fields specific to the Active Health Check type of TCP Connection', () => {
- const { getByLabelText, queryByTestId } = renderWithTheme(
-
+ const { getByLabelText, getByText, queryByTestId } = renderWithTheme(
+
);
- activeHealthChecks.forEach((type) => {
- expect(getByLabelText(type)).toBeVisible();
+ activeHealthChecksFormInputs.forEach((formLabel) => {
+ expect(getByLabelText(formLabel)).toBeVisible();
+ });
+ activeHealthChecksHelperText.forEach((helperText) => {
+ expect(getByText(helperText)).toBeVisible();
});
expect(queryByTestId('http-path')).not.toBeInTheDocument();
expect(queryByTestId('http-body')).not.toBeInTheDocument();
});
it('renders fields specific to the Active Health Check type of HTTP Status', () => {
- const { getByLabelText, getByTestId, queryByTestId } = renderWithTheme(
-
+ const {
+ getByLabelText,
+ getByTestId,
+ getByText,
+ queryByTestId,
+ } = renderWithTheme(
+
);
- activeHealthChecks.forEach((type) => {
- expect(getByLabelText(type)).toBeVisible();
+ activeHealthChecksFormInputs.forEach((formLabel) => {
+ expect(getByLabelText(formLabel)).toBeVisible();
+ });
+ activeHealthChecksHelperText.forEach((helperText) => {
+ expect(getByText(helperText)).toBeVisible();
});
expect(getByTestId('http-path')).toBeVisible();
expect(queryByTestId('http-body')).not.toBeInTheDocument();
});
it('renders fields specific to the Active Health Check type of HTTP Body', () => {
- const { getByLabelText, getByTestId } = renderWithTheme(
-
+ const { getByLabelText, getByTestId, getByText } = renderWithTheme(
+
);
- activeHealthChecks.forEach((type) => {
- expect(getByLabelText(type)).toBeVisible();
+ activeHealthChecksFormInputs.forEach((formLabel) => {
+ expect(getByLabelText(formLabel)).toBeVisible();
+ });
+ activeHealthChecksHelperText.forEach((helperText) => {
+ expect(getByText(helperText)).toBeVisible();
});
expect(getByTestId('http-path')).toBeVisible();
expect(getByTestId('http-body')).toBeVisible();
@@ -177,7 +222,7 @@ describe('NodeBalancerConfigPanel', () => {
it('renders the relevant helper text for the Round Robin algorithm', () => {
const { getByText, queryByText } = renderWithTheme(
-
+
);
expect(getByText(ROUND_ROBIN_ALGORITHM_HELPER_TEXT)).toBeVisible();
@@ -189,7 +234,10 @@ describe('NodeBalancerConfigPanel', () => {
it('renders the relevant helper text for the Least Connections algorithm', () => {
const { getByText, queryByText } = renderWithTheme(
-
+
);
expect(getByText(LEAST_CONNECTIONS_ALGORITHM_HELPER_TEXT)).toBeVisible();
@@ -201,7 +249,10 @@ describe('NodeBalancerConfigPanel', () => {
it('renders the relevant helper text for the Source algorithm', () => {
const { getByText, queryByText } = renderWithTheme(
-
+
);
expect(getByText(SOURCE_ALGORITHM_HELPER_TEXT)).toBeVisible();
@@ -213,51 +264,57 @@ describe('NodeBalancerConfigPanel', () => {
).not.toBeInTheDocument();
});
- it('adds another backend node', () => {
+ it('adds another backend node', async () => {
const { getByText } = renderWithTheme(
-
+
);
const addNodeButton = getByText('Add a Node');
- fireEvent.click(addNodeButton);
- expect(props.addNode).toHaveBeenCalled();
+ await userEvent.click(addNodeButton);
+ expect(nbConfigPanelMockPropsForTest.addNode).toHaveBeenCalled();
});
it('cannot remove a backend node if there is only one node', () => {
const { queryByText } = renderWithTheme(
-
+
);
expect(queryByText('Remove')).not.toBeInTheDocument();
});
- it('removes a backend node', () => {
+ it('removes a backend node', async () => {
const { getByText } = renderWithTheme(
-
+
);
const removeNodeButton = getByText('Remove');
- fireEvent.click(removeNodeButton);
- expect(props.removeNode).toHaveBeenCalled();
+ await userEvent.click(removeNodeButton);
+ expect(nbConfigPanelMockPropsForTest.removeNode).toHaveBeenCalled();
});
- it('deletes the configuration panel', () => {
+ it('deletes the configuration panel', async () => {
const { getByText } = renderWithTheme(
-
+
);
const deleteConfigButton = getByText('Delete');
- fireEvent.click(deleteConfigButton);
- expect(props.onDelete).toHaveBeenCalled();
+ await userEvent.click(deleteConfigButton);
+ expect(nbConfigPanelMockPropsForTest.onDelete).toHaveBeenCalled();
});
- it('saves the input after editing the configuration', () => {
+ it('saves the input after editing the configuration', async () => {
const { getByText } = renderWithTheme(
-
+
);
const editConfigButton = getByText('Save');
- fireEvent.click(editConfigButton);
- expect(props.onSave).toHaveBeenCalled();
+ await userEvent.click(editConfigButton);
+ expect(nbConfigPanelMockPropsForTest.onSave).toHaveBeenCalled();
});
});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx
new file mode 100644
index 00000000000..3a2f0a171aa
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerCreate.test.tsx
@@ -0,0 +1,41 @@
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import NodeBalancerCreate from './NodeBalancerCreate';
+
+// Note: see nodeblaancers-create-in-complex-form.spec.ts for an e2e test of this flow
+describe('NodeBalancerCreate', () => {
+ it('renders all parts of the NodeBalancerCreate page', () => {
+ const { getAllByText, getByLabelText, getByText } = renderWithTheme(
+
+ );
+
+ // confirm nodebalancer fields render
+ expect(getByLabelText('NodeBalancer Label')).toBeVisible();
+ expect(getByLabelText('Add Tags')).toBeVisible();
+ expect(getByLabelText('Region')).toBeVisible();
+
+ // confirm Firewall panel renders
+ expect(getByLabelText('Assign Firewall')).toBeVisible();
+ expect(getByText('Create Firewall')).toBeVisible();
+ expect(
+ getByText(
+ /Assign an existing Firewall to this NodeBalancer to control inbound network traffic./
+ )
+ ).toBeVisible();
+
+ // confirm default configuration renders - only confirming headers, as we have additional
+ // unit tests to check the functionality of the NodeBalancerConfigPanel
+ expect(getByText('Configuration - Port 80')).toBeVisible();
+ expect(getByText('Active Health Checks')).toBeVisible();
+ expect(getAllByText('Passive Checks')).toHaveLength(2);
+ expect(getByText('Backend Nodes')).toBeVisible();
+
+ // confirm summary renders
+ expect(getByText('Summary')).toBeVisible();
+ expect(getByText('Configs')).toBeVisible();
+ expect(getByText('Nodes')).toBeVisible();
+ expect(getByText('Create NodeBalancer')).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx
new file mode 100644
index 00000000000..de194e3a23b
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDeleteDialog.test.tsx
@@ -0,0 +1,43 @@
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { NodeBalancerDeleteDialog } from './NodeBalancerDeleteDialog';
+
+const props = {
+ id: 1,
+ label: 'nb-1',
+ onClose: vi.fn(),
+ open: true,
+};
+
+describe('NodeBalancerDeleteDialog', () => {
+ it('renders the NodeBalancerDeleteDialog', () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(
+ getByText('Deleting this NodeBalancer is permanent and can’t be undone.')
+ ).toBeVisible();
+ expect(
+ getByText(
+ 'Traffic will no longer be routed through this NodeBalancer. Please check your DNS settings and either provide the IP address of another active NodeBalancer, or route traffic directly to your Linode.'
+ )
+ ).toBeVisible();
+ expect(getByText('Delete nb-1?')).toBeVisible();
+ expect(getByText('NodeBalancer Label')).toBeVisible();
+ expect(getByText('Cancel')).toBeVisible();
+ expect(getByText('Delete')).toBeVisible();
+ });
+
+ it('calls the onClose function of the dialog', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ await userEvent.click(getByText('Cancel'));
+ expect(props.onClose).toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx
new file mode 100644
index 00000000000..d82c1156bf9
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.test.tsx
@@ -0,0 +1,188 @@
+import { waitForElementToBeRemoved } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import {
+ nodeBalancerConfigFactory,
+ nodeBalancerConfigNodeFactory,
+} from 'src/factories';
+import { makeResourcePage } from 'src/mocks/serverHandlers';
+import { HttpResponse, http, server } from 'src/mocks/testServer';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import NodeBalancerConfigurations from './NodeBalancerConfigurations';
+
+const props = {
+ grants: undefined,
+ nodeBalancerLabel: 'nb-1',
+ nodeBalancerRegion: 'us-east',
+};
+
+const loadingTestId = 'circle-progress';
+const memoryRouter = { initialEntries: ['nodebalancers/1/configurations'] };
+const routePath = 'nodebalancers/:nodeBalancerId/configurations';
+
+const nodeBalancerConfig = nodeBalancerConfigFactory.build({
+ id: 1,
+ port: 3000,
+});
+
+describe('NodeBalancerConfigurations', () => {
+ beforeEach(() => {
+ server.resetHandlers();
+ });
+
+ it('renders the NodeBalancerConfigurations component with one configuration', async () => {
+ server.use(
+ http.get(`*/nodebalancers/:id/configs`, () => {
+ return HttpResponse.json(makeResourcePage([nodeBalancerConfig]));
+ }),
+ http.get(`*/nodebalancers/:id/configs/1/nodes`, () => {
+ return HttpResponse.json(
+ makeResourcePage([nodeBalancerConfigNodeFactory.build({ id: 1 })])
+ );
+ })
+ );
+
+ const { getByLabelText, getByTestId, getByText } = renderWithTheme(
+ ,
+ {
+ MemoryRouter: memoryRouter,
+ routePath,
+ }
+ );
+
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+
+ await waitForElementToBeRemoved(getByTestId(loadingTestId));
+
+ // Expected after mocking the configs returned
+ expect(getByText('Port 3000')).toBeVisible();
+ expect(getByLabelText('Protocol')).toBeInTheDocument();
+ expect(getByLabelText('Algorithm')).toBeInTheDocument();
+ expect(getByLabelText('Session Stickiness')).toBeInTheDocument();
+ expect(getByLabelText('Type')).toBeInTheDocument();
+ expect(getByLabelText('Label')).toBeInTheDocument();
+ expect(getByLabelText('IP Address')).toBeInTheDocument();
+ expect(getByLabelText('Weight')).toBeInTheDocument();
+ expect(getByLabelText('Port')).toBeInTheDocument();
+ expect(getByText('Listen on this port.')).toBeInTheDocument();
+ expect(getByText('Active Health Checks')).toBeInTheDocument();
+ expect(
+ getByText(
+ 'Route subsequent requests from the client to the same backend.'
+ )
+ ).toBeInTheDocument();
+ expect(
+ getByText(
+ 'Enable passive checks based on observing communication with back-end nodes.'
+ )
+ ).toBeInTheDocument();
+ expect(
+ getByText(
+ "Active health checks proactively check the health of back-end nodes. 'HTTP Valid Status' requires a 2xx or 3xx response from the backend node. 'HTTP Body Regex' uses a regex to match against an expected result body."
+ )
+ ).toBeInTheDocument();
+ expect(getByText('Add a Node')).toBeInTheDocument();
+ expect(getByText('Backend Nodes')).toBeInTheDocument();
+
+ // Since there is an existing configuration, the Add Configuration button says 'Add Another Configuration'
+ expect(getByText('Add Another Configuration')).toBeVisible();
+ });
+
+ it('renders the NodeBalancerConfigurations component with no configurations', async () => {
+ server.use(
+ http.get(`*/nodebalancers/:id/configs`, () => {
+ return HttpResponse.json(makeResourcePage([]));
+ })
+ );
+
+ const { getByTestId, getByText, queryByLabelText } = renderWithTheme(
+ ,
+ {
+ MemoryRouter: memoryRouter,
+ routePath,
+ }
+ );
+
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+
+ await waitForElementToBeRemoved(getByTestId(loadingTestId));
+
+ // confirm there are no configs
+ expect(queryByLabelText('Protocol')).not.toBeInTheDocument();
+ expect(queryByLabelText('Algorithm')).not.toBeInTheDocument();
+ expect(queryByLabelText('Session Stickiness')).not.toBeInTheDocument();
+
+ // Since there are no existing configurations, the Add Configuration button says 'Add a Configuration'
+ expect(getByText('Add a Configuration')).toBeVisible();
+ });
+
+ it('adds another configuration', async () => {
+ server.use(
+ http.get(`*/nodebalancers/:id/configs`, () => {
+ return HttpResponse.json(makeResourcePage([]));
+ })
+ );
+
+ const { getByTestId, getByText, queryByLabelText } = renderWithTheme(
+ ,
+ {
+ MemoryRouter: memoryRouter,
+ routePath,
+ }
+ );
+
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+
+ await waitForElementToBeRemoved(getByTestId(loadingTestId));
+
+ // confirm no configuration exists yet
+ expect(queryByLabelText('Protocol')).not.toBeInTheDocument();
+ expect(queryByLabelText('Algorithm')).not.toBeInTheDocument();
+ expect(queryByLabelText('Session Stickiness')).not.toBeInTheDocument();
+
+ await userEvent.click(getByText('Add a Configuration'));
+
+ // confirm new configuration has been added
+ expect(queryByLabelText('Protocol')).toBeVisible();
+ expect(queryByLabelText('Algorithm')).toBeVisible();
+ expect(queryByLabelText('Session Stickiness')).toBeVisible();
+ });
+
+ it('opens the Delete Configuration dialog', async () => {
+ server.use(
+ http.get(`*/nodebalancers/:id/configs`, () => {
+ return HttpResponse.json(makeResourcePage([nodeBalancerConfig]));
+ }),
+ http.get(`*/nodebalancers/:id/configs/1/nodes`, () => {
+ return HttpResponse.json(makeResourcePage([]));
+ })
+ );
+
+ const { getByLabelText, getByTestId, getByText } = renderWithTheme(
+ ,
+ {
+ MemoryRouter: memoryRouter,
+ routePath,
+ }
+ );
+
+ expect(getByTestId(loadingTestId)).toBeInTheDocument();
+
+ await waitForElementToBeRemoved(getByTestId(loadingTestId));
+
+ expect(getByText('Port 3000')).toBeVisible();
+ expect(getByLabelText('Protocol')).toBeInTheDocument();
+ expect(getByLabelText('Algorithm')).toBeInTheDocument();
+
+ await userEvent.click(getByText('Delete'));
+
+ expect(getByText('Delete this configuration on port 3000?')).toBeVisible();
+ expect(
+ getByText(
+ 'Are you sure you want to delete this NodeBalancer Configuration?'
+ )
+ ).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx
index 620cd6e10f9..66e713df1a8 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerConfigurations.tsx
@@ -1,6 +1,4 @@
import {
- NodeBalancerConfig,
- NodeBalancerConfigNode,
createNodeBalancerConfig,
createNodeBalancerConfigNode,
deleteNodeBalancerConfig,
@@ -10,10 +8,8 @@ import {
updateNodeBalancerConfig,
updateNodeBalancerConfigNode,
} from '@linode/api-v4/lib/nodebalancers';
-import { APIError, ResourcePage } from '@linode/api-v4/lib/types';
import { styled } from '@mui/material/styles';
import {
- Lens,
append,
clone,
compose,
@@ -25,7 +21,7 @@ import {
view,
} from 'ramda';
import * as React from 'react';
-import { RouteComponentProps, withRouter } from 'react-router-dom';
+import { withRouter } from 'react-router-dom';
import { compose as composeC } from 'recompose';
import { Accordion } from 'src/components/Accordion';
@@ -34,14 +30,10 @@ import { Box } from 'src/components/Box';
import { Button } from 'src/components/Button/Button';
import { ConfirmationDialog } from 'src/components/ConfirmationDialog/ConfirmationDialog';
import { DocumentTitleSegment } from 'src/components/DocumentTitle';
-import PromiseLoader, {
- PromiseLoaderResponse,
-} from 'src/components/PromiseLoader/PromiseLoader';
+import PromiseLoader from 'src/components/PromiseLoader/PromiseLoader';
import { Typography } from 'src/components/Typography';
-import {
- WithQueryClientProps,
- withQueryClient,
-} from 'src/containers/withQueryClient.container';
+import { withQueryClient } from 'src/containers/withQueryClient.container';
+import { nodebalancerQueries } from 'src/queries/nodebalancers';
import { getAPIErrorOrDefault } from 'src/utilities/errorUtils';
import { scrollErrorIntoView } from 'src/utilities/scrollErrorIntoView';
@@ -62,7 +54,15 @@ import type {
NodeBalancerConfigNodeFields,
} from '../types';
import type { Grants } from '@linode/api-v4';
-import { nodebalancerQueries } from 'src/queries/nodebalancers';
+import type {
+ NodeBalancerConfig,
+ NodeBalancerConfigNode,
+} from '@linode/api-v4/lib/nodebalancers';
+import type { APIError, ResourcePage } from '@linode/api-v4/lib/types';
+import type { Lens } from 'ramda';
+import type { RouteComponentProps } from 'react-router-dom';
+import type { PromiseLoaderResponse } from 'src/components/PromiseLoader/PromiseLoader';
+import type { WithQueryClientProps } from 'src/containers/withQueryClient.container';
const StyledPortsSpan = styled('span', {
label: 'StyledPortsSpan',
@@ -171,62 +171,25 @@ class NodeBalancerConfigurations extends React.Component<
NodeBalancerConfigurationsProps,
State
> {
- render() {
- const { nodeBalancerLabel } = this.props;
- const {
- configErrors,
- configSubmitting,
- configs,
- hasUnsavedConfig,
- panelMessages,
- } = this.state;
-
- const isNodeBalancerReadOnly = this.isNodeBalancerReadOnly();
-
- return (
-
-
- {Array.isArray(configs) &&
- configs.map(
- this.renderConfig(panelMessages, configErrors, configSubmitting)
- )}
+ static defaultDeleteConfigConfirmDialogState = {
+ errors: undefined,
+ idxToDelete: undefined,
+ open: false,
+ portToDelete: undefined,
+ submitting: false,
+ };
- {!hasUnsavedConfig && (
-
- this.addNodeBalancerConfig()}
- >
- {configs.length === 0
- ? 'Add a Configuration'
- : 'Add Another Configuration'}
-
-
- )}
+ static defaultDeleteNodeConfirmDialogState = {
+ configIdxToDelete: undefined,
+ errors: undefined,
+ nodeIdxToDelete: undefined,
+ open: false,
+ submitting: false,
+ };
-
-
- Are you sure you want to delete this NodeBalancer Configuration?
-
-
-
- );
- }
+ static defaultFieldsStates = {
+ configs: [createNewNodeBalancerConfig(true)],
+ };
addNode = (configIdx: number) => () => {
this.setState(
@@ -343,26 +306,6 @@ class NodeBalancerConfigurations extends React.Component<
);
};
- static defaultDeleteConfigConfirmDialogState = {
- errors: undefined,
- idxToDelete: undefined,
- open: false,
- portToDelete: undefined,
- submitting: false,
- };
-
- static defaultDeleteNodeConfirmDialogState = {
- configIdxToDelete: undefined,
- errors: undefined,
- nodeIdxToDelete: undefined,
- open: false,
- submitting: false,
- };
-
- static defaultFieldsStates = {
- configs: [createNewNodeBalancerConfig(true)],
- };
-
deleteConfig = () => {
const {
deleteConfigConfirmDialog: { idxToDelete },
@@ -749,23 +692,6 @@ class NodeBalancerConfigurations extends React.Component<
);
};
- renderConfigConfirmationActions = ({ onClose }: { onClose: () => void }) => (
-
- );
-
resetSubmitting = (configIdx: number) => {
// reset submitting
const newSubmitting = clone(this.state.configSubmitting);
@@ -1164,6 +1090,78 @@ class NodeBalancerConfigurations extends React.Component<
const clampedValue = clampNumericString(0, Number.MAX_SAFE_INTEGER)(value);
this.updateState(lens, L, callback)(clampedValue);
};
+
+ render() {
+ const { nodeBalancerLabel } = this.props;
+ const {
+ configErrors,
+ configSubmitting,
+ configs,
+ hasUnsavedConfig,
+ panelMessages,
+ } = this.state;
+
+ const isNodeBalancerReadOnly = this.isNodeBalancerReadOnly();
+
+ return (
+
+
+ {Array.isArray(configs) &&
+ configs.map(
+ this.renderConfig(panelMessages, configErrors, configSubmitting)
+ )}
+
+ {!hasUnsavedConfig && (
+
+ this.addNodeBalancerConfig()}
+ >
+ {configs.length === 0
+ ? 'Add a Configuration'
+ : 'Add Another Configuration'}
+
+
+ )}
+
+
+ }
+ title={
+ typeof this.state.deleteConfigConfirmDialog.portToDelete !==
+ 'undefined'
+ ? `Delete this configuration on port ${this.state.deleteConfigConfirmDialog.portToDelete}?`
+ : 'Delete this configuration?'
+ }
+ error={this.confirmationConfigError()}
+ onClose={this.onCloseConfirmation}
+ open={this.state.deleteConfigConfirmDialog.open}
+ >
+
+ Are you sure you want to delete this NodeBalancer Configuration?
+
+
+
+ );
+ }
}
const preloaded = PromiseLoader({
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx
index 886043c757e..c951f4e1f8e 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerDetail.tsx
@@ -24,7 +24,7 @@ import { useGrants } from 'src/queries/profile/profile';
import { getErrorMap } from 'src/utilities/errorUtils';
import NodeBalancerConfigurations from './NodeBalancerConfigurations';
-import NodeBalancerSettings from './NodeBalancerSettings';
+import { NodeBalancerSettings } from './NodeBalancerSettings';
import { NodeBalancerSummary } from './NodeBalancerSummary/NodeBalancerSummary';
export const NodeBalancerDetail = () => {
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx
new file mode 100644
index 00000000000..2d6747ef8a3
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.test.tsx
@@ -0,0 +1,111 @@
+import * as React from 'react';
+
+import { firewallFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { NodeBalancerFirewalls } from './NodeBalancerFirewalls';
+
+const firewall = firewallFactory.build({ label: 'mock-firewall-1' });
+
+// Set up various mocks for tests
+
+const queryMocks = vi.hoisted(() => ({
+ useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/nodebalancers', async () => {
+ const actual = await vi.importActual('src/queries/nodebalancers');
+ return {
+ ...actual,
+ useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery,
+ };
+});
+
+const props = {
+ displayFirewallInfoText: false,
+ nodeBalancerId: 1,
+};
+
+describe('NodeBalancerFirewalls', () => {
+ beforeEach(() => {
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewall] },
+ isLoading: false,
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('renders the Firewall table', () => {
+ const { getByText } = renderWithTheme( );
+
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('Rules')).toBeVisible();
+ expect(getByText('mock-firewall-1')).toBeVisible();
+ expect(getByText('Enabled')).toBeVisible();
+ expect(getByText('1 Inbound / 1 Outbound')).toBeVisible();
+ expect(getByText('Unassign')).toBeVisible();
+ });
+
+ it('displays the FirewallInfo text', () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('Learn more about creating Firewalls.')).toBeVisible();
+ });
+
+ it('displays a loading placeholder', () => {
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewall] },
+ isLoading: true,
+ });
+ const { getByTestId, getByText } = renderWithTheme(
+
+ );
+
+ // headers still exist
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('Rules')).toBeVisible();
+
+ // table is loading
+ expect(getByTestId('table-row-loading')).toBeVisible();
+ });
+
+ it('displays an error for the firewall table', () => {
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewall] },
+ error: [{ reason: 'This is a firewall table error.' }],
+ isLoading: false,
+ });
+ const { getByText } = renderWithTheme( );
+
+ // headers still exist
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('Rules')).toBeVisible();
+
+ // error message displays if there was an error getting firewalls
+ expect(getByText('This is a firewall table error.')).toBeVisible();
+ });
+
+ it('shows that no firewalls are assigned', () => {
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [] },
+ isLoading: false,
+ });
+ const { getByText } = renderWithTheme( );
+
+ // headers still exist
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('Rules')).toBeVisible();
+
+ // no firewalls exist
+ expect(getByText('No Firewalls are assigned.')).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx
index 5b962fd53a6..a328bac072e 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewalls.tsx
@@ -1,5 +1,4 @@
/* eslint-disable jsx-a11y/anchor-is-valid */
-import { Firewall, FirewallDevice } from '@linode/api-v4';
import { Stack } from '@mui/material';
import * as React from 'react';
@@ -20,6 +19,8 @@ import { useNodeBalancersFirewallsQuery } from 'src/queries/nodebalancers';
import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow';
+import type { Firewall, FirewallDevice } from '@linode/api-v4';
+
interface Props {
displayFirewallInfoText: boolean;
nodeBalancerId: number;
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx
index 74507f315f6..7c92a160cd0 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsActionMenu.tsx
@@ -1,11 +1,12 @@
import * as React from 'react';
-import { Action } from 'src/components/ActionMenu/ActionMenu';
import { InlineMenuAction } from 'src/components/InlineMenuAction/InlineMenuAction';
import { noPermissionTooltipText } from 'src/features/Firewalls/FirewallLanding/FirewallActionMenu';
import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared';
import { useGrants, useProfile } from 'src/queries/profile/profile';
+import type { Action } from 'src/components/ActionMenu/ActionMenu';
+
interface Props {
firewallID: number;
onUnassign: () => void;
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx
new file mode 100644
index 00000000000..c102b00de25
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.test.tsx
@@ -0,0 +1,77 @@
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { firewallFactory } from 'src/factories';
+import { checkIfUserCanModifyFirewall } from 'src/features/Firewalls/shared';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { NodeBalancerFirewallsRow } from './NodeBalancerFirewallsRow';
+
+const firewall = firewallFactory.build({ label: 'mock-firewall-1' });
+
+// Set up various mocks for tests
+vi.mock('src/features/Firewalls/shared');
+
+const queryMocks = vi.hoisted(() => ({
+ useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/nodebalancers', async () => {
+ const actual = await vi.importActual('src/queries/nodebalancers');
+ return {
+ ...actual,
+ useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery,
+ };
+});
+
+const props = {
+ firewall,
+ nodeBalancerID: 1,
+ onClickUnassign: vi.fn(),
+};
+
+describe('NodeBalancerFirewallsRow', () => {
+ beforeEach(() => {
+ vi.mocked(checkIfUserCanModifyFirewall).mockReturnValue(true);
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewall] },
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('renders the NodeBalancerFirewallsRow', () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ expect(getByText('mock-firewall-1')).toBeVisible();
+ expect(getByText('Enabled')).toBeVisible();
+ expect(getByText('1 Inbound / 1 Outbound')).toBeVisible();
+ expect(getByText('Unassign')).toBeVisible();
+ });
+
+ it('unassigns the firewall', async () => {
+ const { getByText } = renderWithTheme(
+
+ );
+
+ const unassignButton = getByText('Unassign');
+ await userEvent.click(unassignButton);
+ expect(props.onClickUnassign).toHaveBeenCalled();
+ });
+
+ it('disables unassigning the firewall if user cannot modify firewall', async () => {
+ vi.mocked(checkIfUserCanModifyFirewall).mockReturnValue(false);
+ const { getByTestId } = renderWithTheme(
+
+ );
+
+ const unassignButton = getByTestId('Button');
+ expect(unassignButton).toBeDisabled();
+ await userEvent.click(unassignButton);
+ expect(props.onClickUnassign).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx
index b16129be7c1..d429cc56d3f 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerFirewallsRow.tsx
@@ -1,4 +1,3 @@
-import { Firewall, FirewallDevice } from '@linode/api-v4';
import * as React from 'react';
import { Link } from 'react-router-dom';
@@ -14,6 +13,8 @@ import { capitalize } from 'src/utilities/capitalize';
import { NodeBalancerFirewallsActionMenu } from './NodeBalancerFirewallsActionMenu';
+import type { Firewall, FirewallDevice } from '@linode/api-v4';
+
interface Props {
firewall: Firewall;
nodeBalancerID: number;
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx
new file mode 100644
index 00000000000..af1138e660b
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.test.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react';
+
+import { firewallFactory, nodeBalancerFactory } from 'src/factories';
+import { useIsResourceRestricted } from 'src/hooks/useIsResourceRestricted';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { NodeBalancerSettings } from './NodeBalancerSettings';
+
+// Set up various mocks for tests
+vi.mock('src/hooks/useIsResourceRestricted');
+
+const queryMocks = vi.hoisted(() => ({
+ useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }),
+ useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/nodebalancers', async () => {
+ const actual = await vi.importActual('src/queries/nodebalancers');
+ return {
+ ...actual,
+ useNodeBalancerQuery: queryMocks.useNodeBalancerQuery,
+ useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery,
+ };
+});
+
+const connectionThrottle = 'Connection Throttle';
+
+describe('NodeBalancerSettings', () => {
+ beforeEach(() => {
+ queryMocks.useNodeBalancerQuery.mockReturnValue({
+ data: nodeBalancerFactory.build(),
+ });
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] },
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('renders the NodeBalancerSettings component', () => {
+ const {
+ getAllByText,
+ getByLabelText,
+ getByTestId,
+ getByText,
+ } = renderWithTheme( );
+
+ // NodeBalancer Label panel
+ expect(getByText('NodeBalancer Label')).toBeVisible();
+ expect(getByText('Label')).toBeVisible();
+ expect(getByLabelText('Label')).not.toBeDisabled();
+
+ // Firewall panel
+ expect(getByText('Firewalls')).toBeVisible();
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('Status')).toBeVisible();
+ expect(getByText('Rules')).toBeVisible();
+ expect(getByText('mock-firewall-1')).toBeVisible();
+ expect(getByText('Enabled')).toBeVisible();
+ expect(getByText('1 Inbound / 1 Outbound')).toBeVisible();
+ expect(getByText('Unassign')).toBeVisible();
+
+ // Client Connection Throttle panel
+ expect(getByText('Client Connection Throttle')).toBeVisible();
+ expect(getByText(connectionThrottle)).toBeVisible();
+ expect(getByLabelText(connectionThrottle)).not.toBeDisabled();
+ expect(
+ getByText(
+ 'To help mitigate abuse, throttle connections from a single client IP to this number per second. 0 to disable.'
+ )
+ ).toBeVisible();
+ expect(getAllByText('Save')).toHaveLength(2);
+
+ // Delete panel
+ expect(getByText('Delete NodeBalancer')).toBeVisible();
+ expect(getByText('Delete')).toBeVisible();
+ expect(getByTestId('delete-nodebalancer')).not.toBeDisabled();
+ });
+
+ it('disables inputs and buttons if the Node Balancer is read only', () => {
+ vi.mocked(useIsResourceRestricted).mockReturnValue(true);
+ const { getByLabelText, getByTestId } = renderWithTheme(
+
+ );
+
+ expect(getByLabelText('Label')).toBeDisabled();
+ expect(getByLabelText(connectionThrottle)).toBeDisabled();
+ expect(getByTestId('delete-nodebalancer')).toBeDisabled();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx
index 9afca8890d6..2c3b66cc24f 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSettings.tsx
@@ -140,6 +140,7 @@ export const NodeBalancerSettings = () => {
setIsDeleteDialogOpen(true)}
>
@@ -155,5 +156,3 @@ export const NodeBalancerSettings = () => {
);
};
-
-export default NodeBalancerSettings;
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx
index c13926a5156..24a13313f92 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/NodeBalancerSummary.tsx
@@ -1,5 +1,5 @@
-import Grid from '@mui/material/Unstable_Grid2';
import { styled } from '@mui/material/styles';
+import Grid from '@mui/material/Unstable_Grid2';
import * as React from 'react';
import { useParams } from 'react-router-dom';
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx
new file mode 100644
index 00000000000..42fceb03770
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/SummaryPanel.test.tsx
@@ -0,0 +1,92 @@
+import * as React from 'react';
+
+import {
+ firewallFactory,
+ nodeBalancerConfigFactory,
+ nodeBalancerFactory,
+} from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { SummaryPanel } from './SummaryPanel';
+
+// Set up various mocks for tests
+const queryMocks = vi.hoisted(() => ({
+ useAllNodeBalancerConfigsQuery: vi.fn().mockReturnValue({ data: undefined }),
+ useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }),
+ useNodeBalancersFirewallsQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/nodebalancers', async () => {
+ const actual = await vi.importActual('src/queries/nodebalancers');
+ return {
+ ...actual,
+ useAllNodeBalancerConfigsQuery: queryMocks.useAllNodeBalancerConfigsQuery,
+ useNodeBalancerQuery: queryMocks.useNodeBalancerQuery,
+ useNodeBalancersFirewallsQuery: queryMocks.useNodeBalancersFirewallsQuery,
+ };
+});
+
+const nodeBalancerDetails = 'NodeBalancer Details';
+
+describe('SummaryPanel', () => {
+ beforeEach(() => {
+ queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({
+ data: nodeBalancerConfigFactory.buildList(2),
+ });
+ queryMocks.useNodeBalancerQuery.mockReturnValue({
+ data: nodeBalancerFactory.build(),
+ });
+ queryMocks.useNodeBalancersFirewallsQuery.mockReturnValue({
+ data: { data: [firewallFactory.build({ label: 'mock-firewall-1' })] },
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('does not render anything if there is no nodebalancer', () => {
+ queryMocks.useAllNodeBalancerConfigsQuery.mockReturnValue({
+ data: undefined,
+ });
+ const { queryByText } = renderWithTheme( );
+
+ expect(queryByText(nodeBalancerDetails)).not.toBeInTheDocument();
+ });
+
+ it('does not render anything if there are no configs', () => {
+ queryMocks.useNodeBalancerQuery.mockReturnValue({
+ data: undefined,
+ });
+ const { queryByText } = renderWithTheme( );
+
+ expect(queryByText(nodeBalancerDetails)).not.toBeInTheDocument();
+ });
+
+ it('renders the panel if there is data to render', () => {
+ const { getByText } = renderWithTheme( );
+
+ // Main summary panel
+ expect(getByText(nodeBalancerDetails)).toBeVisible();
+ expect(getByText('Ports:')).toBeVisible();
+ expect(getByText('Backend Status:')).toBeVisible();
+ expect(getByText('0 up, 2 down'));
+ expect(getByText('Transferred:')).toBeVisible();
+ expect(getByText('0 bytes')).toBeVisible();
+ expect(getByText('Host Name:')).toBeVisible();
+ expect(getByText('example.com')).toBeVisible();
+ expect(getByText('Region:')).toBeVisible();
+
+ // Firewall panel
+ expect(getByText('Firewall')).toBeVisible();
+ expect(getByText('mock-firewall-1')).toBeVisible();
+
+ // IP Address panel
+ expect(getByText('IP Addresses')).toBeVisible();
+ expect(getByText('0.0.0.0')).toBeVisible();
+
+ // Tags panel
+ expect(getByText('Tags')).toBeVisible();
+ expect(getByText('Add a tag')).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx
new file mode 100644
index 00000000000..2aadf119823
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerDetail/NodeBalancerSummary/TablesPanel.test.tsx
@@ -0,0 +1,126 @@
+/* eslint-disable @typescript-eslint/no-empty-function */
+import * as React from 'react';
+
+import { nodeBalancerFactory } from 'src/factories';
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { TablesPanel } from './TablesPanel';
+
+// Set up various mocks for tests
+const queryMocks = vi.hoisted(() => ({
+ useNodeBalancerQuery: vi.fn().mockReturnValue({ data: undefined }),
+ useNodeBalancerStatsQuery: vi.fn().mockReturnValue({ data: undefined }),
+}));
+
+vi.mock('src/queries/nodebalancers', async () => {
+ const actual = await vi.importActual('src/queries/nodebalancers');
+ return {
+ ...actual,
+ useNodeBalancerQuery: queryMocks.useNodeBalancerQuery,
+ useNodeBalancerStatsQuery: queryMocks.useNodeBalancerStatsQuery,
+ };
+});
+
+const connectionGraphHeader = 'Connections (CXN/s, 5 min avg.)';
+const trafficGraphHeader = 'Traffic (bits/s, 5 min avg.)';
+
+class ResizeObserver {
+ disconnect() {}
+ observe() {}
+ unobserve() {}
+}
+
+describe('TablesPanel', () => {
+ beforeEach(() => {
+ queryMocks.useNodeBalancerQuery.mockReturnValue({
+ data: nodeBalancerFactory.build(),
+ });
+ });
+
+ afterEach(() => {
+ vi.resetAllMocks();
+ });
+
+ it('shows when the Node Balancer stats are not yet ready', () => {
+ queryMocks.useNodeBalancerStatsQuery.mockReturnValue({
+ data: undefined,
+ error: [{ reason: 'Stats are unavailable at this time.' }],
+ isLoading: true,
+ });
+ const { getByText } = renderWithTheme( );
+
+ // Headers show regardless of which state the charts are in
+ expect(getByText('Graphs')).toBeVisible();
+ expect(getByText(connectionGraphHeader)).toBeVisible();
+ expect(getByText(trafficGraphHeader)).toBeVisible();
+
+ // Stats availability message displays in place of graphs
+ expect(
+ getByText('Connection stats will be available shortly')
+ ).toBeVisible();
+ expect(getByText('Traffic stats will be available shortly')).toBeVisible();
+ });
+
+ it('shows the error state for the charts', () => {
+ queryMocks.useNodeBalancerStatsQuery.mockReturnValue({
+ data: undefined,
+ error: [{ reason: 'Not found.' }],
+ isLoading: true,
+ });
+ const { getAllByText, getByText } = renderWithTheme( );
+
+ // Headers show regardless of which state the charts are in
+ expect(getByText('Graphs')).toBeVisible();
+ expect(getByText(connectionGraphHeader)).toBeVisible();
+ expect(getByText(trafficGraphHeader)).toBeVisible();
+
+ // Error message shows in place of graphs
+ expect(getAllByText('Not found.')).toHaveLength(2);
+ });
+
+ it('returns the loading state for the charts', () => {
+ queryMocks.useNodeBalancerStatsQuery.mockReturnValue({
+ data: undefined,
+ error: undefined,
+ isLoading: true,
+ });
+ const { getAllByTestId, getByText } = renderWithTheme( );
+
+ // Headers show regardless of which state the charts are in
+ expect(getByText('Graphs')).toBeVisible();
+ expect(getByText(connectionGraphHeader)).toBeVisible();
+ expect(getByText(trafficGraphHeader)).toBeVisible();
+
+ // Confirm loading state exists in place of graphs
+ expect(getAllByTestId('circle-progress')).toHaveLength(2);
+ });
+
+ it('renders the Node Balancer stats and traffic', () => {
+ window.ResizeObserver = ResizeObserver;
+ queryMocks.useNodeBalancerStatsQuery.mockReturnValue({
+ data: {
+ data: {
+ connections: [],
+ traffic: {
+ in: [],
+ out: [],
+ },
+ },
+ title: 'NodeBalancer stats',
+ },
+ error: undefined,
+ isLoading: false,
+ });
+ const { getByText } = renderWithTheme( );
+
+ // Headers show regardless of which state the charts are in
+ expect(getByText('Graphs')).toBeVisible();
+ expect(getByText(connectionGraphHeader)).toBeVisible();
+ expect(getByText(trafficGraphHeader)).toBeVisible();
+
+ // stats and traffic show
+ expect(getByText('Connections')).toBeVisible();
+ expect(getByText('Traffic In')).toBeVisible();
+ expect(getByText('Traffic Out')).toBeVisible();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.test.tsx
new file mode 100644
index 00000000000..e08065e7029
--- /dev/null
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerPassiveCheck.test.tsx
@@ -0,0 +1,36 @@
+import userEvent from '@testing-library/user-event';
+import * as React from 'react';
+
+import { renderWithTheme } from 'src/utilities/testHelpers';
+
+import { nbConfigPanelMockPropsForTest } from './NodeBalancerConfigPanel.test';
+import { PassiveCheck } from './NodeBalancerPassiveCheck';
+
+describe('NodeBalancer PassiveCheck', () => {
+ it('renders the passive check', () => {
+ const { getAllByText, getByText } = renderWithTheme(
+
+ );
+
+ expect(getAllByText('Passive Checks')).toHaveLength(2);
+ expect(
+ getByText(
+ 'Enable passive checks based on observing communication with back-end nodes.'
+ )
+ ).toBeVisible();
+ });
+
+ it('calls onCheckPassiveChange when the check is toggled', async () => {
+ const { getByLabelText } = renderWithTheme(
+
+ );
+
+ const passiveChecksToggle = getByLabelText('Passive Checks');
+ expect(passiveChecksToggle).toBeInTheDocument();
+
+ await userEvent.click(passiveChecksToggle);
+ expect(
+ nbConfigPanelMockPropsForTest.onCheckPassiveChange
+ ).toHaveBeenCalled();
+ });
+});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx
index 1c55588a837..4a3d494c358 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancerSelect.test.tsx
@@ -1,4 +1,3 @@
-import { NodeBalancer } from '@linode/api-v4';
import { screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
@@ -8,6 +7,8 @@ import { renderWithTheme } from 'src/utilities/testHelpers';
import { NodeBalancerSelect } from './NodeBalancerSelect';
+import type { NodeBalancer } from '@linode/api-v4';
+
const fakeNodeBalancerData = nodeBalancerFactory.build({
id: 1,
label: 'metadata-test-region',
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx
index ade67435282..e950688d3d4 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerActionMenu.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { renderWithTheme } from 'src/utilities/testHelpers';
@@ -26,13 +26,13 @@ describe('NodeBalancerActionMenu', () => {
expect(getByText('Delete')).toBeVisible();
});
- it('triggers the action to delete the NodeBalancer', () => {
+ it('triggers the action to delete the NodeBalancer', async () => {
const { getByText } = renderWithTheme(
);
const deleteButton = getByText('Delete');
- fireEvent.click(deleteButton);
+ await userEvent.click(deleteButton);
expect(props.toggleDialog).toHaveBeenCalled();
});
});
diff --git a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx
index 80df645c989..42d3acaad73 100644
--- a/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx
+++ b/packages/manager/src/features/NodeBalancers/NodeBalancersLanding/NodeBalancerTableRow.test.tsx
@@ -1,4 +1,4 @@
-import { fireEvent } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { nodeBalancerFactory } from 'src/factories';
@@ -42,20 +42,20 @@ describe('NodeBalancerTableRow', () => {
expect(getByText('us-east')).toBeVisible();
});
- it('deletes the NodeBalancer', () => {
+ it('deletes the NodeBalancer', async () => {
const { getByText } = renderWithTheme( );
const deleteButton = getByText('Delete');
- fireEvent.click(deleteButton);
+ await userEvent.click(deleteButton);
expect(props.onDelete).toHaveBeenCalled();
});
- it('does not delete the NodeBalancer if the delete button is disabled', () => {
+ it('does not delete the NodeBalancer if the delete button is disabled', async () => {
vi.mocked(useIsResourceRestricted).mockReturnValue(true);
const { getByText } = renderWithTheme( );
const deleteButton = getByText('Delete');
- fireEvent.click(deleteButton);
+ await userEvent.click(deleteButton);
expect(props.onDelete).not.toHaveBeenCalled();
});
});
diff --git a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
index fd0a62cdb70..9940a8527d7 100644
--- a/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
+++ b/packages/manager/src/features/NotificationCenter/Events/NotificationCenterEvent.tsx
@@ -3,7 +3,6 @@ import * as React from 'react';
import { BarPercent } from 'src/components/BarPercent';
import { Box } from 'src/components/Box';
-import { GravatarOrAvatar } from 'src/components/GravatarOrAvatar';
import { Typography } from 'src/components/Typography';
import {
formatProgressEvent,
@@ -14,7 +13,6 @@ import { useProfile } from 'src/queries/profile/profile';
import {
NotificationEventAvatar,
- NotificationEventGravatar,
NotificationEventStyledBox,
notificationEventStyles,
} from '../NotificationCenter.styles';
@@ -55,21 +53,15 @@ export const NotificationCenterEvent = React.memo(
data-qa-event-seen={event.seen}
data-testid={event.action}
>
-
+ }
- height={32}
- width={32}
+ username={username}
/>
+
{message}
{showProgress && (
diff --git a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
index 620151a4769..6820ce92683 100644
--- a/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
+++ b/packages/manager/src/features/NotificationCenter/NotificationCenter.styles.ts
@@ -4,10 +4,8 @@ import { makeStyles } from 'tss-react/mui';
import { Avatar } from 'src/components/Avatar/Avatar';
import { Box } from 'src/components/Box';
-import { GravatarByUsername } from 'src/components/GravatarByUsername';
import { Link } from 'src/components/Link';
import { Typography } from 'src/components/Typography';
-import { fadeIn } from 'src/styles/keyframes';
import { omittedProps } from 'src/utilities/omittedProps';
import type { NotificationCenterNotificationMessageProps } from './types';
@@ -120,16 +118,6 @@ export const NotificationEventStyledBox = styled(Box, {
width: '100%',
}));
-export const NotificationEventGravatar = styled(GravatarByUsername, {
- label: 'StyledGravatarByUsername',
-})(() => ({
- animation: `${fadeIn} .2s ease-in-out forwards`,
- height: 32,
- marginTop: 2,
- minWidth: 32,
- width: 32,
-}));
-
export const NotificationEventAvatar = styled(Avatar, {
label: 'StyledAvatar',
})(() => ({
diff --git a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx
index a72be5b389b..ec2f91870a0 100644
--- a/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx
+++ b/packages/manager/src/features/ObjectStorage/BucketDetail/BucketDetail.tsx
@@ -1,5 +1,5 @@
import { getObjectList, getObjectURL } from '@linode/api-v4/lib/object-storage';
-import { useQueryClient } from '@tanstack/react-query';
+import { InfiniteData, useQueryClient } from '@tanstack/react-query';
import produce from 'immer';
import { useSnackbar } from 'notistack';
import * as React from 'react';
@@ -21,14 +21,12 @@ import { OBJECT_STORAGE_DELIMITER } from 'src/constants';
import { useFlags } from 'src/hooks/useFlags';
import { useAccount } from 'src/queries/account/account';
import {
+ getObjectBucketObjectsQueryKey,
objectStorageQueries,
useObjectBucketObjectsInfiniteQuery,
useObjectStorageBuckets,
} from 'src/queries/object-storage/queries';
-import {
- fetchBucketAndUpdateCache,
- prefixToQueryKey,
-} from 'src/queries/object-storage/utilities';
+import { fetchBucketAndUpdateCache } from 'src/queries/object-storage/utilities';
import { isFeatureEnabledV2 } from 'src/utilities/accountCapabilities';
import { sendDownloadObjectEvent } from 'src/utilities/analytics/customEventAnalytics';
import { getQueryParamFromQueryString } from 'src/utilities/queryParams';
@@ -237,11 +235,7 @@ export const BucketDetail = (props: Props) => {
pageParams: string[];
pages: ObjectStorageObjectList[];
}>(
- [
- ...objectStorageQueries.bucket(clusterId, bucketName)._ctx.objects
- .queryKey,
- ...prefixToQueryKey(prefix),
- ],
+ getObjectBucketObjectsQueryKey(clusterId, bucketName, prefix),
(data) => ({
pageParams: data?.pageParams || [],
pages,
@@ -279,7 +273,11 @@ export const BucketDetail = (props: Props) => {
};
const addOneFile = (objectName: string, sizeInBytes: number) => {
- if (!data) {
+ const currentData = queryClient.getQueryData<
+ InfiniteData
+ >(getObjectBucketObjectsQueryKey(clusterId, bucketName, prefix));
+
+ if (!currentData) {
return;
}
@@ -291,13 +289,13 @@ export const BucketDetail = (props: Props) => {
size: sizeInBytes,
};
- for (let i = 0; i < data.pages.length; i++) {
- const foundObjectIndex = data.pages[i].data.findIndex(
+ for (let i = 0; i < currentData.pages.length; i++) {
+ const foundObjectIndex = currentData.pages[i].data.findIndex(
(_object) => _object.name === object.name
);
if (foundObjectIndex !== -1) {
- const copy = [...data.pages];
- const pageCopy = [...data.pages[i].data];
+ const copy = [...currentData.pages];
+ const pageCopy = [...currentData.pages[i].data];
pageCopy[foundObjectIndex] = object;
@@ -309,7 +307,7 @@ export const BucketDetail = (props: Props) => {
}
}
- const copy = [...data.pages];
+ const copy = [...currentData.pages];
const dataCopy = [...copy[copy.length - 1].data];
dataCopy.push(object);
@@ -322,7 +320,11 @@ export const BucketDetail = (props: Props) => {
};
const addOneFolder = (objectName: string) => {
- if (!data) {
+ const currentData = queryClient.getQueryData<
+ InfiniteData
+ >(getObjectBucketObjectsQueryKey(clusterId, bucketName, prefix));
+
+ if (!currentData) {
return;
}
@@ -334,7 +336,7 @@ export const BucketDetail = (props: Props) => {
size: null,
};
- for (const page of data.pages) {
+ for (const page of currentData.pages) {
if (page.data.find((object) => object.name === folder.name)) {
// If a folder already exists in the store, invalidate that store for that specific
// prefix. Due to how invalidateQueries works, all subdirectories also get invalidated.
@@ -349,7 +351,7 @@ export const BucketDetail = (props: Props) => {
}
}
- const copy = [...data.pages];
+ const copy = [...currentData.pages];
const dataCopy = [...copy[copy.length - 1].data];
dataCopy.push(folder);
diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.test.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.test.tsx
deleted file mode 100644
index 193e20874c8..00000000000
--- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.test.tsx
+++ /dev/null
@@ -1,46 +0,0 @@
-import * as React from 'react';
-
-import { renderWithTheme } from 'src/utilities/testHelpers';
-
-import AppDetailDrawer from './AppDetailDrawer';
-
-describe('AppDetailDrawer component', () => {
- it("should have a title ending in 'Cluster' if the app is a cluster", () => {
- const { queryByTestId } = renderWithTheme(
- {}}
- open={true}
- stackScriptLabel="Galera Cluster "
- />
- );
-
- const innerHTML = queryByTestId('app-name')?.innerHTML;
- expect(innerHTML).toBe('Galera Cluster');
- });
-
- it("should not have a title ending in 'Cluster' if the app is not a cluster", () => {
- const { queryByTestId } = renderWithTheme(
- {}}
- open={true}
- stackScriptLabel="Docker "
- />
- );
-
- const innerHTML = queryByTestId('app-name')?.innerHTML;
- expect(innerHTML).toBe('Docker');
- });
-
- it('should not logically break if the stackScriptLabel for a cluster does not have the expected spaces', () => {
- const { queryByTestId } = renderWithTheme(
- {}}
- open={true}
- stackScriptLabel="Galera Cluster"
- />
- );
-
- const innerHTML = queryByTestId('app-name')?.innerHTML;
- expect(innerHTML).toBe('Galera Cluster');
- });
-});
diff --git a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx b/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx
deleted file mode 100644
index fab152f1a4d..00000000000
--- a/packages/manager/src/features/OneClickApps/AppDetailDrawer.tsx
+++ /dev/null
@@ -1,258 +0,0 @@
-import Close from '@mui/icons-material/Close';
-import Drawer from '@mui/material/Drawer';
-import { Theme } from '@mui/material/styles';
-import { makeStyles } from 'tss-react/mui';
-import * as React from 'react';
-
-import { Box } from 'src/components/Box';
-import { Button } from 'src/components/Button/Button';
-import { Link } from 'src/components/Link';
-import { Typography } from 'src/components/Typography';
-import { useFlags } from 'src/hooks/useFlags';
-import { sanitizeHTML } from 'src/utilities/sanitizeHTML';
-
-import { oneClickApps } from './oneClickApps';
-import { mapStackScriptLabelToOCA } from './utils';
-
-import type { OCA } from './types';
-
-const useStyles = makeStyles()((theme: Theme) => ({
- appName: {
- color: '#fff !important',
- fontFamily: theme.font.bold,
- fontSize: '2.2rem',
- lineHeight: '2.5rem',
- textAlign: 'center',
- },
- button: {
- '& :hover, & :focus, & :active': {
- backgroundColor: 'unset !important',
- color: 'white',
- },
- backgroundColor: 'unset !important',
- borderRadius: '50%',
- color: 'white',
- margin: theme.spacing(2),
- minHeight: 'auto',
- minWidth: 'auto',
- padding: theme.spacing(2),
- position: 'absolute',
- },
- container: {
- display: 'flex',
- flexDirection: 'column',
- gap: theme.spacing(2),
- padding: theme.spacing(4),
- },
- description: {
- lineHeight: 1.5,
- marginBottom: theme.spacing(2),
- marginTop: theme.spacing(2),
- },
- image: {
- width: 50,
- },
- link: {
- fontSize: '0.875rem',
- lineHeight: '24px',
- wordBreak: 'break-word',
- },
- logoContainer: {
- gap: theme.spacing(),
- height: 225,
- padding: theme.spacing(2),
- },
- paper: {
- [theme.breakpoints.up('sm')]: {
- width: 480,
- },
- },
- wrapAppName: {
- maxWidth: 'fit-content',
- minWidth: 170,
- },
- wrapLogo: {
- marginLeft: `-${theme.spacing(3)}`,
- },
-}));
-
-interface Props {
- onClose: () => void;
- open: boolean;
- stackScriptLabel: string;
-}
-
-export const AppDetailDrawer: React.FunctionComponent = (props) => {
- const { onClose, open, stackScriptLabel } = props;
- const { classes } = useStyles();
- const { oneClickAppsDocsOverride } = useFlags();
-
- const [selectedApp, setSelectedApp] = React.useState(null);
-
- const gradient = {
- backgroundImage: `url(/assets/marketplace-background.png),linear-gradient(to right, #${selectedApp?.colors.start}, #${selectedApp?.colors.end})`,
- };
-
- React.useEffect(() => {
- const app = mapStackScriptLabelToOCA({
- oneClickApps,
- stackScriptLabel,
- });
-
- if (!app) {
- return;
- }
-
- setSelectedApp(app);
-
- return () => {
- setSelectedApp(null);
- };
- }, [stackScriptLabel]);
-
- return (
-
-
-
-
-
-
- {selectedApp ? (
- <>
-
-
-
-
-
-
- {selectedApp.summary}
-
-
- {selectedApp.website ? (
-
- Website
-
- {selectedApp.website}
-
-
- ) : null}
- {selectedApp.related_guides ? (
-
- Guides
-
- {(
- oneClickAppsDocsOverride?.[selectedApp.name] ??
- selectedApp.related_guides
- ).map((link, idx) => (
-
- {sanitizeHTML({
- sanitizingTier: 'flexible',
- text: link.title,
- })}
-
- ))}
-
-
- ) : null}
- {selectedApp.tips ? (
-
- Tips
-
- {selectedApp.tips.map((tip, idx) => (
-
- {tip}
-
- ))}
-
-
- ) : null}
-
- >
- ) : (
-
- App Details Not Found
-
- We were unable to load the details of this app.
-
-
- Exit
-
-
- )}
-
- );
-};
-
-// remove this when we make the svgs white via css
-const REUSE_WHITE_ICONS = {
- 'mongodbmarketplaceocc.svg': 'mongodb.svg',
- 'postgresqlmarketplaceocc.svg': 'postgresql.svg',
- 'redissentinelmarketplaceocc.svg': 'redis.svg',
-};
-
-export default AppDetailDrawer;
diff --git a/packages/manager/src/features/OneClickApps/index.tsx b/packages/manager/src/features/OneClickApps/index.tsx
deleted file mode 100644
index d910f9ab161..00000000000
--- a/packages/manager/src/features/OneClickApps/index.tsx
+++ /dev/null
@@ -1 +0,0 @@
-export { default as AppDetailDrawer } from './AppDetailDrawer';
diff --git a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
index 4dbb5d87fc9..61094ec2475 100644
--- a/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
+++ b/packages/manager/src/features/OneClickApps/oneClickAppsv2.ts
@@ -1,5 +1,3 @@
-import { oneClickAppFactory } from 'src/factories/stackscripts';
-
import type { OCA } from './types';
/**
@@ -9,10 +7,6 @@ import type { OCA } from './types';
* for it to be visible to users.
*/
export const oneClickApps: Record = {
- 0: oneClickAppFactory.build({
- isNew: true,
- name: 'E2E Test App',
- }),
401697: {
alt_description: 'Popular website content management system.',
alt_name: 'CMS: content management system',
@@ -2606,7 +2600,7 @@ export const oneClickApps: Record = {
related_guides: [
{
href:
- 'https://www.linode.com/docs/marketplace-docs/guides/apache-spark/',
+ 'https://www.linode.com/docs/marketplace-docs/guides/apache-spark-cluster/',
title: 'Deploy Apache Spark through the Linode Marketplace',
},
],
diff --git a/packages/manager/src/features/OneClickApps/utils.test.ts b/packages/manager/src/features/OneClickApps/utils.test.ts
deleted file mode 100644
index 1f9809436f6..00000000000
--- a/packages/manager/src/features/OneClickApps/utils.test.ts
+++ /dev/null
@@ -1,38 +0,0 @@
-import { oneClickAppFactory } from 'src/factories/stackscripts';
-
-import { mapStackScriptLabelToOCA } from './utils';
-
-describe('mapStackScriptLabelToOneClickAppName', () => {
- const onClickApp = oneClickAppFactory.build();
-
- it('should return undefined if no match is found', () => {
- const result = mapStackScriptLabelToOCA({
- oneClickApps: [],
- stackScriptLabel: '',
- });
-
- expect(result).toBeUndefined();
- });
-
- it('should return the matching app', () => {
- const result = mapStackScriptLabelToOCA({
- oneClickApps: [onClickApp],
- stackScriptLabel: 'Test App',
- });
-
- expect(result).toBeDefined();
- });
-
- it('should return the matching app when the StackScript label contains unexpected characters', () => {
- const onClickAppWithUnexpectedCharacters = oneClickAppFactory.build({
- name: 'Test @App ®',
- });
-
- const result = mapStackScriptLabelToOCA({
- oneClickApps: [onClickAppWithUnexpectedCharacters],
- stackScriptLabel: 'Test App',
- });
-
- expect(result).toBeDefined();
- });
-});
diff --git a/packages/manager/src/features/OneClickApps/utils.ts b/packages/manager/src/features/OneClickApps/utils.ts
deleted file mode 100644
index 302ad6cd953..00000000000
--- a/packages/manager/src/features/OneClickApps/utils.ts
+++ /dev/null
@@ -1,32 +0,0 @@
-import type { OCA } from './types';
-
-const OCA_MAPPING_REGEX = /[^A-Za-z0-9\s\/$*+\-?&.:()]/g;
-
-interface Options {
- oneClickApps: OCA[];
- stackScriptLabel: string;
-}
-
-/**
- * Given a StackScript label, return the corresponding One-Click App name
- * @param oneClickApps
- * @param stackScriptLabel
- * @returns {string}
- */
-export const mapStackScriptLabelToOCA = ({
- oneClickApps,
- stackScriptLabel,
-}: Options): OCA | undefined => {
- return oneClickApps.find((app) => {
- const cleanedStackScriptLabel = stackScriptLabel
- .replace(OCA_MAPPING_REGEX, '')
- .trim();
-
- const cleanedAppName = app.name
- .replace('®', '')
- .replace(OCA_MAPPING_REGEX, '')
- .trim();
-
- return cleanedStackScriptLabel === cleanedAppName;
- });
-};
diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx
index 124ffd92732..96de8bb2306 100644
--- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx
+++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.test.tsx
@@ -32,16 +32,16 @@ const props = {
describe('Create API Token Drawer', () => {
it('checks API Token Drawer rendering', () => {
- const { getByTestId, getByText } = renderWithTheme(
+ const { getAllByTestId, getByTestId, getByText } = renderWithTheme(
);
const drawerTitle = getByText('Add Personal Access Token');
expect(drawerTitle).toBeVisible();
const labelTitle = getByText(/Label/);
- const labelField = getByTestId('textfield-input');
+ const labelField = getAllByTestId('textfield-input');
expect(labelTitle).toBeVisible();
- expect(labelField).toBeEnabled();
+ expect(labelField[0]).toBeEnabled();
const expiry = getByText(/Expiry/);
expect(expiry).toBeVisible();
@@ -67,12 +67,12 @@ describe('Create API Token Drawer', () => {
})
);
- const { getByLabelText, getByTestId, getByText } = renderWithTheme(
+ const { getAllByTestId, getByLabelText, getByText } = renderWithTheme(
);
- const labelField = getByTestId('textfield-input');
- await userEvent.type(labelField, 'my-test-token');
+ const labelField = getAllByTestId('textfield-input');
+ await userEvent.type(labelField[0], 'my-test-token');
const selectAllNoAccessPermRadioButton = getByLabelText(
'Select no access for all'
@@ -110,8 +110,10 @@ describe('Create API Token Drawer', () => {
});
it('Should default to 6 months for expiration', () => {
- const { getByText } = renderWithTheme( );
- getByText('In 6 months');
+ const { getAllByRole } = renderWithTheme(
+
+ );
+ expect(getAllByRole('combobox')[0]).toHaveDisplayValue('In 6 months');
});
it('Should show the Child Account Access scope for a parent user account with the parent/child feature flag on', () => {
diff --git a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx
index bf6eab61363..2156c1524fc 100644
--- a/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx
+++ b/packages/manager/src/features/Profile/APITokens/CreateAPITokenDrawer.tsx
@@ -3,8 +3,8 @@ import { DateTime } from 'luxon';
import * as React from 'react';
import { ActionsPanel } from 'src/components/ActionsPanel/ActionsPanel';
+import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { Drawer } from 'src/components/Drawer';
-import Select, { Item } from 'src/components/EnhancedSelect/Select';
import { FormControl } from 'src/components/FormControl';
import { FormHelperText } from 'src/components/FormHelperText';
import { Notice } from 'src/components/Notice/Notice';
@@ -30,7 +30,6 @@ import {
StyledSelectCell,
} from './APITokenDrawer.styles';
import {
- Permission,
allScopesAreTheSame,
basePermNameMap,
hasAccessBeenSelectedForAllScopes,
@@ -38,6 +37,8 @@ import {
scopeStringToPermTuples,
} from './utils';
+import type { Permission } from './utils';
+
type Expiry = [string, string];
export const genExpiryTups = (): Expiry[] => {
@@ -172,10 +173,6 @@ export const CreateAPITokenDrawer = (props: Props) => {
form.setFieldValue('scopes', newScopes);
};
- const handleExpiryChange = (e: Item) => {
- form.setFieldValue('expiry', e.value);
- };
-
// Permission scopes with a different default when Selecting All for the specified access level.
const excludedScopesFromSelectAll: ExcludedScope[] = [
{
@@ -214,11 +211,30 @@ export const CreateAPITokenDrawer = (props: Props) => {
value={form.values.label}
/>
-
+ form.setFieldValue('expiry', selected.value)
+ }
+ slotProps={{
+ popper: {
+ sx: {
+ '&& .MuiAutocomplete-listbox': {
+ padding: 0,
+ },
+ },
+ },
+ }}
+ sx={{
+ '&& .MuiAutocomplete-inputRoot': {
+ paddingLeft: 1,
+ paddingRight: 0,
+ },
+ '&& .MuiInput-input': {
+ padding: '0px 2px',
+ },
+ }}
+ disableClearable
label="Expiry"
- name="expiry"
- onChange={handleExpiryChange}
options={expiryList}
value={expiryList.find((item) => item.value === form.values.expiry)}
/>
diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts
index 4a05ca75936..d6caaa14b50 100644
--- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts
+++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.styles.ts
@@ -1,10 +1,11 @@
import { styled } from '@mui/material/styles';
+import { Autocomplete } from 'src/components/Autocomplete/Autocomplete';
import { Box } from 'src/components/Box';
-import Select from 'src/components/EnhancedSelect/Select';
+import { FormHelperText } from 'src/components/FormHelperText';
import { TextField } from 'src/components/TextField';
import { Typography } from 'src/components/Typography';
-import { FormHelperText } from 'src/components/FormHelperText';
+import { omittedProps } from 'src/utilities/omittedProps';
export const StyledCodeSentMessageBox = styled(Box, {
label: 'StyledCodeSentMessageBox',
@@ -19,18 +20,22 @@ export const StyledPhoneNumberTitle = styled(Typography, {
marginTop: theme.spacing(1.5),
}));
-export const StyledButtonContainer = styled(Box, {
- label: 'StyledButtonContainer',
+export const StyledLabel = styled(Typography, {
+ label: 'StyledLabel',
})(({ theme }) => ({
- gap: theme.spacing(),
- [theme.breakpoints.down('md')]: {
- marginTop: theme.spacing(2),
- },
+ color: theme.name === 'light' ? '#555' : '#c9cacb',
+ fontSize: '.875rem',
+ lineHeight: '1',
+ marginBottom: '8px',
+ marginTop: theme.spacing(2),
+ padding: 0,
}));
export const StyledInputContainer = styled(Box, {
label: 'StyledInputContainer',
+ shouldForwardProp: omittedProps(['isPhoneInputFocused']),
})<{ isPhoneInputFocused: boolean }>(({ isPhoneInputFocused, theme }) => ({
+ backgroundColor: theme.name === 'dark' ? '#343438' : undefined,
border: theme.name === 'light' ? '1px solid #ccc' : '1px solid #222',
transition: 'border-color 225ms ease-in-out',
width: 'fit-content',
@@ -46,28 +51,31 @@ export const StyledInputContainer = styled(Box, {
})),
}));
-export const StyledPhoneNumberInput = styled(TextField, {
- label: 'StyledPhoneNumberInput',
-})(() => ({
- '&.Mui-focused': {
+export const StyledISOCodeSelect = styled(Autocomplete, {
+ label: 'StyledISOCodeSelect',
+})(({ theme }) => ({
+ '& div.Mui-focused': {
borderColor: 'unset',
boxShadow: 'none',
},
+ '& div.MuiAutocomplete-inputRoot': {
+ border: 'unset',
+ },
+ '&& .MuiInputBase-root svg': {
+ color: `${theme.palette.primary.main}`,
+ opacity: '1',
+ },
'&:focus': {
borderColor: 'unset',
boxShadow: 'unset',
},
- border: 'unset',
- minWidth: '300px',
+ height: '34px',
+ width: '70px !important',
}));
-export const StyledSelect = styled(Select, {
- label: 'StyledSelect',
-})(({ theme }) => ({
- '& .MuiInputBase-input .react-select__indicators svg': {
- color: `${theme.palette.primary.main} !important`,
- opacity: '1 !important',
- },
+export const StyledPhoneNumberInput = styled(TextField, {
+ label: 'StyledPhoneNumberInput',
+})(() => ({
'&.Mui-focused': {
borderColor: 'unset',
boxShadow: 'none',
@@ -77,19 +85,7 @@ export const StyledSelect = styled(Select, {
boxShadow: 'unset',
},
border: 'unset',
- height: '34px',
- width: '70px !important',
-}));
-
-export const StyledLabel = styled(Typography, {
- label: 'StyledLabel',
-})(({ theme }) => ({
- color: theme.name === 'light' ? '#555' : '#c9cacb',
- fontSize: '.875rem',
- lineHeight: '1',
- marginBottom: '8px',
- marginTop: theme.spacing(2),
- padding: 0,
+ minWidth: '300px',
}));
export const StyledFormHelperText = styled(FormHelperText, {
@@ -102,3 +98,12 @@ export const StyledFormHelperText = styled(FormHelperText, {
top: 42,
width: '100%',
}));
+
+export const StyledButtonContainer = styled(Box, {
+ label: 'StyledButtonContainer',
+})(({ theme }) => ({
+ gap: theme.spacing(),
+ [theme.breakpoints.down('md')]: {
+ marginTop: theme.spacing(2),
+ },
+}));
diff --git a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
index c22efc95617..c0d5334bc02 100644
--- a/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
+++ b/packages/manager/src/features/Profile/AuthenticationSettings/PhoneVerification/PhoneVerification.tsx
@@ -1,9 +1,8 @@
-import { APIError } from '@linode/api-v4/lib/types';
+import { useQueryClient } from '@tanstack/react-query';
import { useFormik } from 'formik';
-import { CountryCode, parsePhoneNumber } from 'libphonenumber-js';
+import { parsePhoneNumber } from 'libphonenumber-js';
import { useSnackbar } from 'notistack';
import * as React from 'react';
-import { useQueryClient } from '@tanstack/react-query';
import { Box } from 'src/components/Box';
import { Button } from 'src/components/Button/Button';
@@ -19,24 +18,30 @@ import {
useVerifyPhoneVerificationCodeMutation,
} from 'src/queries/profile/profile';
+import { countries } from './countries';
+import { getCountryFlag, getCountryName, getFormattedNumber } from './helpers';
import {
StyledButtonContainer,
StyledCodeSentMessageBox,
StyledFormHelperText,
+ StyledISOCodeSelect,
StyledInputContainer,
StyledLabel,
StyledPhoneNumberInput,
StyledPhoneNumberTitle,
- StyledSelect,
} from './PhoneVerification.styles';
-import { countries } from './countries';
-import { getCountryFlag, getCountryName, getFormattedNumber } from './helpers';
import type {
SendPhoneVerificationCodePayload,
VerifyVerificationCodePayload,
} from '@linode/api-v4/lib/profile/types';
-import type { Item } from 'src/components/EnhancedSelect/Select';
+import type { APIError } from '@linode/api-v4/lib/types';
+import type { CountryCode } from 'libphonenumber-js';
+
+export interface SelectPhoneVerificationOption {
+ label: string;
+ value: string;
+}
export const PhoneVerification = ({
phoneNumberRef,
@@ -67,18 +72,22 @@ export const PhoneVerification = ({
mutateAsync: sendPhoneVerificationCode,
reset: resetSendCodeMutation,
} = useSendPhoneVerificationCodeMutation();
+
const {
error: verifyError,
mutateAsync: sendVerificationCode,
reset: resetCodeMutation,
} = useVerifyPhoneVerificationCodeMutation();
+
const isCodeSent = data !== undefined;
+
const onSubmitPhoneNumber = async (
values: SendPhoneVerificationCodePayload
) => {
resetCodeMutation();
return await sendPhoneVerificationCode(values);
};
+
const onSubmitVerificationCode = async (
values: VerifyVerificationCodePayload
) => {
@@ -166,22 +175,10 @@ export const PhoneVerification = ({
);
};
- const customStyles = {
- menu: () => ({
- marginLeft: '-1px !important',
- marginTop: '0px !important',
- width: '500px',
- }),
- singleValue: (provided: React.CSSProperties) =>
- ({
- ...provided,
- fontSize: '20px',
- textAlign: 'center',
- } as const),
- };
const selectedCountry = countries.find(
(country) => country.code === sendCodeForm.values.iso_code
);
+
const isFormSubmitting = isCodeSent
? verifyCodeForm.isSubmitting
: sendCodeForm.isSubmitting;
@@ -204,6 +201,7 @@ export const PhoneVerification = ({