Skip to content

Commit 63aa643

Browse files
priscilawebdevclaudecodexClaude Opus 4.6
authored
ref(plugins): migrate plugin config to BackendJsonSubmitForm (#112340)
Migrate the project-level plugin configuration forms from the legacy `PluginSettings` class component + `GenericField` rendering to the new `BackendJsonSubmitForm` adapter. `PluginConfig` now fetches field definitions directly via `useApiQuery` from `GET /projects/{org}/{project}/plugins/{id}/` and maps the backend field types (`bool` → `boolean`, `readonly` → `disabled`, etc.) to the `JsonFormAdapterFieldConfig` shape. This removes the dependency on the client-side plugin module system (`plugins.get(plugin).renderSettings()`) for rendering settings. I considered preserving some plugin-specific legacy UX in the migrated forms. Jira previously used a multi-step settings flow and SessionStack used a collapsible advanced section. I did not carry those behaviors forward here; the migrated form is intentionally flat because restoring that legacy custom UI did not seem worth the extra complexity for these hidden/deprecated plugin settings. closes https://linear.app/getsentry/issue/DE-1054/endpoint-4-get-api0organizationsorgpluginsconfigs --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Codex <codex@openai.com> Co-authored-by: Claude Opus 4.6 <noreply@example.com>
1 parent b02565e commit 63aa643

File tree

5 files changed

+496
-86
lines changed

5 files changed

+496
-86
lines changed

static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -315,6 +315,36 @@ describe('BackendJsonSubmitForm', () => {
315315
);
316316
});
317317

318+
it('preserves explicit null initialValues over field defaults', async () => {
319+
render(
320+
<BackendJsonSubmitForm
321+
fields={[
322+
{
323+
name: 'priority',
324+
type: 'select',
325+
label: 'Priority',
326+
choices: [
327+
['high', 'High'],
328+
['medium', 'Medium'],
329+
['low', 'Low'],
330+
],
331+
default: 'medium',
332+
},
333+
]}
334+
initialValues={{priority: null}}
335+
onSubmit={onSubmit}
336+
submitLabel="Create"
337+
/>,
338+
{organization: org}
339+
);
340+
341+
await userEvent.click(screen.getByRole('button', {name: 'Create'}));
342+
343+
await waitFor(() => {
344+
expect(onSubmit).toHaveBeenCalledWith(expect.objectContaining({priority: null}));
345+
});
346+
});
347+
318348
it('renders footer with SubmitButton when footer prop provided', () => {
319349
render(
320350
<BackendJsonSubmitForm

static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -115,8 +115,11 @@ function computeDefaultValues(
115115
const defaults: Record<string, unknown> = {};
116116
for (const field of fields) {
117117
if (field.name && field.type !== 'blank') {
118+
const initialValue = initialValues?.[field.name];
118119
defaults[field.name] =
119-
initialValues?.[field.name] ?? field.default ?? getDefaultForField(field);
120+
initialValue === undefined
121+
? (field.default ?? getDefaultForField(field))
122+
: initialValue;
120123
}
121124
}
122125
return defaults;
@@ -270,7 +273,7 @@ export function BackendJsonSubmitForm({
270273
</fieldApi.Layout.Stack>
271274
);
272275
case 'select':
273-
case 'choice':
276+
case 'choice': {
274277
if (field.url) {
275278
// Async select: fetch options from URL as user types.
276279
// Show static choices as initial options before any search.
@@ -318,8 +321,12 @@ export function BackendJsonSubmitForm({
318321
>
319322
<fieldApi.SelectAsync
320323
multiple
321-
value={(fieldApi.state.value as string[]) ?? []}
322-
onChange={handleChange}
324+
value={
325+
(fieldApi.state.value as Array<string | number>) ?? []
326+
}
327+
onChange={(value: Array<string | number>) =>
328+
handleChange(value)
329+
}
323330
disabled={field.disabled}
324331
queryOptions={asyncQueryOptions}
325332
/>
@@ -332,12 +339,28 @@ export function BackendJsonSubmitForm({
332339
hintText={field.help}
333340
required={field.required}
334341
>
335-
<fieldApi.SelectAsync
336-
value={fieldApi.state.value as string | null}
337-
onChange={handleChange}
338-
disabled={field.disabled}
339-
queryOptions={asyncQueryOptions}
340-
/>
342+
{field.required ? (
343+
<fieldApi.SelectAsync
344+
value={
345+
(fieldApi.state.value ?? null) as string | number | null
346+
}
347+
onChange={(value: string | number) => handleChange(value)}
348+
disabled={field.disabled}
349+
queryOptions={asyncQueryOptions}
350+
/>
351+
) : (
352+
<fieldApi.SelectAsync
353+
clearable
354+
value={
355+
(fieldApi.state.value ?? null) as string | number | null
356+
}
357+
onChange={(value: string | number | null) =>
358+
handleChange(value)
359+
}
360+
disabled={field.disabled}
361+
queryOptions={asyncQueryOptions}
362+
/>
363+
)}
341364
</fieldApi.Layout.Stack>
342365
);
343366
}
@@ -351,7 +374,7 @@ export function BackendJsonSubmitForm({
351374
<fieldApi.Select
352375
multiple
353376
value={(fieldApi.state.value as string[]) ?? []}
354-
onChange={handleChange}
377+
onChange={(value: string[]) => handleChange(value)}
355378
options={transformChoices(field.choices)}
356379
disabled={field.disabled}
357380
/>
@@ -364,14 +387,25 @@ export function BackendJsonSubmitForm({
364387
hintText={field.help}
365388
required={field.required}
366389
>
367-
<fieldApi.Select
368-
value={fieldApi.state.value as string | null}
369-
onChange={handleChange}
370-
options={transformChoices(field.choices)}
371-
disabled={field.disabled}
372-
/>
390+
{field.required ? (
391+
<fieldApi.Select
392+
value={(fieldApi.state.value ?? null) as string | null}
393+
onChange={(value: string) => handleChange(value)}
394+
options={transformChoices(field.choices)}
395+
disabled={field.disabled}
396+
/>
397+
) : (
398+
<fieldApi.Select
399+
clearable
400+
value={(fieldApi.state.value ?? null) as string | null}
401+
onChange={(value: string | null) => handleChange(value)}
402+
options={transformChoices(field.choices)}
403+
disabled={field.disabled}
404+
/>
405+
)}
373406
</fieldApi.Layout.Stack>
374407
);
408+
}
375409
case 'secret':
376410
return (
377411
<fieldApi.Layout.Stack
Lines changed: 129 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,7 @@
11
import {WebhookPluginConfigFixture} from 'sentry-fixture/integrationListDirectory';
22

33
import {initializeOrg} from 'sentry-test/initializeOrg';
4-
import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
5-
6-
import {plugins} from 'sentry/plugins';
4+
import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary';
75

86
import {PluginConfig} from './pluginConfig';
97

@@ -16,21 +14,33 @@ describe('PluginConfig', () => {
1614
MockApiClient.addMockResponse({
1715
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
1816
method: 'GET',
19-
body: webhookPlugin,
17+
body: {
18+
...webhookPlugin,
19+
config: [
20+
{
21+
name: 'urls',
22+
label: 'Callback URLs',
23+
type: 'textarea',
24+
placeholder: 'https://sentry.io/callback/url',
25+
required: false,
26+
help: 'Enter callback URLs, separated by newlines.',
27+
value: 'https://example.com/hook',
28+
defaultValue: '',
29+
},
30+
],
31+
},
2032
});
2133
const testWebhookMock = MockApiClient.addMockResponse({
2234
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
2335
method: 'POST',
2436
body: {detail: 'No errors returned'},
2537
});
2638

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

31-
await userEvent.click(screen.getByRole('button', {name: 'Test Plugin'}));
41+
await userEvent.click(await screen.findByRole('button', {name: 'Test Plugin'}));
3242

33-
expect(await screen.findByText('"No errors returned"')).toBeInTheDocument();
43+
expect(await screen.findByText('No errors returned')).toBeInTheDocument();
3444
expect(testWebhookMock).toHaveBeenCalledWith(
3545
expect.any(String),
3646
expect.objectContaining({
@@ -39,4 +49,115 @@ describe('PluginConfig', () => {
3949
})
4050
);
4151
});
52+
53+
it('renders config fields from backend', async () => {
54+
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});
55+
56+
MockApiClient.addMockResponse({
57+
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
58+
method: 'GET',
59+
body: {
60+
...webhookPlugin,
61+
config: [
62+
{
63+
name: 'urls',
64+
label: 'Callback URLs',
65+
type: 'textarea',
66+
placeholder: 'https://sentry.io/callback/url',
67+
required: false,
68+
help: 'Enter callback URLs, separated by newlines.',
69+
value: '',
70+
defaultValue: '',
71+
},
72+
],
73+
},
74+
});
75+
76+
render(<PluginConfig plugin={webhookPlugin} project={project} />);
77+
78+
expect(await screen.findByLabelText('Callback URLs')).toBeInTheDocument();
79+
});
80+
81+
it('renders auth error state', async () => {
82+
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});
83+
84+
MockApiClient.addMockResponse({
85+
url: `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`,
86+
method: 'GET',
87+
body: {
88+
...webhookPlugin,
89+
config_error: 'You need to associate an identity',
90+
auth_url: '/auth/associate/webhooks/',
91+
},
92+
});
93+
94+
render(<PluginConfig plugin={webhookPlugin} project={project} />);
95+
96+
expect(
97+
await screen.findByText('You need to associate an identity')
98+
).toBeInTheDocument();
99+
expect(screen.getByRole('button', {name: 'Associate Identity'})).toBeInTheDocument();
100+
});
101+
102+
it('submits typed defaults when backend returns null for non-select fields', async () => {
103+
const webhookPlugin = WebhookPluginConfigFixture({enabled: true});
104+
const url = `/projects/${organization.slug}/${project.slug}/plugins/${webhookPlugin.id}/`;
105+
106+
const configBody = {
107+
...webhookPlugin,
108+
config: [
109+
{
110+
name: 'auto_create',
111+
label: 'Automatically create tickets',
112+
type: 'bool',
113+
required: false,
114+
value: null,
115+
},
116+
{
117+
name: 'repository',
118+
label: 'Repository',
119+
type: 'select',
120+
choices: [
121+
['', 'select a repo'],
122+
['getsentry/sentry', 'getsentry/sentry'],
123+
],
124+
required: false,
125+
value: null,
126+
},
127+
],
128+
};
129+
130+
MockApiClient.addMockResponse({
131+
url,
132+
method: 'GET',
133+
body: configBody,
134+
});
135+
const saveRequest = MockApiClient.addMockResponse({
136+
url,
137+
method: 'PUT',
138+
body: configBody,
139+
});
140+
MockApiClient.addMockResponse({
141+
url,
142+
method: 'GET',
143+
body: configBody,
144+
});
145+
146+
render(<PluginConfig plugin={webhookPlugin} project={project} />);
147+
148+
await userEvent.click(await screen.findByRole('button', {name: 'Save Changes'}));
149+
150+
await waitFor(() =>
151+
expect(saveRequest).toHaveBeenCalledWith(
152+
expect.any(String),
153+
expect.objectContaining({
154+
method: 'PUT',
155+
data: {
156+
auto_create: false,
157+
repository: null,
158+
},
159+
})
160+
)
161+
);
162+
});
42163
});

0 commit comments

Comments
 (0)