Browse Source

ref(ui): Refactors CreateProject UI to styled components (#12533)

This is a relatively large refactoring of the CreateProject component.
Some of the highlights are:

 * The component is now nearly 100% Styled Components. Much of the less
   has been removed from onboarding and shared-components files.

 * The no-teams state has been removed, and a create team icon has been
   added next to the team selector.

 * The Platform picker now has a much better empty state, making it
   clear that you can create your project and that there are 3rd party
   clients available.

 * Small intermediate componets have been removed (Platformcard,
   onboarding project index).

 * You can now clear the platform via the platform picker.

Overall the UI should be much cleaner and consistent, many small details
have been updated.
Evan Purkhiser 6 years ago
parent
commit
99a1a79133

+ 3 - 3
src/sentry/static/sentry/app/routes.jsx

@@ -673,13 +673,13 @@ function routes() {
       />
       <Route path="/organizations/new/" component={errorHandler(OrganizationCreate)} />
       <Route path="/onboarding/:orgId/" component={errorHandler(OrganizationContext)}>
-        <Route path="" component={errorHandler(OnboardingWizard)}>
+        <Route component={errorHandler(OnboardingWizard)}>
           <IndexRoute component={errorHandler(CreateProject)} />
-          {hook('routes:onboarding-survey')}
           <Route
-            path=":projectId/configure/(:platform)"
+            path=":projectId/configure/:platform/"
             component={errorHandler(OnboardingConfigure)}
           />
+          {hook('routes:onboarding-survey')}
         </Route>
       </Route>
       <Route component={errorHandler(OrganizationDetails)}>

+ 180 - 124
src/sentry/static/sentry/app/views/onboarding/createProject.jsx

@@ -1,98 +1,70 @@
+import {browserHistory} from 'react-router';
 import PropTypes from 'prop-types';
 import React from 'react';
-import Reflux from 'reflux';
-import createReactClass from 'create-react-class';
-import styled from 'react-emotion';
 import * as Sentry from '@sentry/browser';
+import styled from 'react-emotion';
 
-import {Panel} from 'app/components/panels';
 import {getPlatformName} from 'app/views/onboarding/utils';
+import {inputStyles} from 'app/styles/input';
 import {openCreateTeamModal} from 'app/actionCreators/modal';
 import {t} from 'app/locale';
-import ApiMixin from 'app/mixins/apiMixin';
+import Alert from 'app/components/alert';
 import Button from 'app/components/button';
 import HookStore from 'app/stores/hookStore';
-import OnboardingProject from 'app/views/onboarding/project';
-import OrganizationState from 'app/mixins/organizationState';
-import PanelAlert from 'app/components/panels/panelAlert';
+import PageHeading from 'app/components/pageHeading';
+import PlatformPicker from 'app/views/onboarding/project/platformpicker';
+import PlatformiconTile from 'app/views/onboarding/project/platformiconTile';
 import ProjectActions from 'app/actions/projectActions';
-import TeamActions from 'app/actions/teamActions';
+import SelectControl from 'app/components/forms/selectControl';
+import SentryTypes from 'app/sentryTypes';
+import Tooltip from 'app/components/tooltip';
 import space from 'app/styles/space';
+import withApi from 'app/utils/withApi';
+import withOrganization from 'app/utils/withOrganization';
+import withTeams from 'app/utils/withTeams';
 
-const CreateProject = createReactClass({
-  displayName: 'CreateProject',
+class CreateProject extends React.Component {
+  static propTypes = {
+    api: PropTypes.object,
+    teams: PropTypes.arrayOf(SentryTypes.Team),
+    organization: SentryTypes.Organization,
+    nextStepUrl: PropTypes.func,
+  };
 
-  propTypes: {
-    getDocsUrl: PropTypes.func,
-  },
+  static defaultProps = {
+    nextStepUrl: ({slug, projectSlug, platform}) =>
+      `/onboarding/${slug}/${projectSlug}/configure/${platform}`,
+  };
 
-  contextTypes: {
-    router: PropTypes.object,
+  static contextTypes = {
     location: PropTypes.object,
-  },
-
-  mixins: [
-    ApiMixin,
-    OrganizationState,
-    Reflux.listenTo(TeamActions.createTeamSuccess, 'onTeamCreated'),
-  ],
-
-  getDefaultProps() {
-    return {
-      getDocsUrl: ({slug, projectSlug, platform}) =>
-        `/onboarding/${slug}/${projectSlug}/configure/${platform}`,
-    };
-  },
+  };
+
+  constructor(props, ...args) {
+    super(props, ...args);
 
-  getInitialState() {
-    const {teams} = this.getOrganization();
-    const accessTeams = teams.filter(team => team.hasAccess);
     const {query} = this.context.location;
+    const {teams} = this.props.organization;
+    const accessTeams = teams.filter(team => team.hasAccess);
 
     const team = query.team || (accessTeams.length && accessTeams[0].slug);
     const platform = getPlatformName(query.platform) ? query.platform : '';
 
-    return {
-      loading: true,
+    this.state = {
       error: false,
       projectName: getPlatformName(platform) || '',
       team,
       platform,
       inFlight: false,
     };
-  },
-
-  onTeamCreated() {
-    const {router} = this.context;
-    // After team gets created we need to force OrganizationContext to basically remount
-    router.replace({
-      pathname: router.location.pathname,
-      state: 'refresh',
-    });
-  },
-
-  navigateNextUrl(data) {
-    const organization = this.getOrganization();
-
-    const url =
-      HookStore.get('utils:onboarding-survey-url').length &&
-      organization.projects.length === 0
-        ? HookStore.get('utils:onboarding-survey-url')[0](data, organization)
-        : data.docsUrl;
+  }
 
-    this.setState({inFlight: false});
-    data.router.push(url);
-  },
+  createProject = e => {
+    e.preventDefault();
+    const {organization, api, nextStepUrl} = this.props;
+    const {projectName, platform, team} = this.state;
+    const {slug} = organization;
 
-  createProject() {
-    const {router} = this.context;
-    const {slug} = this.getOrganization();
-    const {projectName, platform, team, inFlight} = this.state;
-
-    //prevent double-trigger
-    if (inFlight) {
-      return;
-    }
     this.setState({inFlight: true});
 
     if (!projectName) {
@@ -103,7 +75,7 @@ const CreateProject = createReactClass({
       });
     }
 
-    this.api.request(`/teams/${slug}/${team}/projects/`, {
+    api.request(`/teams/${slug}/${team}/projects/`, {
       method: 'POST',
       data: {
         name: projectName,
@@ -112,9 +84,19 @@ const CreateProject = createReactClass({
       success: data => {
         ProjectActions.createSuccess(data);
 
-        // navigate to new url _now_
-        const docsUrl = this.props.getDocsUrl({slug, projectSlug: data.slug, platform});
-        this.navigateNextUrl({router, slug, projectSlug: data.slug, platform, docsUrl});
+        const urlData = {
+          slug: organization.slug,
+          projectSlug: data.slug,
+          platform: platform || 'other',
+        };
+
+        const defaultNextUrl = nextStepUrl(urlData);
+        const hookNextUrl =
+          organization.projects.length === 0 &&
+          HookStore.get('utils:onboarding-survey-url').length &&
+          HookStore.get('utils:onboarding-survey-url')[0](urlData, organization);
+
+        browserHistory.push(hookNextUrl || defaultNextUrl);
       },
       error: err => {
         this.setState({
@@ -135,70 +117,144 @@ const CreateProject = createReactClass({
         }
       },
     });
-  },
+  };
 
-  render() {
-    const {projectName, platform, error} = this.state;
-    const organization = this.getOrganization();
-    const {teams} = organization;
-    const accessTeams = teams.filter(team => team.hasAccess);
+  setPlatform = platformId =>
+    this.setState(({projectName, platform}) => ({
+      platform: platformId,
+      projectName:
+        !projectName || (platform && getPlatformName(platform) === projectName)
+          ? getPlatformName(platformId)
+          : projectName,
+    }));
 
-    const stepProps = {
-      next: this.createProject,
-      platform,
-      setPlatform: p => {
-        if (!projectName || (platform && getPlatformName(platform) === projectName)) {
-          this.setState({projectName: getPlatformName(p)});
-        }
-        this.setState({platform: p});
-      },
-      name: projectName,
-      setName: n => this.setState({projectName: n}),
-      team: this.state.team,
-      teams: accessTeams,
-      setTeam: teamSlug => this.setState({team: teamSlug}),
-    };
+  render() {
+    const {organization} = this.props;
+    const {projectName, team, platform, error, inFlight} = this.state;
+    const teams = this.props.teams.filter(filterTeam => filterTeam.hasAccess);
 
     return (
-      <div>
-        {error && <h2 className="alert alert-error">{error}</h2>}
-        {accessTeams.length ? (
-          <OnboardingProject {...stepProps} />
-        ) : (
-          <Panel
-            title={t('Cannot Create Project')}
-            body={
-              <React.Fragment>
-                <PanelAlert type="error">
-                  {t(
-                    'You cannot create a new project because there are no teams to assign it to.'
-                  )}
-                </PanelAlert>
-                <CreateTeamBody>
+      <React.Fragment>
+        {error && <Alert type="error">{error}</Alert>}
+
+        <div data-test-id="onboarding-info">
+          <PageHeading withMargins>{t('Create a new Project')}</PageHeading>
+          <HelpText>
+            {t(
+              `Projects allow you to scope events to a specific application in
+               your organization. For example, you might have separate projects
+               for your API server and frontend client.`
+            )}
+          </HelpText>
+
+          <PlatformPicker platform={platform} setPlatform={this.setPlatform} showOther />
+          <CreateProjectForm onSubmit={this.createProject}>
+            <div>
+              <FormLabel>{t('Give your project a name')}</FormLabel>
+              <ProjectNameInput>
+                <ProjectPlatformicon monoTone platform={platform} />
+                <input
+                  type="text"
+                  name="name"
+                  label={t('Project Name')}
+                  placeholder={t('Project name')}
+                  autoComplete="off"
+                  value={projectName}
+                  onChange={e => this.setState({projectName: e.target.value})}
+                />
+              </ProjectNameInput>
+            </div>
+            <div>
+              <FormLabel>{t('Assign a Team')}</FormLabel>
+              <TeamSelectInput>
+                <SelectControl
+                  name="select-team"
+                  clearable={false}
+                  value={team}
+                  placeholder={t('Select a Team')}
+                  onChange={val => this.setState({team: val})}
+                  options={teams.map(({slug}) => ({
+                    label: `#${slug}`,
+                    value: slug,
+                  }))}
+                />
+                <Tooltip title={t('Create a team')}>
                   <Button
-                    className="ref-create-team"
-                    priority="primary"
+                    borderless
+                    data-test-id="create-team"
+                    type="button"
+                    icon="icon-circle-add"
                     onClick={() =>
                       openCreateTeamModal({
                         organization,
+                        onClose: ({slug}) => this.setState({team: slug}),
                       })}
-                  >
-                    {t('Create a Team')}
-                  </Button>
-                </CreateTeamBody>
-              </React.Fragment>
-            }
-          />
-        )}
-      </div>
+                  />
+                </Tooltip>
+              </TeamSelectInput>
+            </div>
+            <div>
+              <Button
+                data-test-id="create-project"
+                priority="primary"
+                disabled={inFlight || !team || projectName === ''}
+              >
+                {t('Create Project')}
+              </Button>
+            </div>
+          </CreateProjectForm>
+        </div>
+      </React.Fragment>
     );
-  },
-});
+  }
+}
+
+export default withApi(withTeams(withOrganization(CreateProject)));
+export {CreateProject};
+
+const CreateProjectForm = styled('form')`
+  display: grid;
+  grid-template-columns: 300px 250px max-content;
+  grid-gap: ${space(2)};
+  align-items: end;
+  padding: ${space(3)} 0;
+  margin-top: ${space(2)};
+  box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
+  background: #fff;
+  position: sticky;
+  bottom: 0;
+`;
 
-export default CreateProject;
+const FormLabel = styled('div')`
+  font-size: ${p => p.theme.fontSizeExtraLarge};
+  margin-bottom: ${space(1)};
+`;
+
+const ProjectPlatformicon = styled(PlatformiconTile)`
+  font-size: 25px;
+`;
+
+const ProjectNameInput = styled('div')`
+  ${inputStyles};
+  display: grid;
+  grid-template-columns: min-content 1fr;
+  grid-gap: ${space(1)};
+  align-items: center;
+  padding: 5px 10px;
+
+  input {
+    border: 0;
+    outline: 0;
+  }
+`;
+
+const TeamSelectInput = styled('div')`
+  display: grid;
+  grid-template-columns: 1fr min-content;
+  align-items: center;
+`;
 
-const CreateTeamBody = styled('div')`
-  display: flex;
-  justify-content: center;
-  padding: ${space(2)};
+const HelpText = styled('p')`
+  color: ${p => p.theme.gray3};
+  max-width: 700px;
 `;

+ 0 - 115
src/sentry/static/sentry/app/views/onboarding/project/index.jsx

@@ -1,115 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import classnames from 'classnames';
-
-import {analytics} from 'app/utils/analytics';
-import PlatformPicker from 'app/views/onboarding/project/platformpicker';
-import PlatformiconTile from 'app/views/onboarding/project/platformiconTile';
-import SelectField from 'app/components/forms/selectField';
-import PageHeading from 'app/components/pageHeading';
-import {t} from 'app/locale';
-
-class OnboardingProject extends React.Component {
-  static propTypes = {
-    next: PropTypes.func,
-    setPlatform: PropTypes.func,
-    platform: PropTypes.string,
-    setName: PropTypes.func,
-    name: PropTypes.string,
-    team: PropTypes.string,
-    setTeam: PropTypes.func,
-    teams: PropTypes.array,
-  };
-
-  static defaultProps = {
-    team: '',
-    setTeam: () => {},
-    teams: [],
-  };
-
-  constructor(...args) {
-    super(...args);
-    this.state = {projectRequired: false};
-  }
-
-  componentWillReceiveProps(newProps) {
-    this.setWarning(newProps.name);
-  }
-
-  setWarning = value => {
-    this.setState({projectRequired: !value});
-  };
-
-  submit = () => {
-    this.setWarning(this.props.name);
-    if (this.props.name) {
-      analytics('platformpicker.create_project');
-      this.props.next();
-    }
-  };
-
-  renderTeamPicker = () => {
-    const {team, teams, setTeam} = this.props;
-    return (
-      <div className="new-project-team">
-        <PageHeading withMargins>{t('Team') + ':'}</PageHeading>
-        <div>
-          <SelectField
-            name="select-team"
-            clearable={false}
-            value={team}
-            style={{width: 180, marginBottom: 0}}
-            onChange={val => setTeam(val)}
-            options={teams.map(({slug}) => ({
-              label: `#${slug}`,
-              value: slug,
-            }))}
-          />
-        </div>
-      </div>
-    );
-  };
-
-  render() {
-    return (
-      <div className="onboarding-info">
-        <PageHeading withMargins>{t('Choose a language or framework') + ':'}</PageHeading>
-        <PlatformPicker {...this.props} showOther={true} />
-        <div className="create-project-form">
-          <div className="new-project-name client-platform">
-            <PageHeading withMargins>{t('Give your project a name') + ':'}</PageHeading>
-            <div
-              className={classnames('project-name-wrapper', {
-                required: this.state.projectRequired,
-              })}
-            >
-              <PlatformiconTile platform={this.props.platform} />
-              <input
-                type="text"
-                name="name"
-                label={t('Project Name')}
-                placeholder={t('Project name')}
-                autoComplete="off"
-                value={this.props.name}
-                onChange={e => this.props.setName(e.target.value)}
-              />
-            </div>
-          </div>
-          {this.renderTeamPicker()}
-          <div>
-            <button className="btn btn-primary new-project-submit" onClick={this.submit}>
-              {t('Create Project')}
-            </button>
-          </div>
-          <p>
-            {t(
-              'Projects allow you to scope events to a specific application in your organization. For example, you might have separate projects for your API server and frontend client.'
-            )}
-          </p>
-        </div>
-      </div>
-    );
-  }
-}
-
-export default OnboardingProject;

+ 0 - 29
src/sentry/static/sentry/app/views/onboarding/project/platformCard.jsx

@@ -1,29 +0,0 @@
-import PropTypes from 'prop-types';
-import React from 'react';
-import classnames from 'classnames';
-
-import {flattenedPlatforms} from 'app/views/onboarding/utils';
-import PlatformiconTile from 'app/views/onboarding/project/platformiconTile';
-
-class PlatformCard extends React.Component {
-  static propTypes = {
-    platform: PropTypes.string,
-    onClick: PropTypes.func,
-  };
-
-  render() {
-    const platform = flattenedPlatforms.find(p => p.id === this.props.platform);
-
-    return (
-      <span
-        className={classnames('platform-card', this.props.className)}
-        onClick={this.props.onClick}
-      >
-        <PlatformiconTile {...this.props} />
-        <h5> {platform.name} </h5>
-      </span>
-    );
-  }
-}
-
-export default PlatformCard;

+ 96 - 21
src/sentry/static/sentry/app/views/onboarding/project/platformiconTile.jsx

@@ -1,25 +1,100 @@
 import PropTypes from 'prop-types';
-import React from 'react';
-
-class PlatformiconTile extends React.Component {
-  static propTypes = {
-    platform: PropTypes.string,
-    className: PropTypes.string,
-  };
-
-  render() {
-    const {platform, className} = this.props;
-
-    return (
-      <li
-        className={`platform-tile list-unstyled ${platform} ${platform.split(
-          '-'
-        )[0]} ${className}`}
-      >
-        <span className={`platformicon platformicon-${platform}`} />
-      </li>
-    );
+import styled, {css} from 'react-emotion';
+
+const PLATFORM_ICONS = {
+  'app-engine': '\\e60b',
+  'c-sharp': '\\e60f',
+  'java-appengine': '\\e60b',
+  'javascript-angular': '\\e900',
+  'javascript-angularjs': '\\e900',
+  'javascript-ember': '\\e800',
+  'javascript-react': '\\e801',
+  'objective-c': '\\e60e',
+  'php-laravel': '\\e60d',
+  'python-bottle': '\\e60c',
+  'python-django': '\\e605',
+  'python-flask': '\\e610',
+  'ruby-rails': '\\e603',
+  angular: '\\e900',
+  angularjs: '\\e900',
+  apple: '\\e60e',
+  bottle: '\\e60c',
+  csharp: '\\e60f',
+  django: '\\e605',
+  dotnet: '\\e902',
+  elixir: 'e903',
+  ember: '\\e800',
+  flask: '\\e610',
+  generic: '\\e60a',
+  go: '\\e606',
+  ios: '\\e607',
+  java: '\\e608',
+  javascript: '\\e600',
+  js: '\\e600',
+  laravel: '\\e60d',
+  node: '\\e609',
+  objc: '\\e60e',
+  perl: '\\e901',
+  php: '\\e601',
+  python: '\\e602',
+  rails: '\\e603',
+  react: '\\e801',
+  ruby: '\\e604',
+  swift: '\\e60e',
+};
+
+// platformName: [background, forground]
+const PLATFORM_COLORS = {
+  python: ['#3060b8'],
+  javascript: ['#ecd744', '#111'],
+  ruby: ['#e03e2f', '#fff'],
+  rails: ['#e03e2f', '#fff'],
+  java: ['#ec5e44'],
+  php: ['#6c5fc7'],
+  node: ['#90c541'],
+  csharp: ['#638cd7'],
+  go: ['#fff', '#493e54'],
+  elixir: ['#4e3fb4'],
+  'app-engine': ['#ec5e44'],
+  'python-django': ['#57be8c'],
+  'javascript-react': ['#2d2d2d', '#00d8ff'],
+  'javascript-ember': ['#ed573e', '#fff'],
+  'javascript-angular': ['#e03e2f', '#fff'],
+};
+
+const selectPlatfrom = (object, platform) =>
+  object[platform] || object[platform.split('-')[0]];
+
+const getColorStyles = ({monoTone, platform}) => {
+  const [bg, fg] = selectPlatfrom(PLATFORM_COLORS, platform) || [];
+
+  return (
+    !monoTone &&
+    css`
+      background-color: ${bg || '#625471'};
+      color: ${fg || '#fff'};
+    `
+  );
+};
+
+const PlatformiconTile = styled('div')`
+  /* stylelint-disable-next-line font-family-no-missing-generic-family-keyword */
+  font-family: 'platformicons';
+  font-weight: normal;
+  speak: none;
+  -webkit-font-smoothing: antialiased;
+  -moz-osx-font-smoothing: grayscale;
+  ${getColorStyles};
+
+  &:before {
+    content: '${p => selectPlatfrom(PLATFORM_ICONS, p.platform) || '\\e60a'}';
   }
-}
+`;
+
+PlatformiconTile.propTypes = {
+  platform: PropTypes.string,
+  className: PropTypes.string,
+  monoTone: PropTypes.bool,
+};
 
 export default PlatformiconTile;

+ 200 - 82
src/sentry/static/sentry/app/views/onboarding/project/platformpicker.jsx

@@ -1,16 +1,23 @@
+import {debounce} from 'lodash';
 import PropTypes from 'prop-types';
 import React from 'react';
-import classnames from 'classnames';
-import _ from 'lodash';
+import keydown from 'react-keydown';
+import styled from 'react-emotion';
 
 import {analytics} from 'app/utils/analytics';
+import {flattenedPlatforms, categoryList} from 'app/views/onboarding/utils';
+import {inputStyles} from 'app/styles/input';
+import {t, tct} from 'app/locale';
+import Button from 'app/components/button';
+import EmptyMessage from 'app/views/settings/components/emptyMessage';
+import ExternalLink from 'app/components/externalLink';
+import InlineSvg from 'app/components/inlineSvg';
 import ListLink from 'app/components/listLink';
 import NavTabs from 'app/components/navTabs';
-import {flattenedPlatforms, categoryList} from 'app/views/onboarding/utils';
-import PlatformCard from 'app/views/onboarding/project/platformCard';
-import {t} from 'app/locale';
+import PlatformiconTile from 'app/views/onboarding/project/platformiconTile';
+import space from 'app/styles/space';
 
-const allCategories = categoryList.concat({id: 'all', name: t('All')});
+const PLATFORM_CATEGORIES = categoryList.concat({id: 'all', name: t('All')});
 
 class PlatformPicker extends React.Component {
   static propTypes = {
@@ -19,112 +26,223 @@ class PlatformPicker extends React.Component {
     showOther: PropTypes.bool,
   };
 
-  static defaultProps = {showOther: true};
+  static defaultProps = {
+    showOther: true,
+  };
 
-  constructor(...args) {
-    super(...args);
+  constructor(props, ...args) {
+    super(props, ...args);
     this.state = {
-      tab: allCategories[0].id,
-      filter: (this.props.platform || '').split('-')[0],
+      category: PLATFORM_CATEGORIES[0].id,
+      filter: props.platform?.split('-')[0],
     };
   }
 
-  logSearch = _.debounce(() => {
-    if (this.state.filter) {
-      analytics('platformpicker.search', {
-        query: this.state.filter.toLowerCase(),
-        num_results: this.getPlatformList().length,
-      });
-    }
-  }, 300);
+  get platformList() {
+    const {category} = this.state;
+    const currentCategory = categoryList.find(({id}) => id === category);
 
-  getPlatformList = () => {
     const subsetMatch = ({id}) => id.includes(this.state.filter.toLowerCase());
-    let filtered;
+    const categoryMatch = platform =>
+      category === 'all' || currentCategory.platforms.includes(platform.id);
 
+    const filtered = flattenedPlatforms.filter(
+      this.state.filter ? subsetMatch : categoryMatch
+    );
+
+    return this.props.showOther ? filtered : filtered.filter(({id}) => id !== 'other');
+  }
+
+  logSearch = debounce(() => {
     if (this.state.filter) {
-      filtered = flattenedPlatforms.filter(subsetMatch);
-    } else {
-      const {tab} = this.state;
-      const currentCategory = categoryList.find(({id}) => id === tab);
-      const tabSubset = flattenedPlatforms.filter(platform => {
-        return tab === 'all' || currentCategory.platforms.includes(platform.id);
+      analytics('platformpicker.search', {
+        query: this.state.filter.toLowerCase(),
+        num_results: this.platformList.length,
       });
-      filtered = tabSubset.filter(subsetMatch);
     }
+  }, 300);
 
-    if (!this.props.showOther) {
-      filtered = filtered.filter(({id}) => id !== 'other');
+  @keydown('/')
+  focusSearch(e) {
+    if (e.target !== this.searchInput.current) {
+      this.searchInput.current.focus();
+      e.preventDefault();
     }
+  }
 
-    return filtered;
-  };
+  searchInput = React.createRef();
 
   render() {
-    const {filter} = this.state;
-    const filtered = this.getPlatformList();
+    const platformList = this.platformList;
+    const {setPlatform} = this.props;
+    const {filter, category} = this.state;
+
     return (
-      <div className="platform-picker">
-        <NavTabs>
-          <li style={{float: 'right', marginRight: 0}}>
-            <div className="platform-filter-container">
-              <span className="icon icon-search" />
-              <input
-                type="text"
-                value={this.state.filter}
-                className="platform-filter"
-                label={t('Filter')}
-                placeholder="Filter"
-                onChange={e => this.setState({filter: e.target.value}, this.logSearch)}
-              />
-            </div>
-          </li>
-          {allCategories.map(({id, name}) => {
-            return (
+      <React.Fragment>
+        <NavContainer>
+          <CategoryNav>
+            {PLATFORM_CATEGORIES.map(({id, name}) => (
               <ListLink
                 key={id}
                 onClick={e => {
-                  analytics('platformpicker.select_tab', {tab: id});
-                  this.setState({tab: id, filter: ''});
+                  analytics('platformpicker.select_tab', {category: id});
+                  this.setState({category: id, filter: ''});
                   e.preventDefault();
                 }}
                 to={''}
-                isActive={() => id === (filter ? 'all' : this.state.tab)}
+                isActive={() => id === (filter ? 'all' : category)}
               >
                 {name}
               </ListLink>
-            );
-          })}
-        </NavTabs>
-        {filtered.length ? (
-          <ul className="client-platform-list platform-tiles">
-            {filtered.map((platform, idx) => {
-              return (
-                <PlatformCard
-                  platform={platform.id}
-                  className={classnames({
-                    selected: this.props.platform === platform.id,
-                  })}
-                  key={platform.id}
-                  onClick={e => {
-                    analytics('platformpicker.select_platform', {platform: platform.id});
-                    this.props.setPlatform(platform.id);
-                    e.preventDefault();
-                  }}
-                />
-              );
-            })}
-          </ul>
-        ) : (
-          <p>
-            {t(
-              "Not finding your platform? There's a rich ecosystem of community supported SDKs as well (including Perl, CFML, Clojure, and ActionScript).\n Try searching for Sentry clients or contacting support."
+            ))}
+          </CategoryNav>
+          <SearchBar>
+            <InlineSvg src="icon-search" />
+            <input
+              type="text"
+              ref={this.searchInput}
+              value={filter}
+              label={t('Filter Platforms')}
+              placeholder={t('Filter Platforms')}
+              onChange={e => this.setState({filter: e.target.value}, this.logSearch)}
+            />
+          </SearchBar>
+        </NavContainer>
+        <PlatformList>
+          {platformList.map(platform => (
+            <PlatformCard
+              data-test-id={`platform-${platform.id}`}
+              key={platform.id}
+              platform={platform}
+              selected={this.props.platform === platform.id}
+              onClear={e => {
+                setPlatform('');
+                e.stopPropagation();
+              }}
+              onClick={e => {
+                analytics('platformpicker.select_platform', {platform: platform.id});
+                setPlatform(platform.id);
+              }}
+            />
+          ))}
+        </PlatformList>
+        {platformList.length === 0 && (
+          <EmptyMessage
+            icon="icon-project"
+            title={t("We don't have an SDK for that yet!")}
+          >
+            {tct(
+              `Not finding your platform? You can still create your project,
+              but looks like we don't have an official SDK for your platform
+              yet. However, there's a rich ecosystem of community supported
+              SDKs (including Perl, CFML, Clojure, and ActionScript). Try
+              [search:searching for Sentry clients] or contacting support.`,
+              {
+                search: (
+                  <ExternalLink href="https://github.com/search?q=-org%3Agetsentry+topic%3Asentry&type=Repositories" />
+                ),
+              }
             )}
-          </p>
+          </EmptyMessage>
         )}
-      </div>
+      </React.Fragment>
     );
   }
 }
 
+const NavContainer = styled('div')`
+  border-bottom: 1px solid ${p => p.theme.borderLight};
+  margin-bottom: ${space(2)};
+  display: grid;
+  grid-template-columns: 1fr 300px;
+  align-items: start;
+`;
+
+const SearchBar = styled('div')`
+  ${inputStyles};
+  padding: 0 8px;
+  color: ${p => p.theme.gray3};
+  display: flex;
+  align-items: center;
+  font-size: 15px;
+
+  input {
+    border: none;
+    background: none;
+    padding: 2px 4px;
+    width: 100%;
+
+    &:focus {
+      outline: none;
+    }
+  }
+`;
+
+const CategoryNav = styled(NavTabs)`
+  margin: 0;
+  margin-top: 4px;
+`;
+
+const PlatformList = styled('div')`
+  display: grid;
+  grid-gap: ${space(1)};
+  grid-template-columns: repeat(auto-fill, 112px);
+`;
+
+const StyledPlatformiconTile = styled(PlatformiconTile)`
+  width: 56px;
+  height: 56px;
+  font-size: 42px;
+  line-height: 58px;
+  text-align: center;
+  margin: ${space(2)};
+  border-radius: 5px;
+`;
+
+const ClearButton = styled(p => (
+  <Button {...p} icon="icon-circle-close" size="xsmall" borderless />
+))`
+  position: absolute;
+  top: -6px;
+  right: -6px;
+  height: 22px;
+  width: 22px;
+  border-radius: 50%;
+  background: #fff;
+  color: ${p => p.theme.gray4};
+`;
+
+const PlatformCard = styled(({platform, selected, onClear, ...props}) => (
+  <div {...props}>
+    <StyledPlatformiconTile platform={platform.id} />
+    <h3>{platform.name}</h3>
+    {selected && <ClearButton onClick={onClear} />}
+  </div>
+))`
+  position: relative;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  padding: 0 0 14px;
+  border-radius: 4px;
+  cursor: pointer;
+  background: ${p => p.selected && '#ecf5fd'};
+
+  &:hover {
+    background: #f7f5fa;
+  }
+
+  h3 {
+    display: block;
+    width: 100%;
+    text-align: center;
+    font-size: 15px;
+    margin: 0;
+    overflow: hidden;
+    text-overflow: ellipsis;
+    white-space: nowrap;
+    line-height: 1.2;
+  }
+`;
+
 export default PlatformPicker;

+ 28 - 37
src/sentry/static/sentry/app/views/projectInstall/newProject.jsx

@@ -1,46 +1,37 @@
-import React from 'react';
-import createReactClass from 'create-react-class';
 import DocumentTitle from 'react-document-title';
+import React from 'react';
 import styled from 'react-emotion';
-import space from 'app/styles/space';
-
-import OrganizationState from 'app/mixins/organizationState';
 
 import CreateProject from 'app/views/onboarding/createProject';
 import ProjectSelector from 'app/components/projectHeader/projectSelector';
+import SentryTypes from 'app/sentryTypes';
+import space from 'app/styles/space';
+import withOrganization from 'app/utils/withOrganization';
 
-const NewProject = createReactClass({
-  displayName: 'NewProject',
-  mixins: [OrganizationState],
-
-  render() {
-    const hasSentry10 = this.getFeatures().has('sentry10');
-    return (
-      <Container>
-        {!hasSentry10 && (
-          <div className="sub-header flex flex-container flex-vertically-centered">
-            <div className="p-t-1 p-b-1">
-              <ProjectSelector organization={this.getOrganization()} />
-            </div>
-          </div>
-        )}
-        <div className="container">
-          <Content>
-            <DocumentTitle title={'Sentry'} />
-            <CreateProject
-              getDocsUrl={({slug, projectSlug, platform}) => {
-                if (platform === 'other') {
-                  platform = '';
-                }
-                return `/${slug}/${projectSlug}/getting-started/${platform}`;
-              }}
-            />
-          </Content>
+const NewProject = ({organization}) => (
+  <Container>
+    {!organization.features.includes('sentry10') && (
+      <div className="sub-header flex flex-container flex-vertically-centered">
+        <div className="p-t-1 p-b-1">
+          <ProjectSelector organization={organization} />
         </div>
-      </Container>
-    );
-  },
-});
+      </div>
+    )}
+    <div className="container">
+      <Content>
+        <DocumentTitle title={'Sentry'} />
+        <CreateProject
+          nextStepUrl={({slug, projectSlug, platform}) =>
+            `/${slug}/${projectSlug}/getting-started/${platform}/`}
+        />
+      </Content>
+    </div>
+  </Container>
+);
+
+NewProject.propTypes = {
+  organization: SentryTypes.Organization.isRequired,
+};
 
 const Container = styled('div')`
   flex: 1;
@@ -52,4 +43,4 @@ const Content = styled('div')`
   margin-top: ${space(3)};
 `;
 
-export default NewProject;
+export default withOrganization(NewProject);

+ 0 - 157
src/sentry/static/sentry/less/onboarding.less

@@ -110,160 +110,3 @@
     }
   }
 }
-
-.create-project-form {
-  display: flex;
-  justify-content: space-between;
-  align-items: flex-end;
-  flex-wrap: wrap;
-  padding: 20px 0;
-  box-shadow: 0 -1px 0 rgba(0, 0, 0, 0.1);
-
-  position: sticky;
-  bottom: 0;
-  background: white;
-
-  p {
-    margin-top: 20px;
-    color: @50;
-  }
-
-  @media (max-width: @screen-sm-max) {
-    margin-top: 20px;
-  }
-}
-
-.new-project-name {
-  display: flex;
-  flex-direction: column;
-  background: #fff;
-  .clearfix;
-
-  .platform-tile {
-    display: inline-block;
-    vertical-align: middle;
-  }
-
-  h4 {
-    display: inline-block;
-  }
-
-  .project-name-wrapper {
-    .form-control;
-    position: relative;
-    padding-left: 44px;
-    width: 300px;
-
-    &.required {
-      border: 1px #e03e2f solid;
-    }
-
-    input {
-      background: none;
-      border: 0;
-      display: block;
-      width: 100%;
-      font-weight: 600;
-
-      &:focus {
-        outline: none;
-      }
-
-      &::placeholder {
-        color: @50;
-      }
-    }
-
-    .platform-tile {
-      position: absolute;
-      left: 6px;
-      top: 3px;
-      .platformicon {
-        color: @40 !important;
-      }
-    }
-  }
-}
-.platformicon-stack {
-  background-color: white;
-  position: absolute;
-}
-
-.platform-picker {
-  .nav-tabs {
-    border-bottom: 1px solid @trim;
-    margin: 0 0 15px;
-  }
-  .platform-filter-container {
-    .form-control;
-    border-radius: 22px;
-    padding: 0 8px;
-    position: relative;
-    top: -4px;
-
-    @media (max-width: @screen-sm-max) {
-      display: none;
-    }
-
-    .icon-search {
-      color: @30;
-      font-size: 12px;
-      margin-right: 4px;
-    }
-  }
-  .platform-filter {
-    background: transparent;
-    border: none;
-    font-size: 14px;
-    &:focus {
-      outline: none;
-    }
-
-    &::placeholder {
-      color: @50;
-    }
-  }
-  .platform-tiles {
-    margin: auto;
-    display: flex;
-    flex-wrap: wrap;
-    padding: 0 0 15px;
-
-    .platform-card {
-      display: flex;
-      flex-direction: column;
-      width: 120px;
-      padding: 0 0 14px;
-      cursor: pointer;
-      border-radius: 4px;
-
-      @media (max-width: @screen-sm-max) {
-        width: 70px;
-      }
-
-      &:hover {
-        background: #f7f5fa;
-      }
-
-      &.selected {
-        background: darken(@alert-info-bg-color, 2);
-      }
-    }
-
-    &.client-platform-list li {
-      margin: 0 auto;
-    }
-    &.client-platform-list.shade {
-      background-color: @40;
-      filter: brightness(0.4);
-    }
-  }
-
-  h5 {
-    text-align: center;
-    margin: 0;
-    font-size: 15px;
-    .truncate;
-    line-height: 1.2;
-  }
-}

+ 0 - 204
src/sentry/static/sentry/less/shared-components.less

@@ -2057,210 +2057,6 @@ ul.radio-inputs {
   }
 }
 
-/**
-* Client platform list
-* ============================================================================
-*/
-.client-platform-list {
-  .list-unstyled;
-  .clearfix;
-
-  li {
-    float: left;
-    margin: 0 20px 20px 0;
-    text-align: center;
-    position: relative;
-    min-width: 80px;
-    font-size: 16px;
-
-    a {
-      color: inherit;
-      opacity: 0.85;
-      .transition(opacity 0.1s);
-      font-weight: 600;
-
-      &:hover {
-        opacity: 1;
-      }
-
-      &:after {
-        display: block;
-        content: '';
-        position: absolute;
-        top: 0;
-        right: 0;
-        bottom: 0;
-        left: 0;
-      }
-    }
-  }
-
-  .platformicon {
-    display: block;
-    .square(56px);
-    font-size: 42px;
-    line-height: 58px;
-    text-align: center;
-    margin: 15px auto 15px;
-    border-radius: 5px;
-    color: #fff;
-    text-shadow: 0 2px 0 rgba(0, 0, 0, 0.06);
-    background: @gray; // Default BG
-  }
-
-  li.go .platformicon {
-    border: 2px solid @gray;
-  }
-}
-
-.client-platform-list,
-.client-platform {
-  li,
-  span {
-    &.python .platformicon {
-      background: darken(@blue, 6);
-    }
-    &.python-pylons .platformicon:before {
-      content: '\e602';
-    }
-    &.python-celery .platformicon:before {
-      content: '\e602';
-    }
-    &.python-awslambda .platformicon:before {
-      content: '\e602';
-    }
-    &.python-pyramid .platformicon:before {
-      content: '\e602';
-    }
-    &.python-tornado .platformicon:before {
-      content: '\e602';
-    }
-    &.python-rq .platformicon:before {
-      content: '\e602';
-    }
-    &.python-sanic .platformicon:before {
-      content: '\e602';
-    }
-
-    &.javascript .platformicon {
-      background: @yellow;
-      color: #111;
-      text-shadow: none;
-    }
-    &.javascript-backbone .platformicon:before {
-      content: '\e600';
-    }
-    &.javascript-vue .platformicon:before {
-      content: '\e600';
-    }
-
-    &.ruby .platformicon,
-    &.rails .platformicon,
-    &.javascript-angular .platformicon {
-      background: @red;
-      color: #fff;
-    }
-    &.ruby-rack .platformicon:before {
-      content: '\e604';
-    }
-
-    &.javascript-angular2 .platformicon {
-      background: @blue;
-      color: #fff;
-      &:before {
-        content: '\e900';
-      }
-    }
-
-    &.java .platformicon {
-      background: @orange;
-    }
-    &.java-log4j .platformicon:before {
-      content: '\e608';
-    }
-    &.java-log4j2 .platformicon:before {
-      content: '\e608';
-    }
-    &.java-logback .platformicon:before {
-      content: '\e608';
-    }
-
-    &.php .platformicon {
-      background: @purple;
-    }
-    &.php-symfony2 .platformicon:before {
-      content: '\e601';
-    }
-    &.php-monolog .platformicon:before {
-      content: '\e601';
-    }
-
-    &.python-django .platformicon {
-      background: @green;
-    }
-
-    &.node .platformicon {
-      background: #90c541;
-    }
-
-    &.node-express .platformicon:before {
-      content: '\e609';
-    }
-    &.node-connect .platformicon:before {
-      content: '\e609';
-    }
-    &.node-koa .platformicon:before {
-      content: '\e609';
-    }
-
-    &.objc .platformicon,
-    &.cocoa .platformicon {
-      background: @gray;
-    }
-
-    &.app-engine .platformicon {
-      background: @orange;
-    }
-
-    &.csharp .platformicon {
-      background: @blue-light;
-    }
-
-    &.go .platformicon {
-      color: @gray-dark;
-      background: #fff;
-      text-shadow: none;
-    }
-    &.go-http .platformicon:before {
-      content: '\e606';
-    }
-
-    &.javascript-react .platformicon {
-      background: #2d2d2d;
-      color: #00d8ff;
-    }
-
-    &.javascript-ember .platformicon {
-      background: #ed573e;
-      color: #fff;
-    }
-
-    &.elixir .platformicon {
-      background: @purple-dark;
-    }
-  }
-}
-
-.client-platform {
-  li,
-  span {
-    .platformicon {
-      color: black !important;
-      background: white !important;
-    }
-  }
-}
-
 /**
  * Error level colors
  * ============================================================================

+ 3 - 1
src/sentry/utils/pytest/selenium.py

@@ -118,7 +118,7 @@ class Browser(object):
 
         return self
 
-    def wait_until(self, selector=None, title=None, timeout=3):
+    def wait_until(self, selector=None, xpath=None, title=None, timeout=3):
         """
         Waits until ``selector`` is found in the browser, or until ``timeout``
         is hit, whichever happens first.
@@ -127,6 +127,8 @@ class Browser(object):
 
         if selector:
             condition = expected_conditions.presence_of_element_located((By.CSS_SELECTOR, selector))
+        elif xpath:
+            condition = expected_conditions.presence_of_element_located((By.XPATH, xpath))
         elif title:
             condition = expected_conditions.title_is(title)
         else:

Some files were not shown because too many files changed in this diff