diff --git a/packages/manager/.changeset/pr-13276-upcoming-features-1768515896874.md b/packages/manager/.changeset/pr-13276-upcoming-features-1768515896874.md
new file mode 100644
index 00000000000..6b38049b920
--- /dev/null
+++ b/packages/manager/.changeset/pr-13276-upcoming-features-1768515896874.md
@@ -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))
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx
new file mode 100644
index 00000000000..861bade28c2
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.test.tsx
@@ -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();
+
+ 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();
+ // 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();
+
+ // 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();
+
+ // 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();
+ });
+});
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx
new file mode 100644
index 00000000000..647297523a6
--- /dev/null
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseAddConnectionPoolDrawer.tsx
@@ -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({
+ 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 (
+
+ {errors.root?.message && (
+
+ )}
+
+ Add a PgBouncer connection pool to minimize the use of your server
+ resources.
+
+
+
+ );
+};
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx
index c9341cd3254..d0f617f20e9 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.test.tsx
@@ -41,7 +41,7 @@ vi.mock('@linode/queries', async () => {
};
});
-describe('DatabaseManageNetworkingDrawer Component', () => {
+describe('DatabaseConnectionPools Component', () => {
beforeEach(() => {
vi.resetAllMocks();
});
@@ -130,4 +130,24 @@ describe('DatabaseManageNetworkingDrawer Component', () => {
const serviceURIText = screen.queryByText('Service URI');
expect(serviceURIText).not.toBeInTheDocument();
});
+
+ it('should disable the Add Pool button when the database cluster is not active', () => {
+ const provisioningDatabase = databaseFactory.build({
+ platform: 'rdbms-default',
+ private_network: null,
+ engine: 'postgresql',
+ id: 1,
+ status: 'provisioning',
+ });
+ queryMocks.useDatabaseConnectionPoolsQuery.mockReturnValue({
+ data: makeResourcePage([]),
+ isLoading: false,
+ });
+
+ renderWithTheme(
+
+ );
+ const addPoolBtn = screen.getByRole('button');
+ expect(addPoolBtn).toBeDisabled();
+ });
});
diff --git a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx
index 74a3c3c4270..4797bd9625c 100644
--- a/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx
+++ b/packages/manager/src/features/Databases/DatabaseDetail/DatabaseNetworking/DatabaseConnectionPools.tsx
@@ -28,6 +28,7 @@ import { usePaginationV2 } from 'src/hooks/usePaginationV2';
import { makeSettingsItemStyles } from '../../shared.styles';
import { ServiceURI } from '../ServiceURI';
+import { DatabaseAddConnectionPoolDrawer } from './DatabaseAddConnectionPoolDrawer';
import { DatabaseConnectionPoolDeleteDialog } from './DatabaseConnectionPoolDeleteDialog';
import { DatabaseConnectionPoolRow } from './DatabaseConnectionPoolRow';
@@ -41,9 +42,11 @@ interface Props {
export const DatabaseConnectionPools = ({ database }: Props) => {
const { classes } = makeSettingsItemStyles();
const theme = useTheme();
+ const isDatabaseInactive = database.status !== 'active';
const [deletePoolLabelSelection, setDeletePoolLabelSelection] =
React.useState();
+ const [isAddPoolDrawerOpen, setIsAddPoolDrawerOpen] = React.useState(false);
const pagination = usePaginationV2({
currentRoute: '/databases/$engine/$databaseId/networking',
@@ -85,9 +88,14 @@ export const DatabaseConnectionPools = ({ database }: Props) => {
@@ -179,6 +187,11 @@ export const DatabaseConnectionPools = ({ database }: Props) => {
open={Boolean(deletePoolLabelSelection)}
poolLabel={deletePoolLabelSelection ?? ''}
/>
+ setIsAddPoolDrawerOpen(false)}
+ open={isAddPoolDrawerOpen}
+ />
>
);
};
diff --git a/packages/manager/src/mocks/serverHandlers.ts b/packages/manager/src/mocks/serverHandlers.ts
index de5c18e1305..c0a87ebb738 100644
--- a/packages/manager/src/mocks/serverHandlers.ts
+++ b/packages/manager/src/mocks/serverHandlers.ts
@@ -382,9 +382,41 @@ const databases = [
http.get('*/databases/postgresql/instances/:id/connection-pools', () => {
const connectionPools = databaseConnectionPoolFactory.buildList(5);
+ // For mocking error response
+ // return HttpResponse.json({ errors: [{ reason: 'Unable to retrieve connection pools' }] }, { status: 400 });
return HttpResponse.json(makeResourcePage(connectionPools));
}),
+ http.post(
+ '*/databases/postgresql/instances/:id/connection-pools',
+ async ({ request }) => {
+ const body = await request.json();
+ const payload: any = body;
+
+ const connectionPool = databaseConnectionPoolFactory.build({
+ database: payload.database,
+ label: payload.label,
+ mode: payload.mode,
+ size: payload.size,
+ username: payload.username,
+ });
+ // For mocking error response
+ // return HttpResponse.json(
+ // {
+ // errors: [
+ // { field: 'label', reason: 'sample error text' },
+ // { field: 'database', reason: 'sample error text' },
+ // { field: 'mode', reason: 'sample error text' },
+ // { field: 'size', reason: 'sample error text' },
+ // { field: 'username', reason: 'sample error text' },
+ // ],
+ // },
+ // { status: 400 }
+ // );
+ return HttpResponse.json(connectionPool);
+ }
+ ),
+
http.get('*/databases/:engine/instances/:id', ({ params }) => {
const database = makeMockDatabase(params);
return HttpResponse.json(database);
diff --git a/packages/validation/.changeset/pr-13276-upcoming-features-1768516164774.md b/packages/validation/.changeset/pr-13276-upcoming-features-1768516164774.md
new file mode 100644
index 00000000000..a11e05b019c
--- /dev/null
+++ b/packages/validation/.changeset/pr-13276-upcoming-features-1768516164774.md
@@ -0,0 +1,5 @@
+---
+"@linode/validation": Upcoming Features
+---
+
+Updated validation rules for createDatabaseConnectionPoolSchema ([#13276](https://github.com/linode/manager/pull/13276))
diff --git a/packages/validation/src/databases.schema.ts b/packages/validation/src/databases.schema.ts
index 4c764f68f72..921240fc51f 100644
--- a/packages/validation/src/databases.schema.ts
+++ b/packages/validation/src/databases.schema.ts
@@ -224,10 +224,14 @@ export const createDatabaseConnectionPoolSchema = object({
.oneOf(['transaction', 'session', 'statement'], 'Pool mode is required')
.required('Pool mode is required'),
label: string()
- .required('Name is required')
- .max(63, 'Name must not exceed 63 characters'),
- size: number().required('Size is required'),
- username: string().nullable().required('Username is required'),
+ .required('Pool name is required')
+ .max(63, 'Pool name must not exceed 63 characters'),
+ size: number()
+ .required('Pool size is required')
+ .typeError('Pool size is required and must be a number')
+ .integer('Pool size must be a whole number')
+ .min(1, 'The minimum pool size for this database is 1.'),
+ username: string().required('Username is required').nullable(),
});
export const updateDatabaseConnectionPoolSchema = object({