diff options
author | Jeremy <47277647+jeremy-davis-sonarsource@users.noreply.github.com> | 2019-03-04 15:19:47 +0100 |
---|---|---|
committer | sonartech <sonartech@sonarsource.com> | 2019-03-06 11:30:44 +0100 |
commit | b41f2655daa7d58a272f0f98ac1ec16ff10f6e24 (patch) | |
tree | 6c4a2db000811c72ac74a6069a139e91de887313 | |
parent | 052e3b08498bb3c42aaabaaeea2325dfb03e2bac (diff) | |
download | sonarqube-b41f2655daa7d58a272f0f98ac1ec16ff10f6e24.tar.gz sonarqube-b41f2655daa7d58a272f0f98ac1ec16ff10f6e24.zip |
SONARCLOUD-343 improve existing org alm binding
26 files changed, 752 insertions, 310 deletions
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 @@ +<svg width="59" height="54" xmlns="http://www.w3.org/2000/svg"> + <defs> + <linearGradient x1="97.494%" y1="25.5%" x2="46.9%" y2="78.8%" id="a"> + <stop stop-color="#B5B5B5" offset="0%"/> + <stop stop-color="#DDD" offset="100%"/> + </linearGradient> + </defs> + <g fill-rule="nonzero" fill="none"> + <path d="M1.973.987A1.872 1.872 0 0 0 .102 3.159l7.947 48.253a2.547 2.547 0 0 0 2.49 2.125h38.133a1.872 1.872 0 0 0 1.872-1.573l7.949-48.8A1.872 1.872 0 0 0 56.621.992L1.973.987zm33.47 34.875H23.272l-3.3-17.217h18.42l-2.949 17.217z" fill="#DDD"/> + <path d="M55.965 18.644H38.392l-2.949 17.217H23.272L8.901 52.924a2.537 2.537 0 0 0 1.638.618h38.142a1.872 1.872 0 0 0 1.872-1.573l5.412-33.325z" fill="url(#a)"/> + </g> +</svg>
\ 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 @@ +<svg width="14" height="14" xmlns="http://www.w3.org/2000/svg"> + <g fill="none" fill-rule="evenodd"> + <path d="M-1-1h16v16H-1z"/> + <path d="M13.061 3.574A7.06 7.06 0 0 0 10.514.962 6.719 6.719 0 0 0 7 0a6.72 6.72 0 0 0-3.514.962A7.059 7.059 0 0 0 .94 3.574 7.155 7.155 0 0 0 0 7.175c0 1.564.445 2.97 1.335 4.219.89 1.248 2.04 2.113 3.45 2.592.164.031.286.01.365-.065a.37.37 0 0 0 .118-.28l-.005-.505a85.532 85.532 0 0 1-.004-.831l-.21.037a2.61 2.61 0 0 1-.506.033 3.763 3.763 0 0 1-.633-.066 1.399 1.399 0 0 1-.61-.28 1.185 1.185 0 0 1-.402-.574l-.09-.215a2.346 2.346 0 0 0-.288-.477c-.13-.174-.263-.292-.396-.355l-.064-.047a.676.676 0 0 1-.119-.112.514.514 0 0 1-.082-.13c-.018-.044-.003-.08.046-.108.049-.028.137-.042.264-.042l.182.028c.122.025.272.1.452.224.179.125.326.287.442.486.14.255.308.45.505.584.198.134.397.2.597.2.2 0 .374-.015.52-.046.146-.031.282-.078.41-.14.055-.418.204-.738.447-.962a6.103 6.103 0 0 1-.935-.169 3.67 3.67 0 0 1-.856-.364 2.47 2.47 0 0 1-.734-.626c-.194-.25-.354-.576-.478-.981A4.774 4.774 0 0 1 2.534 6.8c0-.753.24-1.395.72-1.924-.225-.567-.204-1.202.064-1.906.176-.056.437-.014.783.126.347.14.6.26.761.36.162.1.29.183.388.252A6.324 6.324 0 0 1 7 3.466c.601 0 1.185.081 1.75.243l.346-.224c.237-.15.517-.287.839-.411.322-.125.568-.16.738-.103.274.704.298 1.339.073 1.906.48.53.72 1.17.72 1.924 0 .53-.062.998-.187 1.407-.124.408-.285.734-.483.98a2.563 2.563 0 0 1-.738.622c-.295.168-.58.29-.857.364a6.097 6.097 0 0 1-.934.169c.316.28.474.722.474 1.326v1.971c0 .112.038.206.114.28.076.075.196.097.36.066 1.41-.48 2.56-1.344 3.45-2.593C13.555 10.145 14 8.74 14 7.175a7.16 7.16 0 0 0-.939-3.601z" fill="#DDD" fill-rule="nonzero"/> + </g> +</svg> + 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<Props & WithRouterPr state: State = { almOrgLoading: false, almUnboundApplications: [], + bindingExistingOrg: false, loading: true, step: Step.OrganizationDetails }; @@ -109,18 +115,29 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr componentDidMount() { this.mounted = true; addWhitePageClass(); - const initRequests = [this.fetchSubscriptionPlans()]; - if (hasAdvancedALMIntegration(this.props.currentUser)) { - initRequests.push(this.fetchAlmApplication()); - const query = parseQuery(this.props.location.query); - if (query.almInstallId) { - this.fetchAlmOrganization(query.almInstallId); - } else { - initRequests.push(this.fetchAlmUnboundApplications()); + const query = parseQuery(this.props.location.query); + + //highjack the process for the organization settings + if ( + hasAdvancedALMIntegration(this.props.currentUser) && + query.almInstallId && + this.isStoredTimestampValid(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP) + ) { + this.bindAndRedirectToOrganizationSettings(query.almInstallId); + } else { + const initRequests = [this.fetchSubscriptionPlans()]; + if (hasAdvancedALMIntegration(this.props.currentUser)) { + initRequests.push(this.fetchAlmApplication()); + + if (query.almInstallId) { + this.fetchAlmOrganization(query.almInstallId); + } else { + initRequests.push(this.fetchAlmUnboundApplications()); + } } + Promise.all(initRequests).then(this.stopLoading, this.stopLoading); } - Promise.all(initRequests).then(this.stopLoading, this.stopLoading); } componentDidUpdate(prevProps: WithRouterProps) { @@ -249,6 +266,36 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr this.updateUrlState({ tab }); }; + bindAndRedirectToOrganizationSettings(installationId: string) { + const organizationKey = get(BIND_ORGANIZATION_KEY) || ''; + remove(BIND_ORGANIZATION_KEY); + + this.setState({ bindingExistingOrg: true }); + + bindAlmOrganization({ + installationId, + organization: organizationKey + }).then( + () => { + 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<Props & WithRouterPr return <AlmApplicationInstalling almKey={query.almKey} />; } - 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<any>).mockReturnValueOnce(Date.now().toString()); + (get as jest.Mock<any>) + .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<any>) + .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<CreateOrganization['props']> = {}) { + return mount<CreateOrganization>(createComponent(props)); +} + function shallowRender(props: Partial<CreateOrganization['props']> = {}) { - return shallow<CreateOrganization>( + return shallow<CreateOrganization>(createComponent(props)); +} + +function createComponent(props: Partial<CreateOrganization['props']> = {}) { + return ( <CreateOrganization createOrganization={jest.fn()} currentUser={user} deleteOrganization={jest.fn()} - // @ts-ignore avoid passing everything from WithRouterProps - location={{}} + location={mockLocation()} + params={{}} router={mockRouter()} + routes={[]} skipOnboarding={jest.fn()} updateOrganization={jest.fn()} userOrganizations={[ diff --git a/server/sonar-web/src/main/js/apps/create/organization/utils.ts b/server/sonar-web/src/main/js/apps/create/organization/utils.ts index b6d88841e2b..0d9485bba1a 100644 --- a/server/sonar-web/src/main/js/apps/create/organization/utils.ts +++ b/server/sonar-web/src/main/js/apps/create/organization/utils.ts @@ -35,6 +35,10 @@ export const ORGANIZATION_IMPORT_BINDING_IN_PROGRESS_TIMESTAMP = export const ORGANIZATION_IMPORT_REDIRECT_TO_PROJECT_TIMESTAMP = 'sonarcloud.import_org.redirect_to_projects'; +export const BIND_ORGANIZATION_KEY = 'sonarcloud.bind_org.key'; + +export const BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP = 'sonarcloud.bind_org.redirect_to_org'; + export enum Step { OrganizationDetails, Plan diff --git a/server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx new file mode 100644 index 00000000000..a46360015c6 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/organizations/components/OrganizationBind.tsx @@ -0,0 +1,126 @@ +/* + * 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 { + BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP, + BIND_ORGANIZATION_KEY +} from '../../create/organization/utils'; +import IdentityProviderLink from '../../../components/ui/IdentityProviderLink'; +import { getAlmAppInfo } from '../../../api/alm-integration'; +import { sanitizeAlmId } from '../../../helpers/almIntegrations'; +import { translateWithParameters, translate } from '../../../helpers/l10n'; +import { save } from '../../../helpers/storage'; +import { getBaseUrl } from '../../../helpers/urls'; + +interface Props { + currentUser: T.LoggedInUser; + organization: T.Organization; +} + +interface State { + almApplication?: T.AlmApplication; +} + +export default class OrganizationBind extends React.PureComponent<Props, State> { + 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 ( + <div className="boxed-group boxed-group-inner"> + <h2 className="boxed-title"> + {translateWithParameters('organization.bind_to_x', translate(almKey))} + </h2> + {organization.alm ? ( + <> + <span>{translate('organization.bound')}</span> + <a + className="link-no-underline big-spacer-left" + href={organization.alm.url} + rel="noopener noreferrer" + target="_blank"> + <img + alt={translate(orgAlmKey)} + className="text-text-top little-spacer-right" + height={16} + src={`${getBaseUrl()}/images/sonarcloud/${orgAlmKey}.svg`} + width={16} + /> + {translateWithParameters('organization.see_on_x', translate(orgAlmKey))} + </a> + </> + ) : ( + <> + <p className="spacer-bottom"> + {translateWithParameters('organization.binding_with_x_easy_sync', translate(almKey))} + </p> + <p className="big-spacer-bottom"> + {translateWithParameters( + 'organization.app_will_be_installed_on_x', + translate(almKey) + )} + </p> + {almApplication && ( + <IdentityProviderLink + className="display-inline-block" + identityProvider={almApplication} + onClick={this.handleInstallAppClick} + small={true} + url={almApplication.installationUrl}> + {translate( + 'onboarding.import_organization.choose_the_organization_button', + almApplication.key + )} + </IdentityProviderLink> + )} + </> + )} + </div> + ); + } +} 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<Props, State> { render() { const { hasPaidPlan } = this.state; - const title = translate('organization.delete'); return ( - <> - <Helmet title={title} /> - <div className="page page-limited"> - <header className="page-header"> - <h1 className="page-title">{title}</h1> - <div className="page-description"> - <InstanceMessage message={translate('organization.delete.description')} /> - </div> - </header> - <ConfirmButton - confirmButtonText={translate('delete')} - confirmDisable={!this.isVerified()} - isDestructive={true} - modalBody={ - <div> - {hasPaidPlan && ( - <Alert variant="warning"> - {translate('organization.delete.sonarcloud.paid_plan_info')} - </Alert> - )} - <p>{translate('organization.delete.question')}</p> - <div className="spacer-top"> - <label htmlFor="downgrade-organization-name"> - {translate('billing.downgrade.modal.type_to_proceed')} - </label> - <div className="little-spacer-top"> - <input - autoFocus={true} - className="input-super-large" - id="downgrade-organization-name" - onChange={this.handleInput} - type="text" - value={this.state.verify} - /> - </div> + <div className="boxed-group boxed-group-inner"> + <h2 className="boxed-title">{translate('organization.delete')}</h2> + <p className="big-spacer-bottom width-50"> + <InstanceMessage message={translate('organization.delete.description')} /> + </p> + <ConfirmButton + confirmButtonText={translate('delete')} + confirmDisable={!this.isVerified()} + isDestructive={true} + modalBody={ + <div> + {hasPaidPlan && ( + <Alert variant="warning"> + {translate('organization.delete.sonarcloud.paid_plan_info')} + </Alert> + )} + <p>{translate('organization.delete.question')}</p> + <div className="spacer-top"> + <label htmlFor="downgrade-organization-name"> + {translate('billing.downgrade.modal.type_to_proceed')} + </label> + <div className="little-spacer-top"> + <input + autoFocus={true} + className="input-super-large" + id="downgrade-organization-name" + onChange={this.handleInput} + type="text" + value={this.state.verify} + /> </div> </div> - } - modalHeader={translateWithParameters( - 'organization.delete_x', - this.props.organization.name - )} - onConfirm={this.onDelete}> - {({ onClick }) => ( - <Button className="js-custom-measure-delete button-red" onClick={onClick}> - {translate('delete')} - </Button> - )} - </ConfirmButton> - </div> - </> + </div> + } + modalHeader={translateWithParameters( + 'organization.delete_x', + this.props.organization.name + )} + onConfirm={this.onDelete}> + {({ onClick }) => ( + <Button className="js-custom-measure-delete button-red" onClick={onClick}> + {translate('delete')} + </Button> + )} + </ConfirmButton> + </div> ); } } 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<Props, State> { }; 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 ( <div className="page page-limited"> <Helmet title={title} /> @@ -110,6 +120,7 @@ export class OrganizationEdit extends React.PureComponent<Props, State> { </header> <div className="boxed-group boxed-group-inner"> + <h2 className="boxed-title">{translate('organization.details')}</h2> <form onSubmit={this.handleSubmit}> <div className="form-field"> <label htmlFor="organization-name"> @@ -198,6 +209,10 @@ export class OrganizationEdit extends React.PureComponent<Props, State> { {this.state.loading && <i className="spinner spacer-left" />} </form> </div> + + {showBinding && <OrganizationBind currentUser={currentUser} organization={organization} />} + + {showDelete && <OrganizationDelete organization={organization} />} </div> ); } @@ -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<any>).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<any>).mock.calls[1]; + expect(secondCallArguments[0]).toBe(BIND_ORGANIZATION_REDIRECT_TO_ORG_TIMESTAMP); +}); + +function shallowRender(props: Partial<OrganizationBind['props']> = {}) { + return shallow<OrganizationBind>( + <OrganizationBind + currentUser={mockCurrentUser()} + organization={mockOrganization()} + {...props} + /> + ); +} 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( - <OrganizationEdit organization={organization} updateOrganization={jest.fn()} /> + <OrganizationEdit + currentUser={mockCurrentUser()} + organization={organization} + updateOrganization={jest.fn()} + /> ); 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`] = ` +<div + className="boxed-group boxed-group-inner" +> + <h2 + className="boxed-title" + > + organization.bind_to_x. + </h2> + <p + className="spacer-bottom" + > + organization.binding_with_x_easy_sync. + </p> + <p + className="big-spacer-bottom" + > + organization.app_will_be_installed_on_x. + </p> +</div> +`; 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`] = ` -<Fragment> - <HelmetWrapper - defer={true} - encodeSpecialCharacters={true} - title="organization.delete" - /> - <div - className="page page-limited" +<div + className="boxed-group boxed-group-inner" +> + <h2 + className="boxed-title" > - <header - className="page-header" - > - <h1 - className="page-title" - > - organization.delete - </h1> - <div - className="page-description" - > - <InstanceMessage - message="organization.delete.description" - /> - </div> - </header> - <ConfirmButton - confirmButtonText="delete" - confirmDisable={true} - isDestructive={true} - modalBody={ - <div> - <Alert - variant="warning" + organization.delete + </h2> + <p + className="big-spacer-bottom width-50" + > + <InstanceMessage + message="organization.delete.description" + /> + </p> + <ConfirmButton + confirmButtonText="delete" + confirmDisable={true} + isDestructive={true} + modalBody={ + <div> + <Alert + variant="warning" + > + organization.delete.sonarcloud.paid_plan_info + </Alert> + <p> + organization.delete.question + </p> + <div + className="spacer-top" + > + <label + htmlFor="downgrade-organization-name" > - organization.delete.sonarcloud.paid_plan_info - </Alert> - <p> - organization.delete.question - </p> + billing.downgrade.modal.type_to_proceed + </label> <div - className="spacer-top" + className="little-spacer-top" > - <label - htmlFor="downgrade-organization-name" - > - billing.downgrade.modal.type_to_proceed - </label> - <div - className="little-spacer-top" - > - <input - autoFocus={true} - className="input-super-large" - id="downgrade-organization-name" - onChange={[Function]} - type="text" - value="" - /> - </div> + <input + autoFocus={true} + className="input-super-large" + id="downgrade-organization-name" + onChange={[Function]} + type="text" + value="" + /> </div> </div> - } - modalHeader="organization.delete_x.Foo" - onConfirm={[Function]} - > - <Component /> - </ConfirmButton> - </div> -</Fragment> + </div> + } + modalHeader="organization.delete_x.Foo" + onConfirm={[Function]} + > + <Component /> + </ConfirmButton> +</div> `; exports[`smoke test 1`] = ` -<Fragment> - <HelmetWrapper - defer={true} - encodeSpecialCharacters={true} - title="organization.delete" - /> - <div - className="page page-limited" +<div + className="boxed-group boxed-group-inner" +> + <h2 + className="boxed-title" > - <header - className="page-header" - > - <h1 - className="page-title" - > - organization.delete - </h1> - <div - className="page-description" - > - <InstanceMessage - message="organization.delete.description" - /> - </div> - </header> - <ConfirmButton - confirmButtonText="delete" - confirmDisable={true} - isDestructive={true} - modalBody={ - <div> - <p> - organization.delete.question - </p> + organization.delete + </h2> + <p + className="big-spacer-bottom width-50" + > + <InstanceMessage + message="organization.delete.description" + /> + </p> + <ConfirmButton + confirmButtonText="delete" + confirmDisable={true} + isDestructive={true} + modalBody={ + <div> + <p> + organization.delete.question + </p> + <div + className="spacer-top" + > + <label + htmlFor="downgrade-organization-name" + > + billing.downgrade.modal.type_to_proceed + </label> <div - className="spacer-top" + className="little-spacer-top" > - <label - htmlFor="downgrade-organization-name" - > - billing.downgrade.modal.type_to_proceed - </label> - <div - className="little-spacer-top" - > - <input - autoFocus={true} - className="input-super-large" - id="downgrade-organization-name" - onChange={[Function]} - type="text" - value="" - /> - </div> + <input + autoFocus={true} + className="input-super-large" + id="downgrade-organization-name" + onChange={[Function]} + type="text" + value="" + /> </div> </div> - } - modalHeader="organization.delete_x.Foo" - onConfirm={[Function]} - > - <Component /> - </ConfirmButton> - </div> -</Fragment> + </div> + } + modalHeader="organization.delete_x.Foo" + onConfirm={[Function]} + > + <Component /> + </ConfirmButton> +</div> `; 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`] = ` <HelmetWrapper defer={true} encodeSpecialCharacters={true} - title="organization.edit" + title="organization.settings" /> <header className="page-header" @@ -15,12 +15,17 @@ exports[`smoke test 1`] = ` <h1 className="page-title" > - organization.edit + organization.settings </h1> </header> <div className="boxed-group boxed-group-inner" > + <h2 + className="boxed-title" + > + organization.details + </h2> <form onSubmit={[Function]} > @@ -162,7 +167,7 @@ exports[`smoke test 2`] = ` <HelmetWrapper defer={true} encodeSpecialCharacters={true} - title="organization.edit" + title="organization.settings" /> <header className="page-header" @@ -170,12 +175,17 @@ exports[`smoke test 2`] = ` <h1 className="page-title" > - organization.edit + organization.settings </h1> </header> <div className="boxed-group boxed-group-inner" > + <h2 + className="boxed-title" + > + organization.details + </h2> <form onSubmit={[Function]} > @@ -317,7 +327,7 @@ exports[`smoke test 3`] = ` <HelmetWrapper defer={true} encodeSpecialCharacters={true} - title="organization.edit" + title="organization.settings" /> <header className="page-header" @@ -325,12 +335,17 @@ exports[`smoke test 3`] = ` <h1 className="page-title" > - organization.edit + organization.settings </h1> </header> <div className="boxed-group boxed-group-inner" > + <h2 + className="boxed-title" + > + organization.details + </h2> <form onSubmit={[Function]} > 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 ( <ContextNavBar height={theme.contextNavHeightRaw} id="context-navigation"> <div className="navbar-context-justified"> - <OrganizationNavigationHeaderContainer organization={organization} /> + <OrganizationNavigationHeader + currentUser={currentUser} + organization={organization} + organizations={userOrganizations} + /> <OrganizationNavigationMeta currentUser={currentUser} organization={organization} diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx index 63228606f33..f1eb48f7807 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/OrganizationNavigationAdministration.tsx @@ -32,7 +32,6 @@ interface Props { const ADMIN_PATHS = [ 'edit', 'groups', - 'delete', 'permissions', 'permission_templates', 'projects_management', @@ -40,7 +39,7 @@ const ADMIN_PATHS = [ ]; export default function OrganizationNavigationAdministration({ location, organization }: Props) { - const { actions = {}, adminPages = [] } = organization; + const { adminPages = [] } = organization; const adminPathsWithExtensions = adminPages.map(e => `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 <Dropdown overlay={ <ul className="menu"> + <li> + <Link activeClassName="active" to={`/organizations/${organization.key}/edit`}> + {translate('organization.settings')} + </Link> + </li> {adminPages.map(extension => ( <li key={extension.key}> <Link @@ -88,18 +92,6 @@ export default function OrganizationNavigationAdministration({ location, organiz {translate('webhooks.page')} </Link> </li> - <li> - <Link activeClassName="active" to={`/organizations/${organization.key}/edit`}> - {translate('edit')} - </Link> - </li> - {actions.delete && ( - <li> - <Link activeClassName="active" to={`/organizations/${organization.key}/delete`}> - {translate('delete')} - </Link> - </li> - )} </ul> } 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 = ( + <> + <p>{translateWithParameters('organization.bound_to_x', translate(almKey))}</p> + <hr className="spacer-top spacer-bottom" /> + <a href={organization.alm.url} rel="noopener noreferrer" target="_blank"> + {translateWithParameters('organization.see_on_x', translate(almKey))} + </a> + </> + ); + tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}.svg`; + } else if (hasAdvancedALMIntegration(currentUser)) { + almKey = getUserAlmKey(currentUser) || ''; + tooltipContent = ( + <> + <p>{translateWithParameters('organization.not_bound_to_x', translate(almKey))}</p> + <hr className="spacer-top spacer-bottom" /> + <Link to={`/organizations/${organization.key}/edit`}> + {translate('organization.go_to_settings_to_bind')} + </Link> + </> + ); + tooltipIconSrc = `${getBaseUrl()}/images/sonarcloud/${almKey}-unbound.svg`; + } + return ( <header className="navbar-context-header"> <OrganizationAvatar organization={organization} /> @@ -57,20 +98,16 @@ export default function OrganizationNavigationHeader({ organization, organizatio ) : ( <span className="spacer-left">{organization.name}</span> )} - {organization.alm && ( - <a - className="link-no-underline" - href={organization.alm.url} - rel="noopener noreferrer" - target="_blank"> + {almKey && ( + <Tooltip mouseLeaveDelay={0.25} overlay={tooltipContent}> <img - alt={sanitizeAlmId(organization.alm.key)} - className="text-text-top spacer-left" + alt={translate(almKey)} + className="text-middle spacer-left" height={16} - src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(organization.alm.key)}.svg`} + src={tooltipIconSrc} width={16} /> - </a> + </Tooltip> )} {organization.description != null && ( <div className="navbar-context-description"> 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( - <OrganizationNavigationHeader - organization={{ key: 'foo', name: 'Foo', projectVisibility: 'public' }} - organizations={[]} - /> - ) + shallowRender({ organization: mockOrganizationWithAlm({ projectVisibility: 'public' }) }) ).toMatchSnapshot(); }); -it('renders with alm integration', () => { +it('renders for external user w/o alm integration', () => { expect( - shallow( - <OrganizationNavigationHeader - organization={mockOrganizationWithAlm({ projectVisibility: 'public' })} - organizations={[]} - /> - ) + 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<Props> = {}) { + return shallow( <OrganizationNavigationHeader + currentUser={mockCurrentUser()} organization={{ key: 'foo', name: 'Foo', projectVisibility: 'public' }} - organizations={organizations} + organizations={[]} + {...props} /> ); - 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`] = ` <div className="navbar-context-justified" > - <Connect(OrganizationNavigationHeader) + <OrganizationNavigationHeader + currentUser={ + Object { + "isLoggedIn": false, + } + } organization={ Object { "key": "foo", @@ -16,6 +21,7 @@ exports[`render 1`] = ` "projectVisibility": "public", } } + organizations={Array []} /> <OrganizationNavigationMeta currentUser={ diff --git a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationAdministration-test.tsx.snap b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationAdministration-test.tsx.snap index 27d6f917f78..da8dbbc2c87 100644 --- a/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationAdministration-test.tsx.snap +++ b/server/sonar-web/src/main/js/apps/organizations/navigation/__tests__/__snapshots__/OrganizationNavigationAdministration-test.tsx.snap @@ -11,6 +11,16 @@ exports[`renders 1`] = ` activeClassName="active" onlyActiveOnIndex={false} style={Object {}} + to="/organizations/foo/edit" + > + organization.settings + </Link> + </li> + <li> + <Link + activeClassName="active" + onlyActiveOnIndex={false} + style={Object {}} to="/organizations/foo/groups" > user_groups.page @@ -56,16 +66,6 @@ exports[`renders 1`] = ` webhooks.page </Link> </li> - <li> - <Link - activeClassName="active" - onlyActiveOnIndex={false} - style={Object {}} - to="/organizations/foo/edit" - > - edit - </Link> - </li> </ul> } 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`] = ` </Dropdown> `; +exports[`renders for external user w/o alm integration 1`] = ` +<header + className="navbar-context-header" +> + <OrganizationAvatar + organization={ + Object { + "key": "foo", + "name": "Foo", + "projectVisibility": "public", + } + } + /> + <span + className="spacer-left" + > + Foo + </span> + <Tooltip + mouseLeaveDelay={0.25} + overlay={ + <React.Fragment> + <p> + organization.not_bound_to_x.github + </p> + <hr + className="spacer-top spacer-bottom" + /> + <Link + onlyActiveOnIndex={false} + style={Object {}} + to="/organizations/foo/edit" + > + organization.go_to_settings_to_bind + </Link> + </React.Fragment> + } + > + <img + alt="github" + className="text-middle spacer-left" + height={16} + src="/images/sonarcloud/github-unbound.svg" + width={16} + /> + </Tooltip> +</header> +`; + exports[`renders with alm integration 1`] = ` <header className="navbar-context-header" @@ -90,19 +139,33 @@ exports[`renders with alm integration 1`] = ` > Foo </span> - <a - className="link-no-underline" - href="https://github.com/foo" - rel="noopener noreferrer" - target="_blank" + <Tooltip + mouseLeaveDelay={0.25} + overlay={ + <React.Fragment> + <p> + organization.bound_to_x.github + </p> + <hr + className="spacer-top spacer-bottom" + /> + <a + href="https://github.com/foo" + rel="noopener noreferrer" + target="_blank" + > + organization.see_on_x.github + </a> + </React.Fragment> + } > <img alt="github" - className="text-text-top spacer-left" + className="text-middle spacer-left" height={16} src="/images/sonarcloud/github.svg" width={16} /> - </a> + </Tooltip> </header> `; 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! |