From da017ea41e9ab5e925fc730dcdfe485328c452f4 Mon Sep 17 00:00:00 2001 From: ArthurKnaus Date: Wed, 22 Jan 2025 09:43:25 +0100 Subject: [PATCH] feat(setup-wizard): Allow project creation (#83811) Allow creating Sentry projects in the setup wizard workflow. It is only enabled if the wizard passes the `project_platform` URL param which is used to define the projects platform. - part of https://github.com/getsentry/sentry-wizard/issues/549 --- .../app/views/setupWizard/utils/features.tsx | 5 + .../utils/useCreateProjectFromWizard.tsx | 28 +++ .../utils/useOrganizationDetails.tsx | 28 +++ .../utils/useOrganizationTeams.tsx | 25 ++ .../setupWizard/wizardProjectSelection.tsx | 219 ++++++++++++++---- 5 files changed, 261 insertions(+), 44 deletions(-) create mode 100644 static/app/views/setupWizard/utils/features.tsx create mode 100644 static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx create mode 100644 static/app/views/setupWizard/utils/useOrganizationDetails.tsx create mode 100644 static/app/views/setupWizard/utils/useOrganizationTeams.tsx diff --git a/static/app/views/setupWizard/utils/features.tsx b/static/app/views/setupWizard/utils/features.tsx new file mode 100644 index 00000000000000..5e911b780e84b1 --- /dev/null +++ b/static/app/views/setupWizard/utils/features.tsx @@ -0,0 +1,5 @@ +import type {Organization} from 'sentry/types/organization'; + +export function hasSetupWizardCreateProjectFeature(organization: Organization) { + return organization.features.includes('setup-wizard-create-project'); +} diff --git a/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx new file mode 100644 index 00000000000000..d94a6927a98e88 --- /dev/null +++ b/static/app/views/setupWizard/utils/useCreateProjectFromWizard.tsx @@ -0,0 +1,28 @@ +import type {Project} from 'sentry/types/project'; +import {useMutation} from 'sentry/utils/queryClient'; +import useApi from 'sentry/utils/useApi'; +import type {OrganizationWithRegion} from 'sentry/views/setupWizard/types'; + +export function useCreateProjectFromWizard() { + const api = useApi(); + return useMutation({ + mutationFn: (params: { + name: string; + organization: OrganizationWithRegion; + platform: string; + team: string; + }): Promise => { + const url = `/teams/${params.organization.slug}/${params.team}/projects/`; + return api.requestPromise(url, { + method: 'POST', + host: params.organization.region.url, + data: { + name: params.name, + platform: params.platform, + default_rules: true, + origin: 'ui', + }, + }); + }, + }); +} diff --git a/static/app/views/setupWizard/utils/useOrganizationDetails.tsx b/static/app/views/setupWizard/utils/useOrganizationDetails.tsx new file mode 100644 index 00000000000000..68493294417053 --- /dev/null +++ b/static/app/views/setupWizard/utils/useOrganizationDetails.tsx @@ -0,0 +1,28 @@ +import type {Organization} from 'sentry/types/organization'; +import {useQuery} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import type {OrganizationWithRegion} from 'sentry/views/setupWizard/types'; + +export function useOrganizationDetails({ + organization, +}: { + organization?: OrganizationWithRegion; +}) { + const api = useApi(); + + return useQuery({ + queryKey: [`/organizations/${organization?.slug}/`], + queryFn: () => { + return api.requestPromise(`/organizations/${organization?.slug}/`, { + host: organization?.region.url, + query: { + include_feature_flags: 1, + }, + }); + }, + enabled: !!organization, + refetchOnWindowFocus: true, + retry: false, + }); +} diff --git a/static/app/views/setupWizard/utils/useOrganizationTeams.tsx b/static/app/views/setupWizard/utils/useOrganizationTeams.tsx new file mode 100644 index 00000000000000..defba620525f08 --- /dev/null +++ b/static/app/views/setupWizard/utils/useOrganizationTeams.tsx @@ -0,0 +1,25 @@ +import type {Team} from 'sentry/types/organization'; +import {useQuery} from 'sentry/utils/queryClient'; +import type RequestError from 'sentry/utils/requestError/requestError'; +import useApi from 'sentry/utils/useApi'; +import type {OrganizationWithRegion} from 'sentry/views/setupWizard/types'; + +export function useOrganizationTeams({ + organization, +}: { + organization?: OrganizationWithRegion; +}) { + const api = useApi(); + + return useQuery({ + queryKey: [`/organizations/${organization?.slug}/teams/`], + queryFn: () => { + return api.requestPromise(`/organizations/${organization?.slug}/teams/`, { + host: organization?.region.url, + }); + }, + enabled: !!organization, + refetchOnWindowFocus: true, + retry: false, + }); +} diff --git a/static/app/views/setupWizard/wizardProjectSelection.tsx b/static/app/views/setupWizard/wizardProjectSelection.tsx index 0f0a38bb73c6bf..4a664183b08376 100644 --- a/static/app/views/setupWizard/wizardProjectSelection.tsx +++ b/static/app/views/setupWizard/wizardProjectSelection.tsx @@ -1,12 +1,16 @@ -import {useCallback, useMemo, useState} from 'react'; +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 {t, tct} from 'sentry/locale'; +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'; @@ -14,18 +18,27 @@ 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 {hasSetupWizardCreateProjectFeature} from 'sentry/views/setupWizard/utils/features'; +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 urlParams = new URLSearchParams(location.search); - const orgSlug = urlParams.get('org_slug'); - const orgMatchingSlug = orgSlug && organizations.find(org => org.slug === orgSlug); + const orgMatchingSlug = + orgSlugParam && organizations.find(org => org.slug === orgSlugParam); if (orgMatchingSlug) { return orgMatchingSlug.id; @@ -48,45 +61,46 @@ export function WizardProjectSelection({ 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 {mutate: updateWizardCache, isPending, isSuccess} = useUpdateWizardCache(hash); + const isCreationEnabled = + orgDetailsRequest.data && + teamsRequest.data && + teamsRequest.data.length > 0 && + hasSetupWizardCreateProjectFeature(orgDetailsRequest.data) && + canCreateProject(orgDetailsRequest.data) && + platformParam; - const handleSubmit = useCallback( - (event: React.FormEvent) => { - event.preventDefault(); - if (!selectedOrgId || !selectedProjectId) { - return; - } - updateWizardCache( - { - organizationId: selectedOrgId, - projectId: selectedProjectId, - }, - { - onError: () => { - addErrorMessage(t('Something went wrong! Please try again.')); - }, - } - ); - }, - [selectedOrgId, selectedProjectId, updateWizardCache] - ); + 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( () => @@ -131,28 +145,71 @@ export function WizardProjectSelection({ [selectedProjectId, sortedProjectOptions] ); - const isFormValid = selectedOrg && selectedProject; + 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 = tct('No projects found. [link:Create a project]', { - organization: selectedOrg?.name || selectedOrg?.slug || 'organization', - link: ( - - ), - }); + let emptyMessage: React.ReactNode = t('No projects found.'); if (orgProjectsRequest.isPending || isSearchStale) { emptyMessage = t('Loading...'); - } - - if (search) { + } else if (search) { emptyMessage = t('No projects matching search'); } @@ -204,22 +261,81 @@ export function WizardProjectSelection({ searchable options={sortedProjectOptions} triggerProps={{ - icon: selectedProject ? ( + icon: isCreateProjectSelected ? ( + + ) : selectedProject ? ( ) : null, }} triggerLabel={ - selectedProject?.name || ( - {t('Select a project')} - ) + 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')} @@ -243,6 +359,16 @@ const FieldWrapper = styled('div')` 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%; @@ -251,6 +377,11 @@ const StyledCompactSelect = styled(CompactSelect)` } `; +const AlignRight = styled('div')` + display: flex; + justify-content: flex-end; +`; + const SelectPlaceholder = styled('span')` ${p => p.theme.overflowEllipsis} color: ${p => p.theme.subText};