import {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import styled from '@emotion/styled'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import {Client} from 'sentry/api'; import Alert from 'sentry/components/alert'; import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar'; import {Button, LinkButton} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import LoadingError from 'sentry/components/loadingError'; import LoadingIndicator from 'sentry/components/loadingIndicator'; import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider'; import {IconCheckmark} from 'sentry/icons/iconCheckmark'; import {t, tct} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import type {Project} from 'sentry/types/project'; import {trackAnalytics} from 'sentry/utils/analytics'; import { DEFAULT_QUERY_CLIENT_CONFIG, QueryClient, QueryClientProvider, useMutation, useQuery, } from 'sentry/utils/queryClient'; import type RequestError from 'sentry/utils/requestError/requestError'; import useApi from 'sentry/utils/useApi'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import {useCompactSelectOptionsCache} from 'sentry/views/insights/common/utils/useCompactSelectOptionsCache'; const queryClient = new QueryClient(DEFAULT_QUERY_CLIENT_CONFIG); function useAnalyticsParams(organizations: Organization[] | undefined) { const urlParams = new URLSearchParams(location.search); const projectPlatform = urlParams.get('project_platform') ?? undefined; // if we have exactly one organization, we can use it for analytics // otherwise we don't know which org the user is in return useMemo( () => ({ organization: organizations?.length === 1 ? organizations[0] : null, project_platform: projectPlatform, }), [organizations, projectPlatform] ); } const useLastOrganization = (organizations: Organization[]) => { const lastOrgSlug = ConfigStore.get('lastOrganization'); return useMemo(() => { if (!lastOrgSlug) { return null; } return organizations.find(org => org.slug === lastOrgSlug); }, [organizations, lastOrgSlug]); }; function useOrganizationProjects({ organization, query, }: { organization?: Organization & {region: string}; query?: string; }) { const api = useApi(); const regions = useMemo(() => ConfigStore.get('memberRegions'), []); const orgRegion = useMemo( () => regions.find(region => region.name === organization?.region), [regions, organization?.region] ); return useQuery({ queryKey: [`/organizations/${organization?.slug}/projects/`, {query: query}], queryFn: () => { return api.requestPromise(`/organizations/${organization?.slug}/projects/`, { host: orgRegion?.url, query: { query: query, }, }); }, enabled: !!(orgRegion && organization), refetchOnWindowFocus: true, retry: false, }); } type Props = { enableProjectSelection?: boolean; hash?: boolean | string; organizations?: (Organization & {region: string})[]; }; function SetupWizard({ hash = false, organizations, enableProjectSelection = false, }: Props) { const analyticsParams = useAnalyticsParams(organizations); useEffect(() => { trackAnalytics('setup_wizard.viewed', analyticsParams); }, [analyticsParams]); return ( {enableProjectSelection ? ( ) : ( )} ); } const BASE_API_CLIENT = new Client({baseUrl: ''}); function ProjectSelection({hash, organizations = []}: Omit) { const baseApi = useApi({api: BASE_API_CLIENT}); const lastOrganization = useLastOrganization(organizations); const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 300); const isSearchStale = search !== debouncedSearch; const [selectedOrgId, setSelectedOrgId] = useState(() => { if (organizations.length === 1) { return organizations[0].id; } const urlParams = new URLSearchParams(location.search); const orgSlug = urlParams.get('org_slug'); const orgMatchingSlug = orgSlug && organizations.find(org => org.slug === orgSlug); if (orgMatchingSlug) { return orgMatchingSlug.id; } // Pre-fill the last used org if there are multiple and no URL param if (lastOrganization) { return lastOrganization.id; } return null; }); const [selectedProjectId, setSelectedProjectId] = useState(null); const selectedOrg = useMemo( () => organizations.find(org => org.id === selectedOrgId), [organizations, selectedOrgId] ); const orgProjectsRequest = useOrganizationProjects({ organization: selectedOrg, query: debouncedSearch, }); const { mutate: updateCache, isPending, isSuccess, } = useMutation({ mutationFn: (params: {organizationId: string; projectId: string}) => { return baseApi.requestPromise(`/account/settings/wizard/${hash}/`, { method: 'POST', data: params, }); }, }); const handleSubmit = useCallback( (event: React.FormEvent) => { event.preventDefault(); if (!selectedOrgId || !selectedProjectId) { return; } updateCache( { organizationId: selectedOrgId, projectId: selectedProjectId, }, { onError: () => { addErrorMessage(t('Something went wrong! Please try again.')); }, } ); }, [selectedOrgId, selectedProjectId, updateCache] ); const orgOptions = useMemo( () => organizations .map(org => ({ value: org.id, label: org.name || org.slug, leadingItems: , })) .toSorted((a, b) => a.label.localeCompare(b.label)), [organizations] ); const projectOptions = useMemo( () => (orgProjectsRequest.data || []).map(project => ({ value: project.id, label: project.name, leadingItems: , project, })), [orgProjectsRequest.data] ); const {options: cachedProjectOptions, clear: clearProjectOptions} = useCompactSelectOptionsCache(projectOptions); // As the cache hook sorts the options by value, we need to sort them afterwards const sortedProjectOptions = useMemo( () => cachedProjectOptions.sort((a, b) => { return a.label.localeCompare(b.label); }), [cachedProjectOptions] ); // Select the project from the cached options to avoid visually clearing the input // when searching while having a selected project const selectedProject = useMemo( () => sortedProjectOptions?.find(option => option.value === selectedProjectId)?.project, [selectedProjectId, sortedProjectOptions] ); const isFormValid = selectedOrg && selectedProject; if (isSuccess) { return ; } return ( {t('Select your Sentry project')} ) : null, }} triggerLabel={ selectedOrg?.name || selectedOrg?.slug || ( {t('Select an organization')} ) } onChange={({value}) => { if (value !== selectedOrgId) { setSelectedOrgId(value as string); setSelectedProjectId(null); clearProjectOptions(); } }} /> {orgProjectsRequest.error ? ( ) : ( setSearch('')} disabled={!selectedOrgId} value={selectedProjectId as string} searchable options={sortedProjectOptions} triggerProps={{ icon: selectedProject ? ( ) : null, }} triggerLabel={ selectedProject?.name || ( {t('Select a project')} ) } onChange={({value}) => { setSelectedProjectId(value as string); }} emptyMessage={ orgProjectsRequest.isPending || isSearchStale ? t('Loading...') : search ? t('No projects matching search') : tct('No projects found. [link:Create a project]', { organization: selectedOrg?.name || selectedOrg?.slug || 'organization', link: ( ), }) } /> )} {t('Continue')} ); } function getSsoLoginUrl(error: RequestError) { const detail = error?.responseJSON?.detail as any; const loginUrl = detail?.extra?.loginUrl; if (!loginUrl || typeof loginUrl !== 'string') { return null; } try { // Pass a base param as the login may be absolute or relative const url = new URL(loginUrl, location.origin); // Pass the current URL as the next URL to redirect to after login url.searchParams.set('next', location.href); return url.toString(); } catch { return null; } } function ProjectLoadingError({ error, onRetry, }: { error: RequestError; onRetry: () => void; }) { const detail = error?.responseJSON?.detail; const code = typeof detail === 'string' ? undefined : detail?.code; const ssoLoginUrl = getSsoLoginUrl(error); if (code === 'sso-required' && ssoLoginUrl) { return ( {t('Log in')} } > {t('This organization requires Single Sign-On.')} ); } return ( { onRetry(); }} /> ); } const AlertWithoutMargin = styled(Alert)` margin: 0; `; const LoadingErrorWithoutMargin = styled(LoadingError)` margin: 0; `; const StyledForm = styled('form')` display: flex; flex-direction: column; gap: ${space(2)}; `; const Heading = styled('h5')` margin-bottom: ${space(0.5)}; `; const FieldWrapper = styled('div')` display: flex; flex-direction: column; gap: ${space(0.5)}; `; const StyledCompactSelect = styled(CompactSelect)` width: 100%; & > button { width: 100%; } `; const SelectPlaceholder = styled('span')` ${p => p.theme.overflowEllipsis} color: ${p => p.theme.subText}; font-weight: normal; text-align: left; `; const SubmitButton = styled(Button)` margin-top: ${space(1)}; `; function WaitingForWizardToConnect({ hash, organizations, }: Omit) { const api = useApi(); const closeTimeoutRef = useRef(undefined); const [finished, setFinished] = useState(false); const analyticsParams = useAnalyticsParams(organizations); useEffect(() => { return () => { if (closeTimeoutRef.current) { window.clearTimeout(closeTimeoutRef.current); } }; }, []); const checkFinished = useCallback(async () => { if (finished) { return; } try { await api.requestPromise(`/wizard/${hash}/`); } catch { setFinished(true); window.clearTimeout(closeTimeoutRef.current); closeTimeoutRef.current = window.setTimeout(() => window.close(), 10000); trackAnalytics('setup_wizard.complete', analyticsParams); } }, [api, hash, analyticsParams, finished]); useEffect(() => { const pollingInterval = window.setInterval(checkFinished, 1000); return () => window.clearInterval(pollingInterval); }, [checkFinished]); return !finished ? (
{t('Waiting for wizard to connect')}
) : ( {t('Return to your terminal to complete your setup.')} ); } const SuccessCheckmark = styled(IconCheckmark)` flex-shrink: 0; `; const SuccessHeading = styled('h5')` margin: 0; `; const SuccessWrapper = styled('div')` display: flex; align-items: center; gap: ${space(3)}; `; export default SetupWizard;