Skip to content

Commit dd68f79

Browse files
authored
ref(onboarding): Extract project-details form into a reusable hook and core (#117209)
## TLDR Splits the SCM onboarding project-details step into a reusable `useScmProjectDetails` hook plus a presentational `ScmProjectDetailsCore`, so the SCM-first project-creation flow can reuse it and place its own Create button. No behavior change; the existing spec passes unchanged. ## Details - `useScmProjectDetails` owns the form state, the create-project + repo-link flow, the reuse ("nothing changed") short-circuit, and the field/create analytics, routed by an `analyticsFlow` prop. It also supports a no-access member path (`allowMemberWithoutTeam`), off by default. - `ScmProjectDetailsCore` is the presentational name / team / alert-frequency form. It fires the `step_viewed` analytics when shown and hides the team selector for no-access members. Keeping the submit handler in the hook (rather than a render-prop) means each host renders its own Create button wherever it wants, decoupled from where the fields render. - `ScmProjectDetails` (onboarding) is now a thin wrapper: the step header plus the fixed footer with the Back and Create buttons. This is the VDY-76 equivalent of the `ScmPlatformFeaturesCore` extraction (#116624). ## Stack - **PR 1 (this):** Extract project-details form into a hook and core - [PR 2](#117325): Keep getting-started rendered while back nav deletes - [PR 3](#117333): Drive the project-details form from host state - [PR 4](#117213): Wire into single-view project creation - [PR 5](#117341): Commit the auto-detected platform to the host - [PR 6](#117342): Explain the disabled Create CTA in the SCM wizard Refs VDY-76
1 parent 6d001f1 commit dd68f79

4 files changed

Lines changed: 523 additions & 234 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import {OrganizationFixture} from 'sentry-fixture/organization';
2+
3+
import {render, screen} from 'sentry-test/reactTestingLibrary';
4+
5+
import * as analytics from 'sentry/utils/analytics';
6+
import {DEFAULT_ISSUE_ALERT_OPTIONS_VALUES} from 'sentry/views/projectInstall/issueAlertOptions';
7+
8+
import {ScmProjectDetailsCore} from './scmProjectDetailsCore';
9+
10+
type CoreProps = React.ComponentProps<typeof ScmProjectDetailsCore>;
11+
12+
function renderCore(overrides: Partial<CoreProps> = {}) {
13+
const props: CoreProps = {
14+
analyticsFlow: 'project-creation',
15+
projectName: 'my-project',
16+
onProjectNameChange: jest.fn(),
17+
onProjectNameBlur: jest.fn(),
18+
teamSlug: 'my-team',
19+
onTeamChange: jest.fn(),
20+
alertRuleConfig: DEFAULT_ISSUE_ALERT_OPTIONS_VALUES,
21+
onAlertChange: jest.fn(),
22+
isOrgMemberWithNoAccess: false,
23+
...overrides,
24+
};
25+
26+
render(<ScmProjectDetailsCore {...props} />, {organization: OrganizationFixture()});
27+
return props;
28+
}
29+
30+
describe('ScmProjectDetailsCore', () => {
31+
afterEach(() => {
32+
jest.restoreAllMocks();
33+
});
34+
35+
it('renders the project name, team, and alert-frequency fields', () => {
36+
renderCore();
37+
38+
expect(screen.getByText('Give your project a name')).toBeInTheDocument();
39+
expect(screen.getByText('Assign a team')).toBeInTheDocument();
40+
expect(screen.getByText('Alert frequency')).toBeInTheDocument();
41+
expect(screen.getByPlaceholderText('project-name')).toHaveValue('my-project');
42+
});
43+
44+
it('fires step_viewed analytics for the given flow on mount', () => {
45+
const trackAnalyticsSpy = jest.spyOn(analytics, 'trackAnalytics');
46+
renderCore({analyticsFlow: 'project-creation'});
47+
48+
expect(trackAnalyticsSpy).toHaveBeenCalledWith(
49+
'project_creation.scm_project_details_step_viewed',
50+
expect.anything()
51+
);
52+
});
53+
54+
it('hides the team selector for a no-access member', () => {
55+
renderCore({isOrgMemberWithNoAccess: true});
56+
57+
expect(screen.getByText('Give your project a name')).toBeInTheDocument();
58+
expect(screen.queryByText('Assign a team')).not.toBeInTheDocument();
59+
});
60+
});
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import {useEffect} from 'react';
2+
3+
import {Input} from '@sentry/scraps/input';
4+
import {Container, Flex, Stack} from '@sentry/scraps/layout';
5+
import {Text} from '@sentry/scraps/text';
6+
7+
import {TeamSelector} from 'sentry/components/teamSelector';
8+
import {IconGroup, IconProject, IconSiren} from 'sentry/icons';
9+
import {t} from 'sentry/locale';
10+
import type {Team} from 'sentry/types/organization';
11+
import {trackAnalytics} from 'sentry/utils/analytics';
12+
import {useOrganization} from 'sentry/utils/useOrganization';
13+
import type {AlertRuleOptions} from 'sentry/views/projectInstall/issueAlertOptions';
14+
15+
import {ScmAlertFrequency} from './scmAlertFrequency';
16+
import type {ScmAnalyticsFlow} from './scmAnalyticsFlow';
17+
18+
const STEP_VIEWED_EVENT = {
19+
onboarding: 'onboarding.scm_project_details_step_viewed',
20+
'project-creation': 'project_creation.scm_project_details_step_viewed',
21+
} as const;
22+
23+
interface ScmProjectDetailsCoreProps {
24+
alertRuleConfig: AlertRuleOptions;
25+
analyticsFlow: ScmAnalyticsFlow;
26+
/** Hides the team selector for a no-access member (see useScmProjectDetails). */
27+
isOrgMemberWithNoAccess: boolean;
28+
onAlertChange: <K extends keyof AlertRuleOptions>(
29+
key: K,
30+
value: AlertRuleOptions[K]
31+
) => void;
32+
onProjectNameBlur: () => void;
33+
onProjectNameChange: (value: string) => void;
34+
onTeamChange: (option: {value: string}) => void;
35+
projectName: string;
36+
teamSlug: string;
37+
/** Max width of the field column. Hosts pass their own step/section width. */
38+
contentMaxWidth?: string;
39+
}
40+
41+
/**
42+
* Presentational project name / team / alert-frequency form shared by the SCM
43+
* onboarding project-details step and the SCM-first project-creation surface.
44+
* Form state, the create flow, and field analytics live in `useScmProjectDetails`;
45+
* the host wires that hook to this component and renders its own Create button.
46+
* This component owns only the rendering and the `step_viewed` analytics, which
47+
* fires when the step becomes visible.
48+
*/
49+
export function ScmProjectDetailsCore({
50+
alertRuleConfig,
51+
analyticsFlow,
52+
isOrgMemberWithNoAccess,
53+
onAlertChange,
54+
onProjectNameBlur,
55+
onProjectNameChange,
56+
onTeamChange,
57+
projectName,
58+
teamSlug,
59+
contentMaxWidth,
60+
}: ScmProjectDetailsCoreProps) {
61+
const organization = useOrganization();
62+
63+
useEffect(() => {
64+
trackAnalytics(STEP_VIEWED_EVENT[analyticsFlow], {organization});
65+
}, [organization, analyticsFlow]);
66+
67+
return (
68+
<Stack gap="3xl" width="100%" maxWidth={contentMaxWidth}>
69+
<Stack gap="md">
70+
<Flex gap="md" align="center" justify="center">
71+
<IconProject size="md" variant="secondary" />
72+
<Container>
73+
<Text bold size="lg" density="comfortable">
74+
{t('Give your project a name')}
75+
</Text>
76+
</Container>
77+
</Flex>
78+
<Input
79+
type="text"
80+
placeholder={t('project-name')}
81+
value={projectName}
82+
onChange={e => onProjectNameChange(e.target.value)}
83+
onBlur={onProjectNameBlur}
84+
/>
85+
</Stack>
86+
87+
{!isOrgMemberWithNoAccess && (
88+
<Stack gap="md">
89+
<Flex gap="md" align="center" justify="center">
90+
<IconGroup size="md" />
91+
<Container>
92+
<Text bold size="lg" density="comfortable">
93+
{t('Assign a team')}
94+
</Text>
95+
</Container>
96+
</Flex>
97+
<TeamSelector
98+
allowCreate
99+
name="team"
100+
aria-label={t('Select a Team')}
101+
clearable={false}
102+
placeholder={t('Select a Team')}
103+
teamFilter={(tm: Team) => tm.access.includes('team:admin')}
104+
value={teamSlug}
105+
onChange={onTeamChange}
106+
/>
107+
</Stack>
108+
)}
109+
110+
<Stack gap="md">
111+
<Flex gap="md" align="center" justify="center">
112+
<IconSiren size="md" />
113+
<Container>
114+
<Text bold size="lg" density="comfortable">
115+
{t('Alert frequency')}
116+
</Text>
117+
</Container>
118+
</Flex>
119+
<Container>
120+
<Text variant="muted" size="lg" density="comfortable" align="center">
121+
{t('Get notified when things go wrong')}
122+
</Text>
123+
</Container>
124+
<ScmAlertFrequency {...alertRuleConfig} onFieldChange={onAlertChange} />
125+
</Stack>
126+
</Stack>
127+
);
128+
}

0 commit comments

Comments
 (0)