From b41f2655daa7d58a272f0f98ac1ec16ff10f6e24 Mon Sep 17 00:00:00 2001 From: Jeremy <47277647+jeremy-davis-sonarsource@users.noreply.github.com> Date: Mon, 4 Mar 2019 15:19:47 +0100 Subject: [PATCH] SONARCLOUD-343 improve existing org alm binding --- .../images/sonarcloud/bitbucket-unbound.svg | 12 + .../images/sonarcloud/github-unbound.svg | 7 + .../js/app/styles/components/boxed-group.css | 6 + .../organization/CreateOrganization.tsx | 76 ++++-- .../__tests__/CreateOrganization-test.tsx | 53 ++++- .../main/js/apps/create/organization/utils.ts | 4 + .../components/OrganizationBind.tsx | 126 ++++++++++ .../components/OrganizationDelete.tsx | 95 ++++---- .../components/OrganizationEdit.tsx | 21 +- .../__tests__/OrganizationBind-test.tsx | 72 ++++++ .../__tests__/OrganizationEdit-test.tsx | 7 +- .../OrganizationBind-test.tsx.snap | 23 ++ .../OrganizationDelete-test.tsx.snap | 222 ++++++++---------- .../OrganizationEdit-test.tsx.snap | 27 ++- .../navigation/OrganizationNavigation.tsx | 8 +- .../OrganizationNavigationAdministration.tsx | 20 +- .../OrganizationNavigationHeader.tsx | 63 ++++- .../OrganizationNavigationHeaderContainer.tsx | 32 --- .../OrganizationNavigationHeader-test.tsx | 40 ++-- .../OrganizationNavigation-test.tsx.snap | 8 +- ...tionNavigationAdministration-test.tsx.snap | 20 +- ...OrganizationNavigationHeader-test.tsx.snap | 77 +++++- .../main/js/components/nav/ContextNavBar.css | 3 +- .../helpers/__tests__/almIntegrations-test.ts | 17 +- .../src/main/js/helpers/almIntegrations.ts | 6 + .../resources/org/sonar/l10n/core.properties | 17 +- 26 files changed, 752 insertions(+), 310 deletions(-) create mode 100644 server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg create mode 100644 server/sonar-web/public/images/sonarcloud/github-unbound.svg create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx create mode 100644 server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap delete mode 100644 server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx diff --git a/server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg b/server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg new file mode 100644 index 00000000000..c0bca9debf3 --- /dev/null +++ b/server/sonar-web/public/images/sonarcloud/bitbucket-unbound.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/server/sonar-web/public/images/sonarcloud/github-unbound.svg b/server/sonar-web/public/images/sonarcloud/github-unbound.svg new file mode 100644 index 00000000000..17307828edc --- /dev/null +++ b/server/sonar-web/public/images/sonarcloud/github-unbound.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/server/sonar-web/src/main/js/app/styles/components/boxed-group.css b/server/sonar-web/src/main/js/app/styles/components/boxed-group.css index 6912e432353..119c9a9cf40 100644 --- a/server/sonar-web/src/main/js/app/styles/components/boxed-group.css +++ b/server/sonar-web/src/main/js/app/styles/components/boxed-group.css @@ -35,6 +35,12 @@ padding: calc(2 * var(--gridSize)) 20px 0; } +.boxed-group > h2.boxed-title { + padding: 0 0 8px; + margin: 0; + font-weight: 600; +} + .boxed-group hr { height: 0; border-top: 1px solid var(--gray94); diff --git a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx index 0e441863c12..4d28a6aed92 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx @@ -31,7 +31,9 @@ import { serializeQuery, Query, ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP, - Step + Step, + BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP, + BIND_ORGANIZATION_KEY } from './utils'; import AlmApplicationInstalling from './AlmApplicationInstalling'; import AutoOrganizationCreate from './AutoOrganizationCreate'; @@ -47,7 +49,8 @@ import { getAlmAppInfo, getAlmOrganization, GetAlmOrganizationResponse, - listUnboundApplications + listUnboundApplications, + bindAlmOrganization } from '../../../api/alm-integration'; import { getSubscriptionPlans } from '../../../api/billing'; import * as api from '../../../api/organizations'; @@ -62,6 +65,7 @@ import { get, remove } from '../../../helpers/storage'; import { slugify } from '../../../helpers/strings'; import { getOrganizationUrl } from '../../../helpers/urls'; import { skipOnboarding } from '../../../store/users'; +import addGlobalSuccessMessage from '../../../app/utils/addGlobalSuccessMessage'; import '../../tutorials/styles.css'; // TODO remove me interface Props { @@ -82,6 +86,7 @@ interface State { almOrganization?: T.AlmOrganization; almOrgLoading: boolean; almUnboundApplications: T.AlmUnboundApplication[]; + bindingExistingOrg: boolean; boundOrganization?: T.OrganizationBase; loading: boolean; organization?: T.Organization; @@ -102,6 +107,7 @@ export class CreateOrganization extends React.PureComponent { + this.props.router.push({ + pathname: `/organizations/${organizationKey}` + }); + addGlobalSuccessMessage(translate('organization.bind.success')); + }, + () => {} + ); + } + + getHeader = (bindingExistingOrg: boolean, importPersonalOrg: boolean) => { + if (bindingExistingOrg) { + return translate('onboarding.binding_organization'); + } else if (importPersonalOrg) { + return translate('onboarding.import_organization.personal.page.header'); + } else { + return translate('onboarding.create_organization.page.header'); + } + }; + setValidOrgKey = (almOrganization: T.AlmOrganization) => { const key = slugify(almOrganization.key); const keys = [key, ...times(9, i => `${key}-${i + 1}`)]; @@ -397,13 +444,12 @@ export class CreateOrganization extends React.PureComponent; } - const { almOrganization, subscriptionPlans } = this.state; + const { almOrganization, bindingExistingOrg, subscriptionPlans } = this.state; const importPersonalOrg = isPersonal(almOrganization) ? this.props.userOrganizations.find(o => o.key === currentUser.personalOrganization) : undefined; - const header = importPersonalOrg - ? translate('onboarding.import_organization.personal.page.header') - : translate('onboarding.create_organization.page.header'); + const header = this.getHeader(bindingExistingOrg, !!importPersonalOrg); + const startedPrice = subscriptionPlans && subscriptionPlans[0] && subscriptionPlans[0].price; return ( diff --git a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx index fc80778c403..cdc8020444b 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx +++ b/server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx @@ -20,9 +20,10 @@ import * as React from 'react'; import { times } from 'lodash'; import { Location } from 'history'; -import { shallow } from 'enzyme'; +import { shallow, mount } from 'enzyme'; import { CreateOrganization } from '../CreateOrganization'; import { + bindAlmOrganization, getAlmAppInfo, getAlmOrganization, listUnboundApplications @@ -35,7 +36,8 @@ import { mockOrganizationWithAdminActions, mockOrganizationWithAlm, mockAlmOrganization, - mockCurrentUser + mockCurrentUser, + mockLocation } from '../../../../helpers/testMocks'; import { waitAndUpdate } from '../../../../helpers/testUtils'; @@ -67,7 +69,8 @@ jest.mock('../../../../api/alm-integration', () => ({ url: 'https://www.sonarsource.com' } }), - listUnboundApplications: jest.fn().mockResolvedValue([]) + listUnboundApplications: jest.fn().mockResolvedValue([]), + bindAlmOrganization: jest.fn().mockResolvedValue({}) })); jest.mock('../../../../api/organizations', () => ({ @@ -246,7 +249,9 @@ it('should display AutoOrganizationCreate with already bound organization', asyn almOrganization: { ...fooBarAlmOrganization, personal: false }, boundOrganization }); - (get as jest.Mock).mockReturnValueOnce(Date.now().toString()); + (get as jest.Mock) + .mockReturnValueOnce(undefined) // For BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP + .mockReturnValueOnce(Date.now().toString()); // For ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP const push = jest.fn(); const wrapper = shallowRender({ currentUser: { ...user, externalProvider: 'github' }, @@ -294,18 +299,50 @@ it('should cancel imports', async () => { const wrapper = shallowRender({ router: mockRouter({ push }) }); await waitAndUpdate(wrapper); wrapper.instance().handleCancelImport(); - expect(push).toBeCalledWith({ query: {} }); + expect(push).toBeCalledWith({ pathname: '/path', query: {}, state: {} }); }); +it('should bind org and redirect to org home when coming from org binding', async () => { + const installation_id = '5328'; + const orgKey = 'org4test'; + const push = jest.fn(); + + (get as jest.Mock) + .mockReturnValueOnce(Date.now().toString()) // For BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP + .mockReturnValueOnce(orgKey); // For BIND_ORGANIZATION_KEY + + const wrapper = mountRender({ + currentUser: mockCurrentUser({ ...user, externalProvider: 'github' }), + location: mockLocation({ query: { installation_id } }), + router: mockRouter({ push }) + }); + await waitAndUpdate(wrapper); + + expect(bindAlmOrganization).toBeCalled(); + expect(getAlmOrganization).not.toBeCalled(); + expect(push).toBeCalledWith({ + pathname: `/organizations/${orgKey}` + }); +}); + +function mountRender(props: Partial = {}) { + return mount(createComponent(props)); +} + function shallowRender(props: Partial = {}) { - return shallow( + return shallow(createComponent(props)); +} + +function createComponent(props: Partial = {}) { + return ( { + mounted = false; + state: State = {}; + + componentDidMount() { + this.mounted = true; + this.fetchAlmApplication(); + } + + componentWillUnmount() { + this.mounted = false; + } + + fetchAlmApplication = () => { + return getAlmAppInfo().then(({ application }) => { + if (this.mounted) { + this.setState({ almApplication: application }); + } + }); + }; + + handleInstallAppClick = () => { + save(BIND_ORGANIZATION_KEY, this.props.organization.key); + save(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP, Date.now().toString()); + }; + + render() { + const { currentUser, organization } = this.props; + + const { almApplication } = this.state; + + const almKey = sanitizeAlmId(currentUser.externalProvider || ''); + const orgAlmKey = organization.alm ? sanitizeAlmId(organization.alm.key) : ''; + return ( +
+

+ {translateWithParameters('organization.bind_to_x', translate(almKey))} +

+ {organization.alm ? ( + <> + {translate('organization.bound')} + + {translate(orgAlmKey)} + {translateWithParameters('organization.see_on_x', translate(orgAlmKey))} + + + ) : ( + <> +

+ {translateWithParameters('organization.binding_with_x_easy_sync', translate(almKey))} +

+

+ {translateWithParameters( + 'organization.app_will_be_installed_on_x', + translate(almKey) + )} +

+ {almApplication && ( + + {translate( + 'onboarding.import_organization.choose_the_organization_button', + almApplication.key + )} + + )} + + )} +
+ ); + } +} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx index 6954e3bf7e7..ab685c20442 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationDelete.tsx @@ -18,7 +18,6 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import ConfirmButton from '../../../components/controls/ConfirmButton'; import InstanceMessage from '../../../components/common/InstanceMessage'; @@ -111,59 +110,53 @@ export class OrganizationDelete extends React.PureComponent { render() { const { hasPaidPlan } = this.state; - const title = translate('organization.delete'); return ( - <> - -
-
-

{title}

-
- -
-
- - {hasPaidPlan && ( - - {translate('organization.delete.sonarcloud.paid_plan_info')} - - )} -

{translate('organization.delete.question')}

-
- -
- -
+
+

{translate('organization.delete')}

+

+ +

+ + {hasPaidPlan && ( + + {translate('organization.delete.sonarcloud.paid_plan_info')} + + )} +

{translate('organization.delete.question')}

+
+ +
+
- } - modalHeader={translateWithParameters( - 'organization.delete_x', - this.props.organization.name - )} - onConfirm={this.onDelete}> - {({ onClick }) => ( - - )} -
-
- +
+ } + modalHeader={translateWithParameters( + 'organization.delete_x', + this.props.organization.name + )} + onConfirm={this.onDelete}> + {({ onClick }) => ( + + )} +
+
); } } diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.tsx index 4d151adf2ab..77942da7b55 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationEdit.tsx @@ -21,9 +21,13 @@ import * as React from 'react'; import Helmet from 'react-helmet'; import { connect } from 'react-redux'; import { debounce } from 'lodash'; +import OrganizationBind from './OrganizationBind'; +import OrganizationDelete from './OrganizationDelete'; +import { updateOrganization } from '../actions'; import OrganizationAvatar from '../../../components/common/OrganizationAvatar'; +import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn'; import { SubmitButton } from '../../../components/ui/buttons'; -import { updateOrganization } from '../actions'; +import { hasAdvancedALMIntegration } from '../../../helpers/almIntegrations'; import { translate } from '../../../helpers/l10n'; interface DispatchProps { @@ -31,6 +35,7 @@ interface DispatchProps { } interface OwnProps { + currentUser: T.LoggedInUser; organization: T.Organization; } @@ -100,7 +105,12 @@ export class OrganizationEdit extends React.PureComponent { }; render() { - const title = translate('organization.edit'); + const { currentUser, organization } = this.props; + const title = translate('organization.settings'); + + const showBinding = hasAdvancedALMIntegration(currentUser); + const showDelete = organization.actions && organization.actions.delete; + return (
@@ -110,6 +120,7 @@ export class OrganizationEdit extends React.PureComponent {
+

{translate('organization.details')}

+ + {showBinding && } + + {showDelete && }
); } @@ -208,4 +223,4 @@ const mapDispatchToProps = { updateOrganization: updateOrganization as any }; export default connect( null, mapDispatchToProps -)(OrganizationEdit); +)(whenLoggedIn(OrganizationEdit)); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx new file mode 100644 index 00000000000..079de73c67e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationBind-test.tsx @@ -0,0 +1,72 @@ +/* + * SonarQube + * Copyright (C) 2009-2019 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 OrganizationBind from '../OrganizationBind'; +import { + BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP, + BIND_ORGANIZATION_KEY +} from '../../../create/organization/utils'; +import { getAlmAppInfo } from '../../../../api/alm-integration'; +import { save } from '../../../../helpers/storage'; +import { + mockAlmApplication, + mockCurrentUser, + mockOrganization +} from '../../../../helpers/testMocks'; + +jest.mock('../../../../api/alm-integration', () => ({ + getAlmAppInfo: jest.fn(() => Promise.resolve({ application: mockAlmApplication() })) +})); +jest.mock('../../../../helpers/storage', () => ({ + save: jest.fn() +})); + +jest.mock('../../../../helpers/system', () => ({ isSonarCloud: jest.fn() })); + +beforeEach(() => { + (getAlmAppInfo as jest.Mock).mockClear(); +}); + +it('should render correctly', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('should save state when handling Install App click', () => { + const orgKey = '56346'; + shallowRender({ organization: mockOrganization({ key: orgKey }) }) + .instance() + .handleInstallAppClick(); + + expect(save).toBeCalledTimes(2); + expect(save).nthCalledWith(1, BIND_ORGANIZATION_KEY, orgKey); + const secondCallArguments = (save as jest.Mock).mock.calls[1]; + expect(secondCallArguments[0]).toBe(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP); +}); + +function shallowRender(props: Partial = {}) { + return shallow( + + ); +} diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationEdit-test.tsx b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationEdit-test.tsx index 1e320c7865b..1daa8265779 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationEdit-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/OrganizationEdit-test.tsx @@ -20,11 +20,16 @@ import * as React from 'react'; import { shallow } from 'enzyme'; import { OrganizationEdit } from '../OrganizationEdit'; +import { mockCurrentUser } from '../../../../helpers/testMocks'; it('smoke test', () => { const organization = { key: 'foo', name: 'Foo' }; const wrapper = shallow( - + ); expect(wrapper).toMatchSnapshot(); diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap new file mode 100644 index 00000000000..69821637e05 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationBind-test.tsx.snap @@ -0,0 +1,23 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`should render correctly 1`] = ` +
+

+ organization.bind_to_x. +

+

+ organization.binding_with_x_easy_sync. +

+

+ organization.app_will_be_installed_on_x. +

+
+`; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap index 43b66bfaea3..c2dee60e8f3 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationDelete-test.tsx.snap @@ -1,140 +1,118 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`should show a info message for paying organization 1`] = ` - - -
+

-
-

- organization.delete -

-
- -
-
- - +

+ +

+ + + organization.delete.sonarcloud.paid_plan_info + +

+ organization.delete.question +

+
+
- -
- -
+
- } - modalHeader="organization.delete_x.Foo" - onConfirm={[Function]} - > - -
-

-
+
+ } + modalHeader="organization.delete_x.Foo" + onConfirm={[Function]} + > + + + `; exports[`smoke test 1`] = ` - - -
+

-
-

- organization.delete -

-
- -
-
- -

- organization.delete.question -

+ organization.delete +

+

+ +

+ +

+ organization.delete.question +

+
+
- -
- -
+
- } - modalHeader="organization.delete_x.Foo" - onConfirm={[Function]} - > - -
-
-
+ + } + modalHeader="organization.delete_x.Foo" + onConfirm={[Function]} + > + + + `; diff --git a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationEdit-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationEdit-test.tsx.snap index e948f1810c5..b80d299eb16 100644 --- a/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationEdit-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/components/__tests__/__snapshots__/OrganizationEdit-test.tsx.snap @@ -7,7 +7,7 @@ exports[`smoke test 1`] = `
- organization.edit + organization.settings
+

+ organization.details +

@@ -162,7 +167,7 @@ exports[`smoke test 2`] = `
- organization.edit + organization.settings
+

+ organization.details +

@@ -317,7 +327,7 @@ exports[`smoke test 3`] = `
- organization.edit + organization.settings
+

+ organization.details +

diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx index 3c7bbdb76a8..e9219a6bffd 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigation.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import OrganizationNavigationHeaderContainer from './OrganizationNavigationHeaderContainer'; +import OrganizationNavigationHeader from './OrganizationNavigationHeader'; import OrganizationNavigationMeta from './OrganizationNavigationMeta'; import OrganizationNavigationMenuContainer from './OrganizationNavigationMenuContainer'; import * as theme from '../../../app/theme'; @@ -40,7 +40,11 @@ export default function OrganizationNavigation({ return (
- + `extension/${e.key}`).concat(ADMIN_PATHS); const adminActive = adminPathsWithExtensions.some(path => location.pathname.endsWith(`organizations/${organization.key}/${path}`) @@ -50,6 +49,11 @@ export default function OrganizationNavigationAdministration({ location, organiz +
  • + + {translate('organization.settings')} + +
  • {adminPages.map(extension => (
  • -
  • - - {translate('edit')} - -
  • - {actions.delete && ( -
  • - - {translate('delete')} - -
  • - )} } tagName="li"> diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx index 2f7426880c4..28b8d1cb6d9 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeader.tsx @@ -18,22 +18,63 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import { Link } from 'react-router'; import { sortBy } from 'lodash'; import OrganizationAvatar from '../../../components/common/OrganizationAvatar'; import Dropdown from '../../../components/controls/Dropdown'; +import Tooltip from '../../../components/controls/Tooltip'; import DropdownIcon from '../../../components/icons-components/DropdownIcon'; import OrganizationListItem from '../../../components/ui/OrganizationListItem'; -import { sanitizeAlmId } from '../../../helpers/almIntegrations'; +import { + sanitizeAlmId, + hasAdvancedALMIntegration, + getUserAlmKey +} from '../../../helpers/almIntegrations'; +import { translate, translateWithParameters } from '../../../helpers/l10n'; import { getBaseUrl } from '../../../helpers/urls'; -interface Props { +export interface Props { + currentUser: T.CurrentUser; organization: T.Organization; organizations: T.Organization[]; } -export default function OrganizationNavigationHeader({ organization, organizations }: Props) { +export default function OrganizationNavigationHeader({ + currentUser, + organization, + organizations +}: Props) { const other = organizations.filter(o => o.key !== organization.key); + let almKey; + let tooltipContent; + let tooltipIconSrc; + if (organization.alm) { + almKey = sanitizeAlmId(organization.alm.key); + tooltipContent = ( + <> +

    {translateWithParameters('organization.bound_to_x', translate(almKey))}

    +
    + + {translateWithParameters('organization.see_on_x', translate(almKey))} + + + ); + tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}.svg`; + } else if (hasAdvancedALMIntegration(currentUser)) { + almKey = getUserAlmKey(currentUser) || ''; + tooltipContent = ( + <> +

    {translateWithParameters('organization.not_bound_to_x', translate(almKey))}

    +
    + + {translate('organization.go_to_settings_to_bind')} + + + ); + tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}-unbound.svg`; + } + return (
    @@ -57,20 +98,16 @@ export default function OrganizationNavigationHeader({ organization, organizatio ) : ( {organization.name} )} - {organization.alm && ( - + {almKey && ( + {sanitizeAlmId(organization.alm.key)} - + )} {organization.description != null && (
    diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx deleted file mode 100644 index 999b68b635f..00000000000 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationHeaderContainer.tsx +++ /dev/null @@ -1,32 +0,0 @@ -/* - * SonarQube - * Copyright (C) 2009-2019 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 { connect } from 'react-redux'; -import OrganizationNavigationHeader from './OrganizationNavigationHeader'; -import { getMyOrganizations, Store } from '../../../store/rootReducer'; - -interface StateProps { - organizations: T.Organization[]; -} - -const mapStateToProps = (state: Store): StateProps => ({ - organizations: getMyOrganizations(state) -}); - -export default connect(mapStateToProps)(OrganizationNavigationHeader); diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx index 39519ffd939..12f1d27fb4c 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/OrganizationNavigationHeader-test.tsx @@ -19,28 +19,22 @@ */ import * as React from 'react'; import { shallow } from 'enzyme'; -import OrganizationNavigationHeader from '../OrganizationNavigationHeader'; -import { mockOrganizationWithAlm } from '../../../../helpers/testMocks'; +import OrganizationNavigationHeader, { Props } from '../OrganizationNavigationHeader'; +import { mockOrganizationWithAlm, mockCurrentUser } from '../../../../helpers/testMocks'; it('renders', () => { + expect(shallowRender()).toMatchSnapshot(); +}); + +it('renders with alm integration', () => { expect( - shallow( - - ) + shallowRender({ organization: mockOrganizationWithAlm({ projectVisibility: 'public' }) }) ).toMatchSnapshot(); }); -it('renders with alm integration', () => { +it('renders for external user w/o alm integration', () => { expect( - shallow( - - ) + shallowRender({ currentUser: mockCurrentUser({ externalProvider: 'github' }) }) ).toMatchSnapshot(); }); @@ -49,15 +43,23 @@ it('renders dropdown', () => { { actions: { admin: true }, key: 'org1', name: 'org1', projectVisibility: 'public' }, { actions: { admin: false }, key: 'org2', name: 'org2', projectVisibility: 'public' } ]; - const wrapper = shallow( + const wrapper = shallowRender({ + organizations + }); + expect(wrapper.find('Dropdown')).toMatchSnapshot(); +}); + +function shallowRender(props: Partial = {}) { + return shallow( ); - expect(wrapper.find('Dropdown')).toMatchSnapshot(); -}); +} diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigation-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigation-test.tsx.snap index 0da0558483a..1db61a2ee48 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigation-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigation-test.tsx.snap @@ -8,7 +8,12 @@ exports[`render 1`] = `
    - +
  • + + organization.settings + +
  • -
  • - - edit - -
  • } tagName="li" diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap index c02c0831a7f..c580daad4e2 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationHeader-test.tsx.snap @@ -67,6 +67,55 @@ exports[`renders dropdown 1`] = ` `; +exports[`renders for external user w/o alm integration 1`] = ` +
    + + + Foo + + +

    + organization.not_bound_to_x.github +

    +
    + + organization.go_to_settings_to_bind + + + } + > + github +
    +
    +`; + exports[`renders with alm integration 1`] = `
    Foo - +

    + organization.bound_to_x.github +

    +
    +
    + organization.see_on_x.github + + + } > github - +
    `; diff --git a/server/sonar-web/src/main/js/components/nav/ContextNavBar.css b/server/sonar-web/src/main/js/components/nav/ContextNavBar.css index 871963945d7..fa4a52d5b96 100644 --- a/server/sonar-web/src/main/js/components/nav/ContextNavBar.css +++ b/server/sonar-web/src/main/js/components/nav/ContextNavBar.css @@ -93,9 +93,8 @@ .navbar-context-description { display: inline-block; line-height: var(--controlHeight); - margin-left: 16px; + margin-left: var(--gridSize); padding-top: 4px; - padding-left: 4px; color: var(--secondFontColor); font-size: var(--smallFontSize); } diff --git a/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts b/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts index b2a09af3ab0..de98abb4968 100644 --- a/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts +++ b/server/sonar-web/src/main/js/helpers/__tests__/almIntegrations-test.ts @@ -23,8 +23,10 @@ import { isPersonal, isVSTS, sanitizeAlmId, - getAlmMembersUrl + getAlmMembersUrl, + getUserAlmKey } from '../almIntegrations'; +import { mockCurrentUser } from '../testMocks'; it('#getAlmMembersUrl', () => { expect(getAlmMembersUrl('github', 'https://github.com/Foo')).toBe( @@ -69,3 +71,16 @@ it('#sanitizeAlmId', () => { expect(sanitizeAlmId('bitbucket')).toBe('bitbucket'); expect(sanitizeAlmId('github')).toBe('github'); }); + +describe('getUserAlmKey', () => { + it('should return sanitized almKey', () => { + expect(getUserAlmKey(mockCurrentUser({ externalProvider: 'bitbucketcloud' }))).toBe( + 'bitbucket' + ); + }); + + it('should return undefined', () => { + expect(getUserAlmKey(mockCurrentUser())).toBeUndefined(); + expect(getUserAlmKey(mockCurrentUser({ isLoggedIn: undefined }))).toBeUndefined(); + }); +}); diff --git a/server/sonar-web/src/main/js/helpers/almIntegrations.ts b/server/sonar-web/src/main/js/helpers/almIntegrations.ts index c3cfc0744a3..dfe28e3dd53 100644 --- a/server/sonar-web/src/main/js/helpers/almIntegrations.ts +++ b/server/sonar-web/src/main/js/helpers/almIntegrations.ts @@ -29,6 +29,12 @@ export function getAlmMembersUrl(key: string, url: string): string { return url + 'profile/members'; } +export function getUserAlmKey(user: T.CurrentUser) { + return isLoggedIn(user) && user.externalProvider + ? sanitizeAlmId(user.externalProvider) + : undefined; +} + export function hasAdvancedALMIntegration(user: T.CurrentUser) { return ( isLoggedIn(user) && (isBitbucket(user.externalProvider) || isGithub(user.externalProvider)) 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 17ffc9ce130..9ba97fab7d9 100644 --- a/sonar-core/src/main/resources/org/sonar/l10n/core.properties +++ b/sonar-core/src/main/resources/org/sonar/l10n/core.properties @@ -2647,6 +2647,11 @@ about_page.scanners.ant=SonarQube Scanner for Ant organization.avatar=Avatar organization.avatar.description=Url of a small image that represents the organization (preferably 30px height). organization.avatar.preview=Preview +organization.bind_to_x=Bind this organization to {0} +organization.go_to_settings_to_bind=Go to Organization Settings to bind it. +organization.bound=This organization is bound. +organization.bound_to_x=This organization is bound to {0} +organization.not_bound_to_x=This organization is not bound to {0} organization.created=Organization "{0}" has been created. organization.delete=Delete Organization organization.delete_x=Delete the "{0}" organization @@ -2657,14 +2662,18 @@ organization.deleted=Organization has been deleted. organization.deleted_x=Organization "{0}" has been deleted. organization.description=Description organization.description.description=Description of the organization. -organization.edit=Edit Organization +organization.details=Organization details organization.key=Key organization.key.description=Key of the organization (up to 255 characters). All chars must be lower-case letters (a to z), digits or dash (but dash can neither be trailing nor heading). When not specified, the key is computed from the name. organization.name=Name organization.name.description=Name of the organization (up to 255 characters). +organization.see_on_x=See on {0} +organization.settings=Organization settings organization.updated=Organization details have been updated. organization.url=Url organization.url.description=Url of the homepage of the organization. +organization.binding_with_x_easy_sync=Binding an organization from SonarCloud with {0} is an easy way to keep them synchronized. +organization.app_will_be_installed_on_x=To bind this organization to {0}, the SonarCloud application will be installed. organization.members.page=Members organization.members.page.description=Add users to the organization and grant them permissions to work on the projects. See {link} documentation. organization.members.add=Add a member @@ -2695,7 +2704,7 @@ organization.paid_plan.badge=Paid plan organization.default_visibility_of_new_projects=Default visibility of new projects: organization.change_visibility_form.header=Set Default Visibility of New Projects organization.change_visibility_form.warning=This will not change the visibility of already existing projects.organization.change_visibility_form.submit=Change Default Visibility - +organization.bind.success=Organization bound successfully #------------------------------------------------------------------------------ # @@ -2827,6 +2836,8 @@ onboarding.import_organization.org_not_found.tips_2=Try to uninstall and re-inst onboarding.import_organization.choose_organization=Choose an organization... onboarding.import_organization.choose_organization_button.bitbucket=Choose a team on Bitbucket onboarding.import_organization.choose_organization_button.github=Choose an organization on GitHub +onboarding.import_organization.choose_the_organization_button.bitbucket=Choose the team on Bitbucket +onboarding.import_organization.choose_the_organization_button.github=Choose the organization on GitHub onboarding.import_organization.installing=Finalize installation of the {0} application... onboarding.import_organization.personal.page.header=Bind to your personal organization onboarding.import_organization.personal.import_org_details=Import personal organization details @@ -2839,7 +2850,7 @@ onboarding.import_organization.members_sync_info_x=All members from your {0} {1} onboarding.import_organization.bind_members_not_sync_info_x=We'll keep your members, groups and permissions as they are today on SonarCloud. To sync your members with your {0}, enable members sync in your Members tab. onboarding.import_organization_x=Import {avatar} {name} into a SonarCloud organization onboarding.import_personal_organization_x=Bind {avatar} {name} with your personal SonarCloud organization {personalAvatar} {personalName} - +onboarding.binding_organization=Binding organization onboarding.team.header=Join a team onboarding.team.first_step=Well congrats, the first step is done! -- 2.39.5