Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import {OrganizationFixture} from 'sentry-fixture/organization';
import {TeamFixture} from 'sentry-fixture/team';

import {renderHookWithProviders} from 'sentry-test/reactTestingLibrary';

import {ProjectsStore} from 'sentry/stores/projectsStore';
import {TeamStore} from 'sentry/stores/teamStore';
import type {OnboardingSelectedSDK} from 'sentry/types/onboarding';

import {useScmProjectDetails} from './useScmProjectDetails';

const pythonPlatform: OnboardingSelectedSDK = {
key: 'python',
name: 'Python',
language: 'python',
type: 'language',
link: 'https://docs.sentry.io/platforms/python/',
category: 'popular',
};

describe('useScmProjectDetails', () => {
const organization = OrganizationFixture();
const adminTeam = TeamFixture({slug: 'admin-team', access: ['team:admin']});

function renderDetails(
overrides: Partial<Parameters<typeof useScmProjectDetails>[0]> = {}
) {
return renderHookWithProviders(
() =>
useScmProjectDetails({
analyticsFlow: 'project-creation',
allowMemberWithoutTeam: true,
selectedPlatform: pythonPlatform,
selectedRepository: undefined,
projectDetailsForm: {projectName: 'my-project'},
onProjectDetailsFormChange: jest.fn(),
onComplete: jest.fn(),
...overrides,
}),
{organization}
);
}

afterEach(() => {
TeamStore.reset();
});

it('does not report the team as missing while teams are still loading', () => {
// TeamStore starts in its loading state with no teams, as during the
// initial fetch. The team is unresolved only because firstAdminTeam isn't
// available yet, not because the user needs to pick one.
TeamStore.reset();
ProjectsStore.loadInitialData([]);

const {result} = renderDetails();

expect(result.current.missingFields.team).toBe(false);
// Submission is still blocked until teams finish loading.
expect(result.current.canSubmit).toBe(false);
});

it('reports the team as missing once teams have loaded and none is available', () => {
// Teams have loaded but the viewer has no team to default to, so the team
// genuinely needs to be selected (onboarding-style: no member fallback).
TeamStore.loadInitialData([]);
ProjectsStore.loadInitialData([]);

const {result} = renderDetails({allowMemberWithoutTeam: false});

expect(result.current.missingFields.team).toBe(true);
});

it('resolves the team from the first admin team once teams have loaded', () => {
TeamStore.loadInitialData([adminTeam]);
ProjectsStore.loadInitialData([]);

const {result} = renderDetails();

expect(result.current.teamSlug).toBe(adminTeam.slug);
expect(result.current.missingFields.team).toBe(false);
expect(result.current.canSubmit).toBe(true);
});
});
19 changes: 16 additions & 3 deletions static/app/views/onboarding/components/useScmProjectDetails.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,8 @@ interface ScmProjectDetailsForm {
isBusy: boolean;
/** Whether the team selector should be hidden (no-access member). */
isOrgMemberWithNoAccess: boolean;
/** Required fields still missing, for disabled-submit messaging. */
missingFields: {platform: boolean; projectName: boolean; team: boolean};
onAlertChange: <K extends keyof AlertRuleOptions>(
key: K,
value: AlertRuleOptions[K]
Expand Down Expand Up @@ -211,12 +213,22 @@ export function useScmProjectDetails({
]
);

const missingFields = {
platform: !selectedPlatform,
projectName: projectNameResolved.length === 0,
// While teams load, teamSlugResolved is empty only because firstAdminTeam
// isn't available yet, not because the user must pick one. Don't report it
// as missing so the disabled-CTA tooltip stays silent for this transient
// blocker (canSubmit gates on !isLoadingTeams independently).
team: !isOrgMemberWithNoAccess && !isLoadingTeams && teamSlugResolved.length === 0,
};

// Block submission until teams and the projects store have loaded so the
// reuse check below can't be bypassed by a race.
const canSubmit =
projectNameResolved.length > 0 &&
(isOrgMemberWithNoAccess || teamSlugResolved.length > 0) &&
!!selectedPlatform &&
!missingFields.projectName &&
!missingFields.team &&
!missingFields.platform &&
!createProjectAndRules.isPending &&
!isLoadingTeams &&
projectsLoaded;
Expand Down Expand Up @@ -323,6 +335,7 @@ export function useScmProjectDetails({
alertRuleConfig,
onAlertChange,
isOrgMemberWithNoAccess,
missingFields,
canSubmit,
isBusy: createProjectAndRules.isPending,
error: createProjectAndRules.error,
Expand Down
34 changes: 34 additions & 0 deletions static/app/views/projectInstall/scmCreateProject.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,40 @@ describe('ScmCreateProject', () => {
expect(screen.getByPlaceholderText('project-name')).toHaveValue('my-restored-name');
});

it('explains what is missing on the disabled Create CTA', async () => {
render(<ScmCreateProject />, {organization});

const createButton = await screen.findByRole('button', {name: 'Create project'});
expect(createButton).toBeDisabled();

// Fresh wizard: platform and project name are both missing.
await userEvent.hover(createButton);
expect(
await screen.findByText('Please fill out all the required fields')
).toBeInTheDocument();
});

it('names the single missing field on the disabled Create CTA', async () => {
// All steps render at once now, so a plain restored session (platform set)
// is enough; no separate "revealed" state to seed.
persistWizardSession();

render(<ScmCreateProject />, {
organization,
initialRouterConfig: returningRouterConfig,
});

// Platform is restored, so the name defaults; clearing it leaves the name
// as the only missing field.
const nameInput = await screen.findByPlaceholderText('project-name');
await userEvent.clear(nameInput);

const createButton = screen.getByRole('button', {name: 'Create project'});
expect(createButton).toBeDisabled();
await userEvent.hover(createButton);
expect(await screen.findByText('Please provide a project name')).toBeInTheDocument();
});

it('restores the wizard when the return params arrive after mount', async () => {
const projectDetailsForm: ProjectDetailsFormState = {
projectName: 'my-restored-name',
Expand Down
51 changes: 42 additions & 9 deletions static/app/views/projectInstall/scmCreateProject.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {Button} from '@sentry/scraps/button';
import {Container, Flex, Stack} from '@sentry/scraps/layout';
import {ExternalLink} from '@sentry/scraps/link';
import {Heading, Text} from '@sentry/scraps/text';
import {Tooltip} from '@sentry/scraps/tooltip';

import {Access} from 'sentry/components/acl/access';
import * as Layout from 'sentry/components/layouts/thirds';
Expand Down Expand Up @@ -60,6 +61,34 @@ const INITIAL_STATE: WizardState = {
selectedRepository: undefined,
};

// Mirrors classic createProject's submit tooltip: name the missing field, or a
// summary when several are missing. Transient blockers (stores loading, create
// in flight) fall through without a message.
function getSubmitTooltipText({
platform,
projectName,
team,
}: {
platform: boolean;
projectName: boolean;
team: boolean;
}): string | undefined {
const missingCount = [platform, projectName, team].filter(Boolean).length;
if (missingCount > 1) {
return t('Please fill out all the required fields');
}
if (platform) {
return t('Please select a platform');
}
if (projectName) {
return t('Please provide a project name');
}
if (team) {
return t('Please select a team');
}
return undefined;
}

export function ScmCreateProject() {
const location = useLocation();
const referrer = decodeScalar(location.query.referrer);
Expand Down Expand Up @@ -209,6 +238,8 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) {
onComplete: handleComplete,
});

const submitTooltipText = getSubmitTooltipText(form.missingFields);

return (
<SentryDocumentTitle title={t('Create a new project')}>
<Access access={canUserCreateProject ? ['project:read'] : ['project:admin']}>
Expand Down Expand Up @@ -321,15 +352,17 @@ function ScmCreateProjectWizard({initialState}: {initialState: WizardState}) {
<Stack gap="md">
<ProjectCreationErrorAlert error={form.error} />
<Flex justify="end">
<Button
variant="primary"
onClick={form.submit}
disabled={!form.canSubmit}
busy={form.isBusy}
icon={<IconProject />}
>
{t('Create project')}
</Button>
<Tooltip title={submitTooltipText} disabled={!submitTooltipText}>
<Button
variant="primary"
onClick={form.submit}
disabled={!form.canSubmit}
busy={form.isBusy}
icon={<IconProject />}
>
{t('Create project')}
</Button>
</Tooltip>
</Flex>
</Stack>
</Stack>
Expand Down
Loading