wizardProjectSelection.tsx 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392
  1. import {Fragment, useCallback, useMemo, useState} from 'react';
  2. import styled from '@emotion/styled';
  3. import {addErrorMessage} from 'sentry/actionCreators/indicator';
  4. import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar';
  5. import {Button} from 'sentry/components/button';
  6. import {CompactSelect} from 'sentry/components/compactSelect';
  7. import IdBadge from 'sentry/components/idBadge';
  8. import ProjectBadge from 'sentry/components/idBadge/projectBadge';
  9. import Input from 'sentry/components/input';
  10. import {canCreateProject} from 'sentry/components/projects/canCreateProject';
  11. import {IconAdd} from 'sentry/icons';
  12. import {t} from 'sentry/locale';
  13. import ConfigStore from 'sentry/stores/configStore';
  14. import {space} from 'sentry/styles/space';
  15. import type {Organization} from 'sentry/types/organization';
  16. import {useDebouncedValue} from 'sentry/utils/useDebouncedValue';
  17. import {useCompactSelectOptionsCache} from 'sentry/views/insights/common/utils/useCompactSelectOptionsCache';
  18. import {ProjectLoadingError} from 'sentry/views/setupWizard/projectLoadingError';
  19. import type {OrganizationWithRegion} from 'sentry/views/setupWizard/types';
  20. import {useCreateProjectFromWizard} from 'sentry/views/setupWizard/utils/useCreateProjectFromWizard';
  21. import {useOrganizationDetails} from 'sentry/views/setupWizard/utils/useOrganizationDetails';
  22. import {useOrganizationProjects} from 'sentry/views/setupWizard/utils/useOrganizationProjects';
  23. import {useOrganizationTeams} from 'sentry/views/setupWizard/utils/useOrganizationTeams';
  24. import {useUpdateWizardCache} from 'sentry/views/setupWizard/utils/useUpdateWizardCache';
  25. import {WaitingForWizardToConnect} from 'sentry/views/setupWizard/waitingForWizardToConnect';
  26. const CREATE_PROJECT_VALUE = 'create-new-project';
  27. const urlParams = new URLSearchParams(location.search);
  28. const platformParam = urlParams.get('project_platform');
  29. const orgSlugParam = urlParams.get('org_slug');
  30. function getInitialOrgId(organizations: Organization[]) {
  31. if (organizations.length === 1) {
  32. return organizations[0]!.id;
  33. }
  34. const orgMatchingSlug =
  35. orgSlugParam && organizations.find(org => org.slug === orgSlugParam);
  36. if (orgMatchingSlug) {
  37. return orgMatchingSlug.id;
  38. }
  39. const lastOrgSlug = ConfigStore.get('lastOrganization');
  40. const lastOrg = lastOrgSlug && organizations.find(org => org.slug === lastOrgSlug);
  41. // Pre-fill the last used org if there are multiple and no URL param
  42. if (lastOrg) {
  43. return lastOrg.id;
  44. }
  45. return null;
  46. }
  47. export function WizardProjectSelection({
  48. hash,
  49. organizations = [],
  50. }: {
  51. hash: string;
  52. organizations: OrganizationWithRegion[];
  53. }) {
  54. const [search, setSearch] = useState('');
  55. const debouncedSearch = useDebouncedValue(search, 300);
  56. const isSearchStale = search !== debouncedSearch;
  57. const [selectedOrgId, setSelectedOrgId] = useState<string | null>(() =>
  58. getInitialOrgId(organizations)
  59. );
  60. const [selectedProjectId, setSelectedProjectId] = useState<string | null>(null);
  61. const isCreateProjectSelected = selectedProjectId === CREATE_PROJECT_VALUE;
  62. const [newProjectName, setNewProjectName] = useState(platformParam || '');
  63. const [newProjectTeam, setNewProjectTeam] = useState<string | null>(null);
  64. const selectedOrg = useMemo(
  65. () => organizations.find(org => org.id === selectedOrgId),
  66. [organizations, selectedOrgId]
  67. );
  68. const orgDetailsRequest = useOrganizationDetails({organization: selectedOrg});
  69. const teamsRequest = useOrganizationTeams({organization: selectedOrg});
  70. const orgProjectsRequest = useOrganizationProjects({
  71. organization: selectedOrg,
  72. query: debouncedSearch,
  73. });
  74. const isCreationEnabled =
  75. orgDetailsRequest.data &&
  76. canCreateProject(orgDetailsRequest.data) &&
  77. teamsRequest.data &&
  78. teamsRequest.data.length > 0 &&
  79. platformParam;
  80. const updateWizardCacheMutation = useUpdateWizardCache(hash);
  81. const createProjectMutation = useCreateProjectFromWizard();
  82. const isPending =
  83. updateWizardCacheMutation.isPending || createProjectMutation.isPending;
  84. const isSuccess = isCreateProjectSelected
  85. ? updateWizardCacheMutation.isSuccess && createProjectMutation.isSuccess
  86. : updateWizardCacheMutation.isSuccess;
  87. const orgOptions = useMemo(
  88. () =>
  89. organizations
  90. .map(org => ({
  91. value: org.id,
  92. label: org.name || org.slug,
  93. leadingItems: <OrganizationAvatar size={16} organization={org} />,
  94. }))
  95. .toSorted((a: any, b: any) => a.label.localeCompare(b.label)),
  96. [organizations]
  97. );
  98. const projectOptions = useMemo(
  99. () =>
  100. (orgProjectsRequest.data || []).map(project => ({
  101. value: project.id,
  102. label: project.name,
  103. leadingItems: <ProjectBadge avatarSize={16} project={project} hideName />,
  104. project,
  105. })),
  106. [orgProjectsRequest.data]
  107. );
  108. const {options: cachedProjectOptions, clear: clearProjectOptions} =
  109. useCompactSelectOptionsCache(projectOptions);
  110. // As the cache hook sorts the options by value, we need to sort them afterwards
  111. const sortedProjectOptions = useMemo(
  112. () =>
  113. cachedProjectOptions.sort((a, b) => {
  114. return a.label.localeCompare(b.label);
  115. }),
  116. [cachedProjectOptions]
  117. );
  118. // Select the project from the cached options to avoid visually clearing the input
  119. // when searching while having a selected project
  120. const selectedProject = useMemo(
  121. () =>
  122. sortedProjectOptions?.find(option => option.value === selectedProjectId)?.project,
  123. [selectedProjectId, sortedProjectOptions]
  124. );
  125. const selectedTeam = useMemo(
  126. () => teamsRequest.data?.find(team => team.slug === newProjectTeam),
  127. [newProjectTeam, teamsRequest]
  128. );
  129. const isProjectSelected = isCreateProjectSelected
  130. ? newProjectName && newProjectTeam
  131. : selectedProject;
  132. const isFormValid = selectedOrg && isProjectSelected;
  133. const handleSubmit = useCallback(
  134. async (event: React.FormEvent) => {
  135. event.preventDefault();
  136. if (!isFormValid || !selectedOrg || !selectedProjectId) {
  137. return;
  138. }
  139. let projectId = selectedProjectId;
  140. try {
  141. if (isCreateProjectSelected) {
  142. const project = await createProjectMutation.mutateAsync({
  143. organization: selectedOrg,
  144. team: newProjectTeam!,
  145. name: newProjectName,
  146. platform: platformParam || 'other',
  147. });
  148. projectId = project.id;
  149. }
  150. } catch {
  151. addErrorMessage('Failed to create project! Please try again');
  152. return;
  153. }
  154. try {
  155. await updateWizardCacheMutation.mutateAsync({
  156. organizationId: selectedOrg.id,
  157. projectId,
  158. });
  159. } catch {
  160. addErrorMessage(t('Something went wrong! Please try again.'));
  161. }
  162. },
  163. [
  164. isFormValid,
  165. selectedOrg,
  166. selectedProjectId,
  167. isCreateProjectSelected,
  168. createProjectMutation,
  169. newProjectTeam,
  170. newProjectName,
  171. updateWizardCacheMutation,
  172. ]
  173. );
  174. if (isSuccess) {
  175. return <WaitingForWizardToConnect hash={hash} organizations={organizations} />;
  176. }
  177. let emptyMessage: React.ReactNode = t('No projects found.');
  178. if (orgProjectsRequest.isPending || isSearchStale) {
  179. emptyMessage = t('Loading...');
  180. } else if (search) {
  181. emptyMessage = t('No projects matching search');
  182. }
  183. return (
  184. <StyledForm onSubmit={handleSubmit}>
  185. <Heading>{t('Select your Sentry project')}</Heading>
  186. <FieldWrapper>
  187. <label>{t('Organization')}</label>
  188. <StyledCompactSelect
  189. autoFocus
  190. value={selectedOrgId as string}
  191. searchable
  192. options={orgOptions}
  193. triggerProps={{
  194. icon: selectedOrg ? (
  195. <OrganizationAvatar size={16} organization={selectedOrg} />
  196. ) : null,
  197. }}
  198. triggerLabel={
  199. selectedOrg?.name ||
  200. selectedOrg?.slug || (
  201. <SelectPlaceholder>{t('Select an organization')}</SelectPlaceholder>
  202. )
  203. }
  204. onChange={({value}) => {
  205. if (value !== selectedOrgId) {
  206. setSelectedOrgId(value as string);
  207. setSelectedProjectId(null);
  208. clearProjectOptions();
  209. }
  210. }}
  211. />
  212. </FieldWrapper>
  213. <FieldWrapper>
  214. <label>{t('Project')}</label>
  215. {orgProjectsRequest.error ? (
  216. <ProjectLoadingError
  217. error={orgProjectsRequest.error}
  218. onRetry={orgProjectsRequest.refetch}
  219. />
  220. ) : (
  221. <StyledCompactSelect
  222. // Remount the component when the org changes to reset the component state
  223. key={selectedOrgId}
  224. onSearch={setSearch}
  225. onClose={() => setSearch('')}
  226. disabled={!selectedOrgId}
  227. value={selectedProjectId as string}
  228. searchable
  229. options={sortedProjectOptions}
  230. triggerProps={{
  231. icon: isCreateProjectSelected ? (
  232. <IconAdd isCircled />
  233. ) : selectedProject ? (
  234. <ProjectBadge avatarSize={16} project={selectedProject} hideName />
  235. ) : null,
  236. }}
  237. triggerLabel={
  238. isCreateProjectSelected
  239. ? t('Create Project')
  240. : selectedProject?.name || (
  241. <SelectPlaceholder>{t('Select a project')}</SelectPlaceholder>
  242. )
  243. }
  244. onChange={({value}) => {
  245. setSelectedProjectId(value as string);
  246. }}
  247. emptyMessage={emptyMessage}
  248. menuFooter={
  249. isCreationEnabled
  250. ? ({closeOverlay}) => (
  251. <AlignRight>
  252. <Button
  253. size="xs"
  254. onClick={() => {
  255. setSelectedProjectId(CREATE_PROJECT_VALUE);
  256. closeOverlay();
  257. }}
  258. icon={<IconAdd isCircled />}
  259. >
  260. {t('Create Project')}
  261. </Button>
  262. </AlignRight>
  263. )
  264. : undefined
  265. }
  266. />
  267. )}
  268. </FieldWrapper>
  269. {isCreateProjectSelected && (
  270. <Fragment>
  271. <Columns>
  272. <FieldWrapper>
  273. <label>{t('Project Name')}</label>
  274. <Input
  275. value={newProjectName}
  276. onChange={event => setNewProjectName(event.target.value)}
  277. placeholder={t('Enter a project name')}
  278. />
  279. </FieldWrapper>
  280. <FieldWrapper>
  281. <label>{t('Team')}</label>
  282. <StyledCompactSelect
  283. value={newProjectTeam as string}
  284. options={
  285. teamsRequest.data?.map(team => ({
  286. value: team.slug,
  287. label: `#${team.slug}`,
  288. leadingItems: <IdBadge team={team} hideName />,
  289. searchKey: team.slug,
  290. })) || []
  291. }
  292. triggerLabel={selectedTeam ? `#${selectedTeam.slug}` : t('Select a team')}
  293. triggerProps={{
  294. icon: selectedTeam ? (
  295. <IdBadge avatarSize={16} team={selectedTeam} hideName />
  296. ) : null,
  297. }}
  298. onChange={({value}) => {
  299. setNewProjectTeam(value as string);
  300. }}
  301. />
  302. </FieldWrapper>
  303. </Columns>
  304. </Fragment>
  305. )}
  306. <SubmitButton disabled={!isFormValid || isPending} priority="primary" type="submit">
  307. {t('Continue')}
  308. </SubmitButton>
  309. </StyledForm>
  310. );
  311. }
  312. const StyledForm = styled('form')`
  313. display: flex;
  314. flex-direction: column;
  315. gap: ${space(2)};
  316. `;
  317. const Heading = styled('h5')`
  318. margin-bottom: ${space(0.5)};
  319. `;
  320. const FieldWrapper = styled('div')`
  321. display: flex;
  322. flex-direction: column;
  323. gap: ${space(0.5)};
  324. `;
  325. const Columns = styled('div')`
  326. display: grid;
  327. grid-template-columns: 1fr 1fr;
  328. gap: ${space(2)};
  329. @media (max-width: ${p => p.theme.breakpoints.xsmall}) {
  330. grid-template-columns: 1fr;
  331. }
  332. `;
  333. const StyledCompactSelect = styled(CompactSelect)`
  334. width: 100%;
  335. & > button {
  336. width: 100%;
  337. }
  338. `;
  339. const AlignRight = styled('div')`
  340. display: flex;
  341. justify-content: flex-end;
  342. `;
  343. const SelectPlaceholder = styled('span')`
  344. ${p => p.theme.overflowEllipsis}
  345. color: ${p => p.theme.subText};
  346. font-weight: normal;
  347. text-align: left;
  348. `;
  349. const SubmitButton = styled(Button)`
  350. margin-top: ${space(1)};
  351. `;