diff options
author | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-05-10 18:13:28 +0200 |
---|---|---|
committer | Grégoire Aubert <gregoire.aubert@sonarsource.com> | 2017-05-12 11:58:12 +0200 |
commit | af0fb06721dcc88c53622f0380f1016aa0bd17c8 (patch) | |
tree | 4f722edeb8e2daf0aaa3f97781ccad3d886d10e0 | |
parent | 9e7cede058c62b5785f5619b90d5e93331929871 (diff) | |
download | sonarqube-af0fb06721dcc88c53622f0380f1016aa0bd17c8.tar.gz sonarqube-af0fb06721dcc88c53622f0380f1016aa0bd17c8.zip |
SONAR-9044 Easy access to my organizations
10 files changed, 578 insertions, 57 deletions
diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js index fca0171d9ec..86ed0974c5c 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNav.js @@ -21,7 +21,7 @@ import React from 'react'; import { connect } from 'react-redux'; import GlobalNavBranding from './GlobalNavBranding'; import GlobalNavMenu from './GlobalNavMenu'; -import GlobalNavUser from './GlobalNavUser'; +import GlobalNavUserContainer from './GlobalNavUserContainer'; import Search from '../../search/Search'; import ShortcutsHelpView from './ShortcutsHelpView'; import { getCurrentUser, getAppState } from '../../../../store/rootReducer'; @@ -76,7 +76,7 @@ class GlobalNav extends React.PureComponent { </svg> </a> </li> - <GlobalNavUser {...this.props} /> + <GlobalNavUserContainer {...this.props} /> </ul> </div> </nav> diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js index 6d60902e1c8..ac0f288819b 100644 --- a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUser.js @@ -19,21 +19,47 @@ */ // @flow import React from 'react'; -import { Link, withRouter } from 'react-router'; +import classNames from 'classnames'; +import { sortBy } from 'lodash'; +import { Link } from 'react-router'; import Avatar from '../../../../components/ui/Avatar'; +import OrganizationLink from '../../../../components/ui/OrganizationLink'; import { translate } from '../../../../helpers/l10n'; -class GlobalNavUser extends React.PureComponent { - props: { - currentUser: { - email?: string, - name: string - }, - location: Object, - router: { push: string => void } +type CurrentUser = { + email?: string, + isLoggedIn: boolean, + name: string +}; + +type Props = { + currentUser: CurrentUser, + fetchMyOrganizations: () => Promise<*>, + location: Object, + organizations: Array<{ key: string, name: string }>, + router: { push: string => void } +}; + +type State = { + open: boolean +}; + +export default class GlobalNavUser extends React.PureComponent { + node: HTMLElement; + props: Props; + state: State = { open: false }; + + componentWillUnmount() { + window.removeEventListener('click', this.handleClickOutside); + } + + handleClickOutside = (event: { target: HTMLElement }) => { + if (!this.node || !this.node.contains(event.target)) { + this.closeDropdown(); + } }; - handleLogin = e => { + handleLogin = (e: Event) => { e.preventDefault(); const shouldReturnToCurrentPage = window.location.pathname !== `${window.baseUrl}/about`; if (shouldReturnToCurrentPage) { @@ -45,36 +71,76 @@ class GlobalNavUser extends React.PureComponent { } }; - handleLogout = e => { + handleLogout = (e: Event) => { e.preventDefault(); + this.closeDropdown(); this.props.router.push('/sessions/logout'); }; + toggleDropdown = (evt: Event) => { + evt.preventDefault(); + if (this.state.open) { + this.closeDropdown(); + } else { + this.openDropdown(); + } + }; + + openDropdown = () => { + this.props.fetchMyOrganizations().then(() => { + window.addEventListener('click', this.handleClickOutside, true); + this.setState({ open: true }); + }); + }; + + closeDropdown = () => { + window.removeEventListener('click', this.handleClickOutside); + this.setState({ open: false }); + }; + renderAuthenticated() { - const { currentUser } = this.props; + const { currentUser, organizations } = this.props; + const hasOrganizations = organizations.length > 0; return ( - <li className="dropdown js-user-authenticated"> - <a className="dropdown-toggle navbar-avatar" data-toggle="dropdown" href="#"> + <li + className={classNames('dropdown js-user-authenticated', { open: this.state.open })} + ref={node => (this.node = node)}> + <a className="dropdown-toggle navbar-avatar" href="#" onClick={this.toggleDropdown}> <Avatar email={currentUser.email} name={currentUser.name} size={24} /> </a> - <ul className="dropdown-menu dropdown-menu-right"> - <li className="dropdown-item"> - <div className="text-ellipsis text-muted" title={currentUser.name}> - <strong>{currentUser.name}</strong> - </div> - {currentUser.email != null && - <div className="little-spacer-top text-ellipsis text-muted" title={currentUser.email}> - {currentUser.email} - </div>} - </li> - <li className="divider" /> - <li> - <Link to="/account">{translate('my_account.page')}</Link> - </li> - <li> - <a onClick={this.handleLogout} href="#">{translate('layout.logout')}</a> - </li> - </ul> + {this.state.open && + <ul className="dropdown-menu dropdown-menu-right"> + <li className="dropdown-item"> + <div className="text-ellipsis text-muted" title={currentUser.name}> + <strong>{currentUser.name}</strong> + </div> + {currentUser.email != null && + <div + className="little-spacer-top text-ellipsis text-muted" + title={currentUser.email}> + {currentUser.email} + </div>} + </li> + <li className="divider" /> + <li> + <Link to="/account" onClick={this.closeDropdown}>{translate('my_account.page')}</Link> + </li> + {hasOrganizations && <li role="separator" className="divider" />} + {hasOrganizations && + <li className="dropdown-header spacer-left">{translate('my_organizations')}</li>} + {hasOrganizations && + sortBy(organizations, org => org.name.toLowerCase()).map(organization => ( + <li key={organization.key}> + <OrganizationLink organization={organization} onClick={this.closeDropdown}> + <span className="spacer-left">{organization.name}</span> + </OrganizationLink> + </li> + ))} + {hasOrganizations && <li role="separator" className="divider" />} + <li> + <a onClick={this.handleLogout} href="#">{translate('layout.logout')}</a> + </li> + </ul>} </li> ); } @@ -91,5 +157,3 @@ class GlobalNavUser extends React.PureComponent { return this.props.currentUser.isLoggedIn ? this.renderAuthenticated() : this.renderAnonymous(); } } - -export default withRouter(GlobalNavUser); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserContainer.js b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserContainer.js new file mode 100644 index 00000000000..c84bdff2a7a --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/GlobalNavUserContainer.js @@ -0,0 +1,35 @@ +/* + * 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. + */ +// @flow +import { withRouter } from 'react-router'; +import { connect } from 'react-redux'; +import GlobalNavUser from './GlobalNavUser'; +import { fetchMyOrganizations } from '../../../../apps/account/organizations/actions'; +import { getMyOrganizations } from '../../../../store/rootReducer'; + +const mapStateToProps = state => ({ + organizations: getMyOrganizations(state) +}); + +const mapDispatchToProps = { + fetchMyOrganizations +}; + +export default connect(mapStateToProps, mapDispatchToProps)(withRouter(GlobalNavUser)); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavUser-test.js b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavUser-test.js new file mode 100644 index 00000000000..0a9f7fb9fa3 --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/GlobalNavUser-test.js @@ -0,0 +1,107 @@ +/* + * 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 React from 'react'; +import { shallow } from 'enzyme'; +import GlobalNavUser from '../GlobalNavUser'; + +const currentUser = { isLoggedIn: true, name: 'foo', email: 'foo@bar.baz' }; +const organizations = [ + { key: 'myorg', name: 'MyOrg' }, + { key: 'foo', name: 'Foo' }, + { key: 'bar', name: 'bar' } +]; + +it('should render the right interface for anonymous user', () => { + const currentUser = { isLoggedIn: false }; + const wrapper = shallow( + <GlobalNavUser currentUser={currentUser} fetchMyOrganizations={() => {}} organizations={[]} /> + ); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render the right interface for logged in user', () => { + const wrapper = shallow( + <GlobalNavUser currentUser={currentUser} fetchMyOrganizations={() => {}} organizations={[]} /> + ); + wrapper.setState({ open: true }); + expect(wrapper).toMatchSnapshot(); +}); + +it('should render the users organizations', () => { + const wrapper = shallow( + <GlobalNavUser + currentUser={currentUser} + fetchMyOrganizations={() => {}} + organizations={organizations} + /> + ); + wrapper.setState({ open: true }); + expect(wrapper).toMatchSnapshot(); +}); + +it('should update the component correctly when the user changes to anonymous', () => { + const fetchMyOrganizations = jest.fn(); + const wrapper = shallow( + <GlobalNavUser + currentUser={currentUser} + fetchMyOrganizations={fetchMyOrganizations} + organizations={[]} + /> + ); + wrapper.setState({ open: true }); + expect(wrapper).toMatchSnapshot(); + wrapper.setProps({ currentUser: { isLoggedIn: false } }); + expect(fetchMyOrganizations.mock.calls.length).toBe(0); + expect(wrapper).toMatchSnapshot(); +}); + +it('should lazyload the organizations when opening the dropdown', () => { + const fetchMyOrganizations = jest.fn(() => Promise.resolve()); + const wrapper = shallow( + <GlobalNavUser + currentUser={currentUser} + fetchMyOrganizations={fetchMyOrganizations} + organizations={organizations} + /> + ); + expect(fetchMyOrganizations.mock.calls.length).toBe(0); + wrapper.instance().openDropdown(); + expect(fetchMyOrganizations.mock.calls.length).toBe(1); + wrapper.instance().openDropdown(); + expect(fetchMyOrganizations.mock.calls.length).toBe(2); +}); + +it('should update the organizations when the user changes', () => { + const fetchMyOrganizations = jest.fn(() => Promise.resolve()); + const wrapper = shallow( + <GlobalNavUser + currentUser={currentUser} + fetchMyOrganizations={fetchMyOrganizations} + organizations={organizations} + /> + ); + wrapper.instance().openDropdown(); + expect(fetchMyOrganizations.mock.calls.length).toBe(1); + wrapper.setProps({ + currentUser: { isLoggedIn: true, name: 'test', email: 'test@sonarsource.com' } + }); + wrapper.instance().openDropdown(); + expect(fetchMyOrganizations.mock.calls.length).toBe(2); +}); diff --git a/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavUser-test.js.snap b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavUser-test.js.snap new file mode 100644 index 00000000000..50f52bb687c --- /dev/null +++ b/server/sonar-web/src/main/js/app/components/nav/global/__tests__/__snapshots__/GlobalNavUser-test.js.snap @@ -0,0 +1,270 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render the right interface for anonymous user 1`] = ` +<li> + <a + href="#" + onClick={[Function]} + > + layout.login + </a> +</li> +`; + +exports[`should render the right interface for logged in user 1`] = ` +<li + className="dropdown js-user-authenticated open" +> + <a + className="dropdown-toggle navbar-avatar" + href="#" + onClick={[Function]} + > + <Connect(Avatar) + email="foo@bar.baz" + name="foo" + size={24} + /> + </a> + <ul + className="dropdown-menu dropdown-menu-right" + > + <li + className="dropdown-item" + > + <div + className="text-ellipsis text-muted" + title="foo" + > + <strong> + foo + </strong> + </div> + <div + className="little-spacer-top text-ellipsis text-muted" + title="foo@bar.baz" + > + foo@bar.baz + </div> + </li> + <li + className="divider" + /> + <li> + <Link + onClick={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to="/account" + > + my_account.page + </Link> + </li> + <li> + <a + href="#" + onClick={[Function]} + > + layout.logout + </a> + </li> + </ul> +</li> +`; + +exports[`should render the users organizations 1`] = ` +<li + className="dropdown js-user-authenticated open" +> + <a + className="dropdown-toggle navbar-avatar" + href="#" + onClick={[Function]} + > + <Connect(Avatar) + email="foo@bar.baz" + name="foo" + size={24} + /> + </a> + <ul + className="dropdown-menu dropdown-menu-right" + > + <li + className="dropdown-item" + > + <div + className="text-ellipsis text-muted" + title="foo" + > + <strong> + foo + </strong> + </div> + <div + className="little-spacer-top text-ellipsis text-muted" + title="foo@bar.baz" + > + foo@bar.baz + </div> + </li> + <li + className="divider" + /> + <li> + <Link + onClick={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to="/account" + > + my_account.page + </Link> + </li> + <li + className="divider" + role="separator" + /> + <li + className="dropdown-header spacer-left" + > + my_organizations + </li> + <li> + <OrganizationLink + onClick={[Function]} + organization={ + Object { + "key": "bar", + "name": "bar", + } + } + > + <span + className="spacer-left" + > + bar + </span> + </OrganizationLink> + </li> + <li> + <OrganizationLink + onClick={[Function]} + organization={ + Object { + "key": "foo", + "name": "Foo", + } + } + > + <span + className="spacer-left" + > + Foo + </span> + </OrganizationLink> + </li> + <li> + <OrganizationLink + onClick={[Function]} + organization={ + Object { + "key": "myorg", + "name": "MyOrg", + } + } + > + <span + className="spacer-left" + > + MyOrg + </span> + </OrganizationLink> + </li> + <li + className="divider" + role="separator" + /> + <li> + <a + href="#" + onClick={[Function]} + > + layout.logout + </a> + </li> + </ul> +</li> +`; + +exports[`should update the component correctly when the user changes to anonymous 1`] = ` +<li + className="dropdown js-user-authenticated open" +> + <a + className="dropdown-toggle navbar-avatar" + href="#" + onClick={[Function]} + > + <Connect(Avatar) + email="foo@bar.baz" + name="foo" + size={24} + /> + </a> + <ul + className="dropdown-menu dropdown-menu-right" + > + <li + className="dropdown-item" + > + <div + className="text-ellipsis text-muted" + title="foo" + > + <strong> + foo + </strong> + </div> + <div + className="little-spacer-top text-ellipsis text-muted" + title="foo@bar.baz" + > + foo@bar.baz + </div> + </li> + <li + className="divider" + /> + <li> + <Link + onClick={[Function]} + onlyActiveOnIndex={false} + style={Object {}} + to="/account" + > + my_account.page + </Link> + </li> + <li> + <a + href="#" + onClick={[Function]} + > + layout.logout + </a> + </li> + </ul> +</li> +`; + +exports[`should update the component correctly when the user changes to anonymous 2`] = ` +<li> + <a + href="#" + onClick={[Function]} + > + layout.login + </a> +</li> +`; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js index 739948592e3..fe3ad60da2e 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationPage.js @@ -31,34 +31,45 @@ type OwnProps = { params: { organizationKey: string } }; +type Props = { + children?: React.Element<*>, + location: Object, + organization: null | Organization, + params: { organizationKey: string }, + fetchOrganization: string => Promise<*> +}; + class OrganizationPage extends React.PureComponent { mounted: boolean; - - props: { - children?: React.Element<*>, - location: Object, - organization: null | Organization, - params: { organizationKey: string }, - fetchOrganization: string => Promise<*> - }; - - state = { - loading: true - }; + props: Props; + state = { loading: true }; componentDidMount() { this.mounted = true; - this.props.fetchOrganization(this.props.params.organizationKey).then(() => { - if (this.mounted) { - this.setState({ loading: false }); - } - }); + this.updateOrganization(this.props.params.organizationKey); + } + + componentWillReceiveProps(nextProps: Props) { + if (nextProps.params.organizationKey !== this.props.params.organizationKey) { + this.updateOrganization(nextProps.params.organizationKey); + } } componentWillUnmount() { this.mounted = false; } + updateOrganization = (organizationKey: string) => { + if (this.mounted) { + this.setState({ loading: true }); + } + this.props.fetchOrganization(organizationKey).then(() => { + if (this.mounted) { + this.setState({ loading: false }); + } + }); + }; + render() { const { organization } = this.props; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationPage-test.js b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationPage-test.js index 69afb1dc902..4ced6f1a369 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationPage-test.js +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationPage-test.js @@ -23,7 +23,7 @@ import { UnconnectedOrganizationPage } from '../OrganizationPage'; it('smoke test', () => { const wrapper = shallow( - <UnconnectedOrganizationPage> + <UnconnectedOrganizationPage params={{ organizationKey: 'foo' }}> <div>hello</div> </UnconnectedOrganizationPage> ); @@ -36,10 +36,23 @@ it('smoke test', () => { it('not found', () => { const wrapper = shallow( - <UnconnectedOrganizationPage> + <UnconnectedOrganizationPage params={{ organizationKey: 'foo' }}> <div>hello</div> </UnconnectedOrganizationPage> ); wrapper.setState({ loading: false }); expect(wrapper).toMatchSnapshot(); }); + +it('should correctly update when the organization changes', () => { + const fetchOrganization = jest.fn(() => Promise.resolve()); + const wrapper = shallow( + <UnconnectedOrganizationPage + params={{ organizationKey: 'foo' }} + fetchOrganization={fetchOrganization}> + <div>hello</div> + </UnconnectedOrganizationPage> + ); + wrapper.setProps({ params: { organizationKey: 'bar' } }); + expect(fetchOrganization.mock.calls).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationPage-test.js.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationPage-test.js.snap index 6c8f73738d4..d9579e1bf4a 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationPage-test.js.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationPage-test.js.snap @@ -2,6 +2,14 @@ exports[`not found 1`] = `<NotFound />`; +exports[`should correctly update when the organization changes 1`] = ` +Array [ + Array [ + "bar", + ], +] +`; + exports[`smoke test 1`] = `null`; exports[`smoke test 2`] = ` diff --git a/server/sonar-web/src/main/js/apps/projects-admin/__tests__/projects-test.js b/server/sonar-web/src/main/js/apps/projects-admin/__tests__/projects-test.js index ebe90b8f947..e17e8668963 100644 --- a/server/sonar-web/src/main/js/apps/projects-admin/__tests__/projects-test.js +++ b/server/sonar-web/src/main/js/apps/projects-admin/__tests__/projects-test.js @@ -28,7 +28,14 @@ it('should render list of projects with no selection', () => { { id: '2', key: 'b', name: 'B', qualifier: 'TRK' } ]; - const result = shallow(<Projects projects={projects} selection={[]} refresh={jest.fn()} />); + const result = shallow( + <Projects + organization={{ key: 'foo' }} + projects={projects} + selection={[]} + refresh={jest.fn()} + /> + ); expect(result.find('tr').length).toBe(2); expect(result.find(Checkbox).filterWhere(n => n.prop('checked')).length).toBe(0); }); @@ -41,7 +48,12 @@ it('should render list of projects with one selected', () => { const selection = ['a']; const result = shallow( - <Projects projects={projects} selection={selection} refresh={jest.fn()} /> + <Projects + organization={{ key: 'foo' }} + projects={projects} + selection={selection} + refresh={jest.fn()} + /> ); expect(result.find('tr').length).toBe(2); expect(result.find(Checkbox).filterWhere(n => n.prop('checked')).length).toBe(1); diff --git a/sonar-core/src/main/resources/org/sonar/l10n/core.properties b/sonar-core/src/main/resources/org/sonar/l10n/core.properties index d09c631e58c..6dbc794bc24 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -254,6 +254,7 @@ logging_out=You're logging out, please wait... manage=Manage move_left=Move left move_right=Move right +my_organizations=My Organizations new_issues=New issues new_violations=New violations new_window=New window |