Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
73 changes: 16 additions & 57 deletions static/app/views/onboarding/components/scmProvidersDropdown.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import {Fragment, useEffect, useRef} from 'react';

import {DropdownMenu} from 'sentry/components/dropdownMenu';
import {t} from 'sentry/locale';
import type {Integration, IntegrationProvider} from 'sentry/types/integrations';
import {getIntegrationIcon} from 'sentry/utils/integrationUtil';
import {useOrganization} from 'sentry/utils/useOrganization';
import {useAddIntegration} from 'sentry/views/settings/organizationIntegrations/addIntegration';
import {useIntegrationLauncher} from 'sentry/views/settings/organizationIntegrations/useIntegrationLauncher';

interface ScmProvidersDropdownProps {
onInstall: (data: Integration) => void;
Expand All @@ -14,58 +12,12 @@ interface ScmProvidersDropdownProps {

/**
* Renders secondary SCM providers (Bitbucket Server, GitHub Enterprise, Azure
* DevOps, etc.) inside a dropdown menu. Each provider's install flow is
* initialized by a hidden {@link ScmProviderFlowSetup} component so that the
* `startFlow` callback is available synchronously from the menu item's click
* handler (required to avoid browser popup blockers for OAuth windows).
* DevOps, etc.) inside a dropdown menu. Uses {@link useIntegrationLauncher} to
* launch the correct OAuth/pipeline flow for whichever provider the user picks.
*/
export function ScmProvidersDropdown({providers, onInstall}: ScmProvidersDropdownProps) {
const flowMapRef = useRef<Map<string, () => void>>(new Map());

return (
<Fragment>
{providers.map(provider => (
<ScmProviderFlowSetup
key={provider.key}
provider={provider}
onInstall={onInstall}
flowMapRef={flowMapRef}
/>
))}
<DropdownMenu
triggerLabel={t('More')}
position="bottom-end"
items={providers.map(provider => ({
key: provider.key,
label: provider.name,
leadingItems: getIntegrationIcon(provider.key, 'sm'),
onAction: () => flowMapRef.current.get(provider.key)?.(),
}))}
/>
</Fragment>
);
}

interface ScmProviderFlowSetupProps {
flowMapRef: React.RefObject<Map<string, () => void>>;
onInstall: (data: Integration) => void;
provider: IntegrationProvider;
}

/**
* Invisible component that initializes {@link useAddIntegration} for a single
* provider and registers the resulting `startFlow` function in a shared ref
* map. This lets the parent's {@link DropdownMenu} trigger the correct
* OAuth/pipeline flow from a data-driven menu item.
*/
function ScmProviderFlowSetup({
provider,
onInstall,
flowMapRef,
}: ScmProviderFlowSetupProps) {
const organization = useOrganization();
const {startFlow} = useAddIntegration({
provider,
const {startFlow} = useIntegrationLauncher({
organization,
onInstall,
analyticsParams: {
Expand All @@ -74,9 +26,16 @@ function ScmProviderFlowSetup({
},
});

useEffect(() => {
flowMapRef.current.set(provider.key, startFlow);
}, [flowMapRef, provider.key, startFlow]);

return null;
return (
<DropdownMenu
triggerLabel={t('More')}
position="bottom-end"
items={providers.map(provider => ({
key: provider.key,
label: provider.name,
leadingItems: getIntegrationIcon(provider.key, 'sm'),
onAction: () => startFlow(provider),
}))}
/>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ const API_PIPELINE_FEATURE_FLAGS = {

type ApiPipelineProvider = keyof typeof API_PIPELINE_FEATURE_FLAGS;

function getApiPipelineProvider(
export function getApiPipelineProvider(
organization: Organization,
providerKey: string
): ApiPipelineProvider | null {
Expand All @@ -65,7 +65,7 @@ function getApiPipelineProvider(
return key;
}

function computeCenteredWindow(width: number, height: number) {
export function computeCenteredWindow(width: number, height: number) {
const screenLeft = window.screenLeft === undefined ? window.screenX : window.screenLeft;
const screenTop = window.screenTop === undefined ? window.screenY : window.screenTop;

Expand Down
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seems like we could actually just refactor useIntegration to have this API, there's not too many call-sites I think so it wouldn't be a huge lift right?

Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import {useCallback, useEffect, useRef} from 'react';
import * as qs from 'query-string';

import {addErrorMessage, addSuccessMessage} from 'sentry/actionCreators/indicator';
import {openPipelineModal} from 'sentry/components/pipeline/modal';
import {t} from 'sentry/locale';
import {ConfigStore} from 'sentry/stores/configStore';
import type {IntegrationProvider, IntegrationWithConfig} from 'sentry/types/integrations';
import {trackIntegrationAnalytics} from 'sentry/utils/integrationUtil';

import type {AddIntegrationParams} from './addIntegration';
import {computeCenteredWindow, getApiPipelineProvider} from './addIntegration';

type UseIntegrationLauncherParams = Omit<
AddIntegrationParams,
'provider' | 'account' | 'modalParams'
>;

/**
* Launches integration install flows for any provider passed at call time.
*
* Unlike {@link useAddIntegration}, which binds to a single provider at hook
* initialization, this hook accepts the provider as an argument to `startFlow`.
* This makes it suitable for data-driven UIs (dropdowns, menus) that need to
* launch flows for multiple providers from a single hook instance.
*
* Only one legacy popup flow can be active at a time. Starting a new flow
* while one is pending will replace the active provider context.
*/
export function useIntegrationLauncher({
organization,
onInstall,
analyticsParams,
}: UseIntegrationLauncherParams) {
const dialogRef = useRef<Window | null>(null);
const activeProviderRef = useRef<IntegrationProvider | null>(null);
const onInstallRef = useRef(onInstall);
onInstallRef.current = onInstall;
const analyticsParamsRef = useRef(analyticsParams);
analyticsParamsRef.current = analyticsParams;

useEffect(() => {
function handleMessage(message: MessageEvent) {
const validOrigins = [
ConfigStore.get('links').sentryUrl,
ConfigStore.get('links').organizationUrl,
document.location.origin,
];
if (!validOrigins.includes(message.origin)) {
return;
}
if (message.source !== dialogRef.current) {
return;
}

const {success, data} = message.data;
dialogRef.current = null;
const provider = activeProviderRef.current;
activeProviderRef.current = null;

if (!success) {
addErrorMessage(data?.error ?? t('An unknown error occurred'));
return;
}
if (!data || !provider) {
return;
}

trackIntegrationAnalytics('integrations.installation_complete', {
integration: provider.key,
integration_type: 'first_party',
organization,
...analyticsParamsRef.current,
});
addSuccessMessage(t('%s added', provider.name));
onInstallRef.current(data);
}

window.addEventListener('message', handleMessage);
return () => {
window.removeEventListener('message', handleMessage);
dialogRef.current?.close();
};
}, [organization]);

const startFlow = useCallback(
(provider: IntegrationProvider, urlParams?: Record<string, string>) => {
trackIntegrationAnalytics('integrations.installation_start', {
integration: provider.key,
integration_type: 'first_party',
organization,
...analyticsParams,
});

const pipelineProvider = getApiPipelineProvider(organization, provider.key);
if (pipelineProvider !== null) {
openPipelineModal({
type: 'integration',
provider: pipelineProvider,
onComplete: (data: IntegrationWithConfig) => {
trackIntegrationAnalytics('integrations.installation_complete', {
integration: provider.key,
integration_type: 'first_party',
organization,
...analyticsParamsRef.current,
});
addSuccessMessage(t('%s added', provider.name));
onInstallRef.current(data);
},
});
return;
}

// Legacy popup flow
const {url, width, height} = provider.setupDialog;
const {left, top} = computeCenteredWindow(width, height);
const installUrl = `${url}?${qs.stringify(urlParams ?? {})}`;
const opts = `scrollbars=yes,width=${width},height=${height},top=${top},left=${left}`;

activeProviderRef.current = provider;
dialogRef.current = window.open(installUrl, 'sentryAddIntegration', opts);
dialogRef.current?.focus();
},
[organization, analyticsParams]
);

return {startFlow};
}
Loading