--- /dev/null
+<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
--- /dev/null
+<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>
+
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);
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';
getAlmAppInfo,
getAlmOrganization,
GetAlmOrganizationResponse,
- listUnboundApplications
+ listUnboundApplications,
+ bindAlmOrganization
} from '../../../api/alm-integration';
import { getSubscriptionPlans } from '../../../api/billing';
import * as api from '../../../api/organizations';
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 {
almOrganization?: T.AlmOrganization;
almOrgLoading: boolean;
almUnboundApplications: T.AlmUnboundApplication[];
+ bindingExistingOrg: boolean;
boundOrganization?: T.OrganizationBase;
loading: boolean;
organization?: T.Organization;
state: State = {
almOrgLoading: false,
almUnboundApplications: [],
+ bindingExistingOrg: false,
loading: true,
step: Step.OrganizationDetails
};
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) {
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}`)];
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 (
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
mockOrganizationWithAdminActions,
mockOrganizationWithAlm,
mockAlmOrganization,
- mockCurrentUser
+ mockCurrentUser,
+ mockLocation
} from '../../../../helpers/testMocks';
import { waitAndUpdate } from '../../../../helpers/testUtils';
url: 'https://www.sonarsource.com'
}
}),
- listUnboundApplications: jest.fn().mockResolvedValue([])
+ listUnboundApplications: jest.fn().mockResolvedValue([]),
+ bindAlmOrganization: jest.fn().mockResolvedValue({})
}));
jest.mock('../../../../api/organizations', () => ({
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' },
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={[
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
--- /dev/null
+/*
+ * 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>
+ );
+ }
+}
* 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';
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>
);
}
}
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 {
}
interface OwnProps {
+ currentUser: T.LoggedInUser;
organization: T.Organization;
}
};
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} />
</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">
{this.state.loading && <i className="spinner spacer-left" />}
</form>
</div>
+
+ {showBinding && <OrganizationBind currentUser={currentUser} organization={organization} />}
+
+ {showDelete && <OrganizationDelete organization={organization} />}
</div>
);
}
export default connect(
null,
mapDispatchToProps
-)(OrganizationEdit);
+)(whenLoggedIn(OrganizationEdit));
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
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();
--- /dev/null
+// 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>
+`;
// 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>
`;
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
- title="organization.edit"
+ title="organization.settings"
/>
<header
className="page-header"
<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]}
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
- title="organization.edit"
+ title="organization.settings"
/>
<header
className="page-header"
<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]}
>
<HelmetWrapper
defer={true}
encodeSpecialCharacters={true}
- title="organization.edit"
+ title="organization.settings"
/>
<header
className="page-header"
<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]}
>
* 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';
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}
const ADMIN_PATHS = [
'edit',
'groups',
- 'delete',
'permissions',
'permission_templates',
'projects_management',
];
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}`)
<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
{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">
* 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} />
) : (
<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">
+++ /dev/null
-/*
- * 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);
*/
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();
});
{ 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();
-});
+}
<div
className="navbar-context-justified"
>
- <Connect(OrganizationNavigationHeader)
+ <OrganizationNavigationHeader
+ currentUser={
+ Object {
+ "isLoggedIn": false,
+ }
+ }
organization={
Object {
"key": "foo",
"projectVisibility": "public",
}
}
+ organizations={Array []}
/>
<OrganizationNavigationMeta
currentUser={
<ul
className="menu"
>
+ <li>
+ <Link
+ activeClassName="active"
+ onlyActiveOnIndex={false}
+ style={Object {}}
+ to="/organizations/foo/edit"
+ >
+ organization.settings
+ </Link>
+ </li>
<li>
<Link
activeClassName="active"
webhooks.page
</Link>
</li>
- <li>
- <Link
- activeClassName="active"
- onlyActiveOnIndex={false}
- style={Object {}}
- to="/organizations/foo/edit"
- >
- edit
- </Link>
- </li>
</ul>
}
tagName="li"
</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"
>
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>
`;
.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);
}
isPersonal,
isVSTS,
sanitizeAlmId,
- getAlmMembersUrl
+ getAlmMembersUrl,
+ getUserAlmKey
} from '../almIntegrations';
+import { mockCurrentUser } from '../testMocks';
it('#getAlmMembersUrl', () => {
expect(getAlmMembersUrl('github', 'https://github.com/Foo')).toBe(
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();
+ });
+});
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))
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
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
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
#------------------------------------------------------------------------------
#
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
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!