Skip to content

Commit 3ceeacd

Browse files
authored
feat(confirm-modal): Add async confirmation actions (#91559)
1 parent 2775c79 commit 3ceeacd

File tree

15 files changed

+201
-32
lines changed

15 files changed

+201
-32
lines changed

static/app/components/confirm.spec.tsx

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,21 @@
11
import {
2+
act,
23
createEvent,
34
fireEvent,
45
render,
56
renderGlobalModal,
67
screen,
78
userEvent,
9+
waitFor,
810
} from 'sentry-test/reactTestingLibrary';
911

1012
import Confirm from 'sentry/components/confirm';
1113
import ModalStore from 'sentry/stores/modalStore';
1214

1315
describe('Confirm', function () {
16+
beforeEach(() => {
17+
jest.useRealTimers();
18+
});
1419
afterEach(() => {
1520
ModalStore.reset();
1621
});
@@ -126,4 +131,81 @@ describe('Confirm', function () {
126131
fireEvent(button, clickEvent);
127132
expect(clickEvent.stopPropagation).toHaveBeenCalled();
128133
});
134+
135+
describe('async onConfirm', function () {
136+
it('should not close the modal until the promise is resolved', async function () {
137+
jest.useFakeTimers();
138+
const onConfirmAsync = jest.fn().mockImplementation(
139+
() =>
140+
new Promise(resolve => {
141+
setTimeout(resolve, 1000);
142+
})
143+
);
144+
145+
render(
146+
<Confirm message="Are you sure?" onConfirm={onConfirmAsync}>
147+
<button>Confirm?</button>
148+
</Confirm>
149+
);
150+
renderGlobalModal();
151+
152+
await userEvent.click(screen.getByRole('button', {name: 'Confirm?'}), {
153+
delay: null,
154+
});
155+
156+
await screen.findByRole('dialog');
157+
158+
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}), {
159+
delay: null,
160+
});
161+
162+
// Should keep modal in view until the promise is resolved
163+
expect(onConfirmAsync).toHaveBeenCalled();
164+
expect(screen.getByRole('dialog')).toBeInTheDocument();
165+
166+
act(() => jest.runAllTimers());
167+
168+
await waitFor(() => {
169+
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
170+
});
171+
});
172+
173+
it('displays an error message if the promise is rejected', async function () {
174+
jest.useFakeTimers();
175+
const onConfirmAsync = jest.fn().mockImplementation(
176+
() =>
177+
new Promise((_, reject) => {
178+
setTimeout(reject, 1000);
179+
})
180+
);
181+
182+
render(
183+
<Confirm message="Are you sure?" onConfirm={onConfirmAsync}>
184+
<button>Confirm?</button>
185+
</Confirm>
186+
);
187+
renderGlobalModal();
188+
189+
await userEvent.click(screen.getByRole('button', {name: 'Confirm?'}), {
190+
delay: null,
191+
});
192+
193+
await screen.findByRole('dialog');
194+
195+
await userEvent.click(screen.getByRole('button', {name: 'Confirm'}), {
196+
delay: null,
197+
});
198+
199+
// Should keep modal in view until the promise is resolved
200+
expect(onConfirmAsync).toHaveBeenCalled();
201+
expect(screen.getByRole('dialog')).toBeInTheDocument();
202+
203+
act(() => jest.runAllTimers());
204+
205+
// Should show error message and not close the modal
206+
await screen.findByText(/something went wrong/i);
207+
expect(screen.getByRole('dialog')).toBeInTheDocument();
208+
expect(screen.getByRole('button', {name: 'Confirm'})).toBeEnabled();
209+
});
210+
});
129211
});

static/app/components/confirm.stories.tsx

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,38 @@ export default storyBook('Confirm', story => {
105105
</Fragment>
106106
));
107107

108+
story('Async Confirmations', () => {
109+
return (
110+
<Fragment>
111+
<p>
112+
If you pass a promise to <JSXProperty name="onConfirm" value={Function} />, the
113+
modal will not close until the promise is resolved. This is useful if you have
114+
actions that require a endpoint to respond before the modal can be closed, such
115+
as when confirming the deletion of the page you are on.
116+
</p>
117+
<Confirm
118+
onConfirm={() => new Promise(resolve => setTimeout(resolve, 1000))}
119+
header="Are you sure?"
120+
message="This confirmation takes 1 second to complete"
121+
>
122+
<Button>This confirmation takes 1 second to complete</Button>
123+
</Confirm>
124+
<p>
125+
This also allows you to respond to display errors in the modal in the case of
126+
network errors.
127+
</p>
128+
<Confirm
129+
onConfirm={() => new Promise((_, reject) => setTimeout(reject, 1000))}
130+
header="Are you sure?"
131+
message="This confirmation will error"
132+
errorMessage="Custom error message"
133+
>
134+
<Button>This confirmation will error</Button>
135+
</Confirm>
136+
</Fragment>
137+
);
138+
});
139+
108140
story('Callbacks & bypass={true}', () => {
109141
const [callbacks, setCallbacks] = useState<string[]>([]);
110142
return (

static/app/components/confirm.tsx

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import {cloneElement, Fragment, isValidElement, useRef, useState} from 'react';
22

33
import type {ModalRenderProps} from 'sentry/actionCreators/modal';
44
import {openModal} from 'sentry/actionCreators/modal';
5+
import {Alert} from 'sentry/components/core/alert';
56
import type {ButtonProps} from 'sentry/components/core/button';
67
import {Button} from 'sentry/components/core/button';
78
import {ButtonBar} from 'sentry/components/core/button/buttonBar';
@@ -71,6 +72,11 @@ export type OpenConfirmOptions = {
7172
* `disableConfirmButton` function that you may use to control the state of it.
7273
*/
7374
disableConfirmButton?: boolean;
75+
/**
76+
* Message to display to user when an error occurs. Only used if `onConfirmAsync` is
77+
* provided and the promise rejects.
78+
*/
79+
errorMessage?: React.ReactNode;
7480
/**
7581
* Header of modal
7682
*/
@@ -95,8 +101,11 @@ export type OpenConfirmOptions = {
95101
onClose?: () => void;
96102
/**
97103
* Callback when user confirms
104+
*
105+
* If you pass a promise, the modal will not close until it resolves.
106+
* To customize the error message in case of rejection, pass the `errorMessage` prop.
98107
*/
99-
onConfirm?: () => void;
108+
onConfirm?: () => void | Promise<void>;
100109
/**
101110
* Callback function when user is in the confirming state called when the
102111
* confirm modal is opened
@@ -224,6 +233,7 @@ type ModalProps = ModalRenderProps &
224233
| 'onCancel'
225234
| 'disableConfirmButton'
226235
| 'onRender'
236+
| 'errorMessage'
227237
>;
228238

229239
function ConfirmModal({
@@ -242,12 +252,14 @@ function ConfirmModal({
242252
onConfirm,
243253
renderMessage,
244254
message,
255+
errorMessage = t('Something went wrong. Please try again.'),
245256
closeModal,
246257
}: ModalProps) {
247258
const confirmCallbackRef = useRef<() => void>(() => {});
248259
const isConfirmingRef = useRef(false);
249260
const [shouldDisableConfirmButton, setShouldDisableConfirmButton] =
250261
useState(disableConfirmButton);
262+
const [isError, setIsError] = useState(false);
251263

252264
const handleClose = () => {
253265
onCancel?.();
@@ -258,14 +270,27 @@ function ConfirmModal({
258270
closeModal();
259271
};
260272

261-
const handleConfirm = () => {
262-
if (!isConfirmingRef.current) {
263-
onConfirm?.();
264-
confirmCallbackRef.current();
273+
const handleConfirm = async () => {
274+
if (isConfirmingRef.current) {
275+
return;
265276
}
266277

267-
setShouldDisableConfirmButton(true);
268278
isConfirmingRef.current = true;
279+
setShouldDisableConfirmButton(true);
280+
281+
if (onConfirm) {
282+
try {
283+
await onConfirm();
284+
} catch (error) {
285+
setIsError(true);
286+
setShouldDisableConfirmButton(disableConfirmButton ?? false);
287+
return;
288+
} finally {
289+
isConfirmingRef.current = false;
290+
}
291+
}
292+
293+
confirmCallbackRef.current();
269294
closeModal();
270295
};
271296

@@ -290,7 +315,14 @@ function ConfirmModal({
290315
return (
291316
<Fragment>
292317
{header && <Header>{header}</Header>}
293-
<Body>{makeConfirmMessage()}</Body>
318+
<Body>
319+
{isError && (
320+
<Alert.Container>
321+
<Alert type="error">{errorMessage}</Alert>
322+
</Alert.Container>
323+
)}
324+
{makeConfirmMessage()}
325+
</Body>
294326
<Footer>
295327
<ButtonBar gap={2}>
296328
{renderCancelButton ? (

static/app/views/alerts/rules/issue/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1204,7 +1204,9 @@ class IssueRuleEditor extends DeprecatedAsyncComponent<Props, State> {
12041204
disabled={disabled}
12051205
priority="danger"
12061206
confirmText={t('Delete Rule')}
1207-
onConfirm={this.handleDeleteRule}
1207+
onConfirm={() => {
1208+
this.handleDeleteRule();
1209+
}}
12081210
header={<h5>{t('Delete Alert Rule?')}</h5>}
12091211
message={t(
12101212
'Are you sure you want to delete "%s"? You won\'t be able to view the history of this alert once it\'s deleted.',

static/app/views/alerts/rules/metric/ruleForm.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1352,7 +1352,9 @@ class RuleFormContainer extends DeprecatedAsyncComponent<Props, State> {
13521352
header={<h5>{t('Delete Alert Rule?')}</h5>}
13531353
priority="danger"
13541354
confirmText={t('Delete Rule')}
1355-
onConfirm={this.handleDeleteRule}
1355+
onConfirm={() => {
1356+
this.handleDeleteRule();
1357+
}}
13561358
>
13571359
<Button priority="danger">{t('Delete Rule')}</Button>
13581360
</Confirm>

static/app/views/settings/account/accountClose.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -109,8 +109,10 @@ function AccountClose() {
109109
data: {organizations: Array.from(orgsToRemove)},
110110
});
111111

112-
openModal(GoodbyeModalContent, {
113-
onClose: leaveRedirect,
112+
requestAnimationFrame(() => {
113+
openModal(GoodbyeModalContent, {
114+
onClose: leaveRedirect,
115+
});
114116
});
115117

116118
// Redirect after 10 seconds

static/app/views/settings/account/confirmAccountClose.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ export function ConfirmAccountClose({
1313
message={t(
1414
'WARNING! This is permanent and cannot be undone, are you really sure you want to do this?'
1515
)}
16-
onConfirm={handleRemoveAccount}
16+
onConfirm={() => {
17+
handleRemoveAccount();
18+
}}
1719
>
1820
<Button priority="danger">{t('Close Account')}</Button>
1921
</Confirm>

static/app/views/settings/organizationDeveloperSettings/sentryApplicationDetails.tsx

Lines changed: 11 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -318,13 +318,15 @@ export default function SentryApplicationDetails(props: Props) {
318318
};
319319

320320
const rotateClientSecret = async () => {
321-
try {
322-
const rotateResponse = await api.requestPromise(
323-
`/sentry-apps/${appSlug}/rotate-secret/`,
324-
{
325-
method: 'POST',
326-
}
327-
);
321+
const rotateResponse = await api.requestPromise(
322+
`/sentry-apps/${appSlug}/rotate-secret/`,
323+
{
324+
method: 'POST',
325+
}
326+
);
327+
328+
// Ensures that the modal is opened after the confirmation modal closes itself
329+
requestAnimationFrame(() => {
328330
openModal(({Body, Header}) => (
329331
<Fragment>
330332
<Header>{t('Your new Client Secret')}</Header>
@@ -340,9 +342,7 @@ export default function SentryApplicationDetails(props: Props) {
340342
</Body>
341343
</Fragment>
342344
));
343-
} catch {
344-
addErrorMessage(t('Error rotating secret'));
345-
}
345+
});
346346
};
347347

348348
const onFieldChange = (name: string, value: FieldValue): void => {
@@ -546,6 +546,7 @@ export default function SentryApplicationDetails(props: Props) {
546546
message={t(
547547
'Are you sure you want to rotate the client secret? The current one will not be usable anymore, and this cannot be undone.'
548548
)}
549+
errorMessage={t('Error rotating secret')}
549550
>
550551
<Button priority="danger">Rotate client secret</Button>
551552
</Confirm>

static/app/views/settings/organizationIntegrations/installedPlugin.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -143,7 +143,9 @@ class InstalledPlugin extends Component<Props> {
143143
onConfirming={this.handleUninstallClick}
144144
disabled={!hasAccess}
145145
confirmText="Delete Installation"
146-
onConfirm={() => this.handleReset()}
146+
onConfirm={() => {
147+
this.handleReset();
148+
}}
147149
message={this.getConfirmMessage()}
148150
>
149151
<StyledButton

static/app/views/settings/organizationTeams/teamSettings/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,9 @@ function TeamSettings({team, params}: TeamSettingsProps) {
126126
<div>
127127
<Confirm
128128
disabled={isIdpProvisioned || !hasTeamAdmin}
129-
onConfirm={handleRemoveTeam}
129+
onConfirm={() => {
130+
handleRemoveTeam();
131+
}}
130132
priority="danger"
131133
message={tct('Are you sure you want to remove the team [team]?', {
132134
team: `#${team.slug}`,

static/app/views/settings/project/projectKeys/details/keySettings.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -172,7 +172,9 @@ export function KeySettings({
172172
message={t(
173173
'Are you sure you want to revoke this key? This will immediately remove and suspend the credentials.'
174174
)}
175-
onConfirm={handleRemove}
175+
onConfirm={() => {
176+
handleRemove();
177+
}}
176178
confirmText={t('Revoke Key')}
177179
disabled={!hasAccess}
178180
>

static/app/views/settings/projectGeneralSettings/index.tsx

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,9 @@ function ProjectGeneralSettings({onChangeSlug}: Props) {
214214

215215
{isOrgOwner && !isInternal && (
216216
<Confirm
217-
onConfirm={handleTransferProject}
217+
onConfirm={() => {
218+
handleTransferProject();
219+
}}
218220
priority="danger"
219221
confirmText={t('Transfer project')}
220222
renderMessage={({confirm}) => (

0 commit comments

Comments
 (0)