diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2018-07-20 16:57:23 +0200 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-08-10 20:21:28 +0200 |
commit | b08814f7807c1443592af65cd68c2a51dfd4ee37 (patch) | |
tree | d7bbaf30c5c0633cd212a30e52db073945ba61ea /server/sonar-web/src/main/js/apps/tutorials | |
parent | 3a39b4fa08b15912c928af35fb7b77cd4b85ab64 (diff) | |
download | sonarqube-b08814f7807c1443592af65cd68c2a51dfd4ee37.tar.gz sonarqube-b08814f7807c1443592af65cd68c2a51dfd4ee37.zip |
SONAR-11036 Install integration with GitHub or BitBucket Cloud
* SONAR-11040 Update tutorial choices modal
* SONAR-11041 Migrate manual installation tab
* SONAR-11041 Rename button to start new project tutorial
* SONAR-11041 Rework sonarcloud tabbed page styling
* SONAR-11042 Add alm app install buttons in create project page
* Make start script compatible with ALM integration
Diffstat (limited to 'server/sonar-web/src/main/js/apps/tutorials')
14 files changed, 1196 insertions, 103 deletions
diff --git a/server/sonar-web/src/main/js/apps/tutorials/Onboarding.tsx b/server/sonar-web/src/main/js/apps/tutorials/Onboarding.tsx index e6a6d887bd3..e7edd09e99f 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/Onboarding.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/Onboarding.tsx @@ -18,19 +18,22 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as PropTypes from 'prop-types'; import { connect } from 'react-redux'; import handleRequiredAuthentication from '../../app/utils/handleRequiredAuthentication'; import Modal from '../../components/controls/Modal'; -import { ResetButtonLink, Button } from '../../components/ui/buttons'; +import OnboardingPrivateIcon from '../../components/icons-components/OnboardingPrivateIcon'; +import OnboardingProjectIcon from '../../components/icons-components/OnboardingProjectIcon'; +import OnboardingTeamIcon from '../../components/icons-components/OnboardingTeamIcon'; +import { Button, ResetButtonLink } from '../../components/ui/buttons'; import { translate } from '../../helpers/l10n'; import { CurrentUser, isLoggedIn } from '../../app/types'; import { getCurrentUser } from '../../store/rootReducer'; import './styles.css'; interface OwnProps { - onFinish: () => void; + onClose: (doSkipOnboarding?: boolean) => void; onOpenOrganizationOnboarding: () => void; - onOpenProjectOnboarding: () => void; onOpenTeamOnboarding: () => void; } @@ -41,12 +44,25 @@ interface StateProps { type Props = OwnProps & StateProps; export class Onboarding extends React.PureComponent<Props> { + static contextTypes = { + router: PropTypes.object + }; + componentDidMount() { if (!isLoggedIn(this.props.currentUser)) { handleRequiredAuthentication(); } } + openProjectOnboarding = () => { + this.props.onClose(false); + this.context.router.push('/onboarding'); + }; + + onFinish = () => { + this.props.onClose(true); + }; + render() { if (!isLoggedIn(this.props.currentUser)) { return null; @@ -57,41 +73,35 @@ export class Onboarding extends React.PureComponent<Props> { <Modal contentLabel={header} medium={true} - onRequestClose={this.props.onFinish} + onRequestClose={this.onFinish} shouldCloseOnOverlayClick={false}> - <header className="modal-head"> - <h2>{header}</h2> - </header> - <div className="modal-body"> - <p className="spacer-top big-spacer-bottom"> - {translate('onboarding.header.description')} - </p> - <ul className="onboarding-choices"> - <li className="text-center"> - <p className="big-spacer-bottom">{translate('onboarding.analyze_public_code')}</p> - <Button onClick={this.props.onOpenProjectOnboarding}> - {translate('onboarding.analyze_public_code.button')} - </Button> - </li> - <li className="text-center"> - <p className="big-spacer-bottom">{translate('onboarding.analyze_private_code')}</p> - <Button onClick={this.props.onOpenOrganizationOnboarding}> - {translate('onboarding.analyze_private_code.button')} - </Button> - </li> - <li className="text-center"> - <p className="big-spacer-bottom"> - {translate('onboarding.contribute_existing_project')} - </p> - <Button onClick={this.props.onOpenTeamOnboarding}> - {translate('onboarding.contribute_existing_project.button')} - </Button> - </li> - </ul> + <div className="modal-simple-head text-center"> + <h1>{translate('onboarding.header')}</h1> + <p className="spacer-top">{translate('onboarding.header.description')}</p> + </div> + <div className="modal-simple-body text-center onboarding-choices"> + <Button className="onboarding-choice" onClick={this.openProjectOnboarding}> + <OnboardingProjectIcon /> + <span>{translate('onboarding.analyze_public_code')}</span> + <p className="note">{translate('onboarding.analyze_public_code.note')}</p> + </Button> + <Button className="onboarding-choice" onClick={this.props.onOpenOrganizationOnboarding}> + <OnboardingPrivateIcon /> + <span>{translate('onboarding.analyze_private_code')}</span> + <p className="note">{translate('onboarding.analyze_private_code.note')}</p> + </Button> + <Button className="onboarding-choice" onClick={this.props.onOpenTeamOnboarding}> + <OnboardingTeamIcon /> + <span>{translate('onboarding.contribute_existing_project')}</span> + <p className="note">{translate('onboarding.contribute_existing_project.note')}</p> + </Button> + </div> + <div className="modal-simple-footer text-center"> + <ResetButtonLink className="spacer-bottom" onClick={this.onFinish}> + {translate('not_now')} + </ResetButtonLink> + <p className="note">{translate('onboarding.footer')}</p> </div> - <footer className="modal-foot"> - <ResetButtonLink onClick={this.props.onFinish}>{translate('close')}</ResetButtonLink> - </footer> </Modal> ); } diff --git a/server/sonar-web/src/main/js/apps/tutorials/__tests__/Onboarding-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/__tests__/Onboarding-test.tsx index c9b3e77500e..d0350d43cc0 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/__tests__/Onboarding-test.tsx +++ b/server/sonar-web/src/main/js/apps/tutorials/__tests__/Onboarding-test.tsx @@ -27,9 +27,8 @@ it('renders correctly', () => { shallow( <Onboarding currentUser={{ isLoggedIn: true }} - onFinish={jest.fn()} + onClose={jest.fn()} onOpenOrganizationOnboarding={jest.fn()} - onOpenProjectOnboarding={jest.fn()} onOpenTeamOnboarding={jest.fn()} /> ) @@ -37,25 +36,25 @@ it('renders correctly', () => { }); it('should correctly open the different tutorials', () => { - const onFinish = jest.fn(); + const onClose = jest.fn(); const onOpenOrganizationOnboarding = jest.fn(); - const onOpenProjectOnboarding = jest.fn(); const onOpenTeamOnboarding = jest.fn(); + const push = jest.fn(); const wrapper = shallow( <Onboarding currentUser={{ isLoggedIn: true }} - onFinish={onFinish} + onClose={onClose} onOpenOrganizationOnboarding={onOpenOrganizationOnboarding} - onOpenProjectOnboarding={onOpenProjectOnboarding} onOpenTeamOnboarding={onOpenTeamOnboarding} - /> + />, + { context: { router: { push } } } ); click(wrapper.find('ResetButtonLink')); - expect(onFinish).toHaveBeenCalled(); + expect(onClose).toHaveBeenCalled(); wrapper.find('Button').forEach(button => click(button)); expect(onOpenOrganizationOnboarding).toHaveBeenCalled(); - expect(onOpenProjectOnboarding).toHaveBeenCalled(); expect(onOpenTeamOnboarding).toHaveBeenCalled(); + expect(push).toHaveBeenCalledWith('/onboarding'); }); diff --git a/server/sonar-web/src/main/js/apps/tutorials/__tests__/__snapshots__/Onboarding-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/__tests__/__snapshots__/Onboarding-test.tsx.snap index e017f778601..e0a56e3fc79 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/__tests__/__snapshots__/Onboarding-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/tutorials/__tests__/__snapshots__/Onboarding-test.tsx.snap @@ -4,79 +4,81 @@ exports[`renders correctly 1`] = ` <Modal contentLabel="onboarding.header" medium={true} - onRequestClose={[MockFunction]} + onRequestClose={[Function]} shouldCloseOnOverlayClick={false} > - <header - className="modal-head" - > - <h2> - onboarding.header - </h2> - </header> <div - className="modal-body" + className="modal-simple-head text-center" > + <h1> + onboarding.header + </h1> <p - className="spacer-top big-spacer-bottom" + className="spacer-top" > onboarding.header.description </p> - <ul - className="onboarding-choices" + </div> + <div + className="modal-simple-body text-center onboarding-choices" + > + <Button + className="onboarding-choice" + onClick={[Function]} > - <li - className="text-center" + <OnboardingProjectIcon /> + <span> + onboarding.analyze_public_code + </span> + <p + className="note" > - <p - className="big-spacer-bottom" - > - onboarding.analyze_public_code - </p> - <Button - onClick={[MockFunction]} - > - onboarding.analyze_public_code.button - </Button> - </li> - <li - className="text-center" + onboarding.analyze_public_code.note + </p> + </Button> + <Button + className="onboarding-choice" + onClick={[MockFunction]} + > + <OnboardingPrivateIcon /> + <span> + onboarding.analyze_private_code + </span> + <p + className="note" > - <p - className="big-spacer-bottom" - > - onboarding.analyze_private_code - </p> - <Button - onClick={[MockFunction]} - > - onboarding.analyze_private_code.button - </Button> - </li> - <li - className="text-center" + onboarding.analyze_private_code.note + </p> + </Button> + <Button + className="onboarding-choice" + onClick={[MockFunction]} + > + <OnboardingTeamIcon /> + <span> + onboarding.contribute_existing_project + </span> + <p + className="note" > - <p - className="big-spacer-bottom" - > - onboarding.contribute_existing_project - </p> - <Button - onClick={[MockFunction]} - > - onboarding.contribute_existing_project.button - </Button> - </li> - </ul> + onboarding.contribute_existing_project.note + </p> + </Button> </div> - <footer - className="modal-foot" + <div + className="modal-simple-footer text-center" > <ResetButtonLink - onClick={[MockFunction]} + className="spacer-bottom" + onClick={[Function]} > - close + not_now </ResetButtonLink> - </footer> + <p + className="note" + > + onboarding.footer + </p> + </div> </Modal> `; diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/AutoProjectCreate.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/AutoProjectCreate.tsx new file mode 100644 index 00000000000..58c86e34696 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/AutoProjectCreate.tsx @@ -0,0 +1,132 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; +import IdentityProviderLink from '../../../components/ui/IdentityProviderLink'; +import { getIdentityProviders } from '../../../api/users'; +import { getRepositories } from '../../../api/alm-integration'; +import { translateWithParameters } from '../../../helpers/l10n'; +import { IdentityProvider, LoggedInUser } from '../../../app/types'; + +interface Props { + currentUser: LoggedInUser; +} + +interface State { + identityProviders: IdentityProvider[]; + installationUrl?: string; + installed?: boolean; + loading: boolean; +} + +export default class AutoProjectCreate extends React.PureComponent<Props, State> { + mounted = false; + state: State = { identityProviders: [], loading: true }; + + componentDidMount() { + this.mounted = true; + Promise.all([this.fetchIdentityProviders(), this.fetchRepositories()]).then( + this.stopLoading, + this.stopLoading + ); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchIdentityProviders = () => { + return getIdentityProviders().then( + ({ identityProviders }) => { + if (this.mounted) { + this.setState({ identityProviders }); + } + }, + () => { + return Promise.resolve(); + } + ); + }; + + fetchRepositories = () => { + return getRepositories().then(({ installation }) => { + if (this.mounted) { + this.setState({ + installationUrl: installation.installationUrl, + installed: installation.enabled + }); + } + }); + }; + + stopLoading = () => { + if (this.mounted) { + this.setState({ loading: false }); + } + }; + + render() { + if (this.state.loading) { + return <DeferredSpinner />; + } + + const { currentUser } = this.props; + const identityProvider = this.state.identityProviders.find( + identityProvider => identityProvider.key === currentUser.externalProvider + ); + + if (!identityProvider) { + return null; + } + + return ( + <> + <p className="alert alert-info width-60 big-spacer-bottom"> + {translateWithParameters( + 'onboarding.create_project.beta_feature_x', + identityProvider.name + )} + </p> + {this.state.installed ? ( + 'Repositories list' + ) : ( + <div> + <p className="spacer-bottom"> + {translateWithParameters( + 'onboarding.create_project.install_app_x', + identityProvider.name + )} + </p> + <IdentityProviderLink + className="display-inline-block" + identityProvider={identityProvider} + small={true} + url={this.state.installationUrl}> + {translateWithParameters( + 'onboarding.create_project.install_app_x.button', + identityProvider.name + )} + </IdentityProviderLink> + </div> + )} + </> + ); + } +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/CreateProjectOnboarding.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/CreateProjectOnboarding.tsx new file mode 100644 index 00000000000..7803335143c --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/CreateProjectOnboarding.tsx @@ -0,0 +1,182 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import * as classNames from 'classnames'; +import * as PropTypes from 'prop-types'; +import { connect } from 'react-redux'; +import Helmet from 'react-helmet'; +import AutoProjectCreate from './AutoProjectCreate'; +import ManualProjectCreate from './ManualProjectCreate'; +import handleRequiredAuthentication from '../../../app/utils/handleRequiredAuthentication'; +import { getCurrentUser } from '../../../store/rootReducer'; +import { skipOnboarding } from '../../../store/users/actions'; +import { CurrentUser, isLoggedIn } from '../../../app/types'; +import { translate } from '../../../helpers/l10n'; +import { ProjectBase } from '../../../api/components'; +import { getProjectUrl, getOrganizationUrl } from '../../../helpers/urls'; +import '../../../app/styles/sonarcloud.css'; +import '../styles.css'; + +interface OwnProps { + onFinishOnboarding: () => void; +} + +interface StateProps { + currentUser: CurrentUser; +} + +interface DispatchProps { + skipOnboarding: () => void; +} + +enum Tabs { + AUTO, + MANUAL +} + +type Props = OwnProps & StateProps & DispatchProps; + +interface State { + activeTab: Tabs; +} + +export class CreateProjectOnboarding extends React.PureComponent<Props, State> { + mounted = false; + static contextTypes = { + router: PropTypes.object + }; + + constructor(props: Props) { + super(props); + this.state = { activeTab: this.shouldDisplayTabs(props) ? Tabs.AUTO : Tabs.MANUAL }; + } + + componentDidMount() { + this.mounted = true; + if (!isLoggedIn(this.props.currentUser)) { + handleRequiredAuthentication(); + } + document.body.classList.add('white-page'); + document.documentElement.classList.add('white-page'); + } + + componentWillUnmount() { + this.mounted = false; + document.body.classList.remove('white-page'); + document.documentElement.classList.remove('white-page'); + } + + handleProjectCreate = (projects: Pick<ProjectBase, 'key'>[], organization?: string) => { + if (projects.length > 1 && organization) { + this.context.router.push(getOrganizationUrl(organization) + '/projects'); + } else if (projects.length === 1) { + this.context.router.push(getProjectUrl(projects[0].key)); + } + }; + + shouldDisplayTabs = ({ currentUser } = this.props) => { + return ( + isLoggedIn(currentUser) && + ['bitbucket', 'github'].includes(currentUser.externalProvider || '') + ); + }; + + showAuto = (event: React.MouseEvent<HTMLAnchorElement>) => { + event.preventDefault(); + this.setState({ activeTab: Tabs.AUTO }); + }; + + showManual = (event: React.MouseEvent<HTMLAnchorElement>) => { + event.preventDefault(); + this.setState({ activeTab: Tabs.MANUAL }); + }; + + render() { + const { currentUser } = this.props; + if (!isLoggedIn(currentUser)) { + return null; + } + + const { activeTab } = this.state; + const header = translate('onboarding.create_project.header'); + return ( + <> + <Helmet title={header} titleTemplate="%s" /> + <div className="sonarcloud page page-limited"> + <div className="page-header"> + <h1 className="page-title">{header}</h1> + </div> + + {this.shouldDisplayTabs() && ( + <ul className="flex-tabs"> + <li> + <a + className={classNames('js-auto', { selected: activeTab === Tabs.AUTO })} + href="#" + onClick={this.showAuto}> + {translate('onboarding.create_project.select_repositories')} + <span + className={classNames( + 'rounded alert alert-small spacer-left display-inline-block', + { + 'alert-info': activeTab === Tabs.AUTO, + 'alert-muted': activeTab !== Tabs.AUTO + } + )}> + {translate('beta')} + </span> + </a> + </li> + <li> + <a + className={classNames('js-manual', { selected: activeTab === Tabs.MANUAL })} + href="#" + onClick={this.showManual}> + {translate('onboarding.create_project.create_manually')} + </a> + </li> + </ul> + )} + + {activeTab === Tabs.AUTO ? ( + <AutoProjectCreate currentUser={currentUser} /> + ) : ( + <ManualProjectCreate + currentUser={currentUser} + onProjectCreate={this.handleProjectCreate} + /> + )} + </div> + </> + ); + } +} + +const mapStateToProps = (state: any): StateProps => { + return { + currentUser: getCurrentUser(state) + }; +}; + +const mapDispatchToProps: DispatchProps = { skipOnboarding }; + +export default connect<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)( + CreateProjectOnboarding +); diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/ManualProjectCreate.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/ManualProjectCreate.tsx new file mode 100644 index 00000000000..59d615583c7 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/ManualProjectCreate.tsx @@ -0,0 +1,227 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { sortBy } from 'lodash'; +import { connect } from 'react-redux'; +import CreateOrganizationForm from '../../account/organizations/CreateOrganizationForm'; +import Select from '../../../components/controls/Select'; +import { Button, SubmitButton } from '../../../components/ui/buttons'; +import { LoggedInUser, Organization } from '../../../app/types'; +import { fetchMyOrganizations } from '../../account/organizations/actions'; +import { getMyOrganizations } from '../../../store/rootReducer'; +import { translate } from '../../../helpers/l10n'; +import { createProject, ProjectBase } from '../../../api/components'; +import DeferredSpinner from '../../../components/common/DeferredSpinner'; + +interface StateProps { + userOrganizations: Organization[]; +} + +interface DispatchProps { + fetchMyOrganizations: () => Promise<void>; +} + +interface OwnProps { + currentUser: LoggedInUser; + onProjectCreate: (project: ProjectBase[]) => void; +} + +type Props = OwnProps & StateProps & DispatchProps; + +interface State { + createOrganizationModal: boolean; + projectName: string; + projectKey: string; + selectedOrganization: string; + submitting: boolean; +} + +export class ManualProjectCreate extends React.PureComponent<Props, State> { + mounted = false; + + constructor(props: Props) { + super(props); + this.state = { + createOrganizationModal: false, + projectName: '', + projectKey: '', + selectedOrganization: + props.userOrganizations.length <= 1 ? props.userOrganizations[0].key : '', + submitting: false + }; + } + + componentDidMount() { + this.mounted = true; + } + + componentWillUnmount() { + this.mounted = false; + } + + closeCreateOrganization = () => { + this.setState({ createOrganizationModal: false }); + }; + + handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { + event.preventDefault(); + + if (this.isValid()) { + const { projectKey, projectName, selectedOrganization } = this.state; + this.setState({ submitting: true }); + createProject({ + project: projectKey, + name: projectName, + organization: selectedOrganization + }).then( + ({ project }) => this.props.onProjectCreate([project]), + () => { + if (this.mounted) { + this.setState({ submitting: false }); + } + } + ); + } + }; + + handleOrganizationSelect = ({ value }: { value: string }) => { + this.setState({ selectedOrganization: value }); + }; + + handleProjectNameChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ projectName: event.currentTarget.value }); + }; + + handleProjectKeyChange = (event: React.ChangeEvent<HTMLInputElement>) => { + this.setState({ projectKey: event.currentTarget.value }); + }; + + isValid = () => { + const { projectKey, projectName, selectedOrganization } = this.state; + return Boolean(projectKey && projectName && selectedOrganization); + }; + + onCreateOrganization = (organization: { key: string }) => { + this.props.fetchMyOrganizations().then( + () => { + this.handleOrganizationSelect({ value: organization.key }); + this.closeCreateOrganization(); + }, + () => { + this.closeCreateOrganization(); + } + ); + }; + + showCreateOrganization = () => { + this.setState({ createOrganizationModal: true }); + }; + + render() { + const { submitting } = this.state; + return ( + <> + <form onSubmit={this.handleFormSubmit}> + <div className="form-field"> + <label htmlFor="select-organization"> + {translate('onboarding.create_project.organization')} + <em className="mandatory">*</em> + </label> + <Select + autoFocus={true} + className="input-super-large" + clearable={false} + id="select-organization" + onChange={this.handleOrganizationSelect} + options={sortBy(this.props.userOrganizations, o => o.name.toLowerCase()).map( + organization => ({ + label: organization.name, + value: organization.key + }) + )} + required={true} + value={this.state.selectedOrganization} + /> + <Button + className="button-link big-spacer-left js-new-org" + onClick={this.showCreateOrganization}> + {translate('onboarding.create_project.create_new_org')} + </Button> + </div> + <div className="form-field"> + <label htmlFor="project-name"> + {translate('onboarding.create_project.project_name')} + <em className="mandatory">*</em> + </label> + <input + className="input-super-large" + id="project-name" + maxLength={400} + minLength={1} + onChange={this.handleProjectNameChange} + required={true} + type="text" + value={this.state.projectName} + /> + </div> + <div className="form-field"> + <label htmlFor="project-key"> + {translate('onboarding.create_project.project_key')} + <em className="mandatory">*</em> + </label> + <input + className="input-super-large" + id="project-key" + maxLength={400} + minLength={1} + onChange={this.handleProjectKeyChange} + required={true} + type="text" + value={this.state.projectKey} + /> + </div> + <SubmitButton disabled={!this.isValid() || submitting}> + {translate('onboarding.create_project.create_project')} + </SubmitButton> + <DeferredSpinner className="spacer-left" loading={submitting} /> + </form> + {this.state.createOrganizationModal && ( + <CreateOrganizationForm + onClose={this.closeCreateOrganization} + onCreate={this.onCreateOrganization} + /> + )} + </> + ); + } +} + +const mapDispatchToProps = ({ + fetchMyOrganizations +} as any) as DispatchProps; + +const mapStateToProps = (state: any): StateProps => { + return { + userOrganizations: getMyOrganizations(state) + }; +}; +export default connect<StateProps, DispatchProps, OwnProps>(mapStateToProps, mapDispatchToProps)( + ManualProjectCreate +); diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/AutoProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/AutoProjectCreate-test.tsx new file mode 100644 index 00000000000..10ea4c7e215 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/AutoProjectCreate-test.tsx @@ -0,0 +1,69 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import AutoProjectCreate from '../AutoProjectCreate'; +import { getIdentityProviders } from '../../../../api/users'; +import { getRepositories } from '../../../../api/alm-integration'; +import { LoggedInUser } from '../../../../app/types'; +import { waitAndUpdate } from '../../../../helpers/testUtils'; + +jest.mock('../../../../api/users', () => ({ + getIdentityProviders: jest.fn().mockResolvedValue({ + identityProviders: [ + { + backgroundColor: 'blue', + iconPath: 'icon/path', + key: 'foo', + name: 'Foo Provider' + } + ] + }) +})); + +jest.mock('../../../../api/alm-integration', () => ({ + getRepositories: jest.fn().mockResolvedValue({ + installation: { + installationUrl: 'https://alm.foo.com/install', + enabled: false + } + }) +})); + +const user: LoggedInUser = { isLoggedIn: true, login: 'foo', name: 'Foo', externalProvider: 'foo' }; + +beforeEach(() => { + (getIdentityProviders as jest.Mock<any>).mockClear(); + (getRepositories as jest.Mock<any>).mockClear(); +}); + +it('should display the provider app install button', async () => { + const wrapper = getWrapper(); + expect(wrapper).toMatchSnapshot(); + expect(getIdentityProviders).toHaveBeenCalled(); + expect(getRepositories).toHaveBeenCalled(); + + await waitAndUpdate(wrapper); + expect(wrapper).toMatchSnapshot(); +}); + +function getWrapper(props = {}) { + return shallow(<AutoProjectCreate currentUser={user} {...props} />); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/CreateProjectOnboarding-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/CreateProjectOnboarding-test.tsx new file mode 100644 index 00000000000..f7cfa3ce8da --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/CreateProjectOnboarding-test.tsx @@ -0,0 +1,58 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { CreateProjectOnboarding } from '../CreateProjectOnboarding'; +import { LoggedInUser } from '../../../../app/types'; +import { click } from '../../../../helpers/testUtils'; + +const user: LoggedInUser = { + externalProvider: 'github', + isLoggedIn: true, + login: 'foo', + name: 'Foo' +}; + +it('should render correctly', () => { + expect(getWrapper()).toMatchSnapshot(); +}); + +it('should render with Manual creation only', () => { + expect(getWrapper({ currentUser: { ...user, externalProvider: 'vsts' } })).toMatchSnapshot(); +}); + +it('should switch tabs', () => { + const wrapper = getWrapper(); + click(wrapper.find('.js-manual')); + expect(wrapper.find('Connect(ManualProjectCreate)').exists()).toBeTruthy(); + click(wrapper.find('.js-auto')); + expect(wrapper.find('AutoProjectCreate').exists()).toBeTruthy(); +}); + +function getWrapper(props = {}) { + return shallow( + <CreateProjectOnboarding + currentUser={user} + onFinishOnboarding={jest.fn()} + skipOnboarding={jest.fn()} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/ManualProjectCreate-test.tsx b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/ManualProjectCreate-test.tsx new file mode 100644 index 00000000000..b79b4e4ae35 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/ManualProjectCreate-test.tsx @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; +import { shallow } from 'enzyme'; +import { ManualProjectCreate } from '../ManualProjectCreate'; +import { change, click, submit, waitAndUpdate } from '../../../../helpers/testUtils'; +import { createProject } from '../../../../api/components'; + +jest.mock('../../../../api/components', () => ({ + createProject: jest.fn().mockResolvedValue({ project: { key: 'bar', name: 'Bar' } }) +})); + +beforeEach(() => { + (createProject as jest.Mock<any>).mockClear(); +}); + +it('should render correctly', () => { + expect(getWrapper()).toMatchSnapshot(); +}); + +it('should allow to create a new org', async () => { + const fetchMyOrganizations = jest.fn().mockResolvedValueOnce([]); + const wrapper = getWrapper({ fetchMyOrganizations }); + + click(wrapper.find('.js-new-org')); + const createForm = wrapper.find('Connect(CreateOrganizationForm)'); + expect(createForm.exists()).toBeTruthy(); + + createForm.prop<Function>('onCreate')({ key: 'baz' }); + expect(fetchMyOrganizations).toHaveBeenCalled(); + await waitAndUpdate(wrapper); + expect(wrapper.state('selectedOrganization')).toBe('baz'); +}); + +it('should correctly create a project', async () => { + const onProjectCreate = jest.fn(); + const wrapper = getWrapper({ onProjectCreate }); + wrapper.find('Select').prop<Function>('onChange')({ value: 'foo' }); + change(wrapper.find('#project-name'), 'Bar'); + expect(wrapper.find('SubmitButton')).toMatchSnapshot(); + + change(wrapper.find('#project-key'), 'bar'); + expect(wrapper.find('SubmitButton')).toMatchSnapshot(); + + submit(wrapper.find('form')); + expect(createProject).toBeCalledWith({ project: 'bar', name: 'Bar', organization: 'foo' }); + + await waitAndUpdate(wrapper); + expect(onProjectCreate).toBeCalledWith([{ key: 'bar', name: 'Bar' }]); +}); + +function getWrapper(props = {}) { + return shallow( + <ManualProjectCreate + currentUser={{ isLoggedIn: true, login: 'foo', name: 'Foo' }} + fetchMyOrganizations={jest.fn()} + onProjectCreate={jest.fn()} + userOrganizations={[{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]} + {...props} + /> + ); +} diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap new file mode 100644 index 00000000000..9320a251046 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/AutoProjectCreate-test.tsx.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should display the provider app install button 1`] = ` +<DeferredSpinner + timeout={100} +/> +`; + +exports[`should display the provider app install button 2`] = ` +<React.Fragment> + <p + className="alert alert-info width-60 big-spacer-bottom" + > + onboarding.create_project.beta_feature_x.Foo Provider + </p> + <div> + <p + className="spacer-bottom" + > + onboarding.create_project.install_app_x.Foo Provider + </p> + <IdentityProviderLink + className="display-inline-block" + identityProvider={ + Object { + "backgroundColor": "blue", + "iconPath": "icon/path", + "key": "foo", + "name": "Foo Provider", + } + } + small={true} + url="https://alm.foo.com/install" + > + onboarding.create_project.install_app_x.button.Foo Provider + </IdentityProviderLink> + </div> +</React.Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/CreateProjectOnboarding-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/CreateProjectOnboarding-test.tsx.snap new file mode 100644 index 00000000000..9542df43fb9 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/CreateProjectOnboarding-test.tsx.snap @@ -0,0 +1,97 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +<React.Fragment> + <HelmetWrapper + defer={true} + encodeSpecialCharacters={true} + title="onboarding.create_project.header" + titleTemplate="%s" + /> + <div + className="sonarcloud page page-limited" + > + <div + className="page-header" + > + <h1 + className="page-title" + > + onboarding.create_project.header + </h1> + </div> + <ul + className="flex-tabs" + > + <li> + <a + className="js-auto selected" + href="#" + onClick={[Function]} + > + onboarding.create_project.select_repositories + <span + className="rounded alert alert-small spacer-left display-inline-block alert-info" + > + beta + </span> + </a> + </li> + <li> + <a + className="js-manual" + href="#" + onClick={[Function]} + > + onboarding.create_project.create_manually + </a> + </li> + </ul> + <AutoProjectCreate + currentUser={ + Object { + "externalProvider": "github", + "isLoggedIn": true, + "login": "foo", + "name": "Foo", + } + } + /> + </div> +</React.Fragment> +`; + +exports[`should render with Manual creation only 1`] = ` +<React.Fragment> + <HelmetWrapper + defer={true} + encodeSpecialCharacters={true} + title="onboarding.create_project.header" + titleTemplate="%s" + /> + <div + className="sonarcloud page page-limited" + > + <div + className="page-header" + > + <h1 + className="page-title" + > + onboarding.create_project.header + </h1> + </div> + <Connect(ManualProjectCreate) + currentUser={ + Object { + "externalProvider": "vsts", + "isLoggedIn": true, + "login": "foo", + "name": "Foo", + } + } + onProjectCreate={[Function]} + /> + </div> +</React.Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap new file mode 100644 index 00000000000..fafb751c9bb --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/createProjectOnboarding/__tests__/__snapshots__/ManualProjectCreate-test.tsx.snap @@ -0,0 +1,125 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should correctly create a project 1`] = ` +<SubmitButton + disabled={true} +> + onboarding.create_project.create_project +</SubmitButton> +`; + +exports[`should correctly create a project 2`] = ` +<SubmitButton + disabled={false} +> + onboarding.create_project.create_project +</SubmitButton> +`; + +exports[`should render correctly 1`] = ` +<React.Fragment> + <form + onSubmit={[Function]} + > + <div + className="form-field" + > + <label + htmlFor="select-organization" + > + onboarding.create_project.organization + <em + className="mandatory" + > + * + </em> + </label> + <Select + autoFocus={true} + className="input-super-large" + clearable={false} + id="select-organization" + onChange={[Function]} + options={ + Array [ + Object { + "label": "Bar", + "value": "bar", + }, + Object { + "label": "Foo", + "value": "foo", + }, + ] + } + required={true} + value="" + /> + <Button + className="button-link big-spacer-left js-new-org" + onClick={[Function]} + > + onboarding.create_project.create_new_org + </Button> + </div> + <div + className="form-field" + > + <label + htmlFor="project-name" + > + onboarding.create_project.project_name + <em + className="mandatory" + > + * + </em> + </label> + <input + className="input-super-large" + id="project-name" + maxLength={400} + minLength={1} + onChange={[Function]} + required={true} + type="text" + value="" + /> + </div> + <div + className="form-field" + > + <label + htmlFor="project-key" + > + onboarding.create_project.project_key + <em + className="mandatory" + > + * + </em> + </label> + <input + className="input-super-large" + id="project-key" + maxLength={400} + minLength={1} + onChange={[Function]} + required={true} + type="text" + value="" + /> + </div> + <SubmitButton + disabled={true} + > + onboarding.create_project.create_project + </SubmitButton> + <DeferredSpinner + className="spacer-left" + loading={false} + timeout={100} + /> + </form> +</React.Fragment> +`; diff --git a/server/sonar-web/src/main/js/apps/tutorials/routes.ts b/server/sonar-web/src/main/js/apps/tutorials/routes.ts new file mode 100644 index 00000000000..9f5b34ba428 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/tutorials/routes.ts @@ -0,0 +1,36 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { lazyLoad } from '../../components/lazyLoad'; +import { isSonarCloud } from '../../helpers/system'; + +const routes = [ + { + indexRoute: { + component: lazyLoad( + () => + isSonarCloud() + ? import('../../apps/tutorials/createProjectOnboarding/CreateProjectOnboarding') + : import('../../apps/tutorials/projectOnboarding/ProjectOnboardingPage') + ) + } + } +]; + +export default routes; diff --git a/server/sonar-web/src/main/js/apps/tutorials/styles.css b/server/sonar-web/src/main/js/apps/tutorials/styles.css index f73428e0f40..798fce21bcc 100644 --- a/server/sonar-web/src/main/js/apps/tutorials/styles.css +++ b/server/sonar-web/src/main/js/apps/tutorials/styles.css @@ -58,5 +58,43 @@ .onboarding-choices { display: flex; justify-content: space-around; - padding: 24px 0 44px; + padding-top: 44px; + padding-bottom: 44px; + background-color: var(--barBackgroundColor); +} + +.onboarding-choice { + display: flex; + flex-direction: column; + justify-content: flex-end; + padding: calc(2 * var(--gridSize)); + width: 190px; + height: 190px; + background-color: #fff; + border: solid 1px #fff; + border-radius: 3px; + transition: all 0.2s ease; + box-shadow: 0 1px 1px 1px var(--barBorderColor); +} + +.onboarding-choice svg { + color: var(--gray40); + margin-bottom: calc(3 * var(--gridSize)); +} + +.onboarding-choice span { + font-size: var(--mediumFontSize); + margin-bottom: calc(var(--gridSize) / 2); +} + +.onboarding-choice .note { + font-weight: 400; +} + +.onboarding-choice:hover, +.onboarding-choice:focus, +.onboarding-choice:active { + background-color: #fff; + box-shadow: 0 10px 30px 0 rgba(0, 0, 0, 0.35); + color: var(--darkBlue); } |