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
---

DBaaS PgBouncer section to display Add New Connection Pool drawer ([#13276](https://github.com/linode/manager/pull/13276))
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import * as React from 'react';
import { describe, it } from 'vitest';

import { renderWithTheme } from 'src/utilities/testHelpers';

import { DatabaseAddConnectionPoolDrawer } from './DatabaseAddConnectionPoolDrawer';

const mockProps = {
databaseId: 123,
onClose: vi.fn(),
open: true,
};

const poolLabel = 'Pool Label';
const addPoolBtnText = 'Add Pool';

// Hoist query mocks
const queryMocks = vi.hoisted(() => {
return {
useCreateDatabaseConnectionPoolMutation: vi.fn(),
};
});

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

describe('DatabaseAddConnectionPoolDrawer Component', () => {
beforeEach(() => {
vi.resetAllMocks();
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({});
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
mutateAsync: vi.fn().mockResolvedValue({}),
isLoading: false,
reset: vi.fn(),
});
});

it('Should render the drawer title', () => {
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);

const addPoolDrawerTitle = screen.getByText('Add a New Connection Pool');
expect(addPoolDrawerTitle).toBeInTheDocument();
});

it('Should submit expected payload with valid selection, then close the drawer', async () => {
const expectedPayloadValues = {
label: 'test-pool',
database: 'defaultdb',
size: 10,
mode: 'transaction',
username: null, // Test default 'Reuse inbound user' option which gets provided as null to the API
};
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);
// Fill out and submit the form
const poolLabelInput = screen.getByLabelText(poolLabel);
const addPoolBtn = screen.getByText(addPoolBtnText);
await userEvent.type(poolLabelInput, expectedPayloadValues.label);
await userEvent.click(addPoolBtn);
// Test that the mutation was called with expected payload
expect(
queryMocks.useCreateDatabaseConnectionPoolMutation().mutateAsync
).toHaveBeenCalledExactlyOnceWith(expectedPayloadValues);
// Test that onClose was called to close the drawer
expect(mockProps.onClose).toHaveBeenCalled();
});

it('Should show error notice on root error', async () => {
const mockErrorMessage = 'This is a root level error';
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
mutateAsync: vi
.fn()
.mockRejectedValue([{ field: 'root', reason: mockErrorMessage }]),
isLoading: false,
reset: vi.fn(),
});

renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);

// Fill out and submit the form
const poolLabelInput = screen.getByLabelText(poolLabel);
const addPoolBtn = screen.getByText(addPoolBtnText);
await userEvent.type(poolLabelInput, 'test-pool');
await userEvent.click(addPoolBtn);

// Check that the error notice is displayed
const errorNotice = await screen.findByText(mockErrorMessage);
expect(errorNotice).toBeInTheDocument();
});

it('Should display inline errors', async () => {
const mockRejectedFieldErrorsMap = {
label: 'Label error message',
size: 'Size error message',
mode: 'Mode error message',
database: 'Database error message',
username: 'Username error message',
};
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
mutateAsync: vi.fn().mockRejectedValue([
{ field: 'label', reason: mockRejectedFieldErrorsMap.label },
{ field: 'size', reason: mockRejectedFieldErrorsMap.size },
{ field: 'mode', reason: mockRejectedFieldErrorsMap.mode },
{ field: 'database', reason: mockRejectedFieldErrorsMap.database },
{ field: 'username', reason: mockRejectedFieldErrorsMap.username },
]),
isLoading: false,
reset: vi.fn(),
});

renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);

// Fill out and submit the form
const poolLabelInput = screen.getByLabelText(poolLabel);
const addPoolBtn = screen.getByText(addPoolBtnText);
await userEvent.type(poolLabelInput, 'test-pool');
await userEvent.click(addPoolBtn);

// Check that inline errors are displayed
const labelError = await screen.findByText(
mockRejectedFieldErrorsMap.label
);
const sizeError = await screen.findByText(mockRejectedFieldErrorsMap.size);
const modeError = await screen.findByText(mockRejectedFieldErrorsMap.mode);
const databaseError = await screen.findByText(
mockRejectedFieldErrorsMap.database
);
const usernameError = await screen.findByText(
mockRejectedFieldErrorsMap.username
);
expect(labelError).toBeInTheDocument();
expect(sizeError).toBeInTheDocument();
expect(modeError).toBeInTheDocument();
expect(databaseError).toBeInTheDocument();
expect(usernameError).toBeInTheDocument();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,230 @@
import { yupResolver } from '@hookform/resolvers/yup';
import { useCreateDatabaseConnectionPoolMutation } from '@linode/queries';
import {
ActionsPanel,
Drawer,
Notice,
Select,
Stack,
TextField,
Typography,
} from '@linode/ui';
import { createDatabaseConnectionPoolSchema } from '@linode/validation';
import { useSnackbar } from 'notistack';
import * as React from 'react';
import { Controller, useForm, useWatch } from 'react-hook-form';

import type { ConnectionPool } from '@linode/api-v4';

interface Props {
databaseId: number;
onClose: () => void;
open: boolean;
}

const defaultUsername = 'Reuse inbound user'; // Represented as null in the API
const poolModeOptions = [
{ label: 'Transaction', value: 'transaction' },
{ label: 'Session', value: 'session' },
{ label: 'Statement', value: 'statement' },
];
const databaseNamesOptions = [{ label: 'defaultdb', value: 'defaultdb' }]; // Currently the only option for the database name field, but more may be introduced later.
const usernameOptions = [
{ label: defaultUsername, value: defaultUsername },
{ label: 'akmadmin', value: 'akmadmin' },
]; // Currently the only options for the username field

export const DatabaseAddConnectionPoolDrawer = (props: Props) => {
const { databaseId, onClose, open } = props;
const { enqueueSnackbar } = useSnackbar();

const {
isPending: submitInProgress,
mutateAsync: createDatabaseConnectionPool,
reset: resetMutation,
} = useCreateDatabaseConnectionPoolMutation(databaseId);

const {
control,
formState: { errors },
handleSubmit,
reset,
setError,
} = useForm<ConnectionPool>({
defaultValues: {
database: 'defaultdb',
label: '',
mode: 'transaction',
size: 10,
username: defaultUsername,
},
mode: 'onBlur',
resolver: yupResolver(createDatabaseConnectionPoolSchema),
});

const [mode, database, username] = useWatch({
control,
name: ['mode', 'database', 'username'],
});

const handleOnClose = () => {
onClose();
reset();
resetMutation?.();
};

const onSubmit = async (values: ConnectionPool) => {
const payload = {
...values,
username: values.username === defaultUsername ? null : values.username,
}; // Provide inbound user as null in the API

try {
await createDatabaseConnectionPool(payload);
enqueueSnackbar('Connection Pool added successfully.', {
variant: 'success',
});
handleOnClose();
} catch (errors) {
for (const error of errors) {
setError(error?.field ?? 'root', { message: error.reason });
}
}
};

return (
<Drawer
onClose={handleOnClose}
open={open}
title="Add a New Connection Pool"
>
{errors.root?.message && (
<Notice text={errors.root.message} variant="error" />
)}
<Typography>
Add a PgBouncer connection pool to minimize the use of your server
resources.
</Typography>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<Controller
control={control}
name="label"
render={({ field, fieldState }) => (
<TextField
clearable
{...field}
errorText={fieldState.error?.message}
id="poolLabel"
label="Pool Label"
onChange={(e) => {
field.onChange(e.target.value);
}}
onClear={() => field.onChange('')}
placeholder="Enter a pool label"
/>
)}
/>

<Controller
control={control}
name="database"
render={({ field, fieldState }) => (
<Select
label="Database Name"
{...field}
data-testid="database-name-select"
errorText={fieldState.error?.message}
id="databaseName"
onChange={(e, option) => {
field.onChange(option.value);
}}
options={databaseNamesOptions}
value={databaseNamesOptions.find(
(option) => option.value === database
)}
/>
)}
/>

<Controller
control={control}
name="mode"
render={({ field, fieldState }) => (
<Select
label="Pool Mode"
{...field}
data-testid="pool-mode-select"
errorText={fieldState.error?.message}
id="poolMode"
onChange={(e, option) => {
field.onChange(option.value);
}}
options={poolModeOptions}
value={poolModeOptions.find((option) => option.value === mode)}
/>
)}
/>

<Controller
control={control}
name="size"
render={({ field, fieldState }) => (
<TextField
id="poolSize"
{...field}
data-testid="pool-size-input"
errorText={fieldState.error?.message}
label="Pool Size"
min={1}
onChange={(e) => {
const value =
e.target.value.length > 0
? Number(e.target.value)
: e.target.value;
field.onChange(value);
}}
Comment thread
hana-akamai marked this conversation as resolved.
style={{ width: '178px' }}
type="number"
/>
)}
/>

<Controller
control={control}
name="username"
render={({ field, fieldState }) => (
<Select
label="Username"
{...field}
data-testid="username-select"
errorText={fieldState.error?.message}
id="username"
onChange={(e, option) => {
field.onChange(option.value);
}}
options={usernameOptions}
value={usernameOptions.find(
(option) => option.value === username
)}
/>
)}
/>
</Stack>

<ActionsPanel
primaryButtonProps={{
label: 'Add Pool',
loading: submitInProgress,
type: 'submit',
'data-testid': 'add-connection-pool-button',
}}
secondaryButtonProps={{
label: 'Cancel',
onClick: handleOnClose,
}}
/>
</form>
</Drawer>
);
};
Loading