diff --git a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx index 74dc6deef4a91f..c8bbaa7d2ecd64 100644 --- a/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx +++ b/static/app/components/backendJsonFormAdapter/backendJsonSubmitForm.spec.tsx @@ -315,6 +315,36 @@ 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; @@ -270,7 +273,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. @@ -318,8 +321,12 @@ export function BackendJsonSubmitForm({ > ) ?? [] + } + onChange={(value: Array) => + handleChange(value) + } disabled={field.disabled} queryOptions={asyncQueryOptions} /> @@ -332,12 +339,28 @@ export function BackendJsonSubmitForm({ hintText={field.help} required={field.required} > - + {field.required ? ( + handleChange(value)} + disabled={field.disabled} + queryOptions={asyncQueryOptions} + /> + ) : ( + + handleChange(value) + } + disabled={field.disabled} + queryOptions={asyncQueryOptions} + /> + )} ); } @@ -351,7 +374,7 @@ export function BackendJsonSubmitForm({ handleChange(value)} options={transformChoices(field.choices)} disabled={field.disabled} /> @@ -364,14 +387,25 @@ export function BackendJsonSubmitForm({ hintText={field.help} required={field.required} > - + {field.required ? ( + handleChange(value)} + options={transformChoices(field.choices)} + disabled={field.disabled} + /> + ) : ( + handleChange(value)} + options={transformChoices(field.choices)} + disabled={field.disabled} + /> + )} ); + } case 'secret': return ( { 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,13 +36,11 @@ 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(await screen.findByText('No errors returned')).toBeInTheDocument(); expect(testWebhookMock).toHaveBeenCalledWith( expect.any(String), expect.objectContaining({ @@ -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(); + + 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(); + + 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(); + + 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 2bc6994e10801e..201f31f0fc27fa 100644 --- a/static/app/components/pluginConfig.tsx +++ b/static/app/components/pluginConfig.tsx @@ -1,7 +1,7 @@ -import {useEffect, 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 +10,130 @@ 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 {trackAnalytics} from 'sentry/utils/analytics'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {fetchMutation, useApiQuery, useMutation} 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; + help?: null | string; + placeholder?: null | string; + readonly?: boolean; + required?: boolean; + value?: unknown; +} + +interface PluginWithConfig extends Plugin { + auth_url?: string; + config?: BackendPluginField[]; + config_error?: string; +} + +interface PluginTestResponse { + detail?: string | unknown; +} + +function getDetailMessage(response: PluginTestResponse): string { + if (response.detail === null || response.detail === undefined) { + return ''; + } + if (typeof response.detail === 'string') { + return response.detail; + } + return JSON.stringify(response.detail); +} + +/** + * 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, + }; + + 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', + default: typeof defaultValue === 'boolean' ? defaultValue : undefined, + }; + case 'select': + case 'choice': { + const emptyChoiceLabel = field.choices?.find(([value]) => 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, + placeholder, + default: normalizedDefault, + choices: normalizedChoices, + }; + } + case 'secret': + return { + ...base, + type: 'secret', + default: defaultValue, + placeholder: field.placeholder ?? t('Enter a secret'), + }; + case 'textarea': + return {...base, type: 'textarea', default: defaultValue}; + case 'number': + return { + ...base, + type: 'number', + default: typeof defaultValue === 'number' ? defaultValue : undefined, + }; + case 'email': + return {...base, type: 'email', default: defaultValue}; + case 'url': + return {...base, type: 'url', default: defaultValue}; + case 'string': + case 'text': + default: + return {...base, type: 'text', default: defaultValue}; + } +} + interface PluginConfigProps { plugin: Plugin; project: Project; @@ -36,59 +147,148 @@ 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 isEnabled = enabled ?? plugin.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 config = pluginData?.config ?? []; + const wasConfigured = config.some( + field => field.value !== null && field.value !== undefined && 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) { + if (field.value === undefined) { + continue; } - }, [plugin]); - - 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, - }, - } - ); - - setTestResults(JSON.stringify(pluginEndpointData.detail)); - addSuccessMessage(t('Test Complete!')); - } catch (_err) { + + const type = field.type === 'bool' ? 'boolean' : field.type; + const isSelect = type === 'select' || type === 'choice'; + + if (field.value === null && !isSelect) { + continue; + } + + initialValues[field.name] = isSelect && field.value === '' ? null : field.value; + } + + const testMutation = useMutation({ + mutationFn: () => { + addLoadingMessage(t('Sending test...')); + return fetchMutation({ + method: 'POST', + url: pluginEndpoint, + data: {test: true}, + }); + }, + onSuccess: () => addSuccessMessage(t('Test Complete!')), + onError: () => addErrorMessage( t('An unexpected error occurred while testing your plugin. Please try again.') - ); - } - }; + ), + }); - const handleDisablePlugin = () => { - onDisablePlugin?.(plugin); - }; + 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', + view: 'plugin_details', + already_installed: wasConfigured, + organization, + }); + if (!wasConfigured) { + trackAnalytics('integrations.installation_complete', { + integration: plugin.id, + integration_type: 'plugin', + view: 'plugin_details', + already_installed: false, + organization, + }); + } + refetch(); + }, + onError: () => 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 ( - + @@ -98,11 +298,15 @@ export function PluginConfig({ {plugin.canDisable && isEnabled && ( {plugin.isTestable && ( - + testMutation.mutate()} size="xs"> {t('Test Plugin')} )} - + onDisablePlugin?.(plugin)} + disabled={!hasWriteAccess} + > {t('Disable')} @@ -115,23 +319,42 @@ export function PluginConfig({ )} - {testResults !== '' && ( + {testMutation.data && ( Test Results - {testResults} + {getDetailMessage(testMutation.data)} )} - {isPluginLoading ? ( + {isPending ? ( - ) : ( - plugins.get(plugin).renderSettings({ - organization, - project, - }) - )} + ) : isError ? ( + + ) : fields.length > 0 ? ( + submitMutation.mutate(values)} + submitLabel={t('Save Changes')} + submitDisabled={!hasWriteAccess} + footer={({SubmitButton, disabled}) => ( + + + {t('Save Changes')} + + + )} + /> + ) : null} ); diff --git a/static/app/views/settings/projectPlugins/details.tsx b/static/app/views/settings/projectPlugins/details.tsx index 115d4588736487..dc6a00e135f6bc 100644 --- a/static/app/views/settings/projectPlugins/details.tsx +++ b/static/app/views/settings/projectPlugins/details.tsx @@ -85,7 +85,7 @@ export default function ProjectPluginDetails() { organization, }); }, - onSuccess: () => { + onSuccess: async () => { addSuccessMessage(t('Plugin was reset')); trackAnalytics('integrations.uninstall_completed', { integration: pluginId, @@ -93,6 +93,8 @@ 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: () => { addErrorMessage(t('An error occurred'));