Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
b11e9c3
ref(plugins): migrate plugin config to BackendJsonSubmitForm
priscilawebdev Apr 7, 2026
c75c1cb
ref(plugins): remove unnecessary type casts and simplify
priscilawebdev Apr 13, 2026
3e592d3
fix(plugins): Improve migrated plugin config form actions
priscilawebdev Apr 13, 2026
fde1e70
fix(plugins): polish migrated plugin config form behavior
priscilawebdev Apr 13, 2026
f8a9377
ref(plugins): tighten plugin config field narrowing
priscilawebdev Apr 13, 2026
a672f43
fix(plugins): scope select clearability to plugin config
priscilawebdev Apr 13, 2026
2c27edd
chore(plugins): clarify reset refetch intent
priscilawebdev Apr 13, 2026
87cb9bb
ref(plugins): replace deprecated integration analytics alias
priscilawebdev Apr 13, 2026
619147c
ref(plugins): inline field narrowing and improve textarea test
priscilawebdev Apr 13, 2026
c4fc6f5
ref(plugins): remove unused legacy plugin field keys
priscilawebdev Apr 13, 2026
ec0bc3c
ref(plugins): rename clearable prop and tighten field assertion
priscilawebdev Apr 13, 2026
908706b
ref(plugins): fix clearable select typing
priscilawebdev Apr 13, 2026
1675cea
fix(forms): narrow static select value types
priscilawebdev Apr 13, 2026
b25bb6f
fix(plugins): Preserve typed defaults in config form
priscilawebdev Apr 13, 2026
c15e4e3
fix(forms): Preserve explicit null initial values
priscilawebdev Apr 13, 2026
0651e67
fix(plugins): treat null as unset for non-select fields
priscilawebdev Apr 13, 2026
6e3b834
fix(plugins): use explicit null check for wasConfigured
priscilawebdev Apr 13, 2026
c2ad728
fix(plugins): handle non-string detail in test results
priscilawebdev Apr 13, 2026
a6124cd
ref(forms): Derive select clearability from field.required
priscilawebdev Apr 14, 2026
137725c
ref(plugins): Use useMutation for test plugin request
priscilawebdev Apr 14, 2026
f2e6348
ref(plugins): Remove redundant type param from useMutation
priscilawebdev Apr 14, 2026
c77b529
ref(plugins): Use useMutation for config submit
priscilawebdev Apr 14, 2026
34af23b
ref(plugins): Remove unused classNames and data-test-id
priscilawebdev Apr 14, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,36 @@ describe('BackendJsonSubmitForm', () => {
);
});

it('preserves explicit null initialValues over field defaults', async () => {
render(
<BackendJsonSubmitForm
fields={[
{
name: 'priority',
type: 'select',
label: 'Priority',
choices: [
['high', 'High'],
['medium', 'Medium'],
['low', 'Low'],
],
default: 'medium',
},
]}
initialValues={{priority: null}}
onSubmit={onSubmit}
submitLabel="Create"
/>,
{organization: org}
);

await userEvent.click(screen.getByRole('button', {name: 'Create'}));

await waitFor(() => {
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({priority: null}));
});
});

it('renders footer with SubmitButton when footer prop provided', () => {
render(
<BackendJsonSubmitForm
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,11 @@ function computeDefaultValues(
const defaults: Record<string, unknown> = {};
for (const field of fields) {
if (field.name && field.type !== 'blank') {
const initialValue = initialValues?.[field.name];
defaults[field.name] =
initialValues?.[field.name] ?? field.default ?? getDefaultForField(field);
initialValue === undefined
? (field.default ?? getDefaultForField(field))
: initialValue;
}
}
return defaults;
Expand Down Expand Up @@ -270,7 +273,7 @@ export function BackendJsonSubmitForm({
</fieldApi.Layout.Stack>
);
case 'select':
case 'choice':
case 'choice': {
if (field.url) {
// Async select: fetch options from URL as user types.
// Show static choices as initial options before any search.
Expand Down Expand Up @@ -318,8 +321,12 @@ export function BackendJsonSubmitForm({
>
<fieldApi.SelectAsync
multiple
value={(fieldApi.state.value as string[]) ?? []}
onChange={handleChange}
value={
(fieldApi.state.value as Array<string | number>) ?? []
}
onChange={(value: Array<string | number>) =>
handleChange(value)
}
disabled={field.disabled}
queryOptions={asyncQueryOptions}
/>
Expand All @@ -332,12 +339,28 @@ export function BackendJsonSubmitForm({
hintText={field.help}
required={field.required}
>
<fieldApi.SelectAsync
value={fieldApi.state.value as string | null}
onChange={handleChange}
disabled={field.disabled}
queryOptions={asyncQueryOptions}
/>
{field.required ? (
<fieldApi.SelectAsync
value={
(fieldApi.state.value ?? null) as string | number | null
}
onChange={(value: string | number) => handleChange(value)}
disabled={field.disabled}
queryOptions={asyncQueryOptions}
/>
) : (
<fieldApi.SelectAsync
clearable
value={
(fieldApi.state.value ?? null) as string | number | null
}
onChange={(value: string | number | null) =>
handleChange(value)
}
disabled={field.disabled}
queryOptions={asyncQueryOptions}
/>
)}
</fieldApi.Layout.Stack>
);
}
Expand All @@ -351,7 +374,7 @@ export function BackendJsonSubmitForm({
<fieldApi.Select
multiple
value={(fieldApi.state.value as string[]) ?? []}
onChange={handleChange}
onChange={(value: string[]) => handleChange(value)}
options={transformChoices(field.choices)}
disabled={field.disabled}
/>
Expand All @@ -364,14 +387,25 @@ export function BackendJsonSubmitForm({
hintText={field.help}
required={field.required}
>
<fieldApi.Select
value={fieldApi.state.value as string | null}
onChange={handleChange}
options={transformChoices(field.choices)}
disabled={field.disabled}
/>
{field.required ? (
<fieldApi.Select
value={(fieldApi.state.value ?? null) as string | null}
onChange={(value: string) => handleChange(value)}
options={transformChoices(field.choices)}
disabled={field.disabled}
/>
) : (
<fieldApi.Select
clearable
value={(fieldApi.state.value ?? null) as string | null}
onChange={(value: string | null) => handleChange(value)}
options={transformChoices(field.choices)}
disabled={field.disabled}
/>
)}
</fieldApi.Layout.Stack>
);
}
case 'secret':
return (
<fieldApi.Layout.Stack
Expand Down
137 changes: 129 additions & 8 deletions static/app/components/pluginConfig.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import {WebhookPluginConfigFixture} from 'sentry-fixture/integrationListDirectory';

import {initializeOrg} from 'sentry-test/initializeOrg';
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';

import {plugins} from 'sentry/plugins';
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';

import {PluginConfig} from './pluginConfig';

Expand All @@ -16,21 +14,33 @@ describe('PluginConfig', () => {
MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
method: 'GET',
body: webhookPlugin,
body: {
...webhookPlugin,
config: [
{
name: 'urls',
label: 'Callback URLs',
type: 'textarea',
placeholder: 'https://sentry.io/callback/url',
required: false,
help: 'Enter callback URLs, separated by newlines.',
value: 'https://example.com/hook',
defaultValue: '',
},
],
},
});
const testWebhookMock = MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
method: 'POST',
body: {detail: 'No errors returned'},
});

expect(plugins.isLoaded(webhookPlugin)).toBe(false);
render(<PluginConfig plugin={webhookPlugin} project={project} />);
expect(plugins.isLoaded(webhookPlugin)).toBe(true);

await userEvent.click(screen.getByRole('button', {name: 'Test Plugin'}));
await userEvent.click(await screen.findByRole('button', {name: 'Test Plugin'}));

expect(await screen.findByText('"No errors returned"')).toBeInTheDocument();
expect(await screen.findByText('No errors returned')).toBeInTheDocument();
expect(testWebhookMock).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
Expand All @@ -39,4 +49,115 @@ describe('PluginConfig', () => {
})
);
});

it('renders config fields from backend', async () => {
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});

MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
method: 'GET',
body: {
...webhookPlugin,
config: [
{
name: 'urls',
label: 'Callback URLs',
type: 'textarea',
placeholder: 'https://sentry.io/callback/url',
required: false,
help: 'Enter callback URLs, separated by newlines.',
value: '',
defaultValue: '',
},
],
},
});

render(<PluginConfig plugin={webhookPlugin} project={project} />);

expect(await screen.findByLabelText('Callback URLs')).toBeInTheDocument();
});

it('renders auth error state', async () => {
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});

MockApiClient.addMockResponse({
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
method: 'GET',
body: {
...webhookPlugin,
config_error: 'You need to associate an identity',
auth_url: '/auth/associate/webhooks/',
},
});

render(<PluginConfig plugin={webhookPlugin} project={project} />);

expect(
await screen.findByText('You need to associate an identity')
).toBeInTheDocument();
expect(screen.getByRole('button', {name: 'Associate Identity'})).toBeInTheDocument();
});

it('submits typed defaults when backend returns null for non-select fields', async () => {
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});
const url = `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`;

const configBody = {
...webhookPlugin,
config: [
{
name: 'auto_create',
label: 'Automatically create tickets',
type: 'bool',
required: false,
value: null,
},
{
name: 'repository',
label: 'Repository',
type: 'select',
choices: [
['', 'select a repo'],
['getsentry/sentry', 'getsentry/sentry'],
],
required: false,
value: null,
},
],
};

MockApiClient.addMockResponse({
url,
method: 'GET',
body: configBody,
});
const saveRequest = MockApiClient.addMockResponse({
url,
method: 'PUT',
body: configBody,
});
MockApiClient.addMockResponse({
url,
method: 'GET',
body: configBody,
});

render(<PluginConfig plugin={webhookPlugin} project={project} />);

await userEvent.click(await screen.findByRole('button', {name: 'Save Changes'}));

await waitFor(() =>
expect(saveRequest).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
method: 'PUT',
data: {
auto_create: false,
repository: null,
},
})
)
);
});
});
Loading
Loading