index.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514
  1. import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
  2. import {createBrowserRouter, RouterProvider} from 'react-router-dom';
  3. import styled from '@emotion/styled';
  4. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  5. import {Client} from 'sentry/api';
  6. import Alert from 'sentry/components/alert';
  7. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  8. import {Button, LinkButton} from 'sentry/components/button';
  9. import {CompactSelect} from 'sentry/components/compactSelect';
  10. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  11. import LoadingError from 'sentry/components/loadingError';
  12. import LoadingIndicator from 'sentry/components/loadingIndicator';
  13. import {ThemeAndStyleProvider} from 'sentry/components/themeAndStyleProvider';
  14. import {IconCheckmark} from 'sentry/icons/iconCheckmark';
  15. import {t, tct} from 'sentry/locale';
  16. import ConfigStore from 'sentry/stores/configStore';
  17. import {space} from 'sentry/styles/space';
  18. import type {Organization} from 'sentry/types/organization';
  19. import type {Project} from 'sentry/types/project';
  20. import {trackAnalytics} from 'sentry/utils/analytics';
  21. import {
  22. DEFAULT_QUERY_CLIENT_CONFIG,
  23. QueryClient,
  24. QueryClientProvider,
  25. useMutation,
  26. useQuery,
  27. } from 'sentry/utils/queryClient';
  28. import type RequestError from 'sentry/utils/requestError/requestError';
  29. import useApi from 'sentry/utils/useApi';
  30. import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
  31. import {useCompactSelectOptionsCache} from 'sentry/views/insights/common/utils/useCompactSelectOptionsCache';
  32. const queryClient = new QueryClient(DEFAULT_QUERY_CLIENT_CONFIG);
  33. function useAnalyticsParams(organizations: Organization[] | undefined) {
  34. const urlParams = new URLSearchParams(location.search);
  35. const projectPlatform = urlParams.get('project_platform') ?? undefined;
  36. // if we have exactly one organization, we can use it for analytics
  37. // otherwise we don't know which org the user is in
  38. return useMemo(
  39. () => ({
  40. organization: organizations?.length === 1 ? organizations[0] : null,
  41. project_platform: projectPlatform,
  42. }),
  43. [organizations, projectPlatform]
  44. );
  45. }
  46. const useLastOrganization = (organizations: Organization[]) => {
  47. const lastOrgSlug = ConfigStore.get('lastOrganization');
  48. return useMemo(() => {
  49. if (!lastOrgSlug) {
  50. return null;
  51. }
  52. return organizations.find(org => org.slug === lastOrgSlug);
  53. }, [organizations, lastOrgSlug]);
  54. };
  55. function useOrganizationProjects({
  56. organization,
  57. query,
  58. }: {
  59. organization?: Organization & {region: string};
  60. query?: string;
  61. }) {
  62. const api = useApi();
  63. const regions = useMemo(() => ConfigStore.get('memberRegions'), []);
  64. const orgRegion = useMemo(
  65. () => regions.find(region => region.name === organization?.region),
  66. [regions, organization?.region]
  67. );
  68. return useQuery<Project[], RequestError>({
  69. queryKey: [`/organizations/${organization?.slug}/projects/`, {query: query}],
  70. queryFn: () => {
  71. return api.requestPromise(`/organizations/${organization?.slug}/projects/`, {
  72. host: orgRegion?.url,
  73. query: {
  74. query: query,
  75. },
  76. });
  77. },
  78. enabled: !!(orgRegion && organization),
  79. refetchOnWindowFocus: true,
  80. retry: false,
  81. });
  82. }
  83. type Props = {
  84. enableProjectSelection?: boolean;
  85. hash?: boolean | string;
  86. organizations?: (Organization & {region: string})[];
  87. };
  88. function SetupWizardContent({hash, organizations, enableProjectSelection}: Props) {
  89. return enableProjectSelection ? (
  90. <ProjectSelection hash={hash} organizations={organizations} />
  91. ) : (
  92. <WaitingForWizardToConnect hash={hash} organizations={organizations} />
  93. );
  94. }
  95. function SetupWizard({
  96. hash = false,
  97. organizations,
  98. enableProjectSelection = false,
  99. }: Props) {
  100. const analyticsParams = useAnalyticsParams(organizations);
  101. const [router] = useState(() =>
  102. createBrowserRouter([
  103. {
  104. path: '*',
  105. element: (
  106. <SetupWizardContent
  107. hash={hash}
  108. organizations={organizations}
  109. enableProjectSelection={enableProjectSelection}
  110. />
  111. ),
  112. },
  113. ])
  114. );
  115. useEffect(() => {
  116. trackAnalytics('setup_wizard.viewed', analyticsParams);
  117. }, [analyticsParams]);
  118. return (
  119. <ThemeAndStyleProvider>
  120. <QueryClientProvider client={queryClient}>
  121. <RouterProvider router={router} />
  122. </QueryClientProvider>
  123. </ThemeAndStyleProvider>
  124. );
  125. }
  126. const BASE_API_CLIENT = new Client({baseUrl: ''});
  127. function ProjectSelection({hash, organizations = []}: Omit<Props, 'allowSelection'>) {
  128. const baseApi = useApi({api: BASE_API_CLIENT});
  129. const lastOrganization = useLastOrganization(organizations);
  130. const [search, setSearch] = useState('');
  131. const debouncedSearch = useDebouncedValue(search, 300);
  132. const isSearchStale = search !== debouncedSearch;
  133. const [selectedOrgId, setSelectedOrgId] = useState<string | null>(() => {
  134. if (organizations.length === 1) {
  135. return organizations[0].id;
  136. }
  137. const urlParams = new URLSearchParams(location.search);
  138. const orgSlug = urlParams.get('org_slug');
  139. const orgMatchingSlug = orgSlug && organizations.find(org => org.slug === orgSlug);
  140. if (orgMatchingSlug) {
  141. return orgMatchingSlug.id;
  142. }
  143. // Pre-fill the last used org if there are multiple and no URL param
  144. if (lastOrganization) {
  145. return lastOrganization.id;
  146. }
  147. return null;
  148. });
  149. const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
  150. const selectedOrg = useMemo(
  151. () => organizations.find(org => org.id === selectedOrgId),
  152. [organizations, selectedOrgId]
  153. );
  154. const orgProjectsRequest = useOrganizationProjects({
  155. organization: selectedOrg,
  156. query: debouncedSearch,
  157. });
  158. const {
  159. mutate: updateCache,
  160. isPending,
  161. isSuccess,
  162. } = useMutation({
  163. mutationFn: (params: {organizationId: string; projectId: string}) => {
  164. return baseApi.requestPromise(`/account/settings/wizard/${hash}/`, {
  165. method: 'POST',
  166. data: params,
  167. });
  168. },
  169. });
  170. const handleSubmit = useCallback(
  171. (event: React.FormEvent) => {
  172. event.preventDefault();
  173. if (!selectedOrgId || !selectedProjectId) {
  174. return;
  175. }
  176. updateCache(
  177. {
  178. organizationId: selectedOrgId,
  179. projectId: selectedProjectId,
  180. },
  181. {
  182. onError: () => {
  183. addErrorMessage(t('Something went wrong! Please try again.'));
  184. },
  185. }
  186. );
  187. },
  188. [selectedOrgId, selectedProjectId, updateCache]
  189. );
  190. const orgOptions = useMemo(
  191. () =>
  192. organizations
  193. .map(org => ({
  194. value: org.id,
  195. label: org.name || org.slug,
  196. leadingItems: <OrganizationAvatar size={16} organization={org} />,
  197. }))
  198. .toSorted((a, b) => a.label.localeCompare(b.label)),
  199. [organizations]
  200. );
  201. const projectOptions = useMemo(
  202. () =>
  203. (orgProjectsRequest.data || []).map(project => ({
  204. value: project.id,
  205. label: project.name,
  206. leadingItems: <ProjectBadge avatarSize={16} project={project} hideName />,
  207. project,
  208. })),
  209. [orgProjectsRequest.data]
  210. );
  211. const {options: cachedProjectOptions, clear: clearProjectOptions} =
  212. useCompactSelectOptionsCache(projectOptions);
  213. // As the cache hook sorts the options by value, we need to sort them afterwards
  214. const sortedProjectOptions = useMemo(
  215. () =>
  216. cachedProjectOptions.sort((a, b) => {
  217. return a.label.localeCompare(b.label);
  218. }),
  219. [cachedProjectOptions]
  220. );
  221. // Select the project from the cached options to avoid visually clearing the input
  222. // when searching while having a selected project
  223. const selectedProject = useMemo(
  224. () =>
  225. sortedProjectOptions?.find(option => option.value === selectedProjectId)?.project,
  226. [selectedProjectId, sortedProjectOptions]
  227. );
  228. const isFormValid = selectedOrg && selectedProject;
  229. if (isSuccess) {
  230. return <WaitingForWizardToConnect hash={hash} organizations={organizations} />;
  231. }
  232. return (
  233. <StyledForm onSubmit={handleSubmit}>
  234. <Heading>{t('Select your Sentry project')}</Heading>
  235. <FieldWrapper>
  236. <label>{t('Organization')}</label>
  237. <StyledCompactSelect
  238. autoFocus
  239. value={selectedOrgId as string}
  240. searchable
  241. options={orgOptions}
  242. triggerProps={{
  243. icon: selectedOrg ? (
  244. <OrganizationAvatar size={16} organization={selectedOrg} />
  245. ) : null,
  246. }}
  247. triggerLabel={
  248. selectedOrg?.name ||
  249. selectedOrg?.slug || (
  250. <SelectPlaceholder>{t('Select an organization')}</SelectPlaceholder>
  251. )
  252. }
  253. onChange={({value}) => {
  254. if (value !== selectedOrgId) {
  255. setSelectedOrgId(value as string);
  256. setSelectedProjectId(null);
  257. clearProjectOptions();
  258. }
  259. }}
  260. />
  261. </FieldWrapper>
  262. <FieldWrapper>
  263. <label>{t('Project')}</label>
  264. {orgProjectsRequest.error ? (
  265. <ProjectLoadingError
  266. error={orgProjectsRequest.error}
  267. onRetry={orgProjectsRequest.refetch}
  268. />
  269. ) : (
  270. <StyledCompactSelect
  271. // Remount the component when the org changes to reset the component state
  272. // TODO(aknaus): investigate why the selection is not reset when the value changes to null
  273. key={selectedOrgId}
  274. onSearch={setSearch}
  275. onClose={() => setSearch('')}
  276. disabled={!selectedOrgId}
  277. value={selectedProjectId as string}
  278. searchable
  279. options={sortedProjectOptions}
  280. triggerProps={{
  281. icon: selectedProject ? (
  282. <ProjectBadge avatarSize={16} project={selectedProject} hideName />
  283. ) : null,
  284. }}
  285. triggerLabel={
  286. selectedProject?.name || (
  287. <SelectPlaceholder>{t('Select a project')}</SelectPlaceholder>
  288. )
  289. }
  290. onChange={({value}) => {
  291. setSelectedProjectId(value as string);
  292. }}
  293. emptyMessage={
  294. orgProjectsRequest.isPending || isSearchStale
  295. ? t('Loading...')
  296. : search
  297. ? t('No projects matching search')
  298. : tct('No projects found. [link:Create a project]', {
  299. organization:
  300. selectedOrg?.name || selectedOrg?.slug || 'organization',
  301. link: (
  302. <a
  303. href={`/organizations/${selectedOrg?.slug}/projects/new`}
  304. target="_blank"
  305. rel="noreferrer"
  306. />
  307. ),
  308. })
  309. }
  310. />
  311. )}
  312. </FieldWrapper>
  313. <SubmitButton disabled={!isFormValid || isPending} priority="primary" type="submit">
  314. {t('Continue')}
  315. </SubmitButton>
  316. </StyledForm>
  317. );
  318. }
  319. function getSsoLoginUrl(error: RequestError) {
  320. const detail = error?.responseJSON?.detail as any;
  321. const loginUrl = detail?.extra?.loginUrl;
  322. if (!loginUrl || typeof loginUrl !== 'string') {
  323. return null;
  324. }
  325. try {
  326. // Pass a base param as the login may be absolute or relative
  327. const url = new URL(loginUrl, location.origin);
  328. // Pass the current URL as the next URL to redirect to after login
  329. url.searchParams.set('next', location.href);
  330. return url.toString();
  331. } catch {
  332. return null;
  333. }
  334. }
  335. function ProjectLoadingError({
  336. error,
  337. onRetry,
  338. }: {
  339. error: RequestError;
  340. onRetry: () => void;
  341. }) {
  342. const detail = error?.responseJSON?.detail;
  343. const code = typeof detail === 'string' ? undefined : detail?.code;
  344. const ssoLoginUrl = getSsoLoginUrl(error);
  345. if (code === 'sso-required' && ssoLoginUrl) {
  346. return (
  347. <AlertWithoutMargin
  348. type="error"
  349. showIcon
  350. trailingItems={
  351. <LinkButton href={ssoLoginUrl} size="xs">
  352. {t('Log in')}
  353. </LinkButton>
  354. }
  355. >
  356. {t('This organization requires Single Sign-On.')}
  357. </AlertWithoutMargin>
  358. );
  359. }
  360. return (
  361. <LoadingErrorWithoutMargin
  362. message={t('Failed to load projects')}
  363. onRetry={() => {
  364. onRetry();
  365. }}
  366. />
  367. );
  368. }
  369. const AlertWithoutMargin = styled(Alert)`
  370. margin: 0;
  371. `;
  372. const LoadingErrorWithoutMargin = styled(LoadingError)`
  373. margin: 0;
  374. `;
  375. const StyledForm = styled('form')`
  376. display: flex;
  377. flex-direction: column;
  378. gap: ${space(2)};
  379. `;
  380. const Heading = styled('h5')`
  381. margin-bottom: ${space(0.5)};
  382. `;
  383. const FieldWrapper = styled('div')`
  384. display: flex;
  385. flex-direction: column;
  386. gap: ${space(0.5)};
  387. `;
  388. const StyledCompactSelect = styled(CompactSelect)`
  389. width: 100%;
  390. & > button {
  391. width: 100%;
  392. }
  393. `;
  394. const SelectPlaceholder = styled('span')`
  395. ${p => p.theme.overflowEllipsis}
  396. color: ${p => p.theme.subText};
  397. font-weight: normal;
  398. text-align: left;
  399. `;
  400. const SubmitButton = styled(Button)`
  401. margin-top: ${space(1)};
  402. `;
  403. function WaitingForWizardToConnect({
  404. hash,
  405. organizations,
  406. }: Omit<Props, 'allowSelection' | 'projects'>) {
  407. const api = useApi();
  408. const closeTimeoutRef = useRef<number | undefined>(undefined);
  409. const [finished, setFinished] = useState(false);
  410. const analyticsParams = useAnalyticsParams(organizations);
  411. useEffect(() => {
  412. return () => {
  413. if (closeTimeoutRef.current) {
  414. window.clearTimeout(closeTimeoutRef.current);
  415. }
  416. };
  417. }, []);
  418. const checkFinished = useCallback(async () => {
  419. if (finished) {
  420. return;
  421. }
  422. try {
  423. await api.requestPromise(`/wizard/${hash}/`);
  424. } catch {
  425. setFinished(true);
  426. window.clearTimeout(closeTimeoutRef.current);
  427. closeTimeoutRef.current = window.setTimeout(() => window.close(), 10000);
  428. trackAnalytics('setup_wizard.complete', analyticsParams);
  429. }
  430. }, [api, hash, analyticsParams, finished]);
  431. useEffect(() => {
  432. const pollingInterval = window.setInterval(checkFinished, 1000);
  433. return () => window.clearInterval(pollingInterval);
  434. }, [checkFinished]);
  435. return !finished ? (
  436. <LoadingIndicator style={{margin: '2em auto'}}>
  437. <h5>{t('Waiting for wizard to connect')}</h5>
  438. </LoadingIndicator>
  439. ) : (
  440. <SuccessWrapper>
  441. <SuccessCheckmark color="green300" size="xl" isCircled />
  442. <SuccessHeading>
  443. {t('Return to your terminal to complete your setup.')}
  444. </SuccessHeading>
  445. </SuccessWrapper>
  446. );
  447. }
  448. const SuccessCheckmark = styled(IconCheckmark)`
  449. flex-shrink: 0;
  450. `;
  451. const SuccessHeading = styled('h5')`
  452. margin: 0;
  453. `;
  454. const SuccessWrapper = styled('div')`
  455. display: flex;
  456. align-items: center;
  457. gap: ${space(3)};
  458. `;
  459. export default SetupWizard;