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. + +
+ + ( + { + field.onChange(e.target.value); + }} + onClear={() => field.onChange('')} + placeholder="Enter a pool label" + /> + )} + /> + + ( + { + field.onChange(option.value); + }} + options={poolModeOptions} + value={poolModeOptions.find((option) => option.value === mode)} + /> + )} + /> + + ( + { + const value = + e.target.value.length > 0 + ? Number(e.target.value) + : e.target.value; + field.onChange(value); + }} + style={{ width: '178px' }} + type="number" + /> + )} + /> + + ( +