import {Fragment, useCallback, useMemo, useState} from 'react'; import styled from '@emotion/styled'; import {addErrorMessage} from 'sentry/actionCreators/indicator'; import OrganizationAvatar from 'sentry/components/avatar/organizationAvatar'; import {Button} from 'sentry/components/button'; import {CompactSelect} from 'sentry/components/compactSelect'; import IdBadge from 'sentry/components/idBadge'; import ProjectBadge from 'sentry/components/idBadge/projectBadge'; import Input from 'sentry/components/input'; import {canCreateProject} from 'sentry/components/projects/canCreateProject'; import {IconAdd} from 'sentry/icons'; import {t} from 'sentry/locale'; import ConfigStore from 'sentry/stores/configStore'; import {space} from 'sentry/styles/space'; import type {Organization} from 'sentry/types/organization'; import {useDebouncedValue} from 'sentry/utils/useDebouncedValue'; import {useCompactSelectOptionsCache} from 'sentry/views/insights/common/utils/useCompactSelectOptionsCache'; import {ProjectLoadingError} from 'sentry/views/setupWizard/projectLoadingError'; import type {OrganizationWithRegion} from 'sentry/views/setupWizard/types'; import {useCreateProjectFromWizard} from 'sentry/views/setupWizard/utils/useCreateProjectFromWizard'; import {useOrganizationDetails} from 'sentry/views/setupWizard/utils/useOrganizationDetails'; import {useOrganizationProjects} from 'sentry/views/setupWizard/utils/useOrganizationProjects'; import {useOrganizationTeams} from 'sentry/views/setupWizard/utils/useOrganizationTeams'; import {useUpdateWizardCache} from 'sentry/views/setupWizard/utils/useUpdateWizardCache'; import {WaitingForWizardToConnect} from 'sentry/views/setupWizard/waitingForWizardToConnect'; const CREATE_PROJECT_VALUE = 'create-new-project'; const urlParams = new URLSearchParams(location.search); const platformParam = urlParams.get('project_platform'); const orgSlugParam = urlParams.get('org_slug'); function getInitialOrgId(organizations: Organization[]) { if (organizations.length === 1) { return organizations[0]!.id; } const orgMatchingSlug = orgSlugParam && organizations.find(org => org.slug === orgSlugParam); if (orgMatchingSlug) { return orgMatchingSlug.id; } const lastOrgSlug = ConfigStore.get('lastOrganization'); const lastOrg = lastOrgSlug && organizations.find(org => org.slug === lastOrgSlug); // Pre-fill the last used org if there are multiple and no URL param if (lastOrg) { return lastOrg.id; } return null; } export function WizardProjectSelection({ hash, organizations = [], }: { hash: string; organizations: OrganizationWithRegion[]; }) { const [search, setSearch] = useState(''); const debouncedSearch = useDebouncedValue(search, 300); const isSearchStale = search !== debouncedSearch; const [selectedOrgId, setSelectedOrgId] = useState(() => getInitialOrgId(organizations) ); const [selectedProjectId, setSelectedProjectId] = useState(null); const isCreateProjectSelected = selectedProjectId === CREATE_PROJECT_VALUE; const [newProjectName, setNewProjectName] = useState(platformParam || ''); const [newProjectTeam, setNewProjectTeam] = useState(null); const selectedOrg = useMemo( () => organizations.find(org => org.id === selectedOrgId), [organizations, selectedOrgId] ); const orgDetailsRequest = useOrganizationDetails({organization: selectedOrg}); const teamsRequest = useOrganizationTeams({organization: selectedOrg}); const orgProjectsRequest = useOrganizationProjects({ organization: selectedOrg, query: debouncedSearch, }); const isCreationEnabled = orgDetailsRequest.data && canCreateProject(orgDetailsRequest.data) && teamsRequest.data && teamsRequest.data.length > 0 && platformParam; const updateWizardCacheMutation = useUpdateWizardCache(hash); const createProjectMutation = useCreateProjectFromWizard(); const isPending = updateWizardCacheMutation.isPending || createProjectMutation.isPending; const isSuccess = isCreateProjectSelected ? updateWizardCacheMutation.isSuccess && createProjectMutation.isSuccess : updateWizardCacheMutation.isSuccess; const orgOptions = useMemo( () => organizations .map(org => ({ value: org.id, label: org.name || org.slug, leadingItems: , })) .toSorted((a: any, b: any) => 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 selectedTeam = useMemo( () => teamsRequest.data?.find(team => team.slug === newProjectTeam), [newProjectTeam, teamsRequest] ); const isProjectSelected = isCreateProjectSelected ? newProjectName && newProjectTeam : selectedProject; const isFormValid = selectedOrg && isProjectSelected; const handleSubmit = useCallback( async (event: React.FormEvent) => { event.preventDefault(); if (!isFormValid || !selectedOrg || !selectedProjectId) { return; } let projectId = selectedProjectId; try { if (isCreateProjectSelected) { const project = await createProjectMutation.mutateAsync({ organization: selectedOrg, team: newProjectTeam!, name: newProjectName, platform: platformParam || 'other', }); projectId = project.id; } } catch { addErrorMessage('Failed to create project! Please try again'); return; } try { await updateWizardCacheMutation.mutateAsync({ organizationId: selectedOrg.id, projectId, }); } catch { addErrorMessage(t('Something went wrong! Please try again.')); } }, [ isFormValid, selectedOrg, selectedProjectId, isCreateProjectSelected, createProjectMutation, newProjectTeam, newProjectName, updateWizardCacheMutation, ] ); if (isSuccess) { return ; } let emptyMessage: React.ReactNode = t('No projects found.'); if (orgProjectsRequest.isPending || isSearchStale) { emptyMessage = t('Loading...'); } else if (search) { emptyMessage = t('No projects matching search'); } 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: isCreateProjectSelected ? ( ) : selectedProject ? ( ) : null, }} triggerLabel={ isCreateProjectSelected ? t('Create Project') : selectedProject?.name || ( {t('Select a project')} ) } onChange={({value}) => { setSelectedProjectId(value as string); }} emptyMessage={emptyMessage} menuFooter={ isCreationEnabled ? ({closeOverlay}) => ( ) : undefined } /> )} {isCreateProjectSelected && ( setNewProjectName(event.target.value)} placeholder={t('Enter a project name')} /> ({ value: team.slug, label: `#${team.slug}`, leadingItems: , searchKey: team.slug, })) || [] } triggerLabel={selectedTeam ? `#${selectedTeam.slug}` : t('Select a team')} triggerProps={{ icon: selectedTeam ? ( ) : null, }} onChange={({value}) => { setNewProjectTeam(value as string); }} /> )} {t('Continue')} ); } 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 Columns = styled('div')` display: grid; grid-template-columns: 1fr 1fr; gap: ${space(2)}; @media (max-width: ${p => p.theme.breakpoints.xsmall}) { grid-template-columns: 1fr; } `; const StyledCompactSelect = styled(CompactSelect)` width: 100%; & > button { width: 100%; } `; const AlignRight = styled('div')` display: flex; justify-content: flex-end; `; 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)}; `;