index.tsx 14 KB

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