Skip to content

Commit 5633d6c

Browse files
Merge branch 'develop' into UIE-10061
2 parents a48ff25 + 8cfec37 commit 5633d6c

18 files changed

+710
-204
lines changed
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Upcoming Features
3+
---
4+
5+
DBaaS PgBouncer section to display Add New Connection Pool drawer ([#13276](https://github.com/linode/manager/pull/13276))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Fixed
3+
---
4+
5+
IAM: changing entity/role can cause an empty page ([#13285](https://github.com/linode/manager/pull/13285))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Fixed
3+
---
4+
5+
IAM Delegation: "Remove" button in remove assignment confirmation popup is not disabled after clicking it ([#13290](https://github.com/linode/manager/pull/13290))
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@linode/manager": Fixed
3+
---
4+
5+
IAM DElegation: remove restriction to update user delegation with empty array, update the delegations after reopening a drawer ([#13300](https://github.com/linode/manager/pull/13300))
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { screen } from '@testing-library/react';
2+
import userEvent from '@testing-library/user-event';
3+
import * as React from 'react';
4+
import { describe, it } from 'vitest';
5+
6+
import { renderWithTheme } from 'src/utilities/testHelpers';
7+
8+
import { DatabaseAddConnectionPoolDrawer } from './DatabaseAddConnectionPoolDrawer';
9+
10+
const mockProps = {
11+
databaseId: 123,
12+
onClose: vi.fn(),
13+
open: true,
14+
};
15+
16+
const poolLabel = 'Pool Label';
17+
const addPoolBtnText = 'Add Pool';
18+
19+
// Hoist query mocks
20+
const queryMocks = vi.hoisted(() => {
21+
return {
22+
useCreateDatabaseConnectionPoolMutation: vi.fn(),
23+
};
24+
});
25+
26+
vi.mock('@linode/queries', async () => {
27+
const actual = await vi.importActual('@linode/queries');
28+
return {
29+
...actual,
30+
useCreateDatabaseConnectionPoolMutation:
31+
queryMocks.useCreateDatabaseConnectionPoolMutation,
32+
};
33+
});
34+
35+
describe('DatabaseAddConnectionPoolDrawer Component', () => {
36+
beforeEach(() => {
37+
vi.resetAllMocks();
38+
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({});
39+
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
40+
mutateAsync: vi.fn().mockResolvedValue({}),
41+
isLoading: false,
42+
reset: vi.fn(),
43+
});
44+
});
45+
46+
it('Should render the drawer title', () => {
47+
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);
48+
49+
const addPoolDrawerTitle = screen.getByText('Add a New Connection Pool');
50+
expect(addPoolDrawerTitle).toBeInTheDocument();
51+
});
52+
53+
it('Should submit expected payload with valid selection, then close the drawer', async () => {
54+
const expectedPayloadValues = {
55+
label: 'test-pool',
56+
database: 'defaultdb',
57+
size: 10,
58+
mode: 'transaction',
59+
username: null, // Test default 'Reuse inbound user' option which gets provided as null to the API
60+
};
61+
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);
62+
// Fill out and submit the form
63+
const poolLabelInput = screen.getByLabelText(poolLabel);
64+
const addPoolBtn = screen.getByText(addPoolBtnText);
65+
await userEvent.type(poolLabelInput, expectedPayloadValues.label);
66+
await userEvent.click(addPoolBtn);
67+
// Test that the mutation was called with expected payload
68+
expect(
69+
queryMocks.useCreateDatabaseConnectionPoolMutation().mutateAsync
70+
).toHaveBeenCalledExactlyOnceWith(expectedPayloadValues);
71+
// Test that onClose was called to close the drawer
72+
expect(mockProps.onClose).toHaveBeenCalled();
73+
});
74+
75+
it('Should show error notice on root error', async () => {
76+
const mockErrorMessage = 'This is a root level error';
77+
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
78+
mutateAsync: vi
79+
.fn()
80+
.mockRejectedValue([{ field: 'root', reason: mockErrorMessage }]),
81+
isLoading: false,
82+
reset: vi.fn(),
83+
});
84+
85+
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);
86+
87+
// Fill out and submit the form
88+
const poolLabelInput = screen.getByLabelText(poolLabel);
89+
const addPoolBtn = screen.getByText(addPoolBtnText);
90+
await userEvent.type(poolLabelInput, 'test-pool');
91+
await userEvent.click(addPoolBtn);
92+
93+
// Check that the error notice is displayed
94+
const errorNotice = await screen.findByText(mockErrorMessage);
95+
expect(errorNotice).toBeInTheDocument();
96+
});
97+
98+
it('Should display inline errors', async () => {
99+
const mockRejectedFieldErrorsMap = {
100+
label: 'Label error message',
101+
size: 'Size error message',
102+
mode: 'Mode error message',
103+
database: 'Database error message',
104+
username: 'Username error message',
105+
};
106+
queryMocks.useCreateDatabaseConnectionPoolMutation.mockReturnValue({
107+
mutateAsync: vi.fn().mockRejectedValue([
108+
{ field: 'label', reason: mockRejectedFieldErrorsMap.label },
109+
{ field: 'size', reason: mockRejectedFieldErrorsMap.size },
110+
{ field: 'mode', reason: mockRejectedFieldErrorsMap.mode },
111+
{ field: 'database', reason: mockRejectedFieldErrorsMap.database },
112+
{ field: 'username', reason: mockRejectedFieldErrorsMap.username },
113+
]),
114+
isLoading: false,
115+
reset: vi.fn(),
116+
});
117+
118+
renderWithTheme(<DatabaseAddConnectionPoolDrawer {...mockProps} />);
119+
120+
// Fill out and submit the form
121+
const poolLabelInput = screen.getByLabelText(poolLabel);
122+
const addPoolBtn = screen.getByText(addPoolBtnText);
123+
await userEvent.type(poolLabelInput, 'test-pool');
124+
await userEvent.click(addPoolBtn);
125+
126+
// Check that inline errors are displayed
127+
const labelError = await screen.findByText(
128+
mockRejectedFieldErrorsMap.label
129+
);
130+
const sizeError = await screen.findByText(mockRejectedFieldErrorsMap.size);
131+
const modeError = await screen.findByText(mockRejectedFieldErrorsMap.mode);
132+
const databaseError = await screen.findByText(
133+
mockRejectedFieldErrorsMap.database
134+
);
135+
const usernameError = await screen.findByText(
136+
mockRejectedFieldErrorsMap.username
137+
);
138+
expect(labelError).toBeInTheDocument();
139+
expect(sizeError).toBeInTheDocument();
140+
expect(modeError).toBeInTheDocument();
141+
expect(databaseError).toBeInTheDocument();
142+
expect(usernameError).toBeInTheDocument();
143+
});
144+
});
Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
import { yupResolver } from '@hookform/resolvers/yup';
2+
import { useCreateDatabaseConnectionPoolMutation } from '@linode/queries';
3+
import {
4+
ActionsPanel,
5+
Drawer,
6+
Notice,
7+
Select,
8+
Stack,
9+
TextField,
10+
Typography,
11+
} from '@linode/ui';
12+
import { createDatabaseConnectionPoolSchema } from '@linode/validation';
13+
import { useSnackbar } from 'notistack';
14+
import * as React from 'react';
15+
import { Controller, useForm, useWatch } from 'react-hook-form';
16+
17+
import type { ConnectionPool } from '@linode/api-v4';
18+
19+
interface Props {
20+
databaseId: number;
21+
onClose: () => void;
22+
open: boolean;
23+
}
24+
25+
const defaultUsername = 'Reuse inbound user'; // Represented as null in the API
26+
const poolModeOptions = [
27+
{ label: 'Transaction', value: 'transaction' },
28+
{ label: 'Session', value: 'session' },
29+
{ label: 'Statement', value: 'statement' },
30+
];
31+
const databaseNamesOptions = [{ label: 'defaultdb', value: 'defaultdb' }]; // Currently the only option for the database name field, but more may be introduced later.
32+
const usernameOptions = [
33+
{ label: defaultUsername, value: defaultUsername },
34+
{ label: 'akmadmin', value: 'akmadmin' },
35+
]; // Currently the only options for the username field
36+
37+
export const DatabaseAddConnectionPoolDrawer = (props: Props) => {
38+
const { databaseId, onClose, open } = props;
39+
const { enqueueSnackbar } = useSnackbar();
40+
41+
const {
42+
isPending: submitInProgress,
43+
mutateAsync: createDatabaseConnectionPool,
44+
reset: resetMutation,
45+
} = useCreateDatabaseConnectionPoolMutation(databaseId);
46+
47+
const {
48+
control,
49+
formState: { errors },
50+
handleSubmit,
51+
reset,
52+
setError,
53+
} = useForm<ConnectionPool>({
54+
defaultValues: {
55+
database: 'defaultdb',
56+
label: '',
57+
mode: 'transaction',
58+
size: 10,
59+
username: defaultUsername,
60+
},
61+
mode: 'onBlur',
62+
resolver: yupResolver(createDatabaseConnectionPoolSchema),
63+
});
64+
65+
const [mode, database, username] = useWatch({
66+
control,
67+
name: ['mode', 'database', 'username'],
68+
});
69+
70+
const handleOnClose = () => {
71+
onClose();
72+
reset();
73+
resetMutation?.();
74+
};
75+
76+
const onSubmit = async (values: ConnectionPool) => {
77+
const payload = {
78+
...values,
79+
username: values.username === defaultUsername ? null : values.username,
80+
}; // Provide inbound user as null in the API
81+
82+
try {
83+
await createDatabaseConnectionPool(payload);
84+
enqueueSnackbar('Connection Pool added successfully.', {
85+
variant: 'success',
86+
});
87+
handleOnClose();
88+
} catch (errors) {
89+
for (const error of errors) {
90+
setError(error?.field ?? 'root', { message: error.reason });
91+
}
92+
}
93+
};
94+
95+
return (
96+
<Drawer
97+
onClose={handleOnClose}
98+
open={open}
99+
title="Add a New Connection Pool"
100+
>
101+
{errors.root?.message && (
102+
<Notice text={errors.root.message} variant="error" />
103+
)}
104+
<Typography>
105+
Add a PgBouncer connection pool to minimize the use of your server
106+
resources.
107+
</Typography>
108+
<form onSubmit={handleSubmit(onSubmit)}>
109+
<Stack>
110+
<Controller
111+
control={control}
112+
name="label"
113+
render={({ field, fieldState }) => (
114+
<TextField
115+
clearable
116+
{...field}
117+
errorText={fieldState.error?.message}
118+
id="poolLabel"
119+
label="Pool Label"
120+
onChange={(e) => {
121+
field.onChange(e.target.value);
122+
}}
123+
onClear={() => field.onChange('')}
124+
placeholder="Enter a pool label"
125+
/>
126+
)}
127+
/>
128+
129+
<Controller
130+
control={control}
131+
name="database"
132+
render={({ field, fieldState }) => (
133+
<Select
134+
label="Database Name"
135+
{...field}
136+
data-testid="database-name-select"
137+
errorText={fieldState.error?.message}
138+
id="databaseName"
139+
onChange={(e, option) => {
140+
field.onChange(option.value);
141+
}}
142+
options={databaseNamesOptions}
143+
value={databaseNamesOptions.find(
144+
(option) => option.value === database
145+
)}
146+
/>
147+
)}
148+
/>
149+
150+
<Controller
151+
control={control}
152+
name="mode"
153+
render={({ field, fieldState }) => (
154+
<Select
155+
label="Pool Mode"
156+
{...field}
157+
data-testid="pool-mode-select"
158+
errorText={fieldState.error?.message}
159+
id="poolMode"
160+
onChange={(e, option) => {
161+
field.onChange(option.value);
162+
}}
163+
options={poolModeOptions}
164+
value={poolModeOptions.find((option) => option.value === mode)}
165+
/>
166+
)}
167+
/>
168+
169+
<Controller
170+
control={control}
171+
name="size"
172+
render={({ field, fieldState }) => (
173+
<TextField
174+
id="poolSize"
175+
{...field}
176+
data-testid="pool-size-input"
177+
errorText={fieldState.error?.message}
178+
label="Pool Size"
179+
min={1}
180+
onChange={(e) => {
181+
const value =
182+
e.target.value.length > 0
183+
? Number(e.target.value)
184+
: e.target.value;
185+
field.onChange(value);
186+
}}
187+
style={{ width: '178px' }}
188+
type="number"
189+
/>
190+
)}
191+
/>
192+
193+
<Controller
194+
control={control}
195+
name="username"
196+
render={({ field, fieldState }) => (
197+
<Select
198+
label="Username"
199+
{...field}
200+
data-testid="username-select"
201+
errorText={fieldState.error?.message}
202+
id="username"
203+
onChange={(e, option) => {
204+
field.onChange(option.value);
205+
}}
206+
options={usernameOptions}
207+
value={usernameOptions.find(
208+
(option) => option.value === username
209+
)}
210+
/>
211+
)}
212+
/>
213+
</Stack>
214+
215+
<ActionsPanel
216+
primaryButtonProps={{
217+
label: 'Add Pool',
218+
loading: submitInProgress,
219+
type: 'submit',
220+
'data-testid': 'add-connection-pool-button',
221+
}}
222+
secondaryButtonProps={{
223+
label: 'Cancel',
224+
onClick: handleOnClose,
225+
}}
226+
/>
227+
</form>
228+
</Drawer>
229+
);
230+
};

0 commit comments

Comments
 (0)