Browse Source

ref(js): Convert CreateProject to a FC (#49065)

Evan Purkhiser 1 year ago
parent
commit
517ab12b53

+ 20 - 42
static/app/views/projectInstall/createProject.spec.jsx

@@ -1,26 +1,21 @@
 import {fireEvent, render, screen, userEvent} from 'sentry-test/reactTestingLibrary';
 
 import {openCreateTeamModal} from 'sentry/actionCreators/modal';
+import TeamStore from 'sentry/stores/teamStore';
 import {CreateProject} from 'sentry/views/projectInstall/createProject';
 
 jest.mock('sentry/actionCreators/modal');
 
 describe('CreateProject', function () {
+  const organization = TestStubs.Organization();
+
   const teamNoAccess = {slug: 'test', id: '1', name: 'test', hasAccess: false};
   const teamWithAccess = {...teamNoAccess, hasAccess: true};
 
-  const baseProps = {
-    api: new MockApiClient(),
-    location: {query: {}},
-    organization: TestStubs.Organization(),
-    teams: [teamNoAccess],
-    params: {
-      projectId: '',
-    },
-    setEventNames: jest.fn(),
-  };
-
   beforeEach(() => {
+    TeamStore.reset();
+    TeamStore.loadUserTeams([teamNoAccess]);
+
     MockApiClient.addMockResponse({
       url: `/projects/testOrg/rule-conditions/`,
       body: {},
@@ -34,7 +29,7 @@ describe('CreateProject', function () {
   });
 
   it('should block if you have access to no teams', function () {
-    const wrapper = render(<CreateProject {...baseProps} />, {
+    const wrapper = render(<CreateProject />, {
       context: TestStubs.routerContext([{organization: {id: '1', slug: 'testOrg'}}]),
     });
 
@@ -42,7 +37,7 @@ describe('CreateProject', function () {
   });
 
   it('can create a new team', async function () {
-    render(<CreateProject {...baseProps} />, {
+    render(<CreateProject />, {
       context: TestStubs.routerContext([{organization: {id: '1', slug: 'testOrg'}}]),
     });
 
@@ -51,10 +46,9 @@ describe('CreateProject', function () {
   });
 
   it('should fill in project name if its empty when platform is chosen', async function () {
-    const wrapper = render(<CreateProject {...baseProps} teams={[teamWithAccess]} />, {
-      context: TestStubs.routerContext([
-        {organization: {id: '1', slug: 'testOrg'}, location: {query: {}}},
-      ]),
+    const wrapper = render(<CreateProject />, {
+      router: {location: {query: {}}},
+      context: TestStubs.routerContext([{organization: {id: '1', slug: 'testOrg'}}]),
     });
 
     await userEvent.click(screen.getByTestId('platform-apple-ios'));
@@ -74,13 +68,9 @@ describe('CreateProject', function () {
   });
 
   it('should fill in platform name if its provided by url', function () {
-    const props = {
-      ...baseProps,
-      location: {query: {platform: 'ruby-rails'}},
-    };
-
-    const wrapper = render(<CreateProject {...props} teams={[teamWithAccess]} />, {
+    const wrapper = render(<CreateProject />, {
       context: TestStubs.routerContext([{organization: {id: '1', slug: 'testOrg'}}]),
+      router: {location: {query: {platform: 'ruby-rails'}}},
     });
 
     expect(screen.getByPlaceholderText('project-name')).toHaveValue('Rails');
@@ -89,13 +79,9 @@ describe('CreateProject', function () {
   });
 
   it('should fill in category name if its provided by url', function () {
-    const props = {
-      ...baseProps,
-      location: {query: {category: 'mobile'}},
-    };
-
-    render(<CreateProject {...props} teams={[teamWithAccess]} />, {
+    render(<CreateProject />, {
       context: TestStubs.routerContext([{organization: {id: '1', slug: 'testOrg'}}]),
+      router: {location: {query: {category: 'mobile'}}},
     });
 
     expect(screen.getByTestId('platform-apple-ios')).toBeInTheDocument();
@@ -103,7 +89,7 @@ describe('CreateProject', function () {
   });
 
   it('should deal with incorrect platform name if its provided by url', function () {
-    const wrapper = render(<CreateProject {...baseProps} teams={[teamWithAccess]} />, {
+    const wrapper = render(<CreateProject />, {
       context: TestStubs.routerContext([
         {
           organization: {id: '1', slug: 'testOrg'},
@@ -118,13 +104,11 @@ describe('CreateProject', function () {
   });
 
   describe('Issue Alerts Options', () => {
-    const props = {
-      ...baseProps,
-      teams: [teamWithAccess],
-    };
     beforeEach(() => {
+      TeamStore.loadUserTeams([teamWithAccess]);
+
       MockApiClient.addMockResponse({
-        url: `/projects/${props.organization.slug}/rule-conditions/`,
+        url: `/projects/${organization.slug}/rule-conditions/`,
         body: TestStubs.MOCK_RESP_VERBOSE,
       });
     });
@@ -134,13 +118,7 @@ describe('CreateProject', function () {
     });
 
     it('should enabled the submit button if and only if all the required information has been filled', async () => {
-      render(<CreateProject {...props} />, {
-        context: TestStubs.routerContext([
-          {
-            location: {query: {}},
-          },
-        ]),
-      });
+      render(<CreateProject />);
 
       const createProjectButton = screen.getByRole('button', {name: 'Create Project'});
 

+ 184 - 240
static/app/views/projectInstall/createProject.tsx

@@ -1,5 +1,5 @@
-import {Component, Fragment} from 'react';
-import {browserHistory, WithRouterProps} from 'react-router';
+import {Fragment, useCallback, useState} from 'react';
+import {browserHistory} from 'react-router';
 import styled from '@emotion/styled';
 import * as Sentry from '@sentry/react';
 import {PlatformIcon} from 'platformicons';
@@ -17,84 +17,170 @@ import {IconAdd} from 'sentry/icons';
 import {t, tct} from 'sentry/locale';
 import ProjectsStore from 'sentry/stores/projectsStore';
 import {space} from 'sentry/styles/space';
-import {Organization, Team} from 'sentry/types';
+import {Team} from 'sentry/types';
 import {trackAnalytics} from 'sentry/utils/analytics';
 import getPlatformName from 'sentry/utils/getPlatformName';
-import withRouteAnalytics, {
-  WithRouteAnalyticsProps,
-} from 'sentry/utils/routeAnalytics/withRouteAnalytics';
+import useRouteAnalyticsEventNames from 'sentry/utils/routeAnalytics/useRouteAnalyticsEventNames';
 import slugify from 'sentry/utils/slugify';
-import withApi from 'sentry/utils/withApi';
+import useApi from 'sentry/utils/useApi';
+import {useLocation} from 'sentry/utils/useLocation';
+import useOrganization from 'sentry/utils/useOrganization';
+import {useTeams} from 'sentry/utils/useTeams';
 import {normalizeUrl} from 'sentry/utils/withDomainRequired';
-import withOrganization from 'sentry/utils/withOrganization';
-// eslint-disable-next-line no-restricted-imports
-import withSentryRouter from 'sentry/utils/withSentryRouter';
-import withTeams from 'sentry/utils/withTeams';
 import IssueAlertOptions from 'sentry/views/projectInstall/issueAlertOptions';
 
 const getCategoryName = (category?: string) =>
   categoryList.find(({id}) => id === category)?.id;
 
-type Props = WithRouterProps &
-  WithRouteAnalyticsProps & {
-    api: any;
-    organization: Organization;
-    teams: Team[];
-  };
-
 type PlatformName = React.ComponentProps<typeof PlatformIcon>['platform'];
 type IssueAlertFragment = Parameters<
   React.ComponentProps<typeof IssueAlertOptions>['onChange']
 >[0];
 
-type State = {
-  dataFragment: IssueAlertFragment | undefined;
-  error: boolean;
-  inFlight: boolean;
-  platform: PlatformName | null;
-  projectName: string;
-  team: string;
-};
-
-class CreateProject extends Component<Props, State> {
-  constructor(props: Props, context) {
-    super(props, context);
-
-    const {teams, location} = props;
-    const {query} = location;
-    const accessTeams = teams.filter((team: Team) => team.hasAccess);
-
-    const team = query.team || (accessTeams.length && accessTeams[0].slug);
-    const platform = getPlatformName(query.platform) ? query.platform : '';
-
-    this.state = {
-      error: false,
-      projectName: getPlatformName(platform) || '',
-      team,
-      platform,
-      inFlight: false,
-      dataFragment: undefined,
-    };
-  }
+function CreateProject() {
+  const api = useApi();
+  const organization = useOrganization();
+
+  const location = useLocation();
+  const {query} = location;
+
+  const accessTeams = useTeams().teams.filter((team: Team) => team.hasAccess);
+
+  useRouteAnalyticsEventNames(
+    'project_creation_page.viewed',
+    'Project Create: Creation page viewed'
+  );
+
+  const platformQuery = Array.isArray(query.platform)
+    ? query.platform[0]
+    : query.platform ?? null;
+
+  const platformName = getPlatformName(platformQuery);
+
+  const defaultCategory = getCategoryName(
+    Array.isArray(query.category) ? query.category[0] : query.category ?? undefined
+  );
+
+  const [projectName, setProjectName] = useState(platformName ?? '');
+  const [platform, setPlatform] = useState(platformName ? platformQuery : '');
+  const [team, setTeam] = useState(query.team || accessTeams?.[0]?.slug);
+
+  const [error, setError] = useState(false);
+  const [inFlight, setInFlight] = useState(false);
+
+  const [alertRuleConfig, setAlertRuleConfig] = useState<IssueAlertFragment | undefined>(
+    undefined
+  );
+
+  const createProject = useCallback(
+    async (e: React.FormEvent) => {
+      e.preventDefault();
+      const {slug} = organization;
+      const {
+        shouldCreateCustomRule,
+        name,
+        conditions,
+        actions,
+        actionMatch,
+        frequency,
+        defaultRules,
+      } = alertRuleConfig || {};
+
+      setInFlight(true);
+
+      try {
+        const projectData = await api.requestPromise(`/teams/${slug}/${team}/projects/`, {
+          method: 'POST',
+          data: {
+            name: projectName,
+            platform,
+            default_rules: defaultRules ?? true,
+          },
+        });
 
-  componentDidMount() {
-    this.props.setEventNames(
-      'project_creation_page.viewed',
-      'Project Create: Creation page viewed'
-    );
-  }
+        let ruleId: string | undefined;
+        if (shouldCreateCustomRule) {
+          const ruleData = await api.requestPromise(
+            `/projects/${organization.slug}/${projectData.slug}/rules/`,
+            {
+              method: 'POST',
+              data: {
+                name,
+                conditions,
+                actions,
+                actionMatch,
+                frequency,
+              },
+            }
+          );
+          ruleId = ruleData.id;
+        }
+        trackAnalytics('project_creation_page.created', {
+          organization,
+          issue_alert: defaultRules
+            ? 'Default'
+            : shouldCreateCustomRule
+            ? 'Custom'
+            : 'No Rule',
+          project_id: projectData.id,
+          rule_id: ruleId || '',
+        });
+
+        ProjectsStore.onCreateSuccess(projectData, organization.slug);
+
+        const platformKey = platform || 'other';
+
+        browserHistory.push(
+          normalizeUrl(
+            `/${organization.slug}/${projectData.slug}/getting-started/${platformKey}/`
+          )
+        );
+      } catch (err) {
+        setInFlight(false);
+        setError(err.responseJSON.detail);
+
+        // Only log this if the error is something other than:
+        // * The user not having access to create a project, or,
+        // * A project with that slug already exists
+        if (err.status !== 403 && err.status !== 409) {
+          Sentry.withScope(scope => {
+            scope.setExtra('err', err);
+            Sentry.captureMessage('Project creation failed');
+          });
+        }
+      }
+    },
+    [api, alertRuleConfig, organization, platform, projectName, team]
+  );
 
-  get defaultCategory() {
-    const {query} = this.props.location;
-    return getCategoryName(query.category);
+  function handlePlatformChange(platformKey: PlatformName | null) {
+    if (!platformKey) {
+      setPlatform(null);
+      setProjectName('');
+      return;
+    }
+
+    const userModifiedName = projectName && projectName !== platform;
+    const newName = userModifiedName ? projectName : platformKey;
+
+    setPlatform(platformKey);
+    setProjectName(newName);
   }
 
-  renderProjectForm() {
-    const {organization} = this.props;
-    const {projectName, platform, team} = this.state;
+  const {shouldCreateCustomRule, conditions} = alertRuleConfig || {};
 
-    const createProjectForm = (
-      <CreateProjectForm onSubmit={this.createProject}>
+  const canSubmitForm =
+    !inFlight &&
+    team &&
+    projectName !== '' &&
+    (!shouldCreateCustomRule || conditions?.every?.(condition => condition.value));
+
+  const createProjectForm = (
+    <Fragment>
+      <Layout.Title withMargins>
+        {t('3. Name your project and assign it a team')}
+      </Layout.Title>
+      <CreateProjectForm onSubmit={createProject}>
         <div>
           <FormLabel>{t('Project name')}</FormLabel>
           <ProjectNameInputWrap>
@@ -105,7 +191,7 @@ class CreateProject extends Component<Props, State> {
               placeholder={t('project-name')}
               autoComplete="off"
               value={projectName}
-              onChange={e => this.setState({projectName: slugify(e.target.value)})}
+              onChange={e => setProjectName(slugify(e.target.value))}
             />
           </ProjectNameInputWrap>
         </div>
@@ -118,7 +204,7 @@ class CreateProject extends Component<Props, State> {
               clearable={false}
               value={team}
               placeholder={t('Select a Team')}
-              onChange={choice => this.setState({team: choice.value})}
+              onChange={choice => setTeam(choice.value)}
               teamFilter={(filterTeam: Team) => filterTeam.hasAccess}
             />
             <Button
@@ -128,7 +214,7 @@ class CreateProject extends Component<Props, State> {
               onClick={() =>
                 openCreateTeamModal({
                   organization,
-                  onClose: ({slug}) => this.setState({team: slug}),
+                  onClose: ({slug}) => setTeam(slug),
                 })
               }
               title={t('Create a team')}
@@ -141,191 +227,49 @@ class CreateProject extends Component<Props, State> {
             type="submit"
             data-test-id="create-project"
             priority="primary"
-            disabled={!this.canSubmitForm}
+            disabled={!canSubmitForm}
           >
             {t('Create Project')}
           </Button>
         </div>
       </CreateProjectForm>
-    );
-
-    return (
-      <Fragment>
-        <Layout.Title withMargins>
-          {t('3. Name your project and assign it a team')}
-        </Layout.Title>
-        {createProjectForm}
-      </Fragment>
-    );
-  }
-
-  get canSubmitForm() {
-    const {projectName, team, inFlight} = this.state;
-    const {shouldCreateCustomRule, conditions} = this.state.dataFragment || {};
-
-    return (
-      !inFlight &&
-      team &&
-      projectName !== '' &&
-      (!shouldCreateCustomRule || conditions?.every?.(condition => condition.value))
-    );
-  }
-
-  createProject = async e => {
-    e.preventDefault();
-    const {organization, api} = this.props;
-    const {projectName, platform, team, dataFragment} = this.state;
-    const {slug} = organization;
-    const {
-      shouldCreateCustomRule,
-      name,
-      conditions,
-      actions,
-      actionMatch,
-      frequency,
-      defaultRules,
-    } = dataFragment || {};
-
-    this.setState({inFlight: true});
-
-    if (!projectName) {
-      Sentry.withScope(scope => {
-        scope.setExtra('props', this.props);
-        scope.setExtra('state', this.state);
-        Sentry.captureMessage('No project name');
-      });
-    }
-
-    try {
-      const projectData = await api.requestPromise(`/teams/${slug}/${team}/projects/`, {
-        method: 'POST',
-        data: {
-          name: projectName,
-          platform,
-          default_rules: defaultRules ?? true,
-        },
-      });
-
-      let ruleId: string | undefined;
-      if (shouldCreateCustomRule) {
-        const ruleData = await api.requestPromise(
-          `/projects/${organization.slug}/${projectData.slug}/rules/`,
-          {
-            method: 'POST',
-            data: {
-              name,
-              conditions,
-              actions,
-              actionMatch,
-              frequency,
-            },
+    </Fragment>
+  );
+
+  return (
+    <Fragment>
+      {error && <Alert type="error">{error}</Alert>}
+
+      <div data-test-id="onboarding-info">
+        <Layout.Title withMargins>{t('Create a new project in 3 steps')}</Layout.Title>
+        <HelpText>
+          {tct(
+            'Set up a separate project for each part of your application (for example, your API server and frontend client), to quickly pinpoint which part of your application errors are coming from. [link: Read the docs].',
+            {
+              link: (
+                <ExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/create-new-project/" />
+              ),
+            }
+          )}
+        </HelpText>
+        <Layout.Title withMargins>{t('1. Choose your platform')}</Layout.Title>
+        <PlatformPicker
+          platform={platform}
+          defaultCategory={defaultCategory}
+          setPlatform={selectedPlatform =>
+            handlePlatformChange(selectedPlatform?.id ?? null)
           }
-        );
-        ruleId = ruleData.id;
-      }
-      trackAnalytics('project_creation_page.created', {
-        organization,
-        issue_alert: defaultRules
-          ? 'Default'
-          : shouldCreateCustomRule
-          ? 'Custom'
-          : 'No Rule',
-        project_id: projectData.id,
-        rule_id: ruleId || '',
-      });
-
-      ProjectsStore.onCreateSuccess(projectData, organization.slug);
-
-      const platformKey = platform || 'other';
-
-      browserHistory.push(
-        normalizeUrl(
-          `/${organization.slug}/${projectData.slug}/getting-started/${platformKey}/`
-        )
-      );
-    } catch (err) {
-      this.setState({
-        inFlight: false,
-        error: err.responseJSON.detail,
-      });
-
-      // Only log this if the error is something other than:
-      // * The user not having access to create a project, or,
-      // * A project with that slug already exists
-      if (err.status !== 403 && err.status !== 409) {
-        Sentry.withScope(scope => {
-          scope.setExtra('err', err);
-          scope.setExtra('props', this.props);
-          scope.setExtra('state', this.state);
-          Sentry.captureMessage('Project creation failed');
-        });
-      }
-    }
-  };
-
-  setPlatform = (platformKey: PlatformName | null) => {
-    if (!platformKey) {
-      this.setState({platform: null, projectName: ''});
-      return;
-    }
+          organization={organization}
+          showOther
+        />
+        <IssueAlertOptions onChange={updatedData => setAlertRuleConfig(updatedData)} />
 
-    this.setState(({projectName, platform}) => {
-      // Avoid replacing project name when the user already modified it
-      const userModifiedName = projectName && projectName !== platform;
-      const newName = userModifiedName ? projectName : platformKey;
-
-      return {
-        platform: platformKey,
-        projectName: slugify(newName),
-      };
-    });
-  };
-
-  render() {
-    const {platform, error} = this.state;
-
-    return (
-      <Fragment>
-        {error && <Alert type="error">{error}</Alert>}
-
-        <div data-test-id="onboarding-info">
-          <Layout.Title withMargins>{t('Create a new project in 3 steps')}</Layout.Title>
-          <HelpText>
-            {tct(
-              'Set up a separate project for each part of your application (for example, your API server and frontend client), to quickly pinpoint which part of your application errors are coming from. [link: Read the docs].',
-              {
-                link: (
-                  <ExternalLink href="https://docs.sentry.io/product/sentry-basics/integrate-frontend/create-new-project/" />
-                ),
-              }
-            )}
-          </HelpText>
-          <Layout.Title withMargins>{t('1. Choose your platform')}</Layout.Title>
-          <PlatformPicker
-            platform={platform}
-            defaultCategory={this.defaultCategory}
-            setPlatform={selectedPlatform =>
-              this.setPlatform(selectedPlatform?.id ?? null)
-            }
-            organization={this.props.organization}
-            showOther
-          />
-          <IssueAlertOptions
-            onChange={updatedData => {
-              this.setState({dataFragment: updatedData});
-            }}
-          />
-          {this.renderProjectForm()}
-        </div>
-      </Fragment>
-    );
-  }
+        {createProjectForm}
+      </div>
+    </Fragment>
+  );
 }
 
-// TODO(davidenwang): change to functional component and replace withTeams with useTeams
-export default withRouteAnalytics(
-  withApi(withSentryRouter(withOrganization(withTeams(CreateProject))))
-);
 export {CreateProject};
 
 const CreateProjectForm = styled('form')`

+ 2 - 1
static/app/views/projectInstall/newProject.tsx

@@ -2,7 +2,8 @@ import styled from '@emotion/styled';
 
 import SentryDocumentTitle from 'sentry/components/sentryDocumentTitle';
 import {space} from 'sentry/styles/space';
-import CreateProject from 'sentry/views/projectInstall/createProject';
+
+import {CreateProject} from './createProject';
 
 function NewProject() {
   return (