* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
-import { getJSON } from '../helpers/request';
+import { getJSON, postJSON } from '../helpers/request';
+import { AlmRepository } from '../app/types';
import throwGlobalError from '../app/utils/throwGlobalError';
export function getRepositories(): Promise<{
- installation: {
+ almIntegration: {
+ installed: boolean;
installationUrl: string;
- enabled: boolean;
};
+ repositories: AlmRepository[];
}> {
return getJSON('/api/alm_integration/list_repositories').catch(throwGlobalError);
}
+
+export function provisionProject(data: { repositories: string[] }) {
+ return postJSON('api/alm_integration/provision_projects', data).catch(throwGlobalError);
+}
this.tryAutoOpenLicense().catch(this.tryAutoOpenOnboarding);
}
- closeOnboarding = (doSkipOnboarding = true) => {
+ closeOnboarding = () => {
this.setState(state => {
if (state.modal !== ModalKey.license) {
- if (doSkipOnboarding) {
- skipOnboarding();
- this.props.skipOnboardingAction();
- }
+ skipOnboarding();
+ this.props.skipOnboardingAction();
return { automatic: false, modal: undefined };
}
return undefined;
};
openProjectOnboarding = () => {
- this.setState({ modal: ModalKey.projectOnboarding });
+ if (isSonarCloud()) {
+ this.setState({ automatic: false, modal: undefined });
+ this.context.router.push(`/onboarding`);
+ } else {
+ this.setState({ modal: ModalKey.projectOnboarding });
+ }
};
openTeamOnboarding = () => {
<Onboarding
onClose={this.closeOnboarding}
onOpenOrganizationOnboarding={this.openOrganizationOnboarding}
+ onOpenProjectOnboarding={this.openProjectOnboarding}
onOpenTeamOnboarding={this.openTeamOnboarding}
/>
)}
border-bottom: none;
}
-.link-checkbox.disabled {
- cursor: not-allowed;
-}
-
+.link-checkbox.disabled,
+.link-checkbox.disabled:hover,
.link-checkbox.disabled label {
color: var(--secondFontColor);
cursor: not-allowed;
// Type ordered alphabetically to prevent merge conflicts
+export interface AlmRepository {
+ label: string;
+ installationKey: string;
+ linkedProjectKey?: string;
+ linkedProjectName?: string;
+}
+
export interface AppState {
adminPages?: Extension[];
authenticationError?: boolean;
import { connect } from 'react-redux';
import * as PropTypes from 'prop-types';
import { sortBy } from 'lodash';
-import { Organization } from '../../../app/types';
import DropdownIcon from '../../../components/icons-components/DropdownIcon';
import Dropdown from '../../../components/controls/Dropdown';
-import { getMyOrganizations } from '../../../store/rootReducer';
import OrganizationListItem from '../../../components/ui/OrganizationListItem';
-import { translate } from '../../../helpers/l10n';
+import { Button } from '../../../components/ui/buttons';
+import { getMyOrganizations } from '../../../store/rootReducer';
import { isSonarCloud } from '../../../helpers/system';
+import { Organization } from '../../../app/types';
+import { translate } from '../../../helpers/l10n';
interface StateProps {
organizations: Organization[];
openProjectOnboarding: PropTypes.func
};
- onAnalyzeProjectClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
- event.preventDefault();
- event.currentTarget.blur();
+ onAnalyzeProjectClick = () => {
this.context.openProjectOnboarding();
};
<div className="spacer-top">
<p>{translate('projects.no_favorite_projects.how_to_add_projects')}</p>
<div className="huge-spacer-top">
- <a className="button" href="#" onClick={this.onAnalyzeProjectClick}>
+ <Button onClick={this.onAnalyzeProjectClick}>
{isSonarCloud()
? translate('provisioning.create_new_project')
: translate('my_account.analyze_new_project')}
- </a>
+ </Button>
+
<Dropdown
className="display-inline-block big-spacer-left"
overlay={
<div
className="huge-spacer-top"
>
- <a
- className="button"
- href="#"
+ <Button
onClick={[Function]}
>
provisioning.create_new_project
- </a>
+ </Button>
<Dropdown
className="display-inline-block big-spacer-left"
overlay={
* 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 './styles.css';
interface OwnProps {
- onClose: (doSkipOnboarding?: boolean) => void;
+ onClose: () => void;
onOpenOrganizationOnboarding: () => void;
+ onOpenProjectOnboarding: () => void;
onOpenTeamOnboarding: () => void;
}
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;
<Modal
contentLabel={header}
medium={true}
- onRequestClose={this.onFinish}
+ onRequestClose={this.props.onClose}
shouldCloseOnOverlayClick={false}>
<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}>
+ <Button className="onboarding-choice" onClick={this.props.onOpenProjectOnboarding}>
<OnboardingProjectIcon />
<span>{translate('onboarding.analyze_public_code')}</span>
<p className="note">{translate('onboarding.analyze_public_code.note')}</p>
</Button>
</div>
<div className="modal-simple-footer text-center">
- <ResetButtonLink className="spacer-bottom" onClick={this.onFinish}>
+ <ResetButtonLink className="spacer-bottom" onClick={this.props.onClose}>
{translate('not_now')}
</ResetButtonLink>
<p className="note">{translate('onboarding.footer')}</p>
currentUser={{ isLoggedIn: true }}
onClose={jest.fn()}
onOpenOrganizationOnboarding={jest.fn()}
+ onOpenProjectOnboarding={jest.fn()}
onOpenTeamOnboarding={jest.fn()}
/>
)
it('should correctly open the different tutorials', () => {
const onClose = jest.fn();
const onOpenOrganizationOnboarding = jest.fn();
+ const onOpenProjectOnboarding = jest.fn();
const onOpenTeamOnboarding = jest.fn();
const push = jest.fn();
const wrapper = shallow(
currentUser={{ isLoggedIn: true }}
onClose={onClose}
onOpenOrganizationOnboarding={onOpenOrganizationOnboarding}
+ onOpenProjectOnboarding={onOpenProjectOnboarding}
onOpenTeamOnboarding={onOpenTeamOnboarding}
/>,
{ context: { router: { push } } }
wrapper.find('Button').forEach(button => click(button));
expect(onOpenOrganizationOnboarding).toHaveBeenCalled();
+ expect(onOpenProjectOnboarding).toHaveBeenCalled();
expect(onOpenTeamOnboarding).toHaveBeenCalled();
- expect(push).toHaveBeenCalledWith('/onboarding');
});
<Modal
contentLabel="onboarding.header"
medium={true}
- onRequestClose={[Function]}
+ onRequestClose={[MockFunction]}
shouldCloseOnOverlayClick={false}
>
<div
>
<Button
className="onboarding-choice"
- onClick={[Function]}
+ onClick={[MockFunction]}
>
<OnboardingProjectIcon />
<span>
>
<ResetButtonLink
className="spacer-bottom"
- onClick={[Function]}
+ onClick={[MockFunction]}
>
not_now
</ResetButtonLink>
--- /dev/null
+/*
+ * 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 Checkbox from '../../../components/controls/Checkbox';
+import { AlmRepository, IdentityProvider } from '../../../app/types';
+import { getBaseUrl } from '../../../helpers/urls';
+import { translate } from '../../../helpers/l10n';
+import CheckIcon from '../../../components/icons-components/CheckIcon';
+
+interface Props {
+ identityProvider: IdentityProvider;
+ repository: AlmRepository;
+ selected: boolean;
+ toggleRepository: (repository: AlmRepository) => void;
+}
+
+export default class AlmRepositoryItem extends React.PureComponent<Props> {
+ handleChange = () => {
+ this.props.toggleRepository(this.props.repository);
+ };
+
+ render() {
+ const { identityProvider, repository, selected } = this.props;
+ const alreadyImported = Boolean(repository.linkedProjectKey);
+ return (
+ <Checkbox
+ checked={selected || alreadyImported}
+ disabled={alreadyImported}
+ onCheck={this.handleChange}>
+ <img
+ alt={identityProvider.name}
+ className="spacer-left"
+ height={14}
+ src={getBaseUrl() + identityProvider.iconPath}
+ style={{ filter: alreadyImported ? 'invert(50%)' : 'invert(100%)' }}
+ width={14}
+ />
+ <span className="spacer-left">{this.props.repository.label}</span>
+ {alreadyImported && (
+ <span className="big-spacer-left">
+ <CheckIcon className="little-spacer-right" />
+ {translate('onboarding.create_project.already_imported')}
+ </span>
+ )}
+ </Checkbox>
+ );
+ }
+}
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
import * as React from 'react';
+import AlmRepositoryItem from './AlmRepositoryItem';
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';
+import { getRepositories, provisionProject } from '../../../api/alm-integration';
+import { IdentityProvider, LoggedInUser, AlmRepository } from '../../../app/types';
+import { ProjectBase } from '../../../api/components';
+import { SubmitButton } from '../../../components/ui/buttons';
+import { translateWithParameters, translate } from '../../../helpers/l10n';
interface Props {
currentUser: LoggedInUser;
+ onProjectCreate: (project: ProjectBase[]) => void;
}
interface State {
installationUrl?: string;
installed?: boolean;
loading: boolean;
+ repositories: AlmRepository[];
+ selectedRepositories: { [key: string]: AlmRepository | undefined };
+ submitting: boolean;
}
export default class AutoProjectCreate extends React.PureComponent<Props, State> {
mounted = false;
- state: State = { identityProviders: [], loading: true };
+ state: State = {
+ identityProviders: [],
+ loading: true,
+ repositories: [],
+ selectedRepositories: {},
+ submitting: false
+ };
componentDidMount() {
this.mounted = true;
};
fetchRepositories = () => {
- return getRepositories().then(({ installation }) => {
+ return getRepositories().then(({ almIntegration, repositories }) => {
if (this.mounted) {
- this.setState({
- installationUrl: installation.installationUrl,
- installed: installation.enabled
- });
+ this.setState({ ...almIntegration, repositories });
}
});
};
+ handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+ event.preventDefault();
+
+ if (this.isValid()) {
+ const { selectedRepositories } = this.state;
+ this.setState({ submitting: true });
+ provisionProject({
+ repositories: Object.keys(selectedRepositories).filter(key =>
+ Boolean(selectedRepositories[key])
+ )
+ }).then(
+ ({ project }) => this.props.onProjectCreate([project]),
+ () => {
+ if (this.mounted) {
+ this.setState({ submitting: false });
+ this.reloadRepositories();
+ }
+ }
+ );
+ }
+ };
+
+ isValid = () => {
+ return this.state.repositories.some(repo =>
+ Boolean(this.state.selectedRepositories[repo.installationKey])
+ );
+ };
+
+ reloadRepositories = () => {
+ this.setState({ loading: true });
+ this.fetchRepositories().then(this.stopLoading, this.stopLoading);
+ };
+
stopLoading = () => {
if (this.mounted) {
this.setState({ loading: false });
}
};
+ toggleRepository = (repository: AlmRepository) => {
+ this.setState(({ selectedRepositories }) => ({
+ selectedRepositories: {
+ ...selectedRepositories,
+ [repository.installationKey]: selectedRepositories[repository.installationKey]
+ ? undefined
+ : repository
+ }
+ }));
+ };
+
render() {
if (this.state.loading) {
return <DeferredSpinner />;
return null;
}
+ const { selectedRepositories, submitting } = this.state;
+
return (
<>
<p className="alert alert-info width-60 big-spacer-bottom">
)}
</p>
{this.state.installed ? (
- 'Repositories list'
+ <form onSubmit={this.handleFormSubmit}>
+ <ul>
+ {this.state.repositories.map(repo => (
+ <li className="big-spacer-bottom" key={repo.installationKey}>
+ <AlmRepositoryItem
+ identityProvider={identityProvider}
+ repository={repo}
+ selected={Boolean(selectedRepositories[repo.installationKey])}
+ toggleRepository={this.toggleRepository}
+ />
+ </li>
+ ))}
+ </ul>
+ <SubmitButton disabled={!this.isValid() || submitting}>
+ {translate('onboarding.create_project.create_project')}
+ </SubmitButton>
+ <DeferredSpinner className="spacer-left" loading={submitting} />
+ </form>
) : (
<div>
<p className="spacer-bottom">
)}
{activeTab === Tabs.AUTO ? (
- <AutoProjectCreate currentUser={currentUser} />
+ <AutoProjectCreate
+ currentUser={currentUser}
+ onProjectCreate={this.handleProjectCreate}
+ />
) : (
<ManualProjectCreate
currentUser={currentUser}
--- /dev/null
+/*
+ * 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 AlmRepositoryItem from '../AlmRepositoryItem';
+
+const identityProviders = {
+ backgroundColor: 'blue',
+ iconPath: 'icon/path',
+ key: 'foo',
+ name: 'Foo Provider'
+};
+
+const repositories = [
+ {
+ label: 'Cool Project',
+ installationKey: 'github/cool',
+ linkedProjectKey: 'proj_cool',
+ linkedProjectName: 'Proj Cool'
+ },
+ {
+ label: 'Awesome Project',
+ installationKey: 'github/awesome'
+ }
+];
+
+it('should render correctly', () => {
+ expect(getWrapper()).toMatchSnapshot();
+});
+
+it('should render selected', () => {
+ expect(getWrapper({ selected: true })).toMatchSnapshot();
+});
+
+it('should render disabled', () => {
+ expect(getWrapper({ repository: repositories[0] })).toMatchSnapshot();
+});
+
+function getWrapper(props = {}) {
+ return shallow(
+ <AlmRepositoryItem
+ identityProvider={identityProviders}
+ repository={repositories[1]}
+ selected={false}
+ toggleRepository={jest.fn()}
+ {...props}
+ />
+ );
+}
jest.mock('../../../../api/alm-integration', () => ({
getRepositories: jest.fn().mockResolvedValue({
- installation: {
+ almIntegration: {
installationUrl: 'https://alm.foo.com/install',
- enabled: false
- }
- })
+ installed: false
+ },
+ repositories: []
+ }),
+ provisionProject: jest.fn().mockResolvedValue({ projects: [] })
}));
const user: LoggedInUser = { isLoggedIn: true, login: 'foo', name: 'Foo', externalProvider: 'foo' };
+const repositories = [
+ {
+ label: 'Cool Project',
+ installationKey: 'github/cool',
+ linkedProjectKey: 'proj_cool',
+ linkedProjectName: 'Proj Cool'
+ },
+ {
+ label: 'Awesome Project',
+ installationKey: 'github/awesome'
+ }
+];
beforeEach(() => {
(getIdentityProviders as jest.Mock<any>).mockClear();
expect(wrapper).toMatchSnapshot();
});
+it('should display the list of repositories', async () => {
+ (getRepositories as jest.Mock<any>).mockResolvedValue({
+ almIntegration: {
+ installationUrl: 'https://alm.foo.com/install',
+ installed: true
+ },
+ repositories
+ });
+ const wrapper = getWrapper();
+ await waitAndUpdate(wrapper);
+ expect(wrapper).toMatchSnapshot();
+});
+
function getWrapper(props = {}) {
- return shallow(<AutoProjectCreate currentUser={user} {...props} />);
+ return shallow(<AutoProjectCreate currentUser={user} onProjectCreate={jest.fn()} {...props} />);
}
--- /dev/null
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly 1`] = `
+<Checkbox
+ checked={false}
+ disabled={false}
+ onCheck={[Function]}
+ thirdState={false}
+>
+ <img
+ alt="Foo Provider"
+ className="spacer-left"
+ height={14}
+ src="icon/path"
+ style={
+ Object {
+ "filter": "invert(100%)",
+ }
+ }
+ width={14}
+ />
+ <span
+ className="spacer-left"
+ >
+ Awesome Project
+ </span>
+</Checkbox>
+`;
+
+exports[`should render disabled 1`] = `
+<Checkbox
+ checked={true}
+ disabled={true}
+ onCheck={[Function]}
+ thirdState={false}
+>
+ <img
+ alt="Foo Provider"
+ className="spacer-left"
+ height={14}
+ src="icon/path"
+ style={
+ Object {
+ "filter": "invert(50%)",
+ }
+ }
+ width={14}
+ />
+ <span
+ className="spacer-left"
+ >
+ Cool Project
+ </span>
+ <span
+ className="big-spacer-left"
+ >
+ <CheckIcon
+ className="little-spacer-right"
+ />
+ onboarding.create_project.already_imported
+ </span>
+</Checkbox>
+`;
+
+exports[`should render selected 1`] = `
+<Checkbox
+ checked={true}
+ disabled={false}
+ onCheck={[Function]}
+ thirdState={false}
+>
+ <img
+ alt="Foo Provider"
+ className="spacer-left"
+ height={14}
+ src="icon/path"
+ style={
+ Object {
+ "filter": "invert(100%)",
+ }
+ }
+ width={14}
+ />
+ <span
+ className="spacer-left"
+ >
+ Awesome Project
+ </span>
+</Checkbox>
+`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
+exports[`should display the list of repositories 1`] = `
+<React.Fragment>
+ <p
+ className="alert alert-info width-60 big-spacer-bottom"
+ >
+ onboarding.create_project.beta_feature_x.Foo Provider
+ </p>
+ <form
+ onSubmit={[Function]}
+ >
+ <ul>
+ <li
+ className="big-spacer-bottom"
+ key="github/cool"
+ >
+ <AlmRepositoryItem
+ identityProvider={
+ Object {
+ "backgroundColor": "blue",
+ "iconPath": "icon/path",
+ "key": "foo",
+ "name": "Foo Provider",
+ }
+ }
+ repository={
+ Object {
+ "installationKey": "github/cool",
+ "label": "Cool Project",
+ "linkedProjectKey": "proj_cool",
+ "linkedProjectName": "Proj Cool",
+ }
+ }
+ selected={false}
+ toggleRepository={[Function]}
+ />
+ </li>
+ <li
+ className="big-spacer-bottom"
+ key="github/awesome"
+ >
+ <AlmRepositoryItem
+ identityProvider={
+ Object {
+ "backgroundColor": "blue",
+ "iconPath": "icon/path",
+ "key": "foo",
+ "name": "Foo Provider",
+ }
+ }
+ repository={
+ Object {
+ "installationKey": "github/awesome",
+ "label": "Awesome Project",
+ }
+ }
+ selected={false}
+ toggleRepository={[Function]}
+ />
+ </li>
+ </ul>
+ <SubmitButton
+ disabled={true}
+ >
+ onboarding.create_project.create_project
+ </SubmitButton>
+ <DeferredSpinner
+ className="spacer-left"
+ loading={false}
+ timeout={100}
+ />
+ </form>
+</React.Fragment>
+`;
+
exports[`should display the provider app install button 1`] = `
<DeferredSpinner
timeout={100}
"name": "Foo",
}
}
+ onProjectCreate={[Function]}
/>
</div>
</React.Fragment>
component: lazyLoad(
() =>
isSonarCloud()
- ? import('../../apps/tutorials/createProjectOnboarding/CreateProjectOnboarding')
- : import('../../apps/tutorials/projectOnboarding/ProjectOnboardingPage')
+ ? import('./createProjectOnboarding/CreateProjectOnboarding')
+ : import('./projectOnboarding/ProjectOnboardingPage')
)
}
}
onboarding.project.header.description=Want to quickly analyze a first project? Follow these {0} easy steps.
onboarding.create_project.header=Create project(s)
+onboarding.create_project.already_imported=Repository already imported
onboarding.create_project.beta_feature_x=This feature is being beta tested. We offer to create projects from your {0} repositories only for public personal projects on your personal SonarCloud organization. For other kind of projects please create them maually.
onboarding.create_project.create_manually=Create manually
onboarding.create_project.create_new_org=I want to create another organization