From b11e9c37449c49a6398f40a9ea3f5978f52072d0 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 7 Apr 2026 12:47:05 +0200 Subject: [PATCH 01/23] ref(plugins): migrate plugin config to BackendJsonSubmitForm Replace the legacy PluginSettings class component and GenericField rendering with BackendJsonSubmitForm for plugin configuration forms. The plugin config now fetches field definitions via useApiQuery and maps backend field types to the new form adapter, eliminating the dependency on the client-side plugin module system for settings. Refs DE-1054 Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.spec.tsx | 71 +++++- static/app/components/pluginConfig.tsx | 256 +++++++++++++++++--- 2 files changed, 286 insertions(+), 41 deletions(-) diff --git a/static/app/components/pluginConfig.spec.tsx b/static/app/components/pluginConfig.spec.tsx index 7fddde5aa09137..7a4e0960b2b909 100644 --- a/static/app/components/pluginConfig.spec.tsx +++ b/static/app/components/pluginConfig.spec.tsx @@ -3,8 +3,6 @@ import {WebhookPluginConfigFixture} from 'sentry-fixture/integrationListDirector import {initializeOrg} from 'sentry-test/initializeOrg'; import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; -import {plugins} from 'sentry/plugins'; - import {PluginConfig} from './pluginConfig'; describe('PluginConfig', () => { @@ -16,7 +14,21 @@ 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}/`, @@ -24,11 +36,9 @@ describe('PluginConfig', () => { body: {detail: 'No errors returned'}, }); - expect(plugins.isLoaded(webhookPlugin)).toBe(false); render(); - 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(testWebhookMock).toHaveBeenCalledWith( @@ -39,4 +49,53 @@ 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(); + + expect(await screen.findByText('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(); + + expect( + await screen.findByText('You need to associate an identity') + ).toBeInTheDocument(); + expect(screen.getByRole('button', {name: 'Associate Identity'})).toBeInTheDocument(); + }); }); diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 2bc6994e10801e..08cddbde4623a6 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -1,7 +1,8 @@ -import {useEffect, useRef, useState} from 'react'; +import {useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; -import {Button} from '@sentry/scraps/button'; +import {Alert} from '@sentry/scraps/alert'; +import {Button, LinkButton} from '@sentry/scraps/button'; import {Flex, Grid} from '@sentry/scraps/layout'; import { @@ -10,19 +11,95 @@ import { addSuccessMessage, } from 'sentry/actionCreators/indicator'; import {hasEveryAccess} from 'sentry/components/acl/access'; +import {BackendJsonSubmitForm} from 'sentry/components/backendJsonFormAdapter/backendJsonSubmitForm'; +import type {JsonFormAdapterFieldConfig} from 'sentry/components/backendJsonFormAdapter/types'; +import {LoadingError} from 'sentry/components/loadingError'; import {LoadingIndicator} from 'sentry/components/loadingIndicator'; import {Panel} from 'sentry/components/panels/panel'; import {PanelAlert} from 'sentry/components/panels/panelAlert'; import {PanelBody} from 'sentry/components/panels/panelBody'; import {PanelHeader} from 'sentry/components/panels/panelHeader'; import {t} from 'sentry/locale'; -import {plugins} from 'sentry/plugins'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; import type {Plugin} from 'sentry/types/integrations'; import type {Project} from 'sentry/types/project'; -import {useApi} from 'sentry/utils/useApi'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; +import {fetchMutation, useApiQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; +/** + * Field config shape returned by the backend's PluginWithConfigSerializer. + */ +interface BackendPluginField { + label: string; + name: string; + type: string; + choices?: Array<[string, string]>; + default?: unknown; + defaultValue?: unknown; + hasSavedValue?: boolean; + help?: string; + isDeprecated?: boolean; + isHidden?: boolean; + placeholder?: string; + prefix?: string; + readonly?: boolean; + required?: boolean; + value?: unknown; +} + +interface PluginWithConfig extends Plugin { + auth_url?: string; + config?: BackendPluginField[]; + config_error?: string; +} + +/** + * Maps a backend plugin field to the JsonFormAdapterFieldConfig shape + * expected by BackendJsonSubmitForm. + */ +function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { + const base = { + name: field.name, + label: field.label, + required: field.required, + help: field.help ?? undefined, + placeholder: field.placeholder ?? undefined, + disabled: field.readonly, + default: field.defaultValue ?? field.default, + }; + + // Backend uses 'bool', adapter uses 'boolean' + const type = field.type === 'bool' ? 'boolean' : field.type; + + switch (type) { + case 'boolean': + return {...base, type: 'boolean'} as JsonFormAdapterFieldConfig; + case 'select': + case 'choice': + return { + ...base, + type: type as 'select' | 'choice', + choices: field.choices, + } as JsonFormAdapterFieldConfig; + case 'secret': + return {...base, type: 'secret'} as JsonFormAdapterFieldConfig; + case 'textarea': + return {...base, type: 'textarea'} as JsonFormAdapterFieldConfig; + case 'number': + return {...base, type: 'number'} as JsonFormAdapterFieldConfig; + case 'email': + return {...base, type: 'email'} as JsonFormAdapterFieldConfig; + case 'url': + return {...base, type: 'url'} as JsonFormAdapterFieldConfig; + case 'string': + case 'text': + default: + return {...base, type: 'text'} as JsonFormAdapterFieldConfig; + } +} + interface PluginConfigProps { plugin: Plugin; project: Project; @@ -36,42 +113,66 @@ export function PluginConfig({ enabled, onDisablePlugin, }: PluginConfigProps) { - const api = useApi(); const organization = useOrganization(); - // If passed via props, use that value instead of from `data` const isEnabled = typeof enabled === 'undefined' ? plugin.enabled : enabled; const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project}); const [testResults, setTestResults] = useState(''); - const [isPluginLoading, setIsPluginLoading] = useState(!plugins.isLoaded(plugin)); - const loadingPluginIdRef = useRef(null); - - useEffect(() => { - // Avoid loading the same plugin multiple times - if (!plugins.isLoaded(plugin) && loadingPluginIdRef.current !== plugin.id) { - setIsPluginLoading(true); - loadingPluginIdRef.current = plugin.id; - plugins.load(plugin, () => { - setIsPluginLoading(false); - }); + + const pluginEndpoint = getApiUrl( + '/projects/$organizationIdOrSlug/$projectIdOrSlug/plugins/$pluginId/', + { + path: { + organizationIdOrSlug: organization.slug, + projectIdOrSlug: project.slug, + pluginId: plugin.id, + }, + } + ); + + const { + data: pluginData, + isPending, + isError, + refetch, + } = useApiQuery([pluginEndpoint], { + staleTime: 0, + }); + + const wasConfiguredRef = useRef(false); + + const {fields, initialValues} = useMemo(() => { + if (!pluginData?.config) { + return {fields: [], initialValues: {}}; } - }, [plugin]); + + let configured = false; + const vals: Record = {}; + const mapped: JsonFormAdapterFieldConfig[] = []; + + for (const field of pluginData.config) { + if (field.value) { + configured = true; + } + vals[field.name] = field.value ?? field.defaultValue ?? ''; + mapped.push(mapPluginField(field)); + } + + wasConfiguredRef.current = configured; + return {fields: mapped, initialValues: vals}; + }, [pluginData]); const handleTestPlugin = async () => { setTestResults(''); addLoadingMessage(t('Sending test...')); try { - const pluginEndpointData = await api.requestPromise( - `/projects/${organization.slug}/${project.slug}/plugins/${plugin.id}/`, - { - method: 'POST', - data: { - test: true, - }, - } - ); + const response = (await fetchMutation({ + method: 'POST', + url: pluginEndpoint, + data: {test: true}, + })) as {detail: string}; - setTestResults(JSON.stringify(pluginEndpointData.detail)); + setTestResults(JSON.stringify(response.detail)); addSuccessMessage(t('Test Complete!')); } catch (_err) { addErrorMessage( @@ -84,6 +185,86 @@ export function PluginConfig({ onDisablePlugin?.(plugin); }; + const handleSubmit = async (values: Record) => { + if (!wasConfiguredRef.current) { + trackIntegrationAnalytics('integrations.installation_start', { + integration: plugin.id, + integration_type: 'plugin', + view: 'plugin_details', + already_installed: false, + organization, + }); + } + + addLoadingMessage(t('Saving changes\u2026')); + + try { + await fetchMutation({ + method: 'PUT', + url: pluginEndpoint, + data: values, + }); + + addSuccessMessage(t('Success!')); + + trackIntegrationAnalytics('integrations.config_saved', { + integration: plugin.id, + integration_type: 'plugin', + view: 'plugin_details', + already_installed: wasConfiguredRef.current, + organization, + }); + + if (!wasConfiguredRef.current) { + trackIntegrationAnalytics('integrations.installation_complete', { + integration: plugin.id, + integration_type: 'plugin', + view: 'plugin_details', + already_installed: false, + organization, + }); + } + + refetch(); + } catch (_err) { + addErrorMessage(t('Unable to save changes. Please try again.')); + } + }; + + // Auth error state (e.g. OAuth plugins needing identity association) + if (pluginData?.config_error) { + let authUrl = pluginData.auth_url ?? ''; + if (authUrl.includes('?')) { + authUrl += '&next=' + encodeURIComponent(document.location.pathname); + } else { + authUrl += '?next=' + encodeURIComponent(document.location.pathname); + } + return ( + + + + + {plugin.name} + + + +
+ + + {pluginData.config_error} + + + + {t('Associate Identity')} + + + + ); + } + return (
- {isPluginLoading ? ( + {isPending ? ( - ) : ( - plugins.get(plugin).renderSettings({ - organization, - project, - }) - )} + ) : isError ? ( + + ) : fields.length > 0 ? ( + + ) : null} ); From c75c1cb8acc2ab651b2176df05be1e62d8c98f9d Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 08:35:15 +0200 Subject: [PATCH 02/23] ref(plugins): remove unnecessary type casts and simplify Remove blanket `as JsonFormAdapterFieldConfig` casts by pulling default out of the base object so TypeScript can narrow each union member. Drop useMemo/useRef in favor of direct derivation from pluginData, use fetchMutation generic instead of cast, and inline one-liner handler. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.tsx | 70 +++++++++++--------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 08cddbde4623a6..208851d07d0a2c 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -1,4 +1,4 @@ -import {useMemo, useRef, useState} from 'react'; +import {useState} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; @@ -67,36 +67,38 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { help: field.help ?? undefined, placeholder: field.placeholder ?? undefined, disabled: field.readonly, - default: field.defaultValue ?? field.default, }; + const defaultValue = field.defaultValue ?? field.default; + // Backend uses 'bool', adapter uses 'boolean' const type = field.type === 'bool' ? 'boolean' : field.type; switch (type) { case 'boolean': - return {...base, type: 'boolean'} as JsonFormAdapterFieldConfig; + return {...base, type: 'boolean', default: defaultValue as boolean | undefined}; case 'select': case 'choice': return { ...base, type: type as 'select' | 'choice', + default: defaultValue, choices: field.choices, - } as JsonFormAdapterFieldConfig; + }; case 'secret': - return {...base, type: 'secret'} as JsonFormAdapterFieldConfig; + return {...base, type: 'secret', default: defaultValue}; case 'textarea': - return {...base, type: 'textarea'} as JsonFormAdapterFieldConfig; + return {...base, type: 'textarea', default: defaultValue}; case 'number': - return {...base, type: 'number'} as JsonFormAdapterFieldConfig; + return {...base, type: 'number', default: defaultValue as number | undefined}; case 'email': - return {...base, type: 'email'} as JsonFormAdapterFieldConfig; + return {...base, type: 'email', default: defaultValue}; case 'url': - return {...base, type: 'url'} as JsonFormAdapterFieldConfig; + return {...base, type: 'url', default: defaultValue}; case 'string': case 'text': default: - return {...base, type: 'text'} as JsonFormAdapterFieldConfig; + return {...base, type: 'text', default: defaultValue}; } } @@ -138,39 +140,25 @@ export function PluginConfig({ staleTime: 0, }); - const wasConfiguredRef = useRef(false); - - const {fields, initialValues} = useMemo(() => { - if (!pluginData?.config) { - return {fields: [], initialValues: {}}; - } - - let configured = false; - const vals: Record = {}; - const mapped: JsonFormAdapterFieldConfig[] = []; + const config = pluginData?.config ?? []; + const wasConfigured = config.some(field => field.value); - for (const field of pluginData.config) { - if (field.value) { - configured = true; - } - vals[field.name] = field.value ?? field.defaultValue ?? ''; - mapped.push(mapPluginField(field)); - } - - wasConfiguredRef.current = configured; - return {fields: mapped, initialValues: vals}; - }, [pluginData]); + const fields = config.map(mapPluginField); + const initialValues: Record = {}; + for (const field of config) { + initialValues[field.name] = field.value ?? field.defaultValue ?? ''; + } const handleTestPlugin = async () => { setTestResults(''); addLoadingMessage(t('Sending test...')); try { - const response = (await fetchMutation({ + const response = await fetchMutation<{detail: string}>({ method: 'POST', url: pluginEndpoint, data: {test: true}, - })) as {detail: string}; + }); setTestResults(JSON.stringify(response.detail)); addSuccessMessage(t('Test Complete!')); @@ -181,12 +169,8 @@ export function PluginConfig({ } }; - const handleDisablePlugin = () => { - onDisablePlugin?.(plugin); - }; - const handleSubmit = async (values: Record) => { - if (!wasConfiguredRef.current) { + if (!wasConfigured) { trackIntegrationAnalytics('integrations.installation_start', { integration: plugin.id, integration_type: 'plugin', @@ -211,11 +195,11 @@ export function PluginConfig({ integration: plugin.id, integration_type: 'plugin', view: 'plugin_details', - already_installed: wasConfiguredRef.current, + already_installed: wasConfigured, organization, }); - if (!wasConfiguredRef.current) { + if (!wasConfigured) { trackIntegrationAnalytics('integrations.installation_complete', { integration: plugin.id, integration_type: 'plugin', @@ -283,7 +267,11 @@ export function PluginConfig({ {t('Test Plugin')} )} - From 3e592d330ecd805e37902116d29fdcc1b854e347 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:04:44 +0200 Subject: [PATCH 03/23] fix(plugins): Improve migrated plugin config form actions Add a settings-style footer action row with a top divider for the migrated plugin configuration form, and provide a fallback placeholder for secret fields when the backend does not send one. This keeps the migrated UI usable while preserving the existing backend-driven form flow. Co-Authored-By: Codex --- static/app/components/pluginConfig.tsx | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 208851d07d0a2c..2419c88371a183 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -81,12 +81,17 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { case 'choice': return { ...base, - type: type as 'select' | 'choice', + type, default: defaultValue, choices: field.choices, }; case 'secret': - return {...base, type: 'secret', default: defaultValue}; + return { + ...base, + type: 'secret', + default: defaultValue, + placeholder: field.placeholder ?? t('Enter a secret'), + }; case 'textarea': return {...base, type: 'textarea', default: defaultValue}; case 'number': @@ -304,6 +309,19 @@ export function PluginConfig({ onSubmit={handleSubmit} submitLabel={t('Save Changes')} submitDisabled={!hasWriteAccess} + footer={({SubmitButton, disabled}) => ( + + + {t('Save Changes')} + + + )} /> ) : null} From fde1e70b9c6ee81d4225e49707fb78c67e5224ba Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:20:44 +0200 Subject: [PATCH 04/23] fix(plugins): polish migrated plugin config form behavior Improve migrated plugin configuration UX by restoring single-select clearability, normalizing legacy empty select options into placeholders, and refreshing plugin data after reset so stale values are not shown. Also simplify plugin config typing around nullable backend fields and enabled state fallback. Co-Authored-By: Codex --- .../backendJsonSubmitForm.tsx | 2 ++ static/app/components/pluginConfig.tsx | 29 +++++++++++++++---- .../views/settings/projectPlugins/details.tsx | 3 +- 3 files changed, 27 insertions(+), 7 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index e16ff542efee5e..1ec7b1fe08c411 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -333,6 +333,7 @@ export function BackendJsonSubmitForm({ required={field.required} > value === '')?.[1]; + const normalizedChoices = field.choices?.filter(([value]) => value !== ''); + + // Legacy plugin configs often encode placeholder text as an empty option. + // Preserve that UX by treating it as placeholder, not as a selectable value. + const placeholder = base.placeholder ?? emptyChoiceLabel ?? undefined; + const normalizedDefault = defaultValue === '' ? undefined : defaultValue; + return { ...base, type, - default: defaultValue, - choices: field.choices, + placeholder, + default: normalizedDefault, + choices: normalizedChoices, }; + } case 'secret': return { ...base, @@ -121,7 +131,7 @@ export function PluginConfig({ onDisablePlugin, }: PluginConfigProps) { const organization = useOrganization(); - const isEnabled = typeof enabled === 'undefined' ? plugin.enabled : enabled; + const isEnabled = enabled ?? plugin.enabled; const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project}); const [testResults, setTestResults] = useState(''); @@ -149,6 +159,12 @@ export function PluginConfig({ const wasConfigured = config.some(field => field.value); const fields = config.map(mapPluginField); + const formKey = config + .map( + field => + `${field.name}:${JSON.stringify(field.value)}:${JSON.stringify(field.defaultValue)}` + ) + .join(','); const initialValues: Record = {}; for (const field of config) { initialValues[field.name] = field.value ?? field.defaultValue ?? ''; @@ -304,6 +320,7 @@ export function PluginConfig({ ) : fields.length > 0 ? ( { + onSuccess: async () => { addSuccessMessage(t('Plugin was reset')); trackAnalytics('integrations.uninstall_completed', { integration: pluginId, @@ -93,6 +93,7 @@ export default function ProjectPluginDetails() { view: 'plugin_details', organization, }); + await Promise.all([refetchPlugins(), refetchPluginDetails()]); }, onError: () => { addErrorMessage(t('An error occurred')); From f8a9377aadfc720da4c1d9b0583ea81fc34313e1 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:25:37 +0200 Subject: [PATCH 05/23] ref(plugins): tighten plugin config field narrowing Replace type assertions in plugin config field mapping with runtime narrowing helpers, and parse test-plugin response detail with a runtime guard instead of a typed fetchMutation cast. This keeps mapper behavior explicit for nullable/unknown backend values and avoids unsafe assumptions about response shape. Co-Authored-By: Codex --- static/app/components/pluginConfig.tsx | 30 +++++++++++++++++++++----- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index cff4d5efd821e2..a65c67efb86a55 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -55,6 +55,26 @@ interface PluginWithConfig extends Plugin { config_error?: string; } +function toBooleanOrUndefined(value: unknown): boolean | undefined { + return typeof value === 'boolean' ? value : undefined; +} + +function toNumberOrUndefined(value: unknown): number | undefined { + return typeof value === 'number' ? value : undefined; +} + +function getDetailMessage(response: unknown): string { + if ( + typeof response === 'object' && + response !== null && + 'detail' in response && + typeof response.detail === 'string' + ) { + return response.detail; + } + return ''; +} + /** * Maps a backend plugin field to the JsonFormAdapterFieldConfig shape * expected by BackendJsonSubmitForm. @@ -76,7 +96,7 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { switch (type) { case 'boolean': - return {...base, type: 'boolean', default: defaultValue as boolean | undefined}; + return {...base, type: 'boolean', default: toBooleanOrUndefined(defaultValue)}; case 'select': case 'choice': { const emptyChoiceLabel = field.choices?.find(([value]) => value === '')?.[1]; @@ -105,7 +125,7 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { case 'textarea': return {...base, type: 'textarea', default: defaultValue}; case 'number': - return {...base, type: 'number', default: defaultValue as number | undefined}; + return {...base, type: 'number', default: toNumberOrUndefined(defaultValue)}; case 'email': return {...base, type: 'email', default: defaultValue}; case 'url': @@ -175,13 +195,13 @@ export function PluginConfig({ addLoadingMessage(t('Sending test...')); try { - const response = await fetchMutation<{detail: string}>({ + const response = await fetchMutation({ method: 'POST', url: pluginEndpoint, data: {test: true}, }); - - setTestResults(JSON.stringify(response.detail)); + const detail = getDetailMessage(response); + setTestResults(JSON.stringify(detail)); addSuccessMessage(t('Test Complete!')); } catch (_err) { addErrorMessage( From a672f438c2b55c0d6d6405e858e8fd93fc5c3933 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:31:33 +0200 Subject: [PATCH 06/23] fix(plugins): scope select clearability to plugin config Add a singleSelectClearable switch to BackendJsonSubmitForm and enable it only for PluginConfig so legacy plugin repo fields can be cleared without changing other adapter consumers. Also keep plugin config runtime narrowing and placeholder handling improvements. Co-Authored-By: Codex --- .../backendJsonFormAdapter/backendJsonSubmitForm.tsx | 10 ++++++++-- static/app/components/pluginConfig.tsx | 1 + 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index 1ec7b1fe08c411..e4a8e7e1fdbe02 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -71,6 +71,11 @@ interface BackendJsonSubmitFormProps { * Called when a field with `updatesForm: true` changes value. */ onFieldChange?: (fieldName: string, value: unknown) => void; + /** + * Enables clearing for single-select fields. + * Defaults to false to preserve existing behavior for non-plugin forms. + */ + singleSelectClearable?: boolean; /** * Whether the submit button should be disabled (e.g., form has errors). */ @@ -156,6 +161,7 @@ export function BackendJsonSubmitForm({ onAsyncOptionsFetched, onFieldChange, footer, + singleSelectClearable = false, }: BackendJsonSubmitFormProps) { // Ref to avoid including the callback in queryKey (would cause refetches) const onAsyncOptionsFetchedRef = useRef(onAsyncOptionsFetched); @@ -333,7 +339,7 @@ export function BackendJsonSubmitForm({ required={field.required} > ( Date: Mon, 13 Apr 2026 10:32:52 +0200 Subject: [PATCH 07/23] chore(plugins): clarify reset refetch intent Add a short inline comment explaining that reset refetches both plugin list and plugin details to keep toggle state and config values synchronized. Co-Authored-By: Codex --- static/app/views/settings/projectPlugins/details.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/static/app/views/settings/projectPlugins/details.tsx b/static/app/views/settings/projectPlugins/details.tsx index 6c526eb86b6478..dc6a00e135f6bc 100644 --- a/static/app/views/settings/projectPlugins/details.tsx +++ b/static/app/views/settings/projectPlugins/details.tsx @@ -93,6 +93,7 @@ export default function ProjectPluginDetails() { view: 'plugin_details', organization, }); + // Keep both the toggle state and config form in sync after reset. await Promise.all([refetchPlugins(), refetchPluginDetails()]); }, onError: () => { From 87cb9bb7757e3c250e4c0f7030948e1abbecc368 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:36:17 +0200 Subject: [PATCH 08/23] ref(plugins): replace deprecated integration analytics alias --- static/app/components/pluginConfig.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 16108a1e0a7152..4f8aee17fc7719 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -23,8 +23,8 @@ import {t} from 'sentry/locale'; import {PluginIcon} from 'sentry/plugins/components/pluginIcon'; import type {Plugin} from 'sentry/types/integrations'; import type {Project} from 'sentry/types/project'; +import {trackAnalytics} from 'sentry/utils/analytics'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil'; import {fetchMutation, useApiQuery} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; @@ -212,7 +212,7 @@ export function PluginConfig({ const handleSubmit = async (values: Record) => { if (!wasConfigured) { - trackIntegrationAnalytics('integrations.installation_start', { + trackAnalytics('integrations.installation_start', { integration: plugin.id, integration_type: 'plugin', view: 'plugin_details', @@ -232,7 +232,7 @@ export function PluginConfig({ addSuccessMessage(t('Success!')); - trackIntegrationAnalytics('integrations.config_saved', { + trackAnalytics('integrations.config_saved', { integration: plugin.id, integration_type: 'plugin', view: 'plugin_details', @@ -241,7 +241,7 @@ export function PluginConfig({ }); if (!wasConfigured) { - trackIntegrationAnalytics('integrations.installation_complete', { + trackAnalytics('integrations.installation_complete', { integration: plugin.id, integration_type: 'plugin', view: 'plugin_details', From 619147ccfa07667ddedb349cf9bca5cc5ddbf7b1 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:38:16 +0200 Subject: [PATCH 09/23] ref(plugins): inline field narrowing and improve textarea test --- static/app/components/pluginConfig.spec.tsx | 4 +++- static/app/components/pluginConfig.tsx | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 11 deletions(-) diff --git a/static/app/components/pluginConfig.spec.tsx b/static/app/components/pluginConfig.spec.tsx index 7a4e0960b2b909..4f86b2e1451e77 100644 --- a/static/app/components/pluginConfig.spec.tsx +++ b/static/app/components/pluginConfig.spec.tsx @@ -75,7 +75,9 @@ describe('PluginConfig', () => { render(); - expect(await screen.findByText('Callback URLs')).toBeInTheDocument(); + expect( + await screen.findByRole('textbox', {name: 'Callback URLs'}) + ).toBeInTheDocument(); }); it('renders auth error state', async () => { diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 4f8aee17fc7719..234fb593875de9 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -55,14 +55,6 @@ interface PluginWithConfig extends Plugin { config_error?: string; } -function toBooleanOrUndefined(value: unknown): boolean | undefined { - return typeof value === 'boolean' ? value : undefined; -} - -function toNumberOrUndefined(value: unknown): number | undefined { - return typeof value === 'number' ? value : undefined; -} - function getDetailMessage(response: unknown): string { if ( typeof response === 'object' && @@ -96,7 +88,11 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { switch (type) { case 'boolean': - return {...base, type: 'boolean', default: toBooleanOrUndefined(defaultValue)}; + return { + ...base, + type: 'boolean', + default: typeof defaultValue === 'boolean' ? defaultValue : undefined, + }; case 'select': case 'choice': { const emptyChoiceLabel = field.choices?.find(([value]) => value === '')?.[1]; @@ -125,7 +121,11 @@ function mapPluginField(field: BackendPluginField): JsonFormAdapterFieldConfig { case 'textarea': return {...base, type: 'textarea', default: defaultValue}; case 'number': - return {...base, type: 'number', default: toNumberOrUndefined(defaultValue)}; + return { + ...base, + type: 'number', + default: typeof defaultValue === 'number' ? defaultValue : undefined, + }; case 'email': return {...base, type: 'email', default: defaultValue}; case 'url': From c4fc6f5c2d9fb9c44bb93bf7ed76161ee8fb9020 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:41:33 +0200 Subject: [PATCH 10/23] ref(plugins): remove unused legacy plugin field keys --- static/app/components/pluginConfig.tsx | 4 ---- 1 file changed, 4 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 234fb593875de9..5859b10eb0119b 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -38,12 +38,8 @@ interface BackendPluginField { choices?: Array<[string, string]>; default?: unknown; defaultValue?: unknown; - hasSavedValue?: boolean; help?: null | string; - isDeprecated?: boolean; - isHidden?: boolean; placeholder?: null | string; - prefix?: string; readonly?: boolean; required?: boolean; value?: unknown; From ec0bc3cd7adf2bd22b38823d0e30a7419f649007 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 10:45:21 +0200 Subject: [PATCH 11/23] ref(plugins): rename clearable prop and tighten field assertion --- .../backendJsonSubmitForm.tsx | 16 ++++++++-------- static/app/components/pluginConfig.spec.tsx | 4 +--- static/app/components/pluginConfig.tsx | 2 +- 3 files changed, 10 insertions(+), 12 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index e4a8e7e1fdbe02..76f5860ef717e7 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -55,6 +55,11 @@ interface BackendJsonSubmitFormProps { * across form remounts. */ initialValues?: Record; + /** + * Enables clearing for single-select fields. + * Defaults to false to preserve existing behavior. + */ + isClearable?: boolean; /** * Whether the form is in a loading state (e.g., dynamic field refetch in progress). */ @@ -71,11 +76,6 @@ interface BackendJsonSubmitFormProps { * Called when a field with `updatesForm: true` changes value. */ onFieldChange?: (fieldName: string, value: unknown) => void; - /** - * Enables clearing for single-select fields. - * Defaults to false to preserve existing behavior for non-plugin forms. - */ - singleSelectClearable?: boolean; /** * Whether the submit button should be disabled (e.g., form has errors). */ @@ -161,7 +161,7 @@ export function BackendJsonSubmitForm({ onAsyncOptionsFetched, onFieldChange, footer, - singleSelectClearable = false, + isClearable = false, }: BackendJsonSubmitFormProps) { // Ref to avoid including the callback in queryKey (would cause refetches) const onAsyncOptionsFetchedRef = useRef(onAsyncOptionsFetched); @@ -339,7 +339,7 @@ export function BackendJsonSubmitForm({ required={field.required} > { render(); - expect( - await screen.findByRole('textbox', {name: 'Callback URLs'}) - ).toBeInTheDocument(); + expect(await screen.findByLabelText('Callback URLs')).toBeInTheDocument(); }); it('renders auth error state', async () => { diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 5859b10eb0119b..1d1efe8e27448e 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -342,7 +342,7 @@ export function PluginConfig({ onSubmit={handleSubmit} submitLabel={t('Save Changes')} submitDisabled={!hasWriteAccess} - singleSelectClearable + isClearable footer={({SubmitButton, disabled}) => ( Date: Mon, 13 Apr 2026 10:53:56 +0200 Subject: [PATCH 12/23] ref(plugins): fix clearable select typing --- .../backendJsonSubmitForm.tsx | 95 +++++++++++++++---- static/app/components/pluginConfig.tsx | 4 +- 2 files changed, 78 insertions(+), 21 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index 76f5860ef717e7..b633fa0d00a904 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -276,7 +276,7 @@ export function BackendJsonSubmitForm({ ); 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. @@ -316,6 +316,11 @@ export function BackendJsonSubmitForm({ }, }); if (field.multiple) { + const handleMultipleSelectChange = ( + value: Array + ) => { + handleChange(value); + }; return ( ) ?? [] + } + onChange={handleMultipleSelectChange} disabled={field.disabled} queryOptions={asyncQueryOptions} /> ); } + const singleSelectValue = (fieldApi.state.value ?? null) as + | string + | number + | null; + const handleSingleClearableSelectChange = ( + value: string | number | null + ) => { + handleChange(value); + }; + const handleSingleSelectChange = (value: string | number) => { + handleChange(value); + }; return ( - + {isClearable ? ( + + ) : ( + + )} ); } if (field.multiple) { + const handleMultipleSelectChange = ( + value: Array + ) => { + handleChange(value); + }; return ( ) ?? [] + } + onChange={handleMultipleSelectChange} options={transformChoices(field.choices)} disabled={field.disabled} /> ); } + const singleSelectValue = (fieldApi.state.value ?? null) as + | string + | number + | null; + const handleSingleClearableSelectChange = ( + value: string | number | null + ) => { + handleChange(value); + }; + const handleSingleSelectChange = (value: string | number) => { + handleChange(value); + }; return ( - + {isClearable ? ( + + ) : ( + + )} ); + } case 'secret': return ( Date: Mon, 13 Apr 2026 10:57:47 +0200 Subject: [PATCH 13/23] fix(forms): narrow static select value types --- .../backendJsonSubmitForm.tsx | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index b633fa0d00a904..4152cd490c5d00 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -377,9 +377,7 @@ export function BackendJsonSubmitForm({ ); } if (field.multiple) { - const handleMultipleSelectChange = ( - value: Array - ) => { + const handleMultipleSelectChange = (value: string[]) => { handleChange(value); }; return ( @@ -390,9 +388,7 @@ export function BackendJsonSubmitForm({ > ) ?? [] - } + value={(fieldApi.state.value as string[]) ?? []} onChange={handleMultipleSelectChange} options={transformChoices(field.choices)} disabled={field.disabled} @@ -402,14 +398,13 @@ export function BackendJsonSubmitForm({ } const singleSelectValue = (fieldApi.state.value ?? null) as | string - | number | null; const handleSingleClearableSelectChange = ( - value: string | number | null + value: string | null ) => { handleChange(value); }; - const handleSingleSelectChange = (value: string | number) => { + const handleSingleSelectChange = (value: string) => { handleChange(value); }; return ( From b25bb6f55c796bbadf3369c3593bbdddf1f24042 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 11:09:24 +0200 Subject: [PATCH 14/23] fix(plugins): Preserve typed defaults in config form Avoid seeding plugin config initial values with empty strings when the backend has no saved value. This lets the form adapter fall back to field-specific defaults so booleans submit false and single selects submit null instead of empty strings. Inline the select change handlers in the backend form adapter now that the clearable branches are typed explicitly. Refs DE-1054 Co-Authored-By: OpenAI Codex --- .../backendJsonSubmitForm.tsx | 59 ++++++------------ static/app/components/pluginConfig.spec.tsx | 62 ++++++++++++++++++- static/app/components/pluginConfig.tsx | 8 ++- 3 files changed, 86 insertions(+), 43 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index 4152cd490c5d00..6563ee2cc12076 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -316,11 +316,6 @@ export function BackendJsonSubmitForm({ }, }); if (field.multiple) { - const handleMultipleSelectChange = ( - value: Array - ) => { - handleChange(value); - }; return ( ) ?? [] } - onChange={handleMultipleSelectChange} + onChange={(value: Array) => + handleChange(value) + } disabled={field.disabled} queryOptions={asyncQueryOptions} /> ); } - const singleSelectValue = (fieldApi.state.value ?? null) as - | string - | number - | null; - const handleSingleClearableSelectChange = ( - value: string | number | null - ) => { - handleChange(value); - }; - const handleSingleSelectChange = (value: string | number) => { - handleChange(value); - }; return ( + handleChange(value) + } disabled={field.disabled} queryOptions={asyncQueryOptions} /> ) : ( handleChange(value)} disabled={field.disabled} queryOptions={asyncQueryOptions} /> @@ -377,9 +368,6 @@ export function BackendJsonSubmitForm({ ); } if (field.multiple) { - const handleMultipleSelectChange = (value: string[]) => { - handleChange(value); - }; return ( handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> ); } - const singleSelectValue = (fieldApi.state.value ?? null) as - | string - | null; - const handleSingleClearableSelectChange = ( - value: string | null - ) => { - handleChange(value); - }; - const handleSingleSelectChange = (value: string) => { - handleChange(value); - }; return ( handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> ) : ( handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> diff --git a/static/app/components/pluginConfig.spec.tsx b/static/app/components/pluginConfig.spec.tsx index 74e63daab87ea5..3a93ae267855a6 100644 --- a/static/app/components/pluginConfig.spec.tsx +++ b/static/app/components/pluginConfig.spec.tsx @@ -1,7 +1,7 @@ import {WebhookPluginConfigFixture} from 'sentry-fixture/integrationListDirectory'; import {initializeOrg} from 'sentry-test/initializeOrg'; -import {render, screen, userEvent} from 'sentry-test/reactTestingLibrary'; +import {render, screen, userEvent, waitFor} from 'sentry-test/reactTestingLibrary'; import {PluginConfig} from './pluginConfig'; @@ -98,4 +98,64 @@ describe('PluginConfig', () => { ).toBeInTheDocument(); expect(screen.getByRole('button', {name: 'Associate Identity'})).toBeInTheDocument(); }); + + it('submits typed defaults for unsaved boolean and 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, + }, + { + name: 'repository', + label: 'Repository', + type: 'select', + choices: [ + ['', 'select a repo'], + ['getsentry/sentry', 'getsentry/sentry'], + ], + required: false, + }, + ], + }; + + MockApiClient.addMockResponse({ + url, + method: 'GET', + body: configBody, + }); + const saveRequest = MockApiClient.addMockResponse({ + url, + method: 'PUT', + body: configBody, + }); + MockApiClient.addMockResponse({ + url, + method: 'GET', + body: configBody, + }); + + render(); + + 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, + }, + }) + ) + ); + }); }); diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 0414b6cb5a9139..71342a8188a282 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -183,7 +183,13 @@ export function PluginConfig({ .join(','); const initialValues: Record = {}; for (const field of config) { - initialValues[field.name] = field.value ?? field.defaultValue ?? ''; + if (field.value === undefined) { + continue; + } + + const type = field.type === 'bool' ? 'boolean' : field.type; + initialValues[field.name] = + (type === 'select' || type === 'choice') && field.value === '' ? null : field.value; } const handleTestPlugin = async () => { From c15e4e32c139bd558e4347721c33b15be7dc7c8c Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 11:33:55 +0200 Subject: [PATCH 15/23] fix(forms): Preserve explicit null initial values Treat null in initialValues as an intentional override instead of falling through to a field default. This keeps cleared select fields empty even when the backend field declares a default option. Add a regression test covering the submit path for a clearable select with a null initial value. Co-Authored-By: Codex --- .../backendJsonSubmitForm.spec.tsx | 31 +++++++++++++++++++ .../backendJsonSubmitForm.tsx | 5 ++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx index 74dc6deef4a91f..9876de451ea474 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx @@ -315,6 +315,37 @@ describe('BackendJsonSubmitForm', () => { ); }); + it('preserves explicit null initialValues over field defaults', async () => { + render( + , + {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( = {}; 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; From 0651e67c7e0f1373123cf9b35c2a8d16c5591086 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 12:01:24 +0200 Subject: [PATCH 16/23] fix(plugins): treat null as unset for non-select fields Backend returns null (Python None) for unconfigured fields. Previously null bypassed typed defaults in computeDefaultValues, causing booleans to submit null instead of false. --- static/app/components/pluginConfig.spec.tsx | 4 +++- static/app/components/pluginConfig.tsx | 9 +++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/static/app/components/pluginConfig.spec.tsx b/static/app/components/pluginConfig.spec.tsx index 3a93ae267855a6..b5fb07a440a5e5 100644 --- a/static/app/components/pluginConfig.spec.tsx +++ b/static/app/components/pluginConfig.spec.tsx @@ -99,7 +99,7 @@ describe('PluginConfig', () => { expect(screen.getByRole('button', {name: 'Associate Identity'})).toBeInTheDocument(); }); - it('submits typed defaults for unsaved boolean and select fields', async () => { + 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}/`; @@ -111,6 +111,7 @@ describe('PluginConfig', () => { label: 'Automatically create tickets', type: 'bool', required: false, + value: null, }, { name: 'repository', @@ -121,6 +122,7 @@ describe('PluginConfig', () => { ['getsentry/sentry', 'getsentry/sentry'], ], required: false, + value: null, }, ], }; diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 71342a8188a282..b70592edbb49ce 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -188,8 +188,13 @@ export function PluginConfig({ } const type = field.type === 'bool' ? 'boolean' : field.type; - initialValues[field.name] = - (type === 'select' || type === 'choice') && field.value === '' ? null : field.value; + const isSelect = type === 'select' || type === 'choice'; + + if (field.value === null && !isSelect) { + continue; + } + + initialValues[field.name] = isSelect && field.value === '' ? null : field.value; } const handleTestPlugin = async () => { From 6e3b8342a75764d2438cc38437a54f9c5ff5dca5 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 12:06:22 +0200 Subject: [PATCH 17/23] fix(plugins): use explicit null check for wasConfigured Truthiness check treated false and 0 as unconfigured, causing duplicate analytics events on subsequent saves. --- static/app/components/pluginConfig.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index b70592edbb49ce..e02b761378815a 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -172,7 +172,9 @@ export function PluginConfig({ }); const config = pluginData?.config ?? []; - const wasConfigured = config.some(field => field.value); + const wasConfigured = config.some( + field => field.value !== null && field.value !== undefined && field.value !== '' + ); const fields = config.map(mapPluginField); const formKey = config From c2ad728bb147e76e019ed336761019c33a5f1dfb Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Mon, 13 Apr 2026 12:21:08 +0200 Subject: [PATCH 18/23] fix(plugins): handle non-string detail in test results getDetailMessage now JSON-stringifies array and object detail values instead of silently dropping them. Also removes the redundant JSON.stringify at the call site that was double-quoting string details. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.spec.tsx | 2 +- static/app/components/pluginConfig.tsx | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/static/app/components/pluginConfig.spec.tsx b/static/app/components/pluginConfig.spec.tsx index b5fb07a440a5e5..7009a7a8b85dca 100644 --- a/static/app/components/pluginConfig.spec.tsx +++ b/static/app/components/pluginConfig.spec.tsx @@ -40,7 +40,7 @@ describe('PluginConfig', () => { 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({ diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index e02b761378815a..a7a8f787ede387 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -56,9 +56,13 @@ function getDetailMessage(response: unknown): string { typeof response === 'object' && response !== null && 'detail' in response && - typeof response.detail === 'string' + response.detail !== undefined && + response.detail !== null ) { - return response.detail; + if (typeof response.detail === 'string') { + return response.detail; + } + return JSON.stringify(response.detail); } return ''; } @@ -210,7 +214,7 @@ export function PluginConfig({ data: {test: true}, }); const detail = getDetailMessage(response); - setTestResults(JSON.stringify(detail)); + setTestResults(detail); addSuccessMessage(t('Test Complete!')); } catch { addErrorMessage( From a6124cd3b9a141758b172234d50696196cde4d5d Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 14 Apr 2026 08:56:13 +0200 Subject: [PATCH 19/23] ref(forms): Derive select clearability from field.required Replace the form-level isClearable prop with per-field logic: optional select fields are clearable, required ones are not. This is a cleaner API since clearability is inherently tied to whether a field is required. Co-Authored-By: Claude Opus 4.6 --- .../backendJsonSubmitForm.spec.tsx | 1 - .../backendJsonSubmitForm.tsx | 26 +++++++------------ static/app/components/pluginConfig.tsx | 1 - 3 files changed, 10 insertions(+), 18 deletions(-) diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx index 9876de451ea474..c8bbaa7d2ecd64 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx @@ -334,7 +334,6 @@ describe('BackendJsonSubmitForm', () => { initialValues={{priority: null}} onSubmit={onSubmit} submitLabel="Create" - isClearable />, {organization: org} ); diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx index 0c8a4edb3c3709..e4312b0ee33071 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.tsx @@ -55,11 +55,6 @@ interface BackendJsonSubmitFormProps { * across form remounts. */ initialValues?: Record; - /** - * Enables clearing for single-select fields. - * Defaults to false to preserve existing behavior. - */ - isClearable?: boolean; /** * Whether the form is in a loading state (e.g., dynamic field refetch in progress). */ @@ -164,7 +159,6 @@ export function BackendJsonSubmitForm({ onAsyncOptionsFetched, onFieldChange, footer, - isClearable = false, }: BackendJsonSubmitFormProps) { // Ref to avoid including the callback in queryKey (would cause refetches) const onAsyncOptionsFetchedRef = useRef(onAsyncOptionsFetched); @@ -345,24 +339,24 @@ export function BackendJsonSubmitForm({ hintText={field.help} required={field.required} > - {isClearable ? ( + {field.required ? ( - handleChange(value) - } + onChange={(value: string | number) => handleChange(value)} disabled={field.disabled} queryOptions={asyncQueryOptions} /> ) : ( handleChange(value)} + onChange={(value: string | number | null) => + handleChange(value) + } disabled={field.disabled} queryOptions={asyncQueryOptions} /> @@ -393,18 +387,18 @@ export function BackendJsonSubmitForm({ hintText={field.help} required={field.required} > - {isClearable ? ( + {field.required ? ( handleChange(value)} + onChange={(value: string) => handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> ) : ( handleChange(value)} + onChange={(value: string | null) => handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index a7a8f787ede387..66c6a6cc5bd83d 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -359,7 +359,6 @@ export function PluginConfig({ onSubmit={handleSubmit} submitLabel={t('Save Changes')} submitDisabled={!hasWriteAccess} - isClearable footer={({SubmitButton, disabled}) => ( Date: Tue, 14 Apr 2026 09:00:26 +0200 Subject: [PATCH 20/23] ref(plugins): Use useMutation for test plugin request Replace manual fetchMutation + useState with useMutation hook. This eliminates the separate testResults state by using mutation.data directly. Also type the response and simplify getDetailMessage. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.tsx | 56 +++++++++++--------------- 1 file changed, 24 insertions(+), 32 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 66c6a6cc5bd83d..534a6a7ac1b34f 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -1,4 +1,3 @@ -import {useState} from 'react'; import styled from '@emotion/styled'; import {Alert} from '@sentry/scraps/alert'; @@ -25,7 +24,7 @@ import type {Plugin} from 'sentry/types/integrations'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import {getApiUrl} from 'sentry/utils/api/getApiUrl'; -import {fetchMutation, useApiQuery} from 'sentry/utils/queryClient'; +import {fetchMutation, useApiQuery, useMutation} from 'sentry/utils/queryClient'; import {useOrganization} from 'sentry/utils/useOrganization'; /** @@ -51,20 +50,18 @@ interface PluginWithConfig extends Plugin { config_error?: string; } -function getDetailMessage(response: unknown): string { - if ( - typeof response === 'object' && - response !== null && - 'detail' in response && - response.detail !== undefined && - response.detail !== null - ) { - if (typeof response.detail === 'string') { - return response.detail; - } - return JSON.stringify(response.detail); +interface PluginTestResponse { + detail?: string | unknown; +} + +function getDetailMessage(response: PluginTestResponse): string { + if (response.detail === null || response.detail === undefined) { + return ''; } - return ''; + if (typeof response.detail === 'string') { + return response.detail; + } + return JSON.stringify(response.detail); } /** @@ -153,7 +150,6 @@ export function PluginConfig({ const organization = useOrganization(); const isEnabled = enabled ?? plugin.enabled; const hasWriteAccess = hasEveryAccess(['project:write'], {organization, project}); - const [testResults, setTestResults] = useState(''); const pluginEndpoint = getApiUrl( '/projects/$organizationIdOrSlug/$projectIdOrSlug/plugins/$pluginId/', @@ -203,25 +199,21 @@ export function PluginConfig({ initialValues[field.name] = isSelect && field.value === '' ? null : field.value; } - const handleTestPlugin = async () => { - setTestResults(''); - addLoadingMessage(t('Sending test...')); - - try { - const response = await fetchMutation({ + const testMutation = useMutation({ + mutationFn: () => { + addLoadingMessage(t('Sending test...')); + return fetchMutation({ method: 'POST', url: pluginEndpoint, data: {test: true}, }); - const detail = getDetailMessage(response); - setTestResults(detail); - addSuccessMessage(t('Test Complete!')); - } catch { + }, + onSuccess: () => addSuccessMessage(t('Test Complete!')), + onError: () => addErrorMessage( t('An unexpected error occurred while testing your plugin. Please try again.') - ); - } - }; + ), + }); const handleSubmit = async (values: Record) => { if (!wasConfigured) { @@ -317,7 +309,7 @@ export function PluginConfig({ {plugin.canDisable && isEnabled && ( {plugin.isTestable && ( - )} @@ -338,10 +330,10 @@ export function PluginConfig({ )} - {testResults !== '' && ( + {testMutation.data && ( Test Results -
{testResults}
+
{getDetailMessage(testMutation.data)}
)} From f2e6348474a90c4db589255ca2c9756f3a31a279 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 14 Apr 2026 09:05:54 +0200 Subject: [PATCH 21/23] ref(plugins): Remove redundant type param from useMutation The type is already inferred from fetchMutation's return type. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 534a6a7ac1b34f..0fa29e08657554 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -199,7 +199,7 @@ export function PluginConfig({ initialValues[field.name] = isSelect && field.value === '' ? null : field.value; } - const testMutation = useMutation({ + const testMutation = useMutation({ mutationFn: () => { addLoadingMessage(t('Sending test...')); return fetchMutation({ From c77b52991616f6d873c3fe42327227fc438415ef Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 14 Apr 2026 09:07:45 +0200 Subject: [PATCH 22/23] ref(plugins): Use useMutation for config submit Replace manual fetchMutation + try/catch with useMutation hook for the plugin config save request, consistent with the test mutation. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.tsx | 43 ++++++++++++-------------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index 0fa29e08657554..a9baa2e8cc1d61 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -215,28 +215,26 @@ export function PluginConfig({ ), }); - const handleSubmit = async (values: Record) => { - if (!wasConfigured) { - trackAnalytics('integrations.installation_start', { - integration: plugin.id, - integration_type: 'plugin', - view: 'plugin_details', - already_installed: false, - organization, - }); - } - - addLoadingMessage(t('Saving changes\u2026')); - - try { - await fetchMutation({ + const submitMutation = useMutation({ + mutationFn: (values: Record) => { + if (!wasConfigured) { + trackAnalytics('integrations.installation_start', { + integration: plugin.id, + integration_type: 'plugin', + view: 'plugin_details', + already_installed: false, + organization, + }); + } + addLoadingMessage(t('Saving changes\u2026')); + return fetchMutation({ method: 'PUT', url: pluginEndpoint, data: values, }); - + }, + onSuccess: () => { addSuccessMessage(t('Success!')); - trackAnalytics('integrations.config_saved', { integration: plugin.id, integration_type: 'plugin', @@ -244,7 +242,6 @@ export function PluginConfig({ already_installed: wasConfigured, organization, }); - if (!wasConfigured) { trackAnalytics('integrations.installation_complete', { integration: plugin.id, @@ -254,12 +251,10 @@ export function PluginConfig({ organization, }); } - refetch(); - } catch { - addErrorMessage(t('Unable to save changes. Please try again.')); - } - }; + }, + onError: () => addErrorMessage(t('Unable to save changes. Please try again.')), + }); // Auth error state (e.g. OAuth plugins needing identity association) if (pluginData?.config_error) { @@ -348,7 +343,7 @@ export function PluginConfig({ key={formKey} fields={fields} initialValues={initialValues} - onSubmit={handleSubmit} + onSubmit={values => submitMutation.mutate(values)} submitLabel={t('Save Changes')} submitDisabled={!hasWriteAccess} footer={({SubmitButton, disabled}) => ( From 34af23b8974c226056a3ea43c2000d7fb364a149 Mon Sep 17 00:00:00 2001 From: Priscila Oliveira Date: Tue, 14 Apr 2026 09:12:42 +0200 Subject: [PATCH 23/23] ref(plugins): Remove unused classNames and data-test-id These attributes are not referenced in any test, backend code, or stylesheet. Remove dead code from both Panel elements. Co-Authored-By: Claude Opus 4.6 --- static/app/components/pluginConfig.tsx | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/static/app/components/pluginConfig.tsx b/static/app/components/pluginConfig.tsx index a9baa2e8cc1d61..201f31f0fc27fa 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -265,10 +265,7 @@ export function PluginConfig({ authUrl += '?next=' + encodeURIComponent(document.location.pathname); } return ( - + @@ -291,10 +288,7 @@ export function PluginConfig({ } return ( - +