@@ -75,6 +75,7 @@ export function createProject(data: { | |||
name: string; | |||
project: string; | |||
organization?: string; | |||
visibility?: T.Visibility; | |||
}): Promise<{ project: ProjectBase }> { | |||
return postJSON('/api/projects/create', data).catch(throwGlobalError); | |||
} |
@@ -30,6 +30,17 @@ | |||
transition: all 0.2s ease; | |||
} | |||
.card-plan.animated { | |||
height: 0; | |||
border-width: 0; | |||
overflow: hidden; | |||
} | |||
.card-plan.animated.open { | |||
height: 210px; | |||
border-width: 1px; | |||
} | |||
.card-plan.highlight { | |||
box-shadow: var(--defaultShadow); | |||
} |
@@ -148,6 +148,7 @@ export class CreateProjectPage extends React.PureComponent<Props & WithRouterPro | |||
{showManualTab || !almApplication ? ( | |||
<ManualProjectCreate | |||
currentUser={currentUser} | |||
fetchMyOrganizations={this.props.fetchMyOrganizations} | |||
onProjectCreate={this.handleProjectCreate} | |||
organization={state.organization} | |||
userOrganizations={userOrganizations.filter( |
@@ -0,0 +1,43 @@ | |||
/* | |||
* 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. | |||
*/ | |||
.manual-project-create { | |||
max-width: 650px; | |||
} | |||
.manual-project-create .visibility-select-option { | |||
margin-left: 0 !important; | |||
margin-bottom: var(--gridSize); | |||
display: flex; | |||
align-items: center; | |||
font-size: var(--mediumFontSize); | |||
} | |||
.manual-project-create .visibility-details { | |||
display: block; | |||
margin: var(--gridSize) 0; | |||
} | |||
.manual-project-create .visibility-select-wrapper { | |||
padding: var(--gridSize) 0 calc(2 * var(--gridSize)) 0; | |||
} | |||
.manual-project-create .button { | |||
margin-top: var(--gridSize); | |||
} |
@@ -18,6 +18,7 @@ | |||
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. | |||
*/ | |||
import * as React from 'react'; | |||
import * as classNames from 'classnames'; | |||
import OrganizationInput from './OrganizationInput'; | |||
import DeferredSpinner from '../../../components/common/DeferredSpinner'; | |||
import { SubmitButton } from '../../../components/ui/buttons'; | |||
@@ -25,9 +26,14 @@ import { createProject } from '../../../api/components'; | |||
import { translate } from '../../../helpers/l10n'; | |||
import ProjectKeyInput from '../components/ProjectKeyInput'; | |||
import ProjectNameInput from '../components/ProjectNameInput'; | |||
import VisibilitySelector from '../../../components/common/VisibilitySelector'; | |||
import { isSonarCloud } from '../../../helpers/system'; | |||
import UpgradeOrganizationBox from '../components/UpgradeOrganizationBox'; | |||
import './ManualProjectCreate.css'; | |||
interface Props { | |||
currentUser: T.LoggedInUser; | |||
fetchMyOrganizations: () => Promise<void>; | |||
onProjectCreate: (projectKeys: string[]) => void; | |||
organization?: string; | |||
userOrganizations: T.Organization[]; | |||
@@ -36,11 +42,13 @@ interface Props { | |||
interface State { | |||
projectName?: string; | |||
projectKey?: string; | |||
selectedOrganization: string; | |||
selectedOrganization?: T.Organization; | |||
selectedVisibility?: T.Visibility; | |||
submitting: boolean; | |||
} | |||
type ValidState = State & Required<Pick<State, 'projectName' | 'projectKey'>>; | |||
type ValidState = State & | |||
Required<Pick<State, 'projectName' | 'projectKey' | 'selectedOrganization'>>; | |||
export default class ManualProjectCreate extends React.PureComponent<Props, State> { | |||
mounted = false; | |||
@@ -61,19 +69,27 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat | |||
this.mounted = false; | |||
} | |||
canChoosePrivate = (selectedOrganization: T.Organization | undefined) => { | |||
return Boolean(selectedOrganization && selectedOrganization.subscription === 'PAID'); | |||
}; | |||
canSubmit(state: State): state is ValidState { | |||
return Boolean(state.projectKey && state.projectName && state.selectedOrganization); | |||
} | |||
getInitialSelectedOrganization(props: Props) { | |||
getInitialSelectedOrganization = (props: Props) => { | |||
if (props.organization) { | |||
return props.organization; | |||
return this.getOrganization(props.organization); | |||
} else if (props.userOrganizations.length === 1) { | |||
return props.userOrganizations[0].key; | |||
return props.userOrganizations[0]; | |||
} else { | |||
return ''; | |||
return undefined; | |||
} | |||
} | |||
}; | |||
getOrganization = (organizationKey: string) => { | |||
return this.props.userOrganizations.find(({ key }: T.Organization) => key === organizationKey); | |||
}; | |||
handleFormSubmit = (event: React.FormEvent<HTMLFormElement>) => { | |||
event.preventDefault(); | |||
@@ -83,7 +99,8 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat | |||
createProject({ | |||
project: state.projectKey, | |||
name: state.projectName, | |||
organization: state.selectedOrganization | |||
organization: state.selectedOrganization.key, | |||
visibility: this.state.selectedVisibility | |||
}).then( | |||
({ project }) => this.props.onProjectCreate([project.key]), | |||
() => { | |||
@@ -96,7 +113,34 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat | |||
}; | |||
handleOrganizationSelect = ({ key }: T.Organization) => { | |||
this.setState({ selectedOrganization: key }); | |||
const selectedOrganization = this.getOrganization(key); | |||
let { selectedVisibility } = this.state; | |||
if (selectedVisibility === undefined) { | |||
selectedVisibility = this.canChoosePrivate(selectedOrganization) ? 'private' : 'public'; | |||
} | |||
this.setState({ | |||
selectedOrganization, | |||
selectedVisibility | |||
}); | |||
}; | |||
handleOrganizationUpgrade = () => { | |||
this.props.fetchMyOrganizations().then( | |||
() => { | |||
this.setState(prevState => { | |||
if (prevState.selectedOrganization) { | |||
const selectedOrganization = this.getOrganization(prevState.selectedOrganization.key); | |||
return { | |||
selectedOrganization | |||
}; | |||
} | |||
return null; | |||
}); | |||
}, | |||
() => {} | |||
); | |||
}; | |||
handleProjectNameChange = (projectName?: string) => { | |||
@@ -107,32 +151,65 @@ export default class ManualProjectCreate extends React.PureComponent<Props, Stat | |||
this.setState({ projectKey }); | |||
}; | |||
handleVisibilityChange = (selectedVisibility: T.Visibility) => { | |||
this.setState({ selectedVisibility }); | |||
}; | |||
render() { | |||
const { submitting } = this.state; | |||
const { selectedOrganization, submitting } = this.state; | |||
const canChoosePrivate = this.canChoosePrivate(selectedOrganization); | |||
return ( | |||
<> | |||
<form onSubmit={this.handleFormSubmit}> | |||
<OrganizationInput | |||
onChange={this.handleOrganizationSelect} | |||
organization={this.state.selectedOrganization} | |||
organizations={this.props.userOrganizations} | |||
/> | |||
<ProjectKeyInput | |||
className="form-field" | |||
initialValue={this.state.projectKey} | |||
onChange={this.handleProjectKeyChange} | |||
/> | |||
<ProjectNameInput | |||
className="form-field" | |||
initialValue={this.state.projectName} | |||
onChange={this.handleProjectNameChange} | |||
/> | |||
<SubmitButton disabled={!this.canSubmit(this.state) || submitting}> | |||
{translate('setup')} | |||
</SubmitButton> | |||
<DeferredSpinner className="spacer-left" loading={submitting} /> | |||
</form> | |||
</> | |||
<div className="create-project"> | |||
<div className="flex-1 huge-spacer-right"> | |||
<form className="manual-project-create" onSubmit={this.handleFormSubmit}> | |||
<OrganizationInput | |||
onChange={this.handleOrganizationSelect} | |||
organization={selectedOrganization ? selectedOrganization.key : ''} | |||
organizations={this.props.userOrganizations} | |||
/> | |||
<ProjectKeyInput | |||
className="form-field" | |||
initialValue={this.state.projectKey} | |||
onChange={this.handleProjectKeyChange} | |||
/> | |||
<ProjectNameInput | |||
className="form-field" | |||
initialValue={this.state.projectName} | |||
onChange={this.handleProjectNameChange} | |||
/> | |||
{isSonarCloud() && | |||
selectedOrganization && ( | |||
<div | |||
className={classNames('visibility-select-wrapper', { | |||
open: Boolean(this.state.selectedOrganization) | |||
})}> | |||
<VisibilitySelector | |||
canTurnToPrivate={canChoosePrivate} | |||
onChange={this.handleVisibilityChange} | |||
showDetails={true} | |||
visibility={canChoosePrivate ? this.state.selectedVisibility : 'public'} | |||
/> | |||
</div> | |||
)} | |||
<SubmitButton disabled={!this.canSubmit(this.state) || submitting}> | |||
{translate('setup')} | |||
</SubmitButton> | |||
<DeferredSpinner className="spacer-left" loading={submitting} /> | |||
</form> | |||
</div> | |||
{isSonarCloud() && | |||
selectedOrganization && ( | |||
<div className="create-project-side-sticky"> | |||
<UpgradeOrganizationBox | |||
className={classNames('animated', { open: !canChoosePrivate })} | |||
onOrganizationUpgrade={this.handleOrganizationUpgrade} | |||
organization={selectedOrganization} | |||
/> | |||
</div> | |||
)} | |||
</div> | |||
); | |||
} | |||
} |
@@ -35,7 +35,7 @@ it('should render correctly', () => { | |||
expect(getWrapper()).toMatchSnapshot(); | |||
}); | |||
it('should correctly create a project', async () => { | |||
it('should correctly create a public project', async () => { | |||
const onProjectCreate = jest.fn(); | |||
const wrapper = getWrapper({ onProjectCreate }); | |||
wrapper.find('withRouter(OrganizationInput)').prop<Function>('onChange')({ key: 'foo' }); | |||
@@ -47,7 +47,32 @@ it('should correctly create a project', async () => { | |||
expect(wrapper.find('SubmitButton').prop('disabled')).toBe(false); | |||
submit(wrapper.find('form')); | |||
expect(createProject).toBeCalledWith({ project: 'bar', name: 'Bar', organization: 'foo' }); | |||
expect(createProject).toBeCalledWith({ | |||
project: 'bar', | |||
name: 'Bar', | |||
organization: 'foo', | |||
visibility: 'public' | |||
}); | |||
await waitAndUpdate(wrapper); | |||
expect(onProjectCreate).toBeCalledWith(['bar']); | |||
}); | |||
it('should correctly create a private project', async () => { | |||
const onProjectCreate = jest.fn(); | |||
const wrapper = getWrapper({ onProjectCreate }); | |||
wrapper.find('withRouter(OrganizationInput)').prop<Function>('onChange')({ key: 'bar' }); | |||
change(wrapper.find('ProjectKeyInput'), 'bar'); | |||
change(wrapper.find('ProjectNameInput'), 'Bar'); | |||
submit(wrapper.find('form')); | |||
expect(createProject).toBeCalledWith({ | |||
project: 'bar', | |||
name: 'Bar', | |||
organization: 'bar', | |||
visibility: 'private' | |||
}); | |||
await waitAndUpdate(wrapper); | |||
expect(onProjectCreate).toBeCalledWith(['bar']); | |||
@@ -57,8 +82,12 @@ function getWrapper(props = {}) { | |||
return shallow( | |||
<ManualProjectCreate | |||
currentUser={{ groups: [], isLoggedIn: true, login: 'foo', name: 'Foo', scmAccounts: [] }} | |||
fetchMyOrganizations={jest.fn()} | |||
onProjectCreate={jest.fn()} | |||
userOrganizations={[{ key: 'foo', name: 'Foo' }, { key: 'bar', name: 'Bar' }]} | |||
userOrganizations={[ | |||
{ key: 'foo', name: 'Foo' }, | |||
{ key: 'bar', name: 'Bar', subscription: 'PAID' } | |||
]} | |||
{...props} | |||
/> | |||
); |
@@ -1,44 +1,52 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`should render correctly 1`] = ` | |||
<Fragment> | |||
<form | |||
onSubmit={[Function]} | |||
<div | |||
className="create-project" | |||
> | |||
<div | |||
className="flex-1 huge-spacer-right" | |||
> | |||
<withRouter(OrganizationInput) | |||
onChange={[Function]} | |||
organization="" | |||
organizations={ | |||
Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
}, | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
}, | |||
] | |||
} | |||
/> | |||
<ProjectKeyInput | |||
className="form-field" | |||
onChange={[Function]} | |||
/> | |||
<ProjectNameInput | |||
className="form-field" | |||
onChange={[Function]} | |||
/> | |||
<SubmitButton | |||
disabled={true} | |||
<form | |||
className="manual-project-create" | |||
onSubmit={[Function]} | |||
> | |||
setup | |||
</SubmitButton> | |||
<DeferredSpinner | |||
className="spacer-left" | |||
loading={false} | |||
timeout={100} | |||
/> | |||
</form> | |||
</Fragment> | |||
<withRouter(OrganizationInput) | |||
onChange={[Function]} | |||
organization="" | |||
organizations={ | |||
Array [ | |||
Object { | |||
"key": "foo", | |||
"name": "Foo", | |||
}, | |||
Object { | |||
"key": "bar", | |||
"name": "Bar", | |||
"subscription": "PAID", | |||
}, | |||
] | |||
} | |||
/> | |||
<ProjectKeyInput | |||
className="form-field" | |||
onChange={[Function]} | |||
/> | |||
<ProjectNameInput | |||
className="form-field" | |||
onChange={[Function]} | |||
/> | |||
<SubmitButton | |||
disabled={true} | |||
> | |||
setup | |||
</SubmitButton> | |||
<DeferredSpinner | |||
className="spacer-left" | |||
loading={false} | |||
timeout={100} | |||
/> | |||
</form> | |||
</div> | |||
</div> | |||
`; |
@@ -25,6 +25,7 @@ interface Props { | |||
canTurnToPrivate?: boolean; | |||
className?: string; | |||
onChange: (visibility: T.Visibility) => void; | |||
showDetails?: boolean; | |||
visibility?: T.Visibility; | |||
} | |||
@@ -43,9 +44,9 @@ export default class VisibilitySelector extends React.PureComponent<Props> { | |||
render() { | |||
return ( | |||
<div className={this.props.className}> | |||
<div className={classNames('visibility-select', this.props.className)}> | |||
<a | |||
className="link-base-color link-no-underline" | |||
className="link-base-color link-no-underline visibility-select-option" | |||
href="#" | |||
id="visibility-public" | |||
onClick={this.handlePublicClick}> | |||
@@ -56,29 +57,50 @@ export default class VisibilitySelector extends React.PureComponent<Props> { | |||
/> | |||
<span className="spacer-left">{translate('visibility.public')}</span> | |||
</a> | |||
{this.props.showDetails && ( | |||
<span className="visibility-details note"> | |||
{translate('visibility.public.description.long')} | |||
</span> | |||
)} | |||
{this.props.canTurnToPrivate ? ( | |||
<a | |||
className="link-base-color link-no-underline huge-spacer-left" | |||
href="#" | |||
id="visibility-private" | |||
onClick={this.handlePrivateClick}> | |||
<i | |||
className={classNames('icon-radio', { | |||
'is-checked': this.props.visibility === 'private' | |||
})} | |||
/> | |||
<span className="spacer-left">{translate('visibility.private')}</span> | |||
</a> | |||
<> | |||
<a | |||
className="link-base-color link-no-underline huge-spacer-left visibility-select-option" | |||
href="#" | |||
id="visibility-private" | |||
onClick={this.handlePrivateClick}> | |||
<i | |||
className={classNames('icon-radio', { | |||
'is-checked': this.props.visibility === 'private' | |||
})} | |||
/> | |||
<span className="spacer-left">{translate('visibility.private')}</span> | |||
</a> | |||
{this.props.showDetails && ( | |||
<span className="visibility-details note"> | |||
{translate('visibility.private.description.long')} | |||
</span> | |||
)} | |||
</> | |||
) : ( | |||
<span className="huge-spacer-left text-muted cursor-not-allowed" id="visibility-private"> | |||
<i | |||
className={classNames('icon-radio', { | |||
'is-checked': this.props.visibility === 'private' | |||
})} | |||
/> | |||
<span className="spacer-left">{translate('visibility.private')}</span> | |||
</span> | |||
<> | |||
<span | |||
className="huge-spacer-left text-muted cursor-not-allowed visibility-select-option" | |||
id="visibility-private"> | |||
<i | |||
className={classNames('icon-radio', { | |||
'is-checked': this.props.visibility === 'private' | |||
})} | |||
/> | |||
<span className="spacer-left">{translate('visibility.private')}</span> | |||
</span> | |||
{this.props.showDetails && ( | |||
<span className="visibility-details note"> | |||
{translate('visibility.private.description.long')} | |||
</span> | |||
)} | |||
</> | |||
)} | |||
</div> | |||
); |
@@ -1,9 +1,11 @@ | |||
// Jest Snapshot v1, https://goo.gl/fbAQLP | |||
exports[`changes visibility 1`] = ` | |||
<div> | |||
<div | |||
className="visibility-select" | |||
> | |||
<a | |||
className="link-base-color link-no-underline" | |||
className="link-base-color link-no-underline visibility-select-option" | |||
href="#" | |||
id="visibility-public" | |||
onClick={[Function]} | |||
@@ -18,7 +20,7 @@ exports[`changes visibility 1`] = ` | |||
</span> | |||
</a> | |||
<a | |||
className="link-base-color link-no-underline huge-spacer-left" | |||
className="link-base-color link-no-underline huge-spacer-left visibility-select-option" | |||
href="#" | |||
id="visibility-private" | |||
onClick={[Function]} | |||
@@ -36,9 +38,11 @@ exports[`changes visibility 1`] = ` | |||
`; | |||
exports[`changes visibility 2`] = ` | |||
<div> | |||
<div | |||
className="visibility-select" | |||
> | |||
<a | |||
className="link-base-color link-no-underline" | |||
className="link-base-color link-no-underline visibility-select-option" | |||
href="#" | |||
id="visibility-public" | |||
onClick={[Function]} | |||
@@ -53,7 +57,7 @@ exports[`changes visibility 2`] = ` | |||
</span> | |||
</a> | |||
<a | |||
className="link-base-color link-no-underline huge-spacer-left" | |||
className="link-base-color link-no-underline huge-spacer-left visibility-select-option" | |||
href="#" | |||
id="visibility-private" | |||
onClick={[Function]} | |||
@@ -71,9 +75,11 @@ exports[`changes visibility 2`] = ` | |||
`; | |||
exports[`renders disabled 1`] = ` | |||
<div> | |||
<div | |||
className="visibility-select" | |||
> | |||
<a | |||
className="link-base-color link-no-underline" | |||
className="link-base-color link-no-underline visibility-select-option" | |||
href="#" | |||
id="visibility-public" | |||
onClick={[Function]} | |||
@@ -88,7 +94,7 @@ exports[`renders disabled 1`] = ` | |||
</span> | |||
</a> | |||
<span | |||
className="huge-spacer-left text-muted cursor-not-allowed" | |||
className="huge-spacer-left text-muted cursor-not-allowed visibility-select-option" | |||
id="visibility-private" | |||
> | |||
<i |
@@ -486,11 +486,13 @@ visibility.public.description.TRK=This project is public. Anyone can browse and | |||
visibility.public.description.VW=This portfolio is public. Anyone can browse it. | |||
visibility.public.description.APP=This application is public. Anyone can browse it. | |||
visibility.public.description.short=Anyone can browse and see the source code. | |||
visibility.public.description.long=Anyone will be able to browse your source code and see the result of your analysis. | |||
visibility.private=Private | |||
visibility.private.description.TRK=This project is private. Only authorized users can browse and see the source code. | |||
visibility.private.description.VW=This portfolio is private. Only authorized users can browse it. | |||
visibility.private.description.APP=This application is private. Only authorized users can browse it. | |||
visibility.private.description.short=Only authorized users can browse and see the source code. | |||
visibility.private.description.long=Only members of the organization will be able to browse your source code and see the result of your analysis. | |||
#------------------------------------------------------------------------------ | |||
@@ -2779,6 +2781,7 @@ onboarding.create_project.repository_imported=Already imported: {link} | |||
onboarding.create_project.see_project=See the project | |||
onboarding.create_project.select_repositories=Select repositories | |||
onboarding.create_project.subscribe_to_import_private_repositories=You need to subscribe your organization to a paid plan to import private projects | |||
onboarding.create_project.encourage_to_subscribe=Subscribe your organization to our paid plan to get unlimited private projects. | |||
onboarding.create_project.subscribtion_success_x={0} has been successfully upgraded to paid plan. You can now import and analyze private projects. | |||
onboarding.create_organization.page.header=Create Organization |