Browse Source

SONAR-9554 Make "Analyze a project" and "Create an org" more discoverable

tags/7.0-RC1
Stas Vilchik 6 years ago
parent
commit
7260d343ea

+ 9
- 2
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.css View File

@@ -27,13 +27,19 @@
border: none !important;
}

.navbar-help {
.navbar-help,
.navbar-plus {
display: inline-block;
height: var(--globalNavHeight);
padding: calc(var(--globalNavHeight) - var(--globalNavContentHeight)) 12px !important;
border-bottom: none !important;
color: #fff !important;
}

.navbar-plus {
margin-right: calc(-1 * var(--gridSize));
}

.global-navbar-menu {
display: flex;
align-items: center;
@@ -56,7 +62,8 @@
.navbar-brand:focus,
.global-navbar-menu > li > a.active,
.global-navbar-menu > li > a:hover,
.global-navbar-menu > li > a:focus {
.global-navbar-menu > li > a:focus,
.global-navbar-menu > .dropdown.open > a {
background-color: #020202;
}


+ 6
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js View File

@@ -24,9 +24,11 @@ import GlobalNavBranding from './GlobalNavBranding';
import GlobalNavMenu from './GlobalNavMenu';
import GlobalNavExplore from './GlobalNavExplore';
import GlobalNavUserContainer from './GlobalNavUserContainer';
import GlobalNavPlus from './GlobalNavPlus';
import Search from '../../search/Search';
import GlobalHelp from '../../help/GlobalHelp';
import * as theme from '../../../theme';
import { isLoggedIn } from '../../../types';
import NavBar from '../../../../components/nav/NavBar';
import Tooltip from '../../../../components/controls/Tooltip';
import HelpIcon from '../../../../components/icons-components/HelpIcon';
@@ -130,6 +132,10 @@ class GlobalNav extends React.PureComponent {
</a>
</li>
<Search appState={this.props.appState} currentUser={this.props.currentUser} />
{isLoggedIn(this.props.currentUser) &&
this.props.onSonarCloud && (
<GlobalNavPlus openOnboardingTutorial={this.openOnboardingTutorial} />
)}
<GlobalNavUserContainer {...this.props} />
</ul>


+ 101
- 0
server/sonar-web/src/main/js/app/components/nav/global/GlobalNavPlus.tsx View File

@@ -0,0 +1,101 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:contact 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 CreateOrganizationForm from '../../../../apps/account/organizations/CreateOrganizationForm';
import PlusIcon from '../../../../components/icons-components/PlusIcon';
import Dropdown from '../../../../components/controls/Dropdown';
import { translate } from '../../../../helpers/l10n';

interface Props {
openOnboardingTutorial: () => void;
}

interface State {
createOrganization: boolean;
}

export default class GlobalNavPlus extends React.PureComponent<Props, State> {
static contextTypes = {
router: PropTypes.object
};

constructor(props: Props) {
super(props);
this.state = { createOrganization: false };
}

handleNewProjectClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.props.openOnboardingTutorial();
};

openCreateOrganizationForm = () => this.setState({ createOrganization: true });

closeCreateOrganizationForm = () => this.setState({ createOrganization: false });

handleNewOrganizationClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
event.preventDefault();
event.currentTarget.blur();
this.openCreateOrganizationForm();
};

handleCreateOrganization = ({ key }: { key: string }) => {
this.closeCreateOrganizationForm();
this.context.router.push(`/organizations/${key}`);
};

render() {
return (
<Dropdown>
{({ onToggleClick, open }) => (
<li className={classNames('dropdown', { open })}>
<a className="navbar-plus" href="#" onClick={onToggleClick}>
<PlusIcon />
</a>
<ul className="dropdown-menu dropdown-menu-right">
<li>
<a className="js-new-project" href="#" onClick={this.handleNewProjectClick}>
{translate('my_account.analyze_new_project')}
</a>
</li>
<li className="divider" />
<li>
<a
className="js-new-organization"
href="#"
onClick={this.handleNewOrganizationClick}>
{translate('my_account.create_new_organization')}
</a>
</li>
</ul>
{this.state.createOrganization && (
<CreateOrganizationForm
onClose={this.closeCreateOrganizationForm}
onCreate={this.handleCreateOrganization}
/>
)}
</li>
)}
</Dropdown>
);
}
}

+ 38
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavPlus-test.tsx View File

@@ -0,0 +1,38 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:contact 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 GlobalNavPlus from '../GlobalNavPlus';
import { click } from '../../../../../helpers/testUtils';

it('render', () => {
const wrapper = shallow(<GlobalNavPlus openOnboardingTutorial={jest.fn()} />);
expect(wrapper.is('Dropdown')).toBe(true);
expect(wrapper.find('Dropdown').shallow()).toMatchSnapshot();
});

it('opens onboarding', () => {
const openOnboardingTutorial = jest.fn();
const wrapper = shallow(<GlobalNavPlus openOnboardingTutorial={openOnboardingTutorial} />)
.find('Dropdown')
.shallow();
click(wrapper.find('.js-new-project'));
expect(openOnboardingTutorial).toBeCalled();
});

+ 40
- 0
server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavPlus-test.tsx.snap View File

@@ -0,0 +1,40 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`render 1`] = `
<li
className="dropdown"
>
<a
className="navbar-plus"
href="#"
onClick={[Function]}
>
<PlusIcon />
</a>
<ul
className="dropdown-menu dropdown-menu-right"
>
<li>
<a
className="js-new-project"
href="#"
onClick={[Function]}
>
my_account.analyze_new_project
</a>
</li>
<li
className="divider"
/>
<li>
<a
className="js-new-organization"
href="#"
onClick={[Function]}
>
my_account.create_new_organization
</a>
</li>
</ul>
</li>
`;

server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.js → server/sonar-web/src/main/js/apps/account/organizations/CreateOrganizationForm.tsx View File

@@ -17,44 +17,49 @@
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*/
// @flow
import React from 'react';
import * as React from 'react';
import { debounce } from 'lodash';
import { connect } from 'react-redux';
import { withRouter } from 'react-router';
import * as PropTypes from 'prop-types';
import { createOrganization } from '../../organizations/actions';
import { Organization } from '../../../app/types';
import Modal from '../../../components/controls/Modal';
import { translate } from '../../../helpers/l10n';
import { createOrganization } from '../../organizations/actions';

/*::
type State = {
loading: boolean,
avatar: string,
avatarImage: string,
description: string,
key: string,
name: string,
url: string
};
*/

class CreateOrganizationForm extends React.PureComponent {
/*:: mounted: boolean; */
/*:: state: State; */
/*:: props: {
createOrganization: (fields: {}) => Promise<*>,
router: { push: string => void }
interface DispatchProps {
createOrganization: (fields: Partial<Organization>) => Promise<{ key: string }>;
}

interface Props extends DispatchProps {
onClose: () => void;
onCreate: (organization: { key: string }) => void;
}

interface State {
avatar: string;
avatarImage: string;
description: string;
key: string;
loading: boolean;
name: string;
url: string;
}

class CreateOrganizationForm extends React.PureComponent<Props, State> {
mounted: boolean;

static contextTypes = {
router: PropTypes.object
};
*/

constructor(props) {
constructor(props: Props) {
super(props);
this.state = {
loading: false,
avatar: '',
avatarImage: '',
description: '',
key: '',
loading: false,
name: '',
url: ''
};
@@ -69,36 +74,37 @@ class CreateOrganizationForm extends React.PureComponent {
this.mounted = false;
}

closeForm = () => {
this.props.router.push('/account/organizations');
};

stopProcessing = () => {
if (this.mounted) {
this.setState({ loading: false });
}
};

stopProcessingAndClose = () => {
if (this.mounted) {
this.setState({ loading: false });
}
this.closeForm();
};

handleAvatarInputChange = (e /*: Object */) => {
const { value } = e.target;
handleAvatarInputChange = (event: React.SyntheticEvent<HTMLInputElement>) => {
const { value } = event.currentTarget;
this.setState({ avatar: value });
this.changeAvatarImage(value);
};

changeAvatarImage = (value /*: string */) => {
changeAvatarImage = (value: string) => {
this.setState({ avatarImage: value });
};

handleSubmit = (e /*: Object */) => {
e.preventDefault();
const organization /*: Object */ = { name: this.state.name };
handleNameChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
this.setState({ name: event.currentTarget.value });

handleKeyChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
this.setState({ key: event.currentTarget.value });

handleDescriptionChange = (event: React.SyntheticEvent<HTMLTextAreaElement>) =>
this.setState({ description: event.currentTarget.value });

handleUrlChange = (event: React.SyntheticEvent<HTMLInputElement>) =>
this.setState({ url: event.currentTarget.value });

handleSubmit = (event: React.SyntheticEvent<HTMLFormElement>) => {
event.preventDefault();
const organization = { name: this.state.name };
if (this.state.avatar) {
Object.assign(organization, { avatar: this.state.avatar });
}
@@ -112,14 +118,12 @@ class CreateOrganizationForm extends React.PureComponent {
Object.assign(organization, { url: this.state.url });
}
this.setState({ loading: true });
this.props
.createOrganization(organization)
.then(this.stopProcessingAndClose, this.stopProcessing);
this.props.createOrganization(organization).then(this.props.onCreate, this.stopProcessing);
};

render() {
return (
<Modal contentLabel="modal form" onRequestClose={this.closeForm}>
<Modal contentLabel="modal form" onRequestClose={this.props.onClose}>
<header className="modal-head">
<h2>{translate('my_account.create_organization')}</h2>
</header>
@@ -137,11 +141,11 @@ class CreateOrganizationForm extends React.PureComponent {
name="name"
required={true}
type="text"
minLength="2"
maxLength="64"
minLength={2}
maxLength={64}
value={this.state.name}
disabled={this.state.loading}
onChange={e => this.setState({ name: e.target.value })}
onChange={this.handleNameChange}
/>
<div className="modal-field-description">
{translate('organization.name.description')}
@@ -153,11 +157,11 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-key"
name="key"
type="text"
minLength="2"
maxLength="64"
minLength={2}
maxLength={64}
value={this.state.key}
disabled={this.state.loading}
onChange={e => this.setState({ key: e.target.value })}
onChange={this.handleKeyChange}
/>
<div className="modal-field-description">
{translate('organization.key.description')}
@@ -169,7 +173,7 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-avatar"
name="avatar"
type="text"
maxLength="256"
maxLength={256}
value={this.state.avatar}
disabled={this.state.loading}
onChange={this.handleAvatarInputChange}
@@ -192,11 +196,11 @@ class CreateOrganizationForm extends React.PureComponent {
<textarea
id="organization-description"
name="description"
rows="3"
maxLength="256"
rows={3}
maxLength={256}
value={this.state.description}
disabled={this.state.loading}
onChange={e => this.setState({ description: e.target.value })}
onChange={this.handleDescriptionChange}
/>
<div className="modal-field-description">
{translate('organization.description.description')}
@@ -208,10 +212,10 @@ class CreateOrganizationForm extends React.PureComponent {
id="organization-url"
name="url"
type="text"
maxLength="256"
maxLength={256}
value={this.state.url}
disabled={this.state.loading}
onChange={e => this.setState({ url: e.target.value })}
onChange={this.handleUrlChange}
/>
<div className="modal-field-description">
{translate('organization.url.description')}
@@ -225,7 +229,7 @@ class CreateOrganizationForm extends React.PureComponent {
<button disabled={this.state.loading} type="submit">
{translate('create')}
</button>
<button type="reset" className="button-link" onClick={this.closeForm}>
<button className="button-link" onClick={this.props.onClose} type="reset">
{translate('cancel')}
</button>
</div>
@@ -236,8 +240,6 @@ class CreateOrganizationForm extends React.PureComponent {
}
}

const mapStateToProps = null;

const mapDispatchToProps = { createOrganization };
const mapDispatchToProps: DispatchProps = { createOrganization: createOrganization as any };

export default connect(mapStateToProps, mapDispatchToProps)(withRouter(CreateOrganizationForm));
export default connect(null, mapDispatchToProps)(CreateOrganizationForm);

+ 25
- 9
server/sonar-web/src/main/js/apps/account/organizations/UserOrganizations.tsx View File

@@ -20,8 +20,8 @@
import * as React from 'react';
import Helmet from 'react-helmet';
import { connect } from 'react-redux';
import { Link } from 'react-router';
import OrganizationsList from './OrganizationsList';
import CreateOrganizationForm from './CreateOrganizationForm';
import { translate } from '../../../helpers/l10n';
import { fetchIfAnyoneCanCreateOrganizations, fetchMyOrganizations } from './actions';
import { getAppState, getMyOrganizations, getGlobalSettingValue } from '../../../store/rootReducer';
@@ -38,17 +38,16 @@ interface DispatchProps {
fetchMyOrganizations: () => Promise<void>;
}

interface Props extends StateProps, DispatchProps {
children?: React.ReactNode;
}
interface Props extends StateProps, DispatchProps {}

interface State {
createOrganization: boolean;
loading: boolean;
}

class UserOrganizations extends React.PureComponent<Props, State> {
mounted: boolean;
state: State = { loading: true };
state: State = { createOrganization: false, loading: true };

componentDidMount() {
this.mounted = true;
@@ -68,6 +67,20 @@ class UserOrganizations extends React.PureComponent<Props, State> {
}
};

openCreateOrganizationForm = () => this.setState({ createOrganization: true });

closeCreateOrganizationForm = () => this.setState({ createOrganization: false });

handleCreateClick = (event: React.SyntheticEvent<HTMLButtonElement>) => {
event.preventDefault();
event.currentTarget.blur();
this.openCreateOrganizationForm();
};

handleCreate = () => {
this.closeCreateOrganizationForm();
};

render() {
const anyoneCanCreate =
this.props.anyoneCanCreate != null && this.props.anyoneCanCreate.value === 'true';
@@ -82,9 +95,7 @@ class UserOrganizations extends React.PureComponent<Props, State> {
<h2 className="page-title">{translate('my_account.organizations')}</h2>
{canCreateOrganizations && (
<div className="page-actions">
<Link to="/account/organizations/create" className="button">
{translate('create')}
</Link>
<button onClick={this.handleCreateClick}>{translate('create')}</button>
</div>
)}
{this.props.organizations.length > 0 ? (
@@ -104,7 +115,12 @@ class UserOrganizations extends React.PureComponent<Props, State> {
<OrganizationsList organizations={this.props.organizations} />
)}

{this.props.children}
{this.state.createOrganization && (
<CreateOrganizationForm
onClose={this.closeCreateOrganizationForm}
onCreate={this.handleCreate}
/>
)}
</div>
);
}

+ 1
- 9
server/sonar-web/src/main/js/apps/account/routes.ts View File

@@ -52,15 +52,7 @@ const routes = [
path: 'organizations',
getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) {
import('./organizations/UserOrganizations').then(i => callback(null, i.default));
},
childRoutes: [
{
path: 'create',
getComponent(_: RouterState, callback: (err: any, component: RouteComponent) => any) {
import('./organizations/CreateOrganizationForm').then(i => callback(null, i.default));
}
}
]
}
}
]
}

+ 1
- 0
server/sonar-web/src/main/js/apps/organizations/actions.js View File

@@ -72,6 +72,7 @@ export const createOrganization = (fields /*: Object */) => (dispatch /*: Functi
dispatch(
addGlobalSuccessMessage(translateWithParameters('organization.created', organization.name))
);
return organization;
};

return api.createOrganization(fields).then(onFulfilled, onRejected(dispatch));

+ 92
- 0
server/sonar-web/src/main/js/components/controls/Dropdown.tsx View File

@@ -0,0 +1,92 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:contact 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';

interface RenderProps {
closeDropdown: () => void;
onToggleClick: (event: React.SyntheticEvent<HTMLElement>) => void;
open: boolean;
}

interface Props {
children: (renderProps: RenderProps) => JSX.Element;
onOpen?: () => void;
}

interface State {
open: boolean;
}

export default class Dropdown extends React.PureComponent<Props, State> {
toggleNode?: HTMLElement;

constructor(props: Props) {
super(props);
this.state = { open: false };
}

componentDidUpdate(_: Props, prevState: State) {
if (!prevState.open && this.state.open) {
this.addClickHandler();
if (this.props.onOpen) {
this.props.onOpen();
}
}

if (prevState.open && !this.state.open) {
this.removeClickHandler();
}
}

componentWillUnmount() {
this.removeClickHandler();
}

addClickHandler = () => {
window.addEventListener('click', this.handleWindowClick);
};

removeClickHandler = () => {
window.removeEventListener('click', this.handleWindowClick);
};

handleWindowClick = (event: MouseEvent) => {
if (!this.toggleNode || !this.toggleNode.contains(event.target as Node)) {
this.closeDropdown();
}
};

closeDropdown = () => this.setState({ open: false });

handleToggleClick = (event: React.SyntheticEvent<HTMLElement>) => {
this.toggleNode = event.currentTarget;
event.preventDefault();
event.currentTarget.blur();
this.setState(state => ({ open: !state.open }));
};

render() {
return this.props.children({
closeDropdown: this.closeDropdown,
onToggleClick: this.handleToggleClick,
open: this.state.open
});
}
}

+ 44
- 0
server/sonar-web/src/main/js/components/controls/__tests__/Dropdown-test.tsx View File

@@ -0,0 +1,44 @@
/*
* SonarQube
* Copyright (C) 2009-2017 SonarSource SA
* mailto:contact 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 Dropdown from '../Dropdown';
import { click } from '../../../helpers/testUtils';

it('renders', () => {
expect(
shallow(<Dropdown>{() => <div />}</Dropdown>)
.find('div')
.exists()
).toBeTruthy();
});

it('toggles', () => {
const wrapper = shallow(
<Dropdown>{({ onToggleClick }) => <button onClick={onToggleClick} />}</Dropdown>
);
expect(wrapper.state()).toEqual({ open: false });

click(wrapper.find('button'));
expect(wrapper.state()).toEqual({ open: true });

click(wrapper.find('button'));
expect(wrapper.state()).toEqual({ open: false });
});

+ 36
- 0
server/sonar-web/src/main/js/components/icons-components/PlusIcon.tsx View File

@@ -0,0 +1,36 @@
/*
* SonarQube
* Copyright (C) 2009-2017 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 { IconProps } from './types';

export default function PlusIcon({ className, fill = 'currentColor', size = 16 }: IconProps) {
return (
<svg
className={className}
width={size}
height={size}
viewBox="0 0 16 16"
version="1.1"
xmlnsXlink="http://www.w3.org/1999/xlink"
xmlSpace="preserve">
<path style={{ fill }} d="M1,7L7,7L7,1L9,1L9,7L15,7L15,9L9,9L9,15L7,15L7,9L1,9L1,7Z" />
</svg>
);
}

+ 2
- 0
sonar-core/src/main/resources/org/sonar/l10n/core.properties View File

@@ -1405,6 +1405,8 @@ my_account.organizations.no_results=You are not a member of any organizations ye
my_account.create_organization=Create Organization
my_account.search_project=Search Project
my_account.set_notifications_for=Set notifications for
my_account.analyze_new_project=Analyze new project
my_account.create_new_organization=Create new organization


#------------------------------------------------------------------------------

Loading…
Cancel
Save