Skip to content

Commit cb68fa8

Browse files
authored
feat(billing): Disable submission form button when adding/removing credits (#100807)
Closes https://linear.app/getsentry/issue/BIL-232/credit-to-a-subscription-can-sometimes-get-duplicated-via-admin Disable submission form button when adding/removing credits. This should prevent accidental double-clicks to add multiple credits.
1 parent e161d04 commit cb68fa8

File tree

2 files changed

+104
-2
lines changed

2 files changed

+104
-2
lines changed

static/gsAdmin/components/changeBalanceAction.spec.tsx

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,12 @@
11
import {OrganizationFixture} from 'sentry-fixture/organization';
22

33
import {SubscriptionFixture} from 'getsentry-test/fixtures/subscription';
4-
import {renderGlobalModal, screen, userEvent} from 'sentry-test/reactTestingLibrary';
4+
import {
5+
renderGlobalModal,
6+
screen,
7+
userEvent,
8+
waitFor,
9+
} from 'sentry-test/reactTestingLibrary';
510

611
import triggerChangeBalanceModal from 'admin/components/changeBalanceAction';
712

@@ -107,4 +112,88 @@ describe('BalanceChangeAction', () => {
107112
})
108113
);
109114
});
115+
116+
it('prevents double submission', async () => {
117+
const updateMock = MockApiClient.addMockResponse({
118+
url: `/_admin/customers/${organization.slug}/balance-changes/`,
119+
method: 'POST',
120+
body: OrganizationFixture(),
121+
});
122+
123+
triggerChangeBalanceModal({subscription, ...modalProps});
124+
125+
renderGlobalModal();
126+
await userEvent.type(screen.getByRole('spinbutton', {name: 'Credit Amount'}), '10');
127+
128+
const submitButton = screen.getByRole('button', {name: 'Submit'});
129+
130+
// Rapidly click submit button multiple times
131+
await userEvent.click(submitButton);
132+
await userEvent.click(submitButton);
133+
await userEvent.click(submitButton);
134+
135+
// Should only call API once (double-clicks prevented)
136+
expect(updateMock).toHaveBeenCalledTimes(1);
137+
});
138+
139+
it('disables form fields during submission', async () => {
140+
// Mock with delay to keep isSubmitting true during test
141+
MockApiClient.addMockResponse({
142+
url: `/_admin/customers/${organization.slug}/balance-changes/`,
143+
method: 'POST',
144+
body: OrganizationFixture(),
145+
asyncDelay: 100,
146+
});
147+
148+
triggerChangeBalanceModal({subscription, ...modalProps});
149+
150+
renderGlobalModal();
151+
const creditInput = screen.getByRole('spinbutton', {name: 'Credit Amount'});
152+
await userEvent.type(creditInput, '10');
153+
154+
const submitButton = screen.getByRole('button', {name: 'Submit'});
155+
await userEvent.click(submitButton);
156+
157+
// During submission, button should show "Submitting...", be disabled, and fields should be disabled
158+
expect(submitButton).toHaveTextContent('Submitting...');
159+
expect(submitButton).toBeDisabled();
160+
expect(creditInput).toBeDisabled();
161+
expect(screen.getByTestId('url-field')).toBeDisabled();
162+
expect(screen.getByTestId('notes-field')).toBeDisabled();
163+
});
164+
165+
it('re-enables form after error', async () => {
166+
MockApiClient.addMockResponse({
167+
url: `/_admin/customers/${organization.slug}/balance-changes/`,
168+
method: 'POST',
169+
statusCode: 400,
170+
body: {detail: 'Invalid amount'},
171+
asyncDelay: 10,
172+
});
173+
174+
triggerChangeBalanceModal({subscription, ...modalProps});
175+
renderGlobalModal();
176+
177+
// Pre-grab stable references to fields using findBy to wait for modal content
178+
const creditInput = await screen.findByRole('spinbutton', {name: 'Credit Amount'});
179+
const urlField = await screen.findByTestId('url-field');
180+
const notesField = await screen.findByTestId('notes-field');
181+
const submitButton = screen.getByRole('button', {name: /submit/i});
182+
183+
await userEvent.type(creditInput, '10');
184+
await waitFor(() => expect(creditInput).toHaveValue(10));
185+
186+
await userEvent.click(submitButton);
187+
188+
// Wait for form to be re-enabled after error
189+
// Don't rely on error message text as the Form component shows different messages
190+
// depending on error response structure. All fields are controlled by isSubmitting
191+
// state, so if one is enabled, all should be enabled.
192+
await waitFor(() => expect(creditInput).toBeEnabled());
193+
194+
// Verify all fields and submit button are re-enabled
195+
expect(urlField).toBeEnabled();
196+
expect(notesField).toBeEnabled();
197+
expect(submitButton).toBeEnabled();
198+
});
110199
});

static/gsAdmin/components/changeBalanceAction.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ function ChangeBalanceModal({
3232
}: ModalProps) {
3333
const [ticketUrl, setTicketUrl] = useState('');
3434
const [notes, setNotes] = useState('');
35+
const [isSubmitting, setIsSubmitting] = useState(false);
3536
const api = useApi();
3637

3738
function coerceValue(value: number) {
@@ -47,6 +48,12 @@ function ChangeBalanceModal({
4748
return;
4849
}
4950

51+
// Prevent concurrent submissions
52+
if (isSubmitting) {
53+
return;
54+
}
55+
56+
setIsSubmitting(true);
5057
try {
5158
await api.requestPromise(`/_admin/customers/${orgId}/balance-changes/`, {
5259
method: 'POST',
@@ -60,6 +67,8 @@ function ChangeBalanceModal({
6067
onSubmitError({
6168
responseJSON: err.responseJSON,
6269
});
70+
} finally {
71+
setIsSubmitting(false);
6372
}
6473
}
6574

@@ -76,14 +85,16 @@ function ChangeBalanceModal({
7685
<Form
7786
onSubmit={onSubmit}
7887
onCancel={closeModal}
79-
submitLabel="Submit"
88+
submitLabel={isSubmitting ? 'Submitting...' : 'Submit'}
89+
submitDisabled={isSubmitting}
8090
cancelLabel="Cancel"
8191
footerClass="modal-footer"
8292
>
8393
<NumberField
8494
label="Credit Amount"
8595
name="creditAmount"
8696
help="Add or remove credit, in dollars"
97+
disabled={isSubmitting}
8798
/>
8899
<AuditFields>
89100
<InputField
@@ -94,6 +105,7 @@ function ChangeBalanceModal({
94105
inline={false}
95106
stacked
96107
flexibleControlStateSize
108+
disabled={isSubmitting}
97109
onChange={(ticketUrlInput: any) => setTicketUrl(ticketUrlInput)}
98110
/>
99111
<TextField
@@ -104,6 +116,7 @@ function ChangeBalanceModal({
104116
stacked
105117
flexibleControlStateSize
106118
maxLength={500}
119+
disabled={isSubmitting}
107120
onChange={(notesInput: any) => setNotes(notesInput)}
108121
/>
109122
</AuditFields>

0 commit comments

Comments
 (0)