diff --git a/static/app/views/settings/organizationRepositories/scmRepositoryTable.tsx b/static/app/views/settings/organizationRepositories/scmRepositoryTable.tsx index 41c7b3caf4f9..09dd21ed9666 100644 --- a/static/app/views/settings/organizationRepositories/scmRepositoryTable.tsx +++ b/static/app/views/settings/organizationRepositories/scmRepositoryTable.tsx @@ -96,6 +96,11 @@ export interface ScmInstallation { * `repositories` is still empty. */ reposLoading?: boolean; + /** + * Props forwarded to the settings button. Use to disable or annotate it + * while per-integration config is still being fetched. + */ + settingsButtonProps?: Omit; /** * Props forwarded to the uninstall button. Use to disable or annotate it * when the viewer lacks the required access. @@ -401,7 +406,8 @@ function InstallationActions({ onUninstall, onSettings, }: InstallationActionsProps) { - const {manageUrl, overflowMenuItems, uninstallButtonProps} = installation; + const {manageUrl, overflowMenuItems, settingsButtonProps, uninstallButtonProps} = + installation; return ( {manageUrl && ( @@ -436,6 +442,7 @@ function InstallationActions({ size="xs" variant="transparent" icon={} + {...settingsButtonProps} onClick={() => onSettings(installation)} /> )} diff --git a/static/app/views/settings/organizationRepositoriesV2/index.spec.tsx b/static/app/views/settings/organizationRepositoriesV2/index.spec.tsx index 0c5ddf63c6d8..68596294f223 100644 --- a/static/app/views/settings/organizationRepositoriesV2/index.spec.tsx +++ b/static/app/views/settings/organizationRepositoriesV2/index.spec.tsx @@ -196,4 +196,43 @@ describe('OrganizationRepositoriesV2', () => { expect(await screen.findByRole('button', {name: 'Uninstall'})).toBeDisabled(); }); + + it('shows the settings button as disabled while the integration config is loading', async () => { + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/config/integrations/', + body: {providers: [GITHUB_PROVIDER]}, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/integrations/', + body: [GITHUB_INTEGRATION], + }); + MockApiClient.addMockResponse({ + url: `/organizations/org-slug/integrations/${GITHUB_INTEGRATION.id}/`, + body: GITHUB_INTEGRATION, + asyncDelay: 10_000, + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/repos/', + body: [], + }); + MockApiClient.addMockResponse({ + url: '/organizations/org-slug/code-mappings/', + body: [], + }); + + render(); + + await screen.findByText('my-org'); + expect(screen.getByRole('button', {name: 'Integration settings'})).toBeDisabled(); + }); + + it('enables the settings button once the integration config has loaded', async () => { + setupDefaultMocks(); + + render(); + + expect( + await screen.findByRole('button', {name: 'Integration settings'}) + ).toBeEnabled(); + }); }); diff --git a/static/app/views/settings/organizationRepositoriesV2/index.tsx b/static/app/views/settings/organizationRepositoriesV2/index.tsx index 350572f03221..246db4734560 100644 --- a/static/app/views/settings/organizationRepositoriesV2/index.tsx +++ b/static/app/views/settings/organizationRepositoriesV2/index.tsx @@ -36,6 +36,7 @@ import {useRepoSearch} from 'sentry/views/settings/organizationRepositories/useR import {organizationIntegrationsQueryOptions} from 'sentry/views/settings/seer/overview/utils/organizationIntegrationsQueryOptions'; import {useDeleteIntegration} from './useDeleteIntegration'; +import {useInstallationSettings} from './useInstallationSettings'; const SCM_PROVIDER_ORDER = [ 'github', @@ -125,6 +126,11 @@ export function OrganizationRepositoriesV2() { codeMappingsQuery.hasNextPage || codeMappingsQuery.isFetchingNextPage; + const {configByIntegrationId, openInstallationSettings} = useInstallationSettings({ + scmIntegrations, + hasAccess, + }); + const installationsByProviderKey = useMemo(() => { const installations = scmIntegrations.map(integration => ({ integration, @@ -133,6 +139,9 @@ export function OrganizationRepositoriesV2() { manageUrl: getProviderConfigUrl(integration) ?? undefined, mappedProjectSlugsByRepoId, mappingsLoading, + settingsButtonProps: { + disabled: configByIntegrationId[integration.id] === undefined, + }, uninstallButtonProps: hasAccess ? undefined : { @@ -151,6 +160,7 @@ export function OrganizationRepositoriesV2() { reposLoading, mappedProjectSlugsByRepoId, mappingsLoading, + configByIntegrationId, hasAccess, ]); @@ -238,6 +248,7 @@ export function OrganizationRepositoriesV2() { installations={installationsByProviderKey[provider.key]!} repoMatches={repoMatches} onUninstall={inst => handleDeleteIntegration(inst.integration)} + onSettings={inst => openInstallationSettings(inst.integration)} /> )) )} diff --git a/static/app/views/settings/organizationRepositoriesV2/useInstallationSettings.tsx b/static/app/views/settings/organizationRepositoriesV2/useInstallationSettings.tsx new file mode 100644 index 000000000000..64c2bf56f784 --- /dev/null +++ b/static/app/views/settings/organizationRepositoriesV2/useInstallationSettings.tsx @@ -0,0 +1,142 @@ +import {Fragment} from 'react'; +import {mutationOptions, useQueries, useQueryClient} from '@tanstack/react-query'; + +import {Alert} from '@sentry/scraps/alert'; +import {DrawerBody, DrawerHeader, useDrawer} from '@sentry/scraps/drawer'; +import {Container, Flex, Stack} from '@sentry/scraps/layout'; + +import {BackendJsonAutoSaveForm} from 'sentry/components/backendJsonFormAdapter/backendJsonAutoSaveForm'; +import type {FieldValue} from 'sentry/components/backendJsonFormAdapter/types'; +import {t} from 'sentry/locale'; +import type {OrganizationIntegration} from 'sentry/types/integrations'; +import {apiOptions} from 'sentry/utils/api/apiOptions'; +import {getApiUrl} from 'sentry/utils/api/getApiUrl'; +import {getIntegrationIcon} from 'sentry/utils/integrationUtil'; +import {fetchMutation} from 'sentry/utils/queryClient'; +import {useOrganization} from 'sentry/utils/useOrganization'; + +const NO_ACCESS_REASON = t( + 'You must be an organization owner, manager, or admin to change these settings.' +); + +interface UseInstallationSettingsOptions { + hasAccess: boolean; + scmIntegrations: OrganizationIntegration[]; +} + +interface UseInstallationSettingsResult { + /** + * Full integration config keyed by integration ID, populated once each + * per-integration config query resolves. Values are `undefined` while + * their query is still in-flight. + */ + configByIntegrationId: Record; + /** + * Opens a settings drawer for the given integration, rendering each + * `configOrganization` field as an auto-saving form. Fields are disabled + * with a permission tooltip when `hasAccess` is false. + */ + openInstallationSettings: (integration: OrganizationIntegration) => void; +} + +export function useInstallationSettings({ + scmIntegrations, + hasAccess, +}: UseInstallationSettingsOptions): UseInstallationSettingsResult { + const organization = useOrganization(); + const {openDrawer} = useDrawer(); + const queryClient = useQueryClient(); + + const configByIntegrationId = useQueries({ + queries: scmIntegrations.map(integration => + apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/$integrationId/', + { + path: { + organizationIdOrSlug: organization.slug, + integrationId: integration.id, + }, + staleTime: 60_000, + } + ) + ), + combine: results => + Object.fromEntries( + scmIntegrations.map((integration, i) => [integration.id, results[i]?.data]) + ), + }); + + const openInstallationSettings = (integration: OrganizationIntegration) => { + const integrationOptions = apiOptions.as()( + '/organizations/$organizationIdOrSlug/integrations/$integrationId/', + { + path: {organizationIdOrSlug: organization.slug, integrationId: integration.id}, + staleTime: 60_000, + } + ); + const integrationEndpoint = getApiUrl( + '/organizations/$organizationIdOrSlug/integrations/$integrationId/', + {path: {organizationIdOrSlug: organization.slug, integrationId: integration.id}} + ); + const integrationMutationOptions = mutationOptions({ + mutationFn: (data: Record) => + fetchMutation({method: 'POST', url: integrationEndpoint, data}), + onSuccess: () => + queryClient.invalidateQueries({queryKey: integrationOptions.queryKey}), + }); + + const resolvedIntegration = configByIntegrationId[integration.id] ?? integration; + const fields = hasAccess + ? (resolvedIntegration.configOrganization ?? []) + : (resolvedIntegration.configOrganization?.map(f => ({ + ...f, + disabled: true, + disabledReason: NO_ACCESS_REASON, + })) ?? []); + + openDrawer( + () => ( + + + + {getIntegrationIcon(integration.provider.key, 'sm')} + {t('%s Settings', integration.name)} + + + + {!hasAccess && ( + + {NO_ACCESS_REASON} + + )} + + {fields.map((fieldConfig, index) => ( + + + } + mutationOptions={integrationMutationOptions} + /> + + ))} + + + + ), + { + ariaLabel: t('Integration settings for %s', integration.name), + drawerKey: `integration-settings-${integration.id}`, + } + ); + }; + + return {configByIntegrationId, openInstallationSettings}; +}