From aa3cf1868a8183008af7614567d21741aa7697a4 Mon Sep 17 00:00:00 2001 From: Rahul Chhabria Date: Thu, 26 Mar 2026 23:46:47 -0700 Subject: [PATCH 1/2] feat(admin): Replace startup program notes field with program dropdown Replace the free-text notes field in the "Add to Startup Program" modal with a dropdown of predefined program options. Selecting "Other" reveals a custom notes text field for free-form input. Co-Authored-By: Claude --- .../addToStartupProgramAction.spec.tsx | 69 ++++++++++++++++--- .../components/addToStartupProgramAction.tsx | 51 ++++++++++++-- 2 files changed, 105 insertions(+), 15 deletions(-) diff --git a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx index 8295bc9cd9c83e..de45f059334536 100644 --- a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx +++ b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx @@ -57,14 +57,14 @@ describe('AddToStartupProgramAction', () => { expect(await screen.findByTestId('balance')).toHaveTextContent('$30.00 owed'); }); - it('has default values for credit amount and notes', async () => { + it('has default values for credit amount and program', async () => { triggerAddToStartupProgramModal(modalProps); renderGlobalModal(); expect(await screen.findByRole('spinbutton', {name: 'Credit Amount'})).toHaveValue( 5000 ); - expect(screen.getByRole('textbox', {name: 'Notes'})).toHaveValue('sentryforstartups'); + expect(screen.getByText('Sentry for Startups')).toBeInTheDocument(); }); it('can submit with default values', async () => { @@ -97,7 +97,57 @@ describe('AddToStartupProgramAction', () => { expect(onSuccess).toHaveBeenCalled(); }); - it('can submit with custom values', async () => { + it('can submit with a different program selected', async () => { + const updateMock = MockApiClient.addMockResponse({ + url: `/_admin/customers/${organization.slug}/balance-changes/`, + method: 'POST', + body: {}, + }); + + triggerAddToStartupProgramModal(modalProps); + + const {waitForModalToHide} = renderGlobalModal(); + + // Change the program dropdown + await userEvent.click(await screen.findByText('Sentry for Startups')); + await userEvent.click(screen.getByText('Y Combinator')); + + await userEvent.click(screen.getByRole('button', {name: 'Submit'})); + + await waitForModalToHide(); + + await waitFor(() => { + expect(updateMock).toHaveBeenCalledWith( + `/_admin/customers/${organization.slug}/balance-changes/`, + expect.objectContaining({ + method: 'POST', + data: { + creditAmount: 500000, + ticketUrl: '', + notes: 'ycombinator', + }, + }) + ); + }); + }); + + it('shows custom notes field when "Other" is selected', async () => { + triggerAddToStartupProgramModal(modalProps); + + renderGlobalModal(); + + // Custom notes should not be visible initially + expect(screen.queryByRole('textbox', {name: 'Custom Notes'})).not.toBeInTheDocument(); + + // Select "Other" + await userEvent.click(await screen.findByText('Sentry for Startups')); + await userEvent.click(screen.getByText('Other')); + + // Custom notes field should now be visible + expect(screen.getByRole('textbox', {name: 'Custom Notes'})).toBeInTheDocument(); + }); + + it('can submit with custom notes when "Other" is selected', async () => { const updateMock = MockApiClient.addMockResponse({ url: `/_admin/customers/${organization.slug}/balance-changes/`, method: 'POST', @@ -115,9 +165,14 @@ describe('AddToStartupProgramAction', () => { await userEvent.type(screen.getByRole('textbox', {name: 'Ticket URL'}), url); - const notesInput = screen.getByRole('textbox', {name: 'Notes'}); - await userEvent.clear(notesInput); - await userEvent.type(notesInput, 'custom note'); + // Select "Other" to show custom notes + await userEvent.click(screen.getByText('Sentry for Startups')); + await userEvent.click(screen.getByText('Other')); + + await userEvent.type( + screen.getByRole('textbox', {name: 'Custom Notes'}), + 'custom note' + ); await userEvent.click(screen.getByRole('button', {name: 'Submit'})); @@ -156,7 +211,6 @@ describe('AddToStartupProgramAction', () => { expect(submitButton).toBeDisabled(); expect(screen.getByRole('spinbutton', {name: 'Credit Amount'})).toBeDisabled(); expect(screen.getByRole('textbox', {name: 'Ticket URL'})).toBeDisabled(); - expect(screen.getByRole('textbox', {name: 'Notes'})).toBeDisabled(); await waitForModalToHide(); }); @@ -205,7 +259,6 @@ describe('AddToStartupProgramAction', () => { {timeout: 5_000} ); expect(screen.getByRole('textbox', {name: 'Ticket URL'})).toBeEnabled(); - expect(screen.getByRole('textbox', {name: 'Notes'})).toBeEnabled(); expect(screen.getByRole('button', {name: /submit/i})).toBeEnabled(); }, 25_000); diff --git a/static/gsAdmin/components/addToStartupProgramAction.tsx b/static/gsAdmin/components/addToStartupProgramAction.tsx index 86b083cbb1d8b1..7ae68c32a6efb0 100644 --- a/static/gsAdmin/components/addToStartupProgramAction.tsx +++ b/static/gsAdmin/components/addToStartupProgramAction.tsx @@ -1,4 +1,4 @@ -import {Fragment} from 'react'; +import {Fragment, useState} from 'react'; import {Flex} from '@sentry/scraps/layout'; import {Heading, Text} from '@sentry/scraps/text'; @@ -8,6 +8,7 @@ import type {ModalRenderProps} from 'sentry/actionCreators/modal'; import {openModal} from 'sentry/actionCreators/modal'; import {InputField} from 'sentry/components/forms/fields/inputField'; import {NumberField} from 'sentry/components/forms/fields/numberField'; +import {SelectField} from 'sentry/components/forms/fields/selectField'; import {TextField} from 'sentry/components/forms/fields/textField'; import {Form, type FormProps} from 'sentry/components/forms/form'; import {fetchMutation, useMutation} from 'sentry/utils/queryClient'; @@ -16,6 +17,20 @@ import type {RequestError} from 'sentry/utils/requestError/requestError'; import type {Subscription} from 'getsentry/types'; import {formatBalance} from 'getsentry/utils/billing'; +const STARTUP_PROGRAM_OPTIONS = [ + {value: 'ycombinator', label: 'Y Combinator'}, + {value: 'sentryforstartups', label: 'Sentry for Startups'}, + {value: 'a16z', label: 'a16z'}, + {value: 'accelatoms', label: 'Accelatoms'}, + {value: 'accelfam', label: 'Accelfam'}, + {value: 'renderstack', label: 'Renderstack'}, + {value: 'finpack', label: 'Finpack'}, + {value: 'betaworks', label: 'Betaworks'}, + {value: 'alchemist', label: 'Alchemist'}, + {value: 'antler', label: 'Antler'}, + {value: 'other', label: 'Other'}, +]; + function coerceValue(value: number) { if (isNaN(value)) { return undefined; @@ -46,6 +61,8 @@ function AddToStartupProgramModal({ Header, Body, }: AddToStartupProgramModalProps) { + const [showCustomNotes, setShowCustomNotes] = useState(false); + const {mutate, isPending} = useMutation< Record, RequestError, @@ -82,7 +99,13 @@ function AddToStartupProgramModal({ const creditAmountInput = Number(data.creditAmount); const creditAmount = coerceValue(creditAmountInput); const ticketUrl = typeof data.ticketUrl === 'string' ? data.ticketUrl : ''; - const notes = typeof data.notes === 'string' ? data.notes : ''; + const notesProgram = typeof data.notesProgram === 'string' ? data.notesProgram : ''; + const notes = + notesProgram === 'other' + ? typeof data.notesCustom === 'string' + ? data.notesCustom + : '' + : notesProgram; if (!creditAmount || isPending) { return; @@ -118,7 +141,8 @@ function AddToStartupProgramModal({ footerClass="modal-footer" initialData={{ creditAmount: 5000, - notes: 'sentryforstartups', + notesProgram: 'sentryforstartups', + notesCustom: '', }} > @@ -139,14 +163,27 @@ function AddToStartupProgramModal({ stacked disabled={isPending} /> - { + setShowCustomNotes(value === 'other'); + }} /> + {showCustomNotes && ( + + )} From 59b1e535749a809d7a315b68656917f1c8d98a64 Mon Sep 17 00:00:00 2001 From: Rahul Chhabria Date: Sat, 28 Mar 2026 19:27:53 -0700 Subject: [PATCH 2/2] ref(admin): Keep dropdown visible with custom notes field below The notes dropdown always stays visible. Selecting "Enter custom notes" shows a text field below the dropdown. Selecting a preset option hides the text field. Uses exact option values as-is. Co-Authored-By: Claude --- .../addToStartupProgramAction.spec.tsx | 43 ++++++++++++------- .../components/addToStartupProgramAction.tsx | 39 ++++++++--------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx index de45f059334536..df0908a33b6096 100644 --- a/static/gsAdmin/components/addToStartupProgramAction.spec.tsx +++ b/static/gsAdmin/components/addToStartupProgramAction.spec.tsx @@ -57,14 +57,14 @@ describe('AddToStartupProgramAction', () => { expect(await screen.findByTestId('balance')).toHaveTextContent('$30.00 owed'); }); - it('has default values for credit amount and program', async () => { + it('has default values for credit amount and notes', async () => { triggerAddToStartupProgramModal(modalProps); renderGlobalModal(); expect(await screen.findByRole('spinbutton', {name: 'Credit Amount'})).toHaveValue( 5000 ); - expect(screen.getByText('Sentry for Startups')).toBeInTheDocument(); + expect(screen.getByText('sentryforstartups')).toBeInTheDocument(); }); it('can submit with default values', async () => { @@ -97,7 +97,7 @@ describe('AddToStartupProgramAction', () => { expect(onSuccess).toHaveBeenCalled(); }); - it('can submit with a different program selected', async () => { + it('can submit with a different option selected', async () => { const updateMock = MockApiClient.addMockResponse({ url: `/_admin/customers/${organization.slug}/balance-changes/`, method: 'POST', @@ -108,9 +108,8 @@ describe('AddToStartupProgramAction', () => { const {waitForModalToHide} = renderGlobalModal(); - // Change the program dropdown - await userEvent.click(await screen.findByText('Sentry for Startups')); - await userEvent.click(screen.getByText('Y Combinator')); + await userEvent.click(await screen.findByText('sentryforstartups')); + await userEvent.click(screen.getByText('ycombinator')); await userEvent.click(screen.getByRole('button', {name: 'Submit'})); @@ -131,23 +130,36 @@ describe('AddToStartupProgramAction', () => { }); }); - it('shows custom notes field when "Other" is selected', async () => { + it('shows custom notes field when "Enter custom notes" is selected', async () => { triggerAddToStartupProgramModal(modalProps); renderGlobalModal(); - // Custom notes should not be visible initially expect(screen.queryByRole('textbox', {name: 'Custom Notes'})).not.toBeInTheDocument(); - // Select "Other" - await userEvent.click(await screen.findByText('Sentry for Startups')); - await userEvent.click(screen.getByText('Other')); + await userEvent.click(await screen.findByText('sentryforstartups')); + await userEvent.click(screen.getByText('Enter custom notes')); - // Custom notes field should now be visible expect(screen.getByRole('textbox', {name: 'Custom Notes'})).toBeInTheDocument(); }); - it('can submit with custom notes when "Other" is selected', async () => { + it('hides custom notes field when switching back to a preset option', async () => { + triggerAddToStartupProgramModal(modalProps); + + renderGlobalModal(); + + // Select "Enter custom notes" + await userEvent.click(await screen.findByText('sentryforstartups')); + await userEvent.click(screen.getByText('Enter custom notes')); + expect(screen.getByRole('textbox', {name: 'Custom Notes'})).toBeInTheDocument(); + + // Switch back to a preset option + await userEvent.click(screen.getByText('Enter custom notes')); + await userEvent.click(screen.getByText('a16z')); + expect(screen.queryByRole('textbox', {name: 'Custom Notes'})).not.toBeInTheDocument(); + }); + + it('can submit with custom notes', async () => { const updateMock = MockApiClient.addMockResponse({ url: `/_admin/customers/${organization.slug}/balance-changes/`, method: 'POST', @@ -165,9 +177,8 @@ describe('AddToStartupProgramAction', () => { await userEvent.type(screen.getByRole('textbox', {name: 'Ticket URL'}), url); - // Select "Other" to show custom notes - await userEvent.click(screen.getByText('Sentry for Startups')); - await userEvent.click(screen.getByText('Other')); + await userEvent.click(screen.getByText('sentryforstartups')); + await userEvent.click(screen.getByText('Enter custom notes')); await userEvent.type( screen.getByRole('textbox', {name: 'Custom Notes'}), diff --git a/static/gsAdmin/components/addToStartupProgramAction.tsx b/static/gsAdmin/components/addToStartupProgramAction.tsx index 7ae68c32a6efb0..f6ac174bf1e447 100644 --- a/static/gsAdmin/components/addToStartupProgramAction.tsx +++ b/static/gsAdmin/components/addToStartupProgramAction.tsx @@ -18,17 +18,17 @@ import type {Subscription} from 'getsentry/types'; import {formatBalance} from 'getsentry/utils/billing'; const STARTUP_PROGRAM_OPTIONS = [ - {value: 'ycombinator', label: 'Y Combinator'}, - {value: 'sentryforstartups', label: 'Sentry for Startups'}, + {value: 'ycombinator', label: 'ycombinator'}, + {value: 'sentryforstartups', label: 'sentryforstartups'}, {value: 'a16z', label: 'a16z'}, - {value: 'accelatoms', label: 'Accelatoms'}, - {value: 'accelfam', label: 'Accelfam'}, - {value: 'renderstack', label: 'Renderstack'}, - {value: 'finpack', label: 'Finpack'}, - {value: 'betaworks', label: 'Betaworks'}, - {value: 'alchemist', label: 'Alchemist'}, - {value: 'antler', label: 'Antler'}, - {value: 'other', label: 'Other'}, + {value: 'accelatoms', label: 'accelatoms'}, + {value: 'accelfam', label: 'accelfam'}, + {value: 'renderstack', label: 'renderstack'}, + {value: 'finpack', label: 'finpack'}, + {value: 'betaworks', label: 'betaworks'}, + {value: 'alchemist', label: 'alchemist'}, + {value: 'antler', label: 'antler'}, + {value: 'other', label: 'Enter custom notes'}, ]; function coerceValue(value: number) { @@ -99,13 +99,13 @@ function AddToStartupProgramModal({ const creditAmountInput = Number(data.creditAmount); const creditAmount = coerceValue(creditAmountInput); const ticketUrl = typeof data.ticketUrl === 'string' ? data.ticketUrl : ''; - const notesProgram = typeof data.notesProgram === 'string' ? data.notesProgram : ''; + const rawNotes = typeof data.notes === 'string' ? data.notes : ''; const notes = - notesProgram === 'other' - ? typeof data.notesCustom === 'string' - ? data.notesCustom + rawNotes === 'other' + ? typeof data.customNotes === 'string' + ? data.customNotes : '' - : notesProgram; + : rawNotes; if (!creditAmount || isPending) { return; @@ -141,8 +141,7 @@ function AddToStartupProgramModal({ footerClass="modal-footer" initialData={{ creditAmount: 5000, - notesProgram: 'sentryforstartups', - notesCustom: '', + notes: 'sentryforstartups', }} > @@ -164,8 +163,8 @@ function AddToStartupProgramModal({ disabled={isPending} /> {showCustomNotes && (