]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-11325 Enable to continue an unfinished alm application installation
authorGrégoire Aubert <gregoire.aubert@sonarsource.com>
Wed, 24 Oct 2018 14:18:56 +0000 (16:18 +0200)
committerSonarTech <sonartech@sonarsource.com>
Fri, 16 Nov 2018 19:21:05 +0000 (20:21 +0100)
16 files changed:
server/sonar-web/src/main/js/api/alm-integration.ts
server/sonar-web/src/main/js/app/components/nav/component/ComponentNavMeta.tsx
server/sonar-web/src/main/js/app/components/nav/component/__tests__/__snapshots__/ComponentNavMeta-test.tsx.snap
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/app/types.ts
server/sonar-web/src/main/js/apps/create/organization/AutoOrganizationCreate.tsx
server/sonar-web/src/main/js/apps/create/organization/ChooseRemoteOrganizationStep.tsx
server/sonar-web/src/main/js/apps/create/organization/CreateOrganization.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/AutoOrganizationCreate-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/ChooseRemoteOrganizationStep-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/CreateOrganization-test.tsx
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/AutoOrganizationCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/ChooseRemoteOrganizationStep-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/__tests__/__snapshots__/CreateOrganization-test.tsx.snap
server/sonar-web/src/main/js/apps/create/organization/utils.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 9b9df940713cfe83f3fa014795ece08065736234..05ba477a887876d4127defdc3d5de3ac402e8a7e 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import { getJSON, postJSON, post } from '../helpers/request';
-import { AlmRepository, AlmApplication, AlmOrganization } from '../app/types';
+import {
+  AlmApplication,
+  AlmOrganization,
+  AlmRepository,
+  AlmUnboundApplication
+} from '../app/types';
 import throwGlobalError from '../app/utils/throwGlobalError';
 
 export function bindAlmOrganization(data: { installationId: string; organization: string }) {
@@ -59,6 +64,10 @@ export function getRepositories(data: {
   return getJSON('/api/alm_integration/list_repositories', data).catch(throwGlobalError);
 }
 
+export function listUnboundApplications(): Promise<{ applications: AlmUnboundApplication[] }> {
+  return getJSON('/api/alm_integration/list_unbound_applications').catch(throwGlobalError);
+}
+
 export function provisionProject(data: {
   installationKeys: string[];
   organization: string;
index 02d69091357de283e72288413bd8a94d5e8b6ce3..a3093d3e2fb66cfa5f779595c2ef55087b132af8 100644 (file)
@@ -115,7 +115,7 @@ export function ComponentNavMeta({
           {branchMeasures &&
             branchMeasures.length > 0 && (
               <>
-                <span className="vertical-separator" />
+                <span className="vertical-separator big-spacer-left big-spacer-right" />
                 <BranchMeasures
                   branchLike={branchLike}
                   componentKey={component.key}
index b0f425fc5417450bd05ce8e76c842971597f2d4c..35f80d6ab41871baee476128e124a055003ecb9b 100644 (file)
@@ -104,7 +104,7 @@ exports[`renders status of short-living branch 1`] = `
       }
     />
     <span
-      className="vertical-separator"
+      className="vertical-separator big-spacer-left big-spacer-right"
     />
     <BranchMeasures
       branchLike={
index 9cdc8dafa4086523d5dd93299f03668ff08c2ccb..d208189636ecc0727fc740d306bd94c682d30917 100644 (file)
@@ -299,6 +299,11 @@ td.big-spacer-top {
   align-items: center;
 }
 
+.display-flex-stretch {
+  display: flex !important;
+  align-items: stretch;
+}
+
 .display-inline-flex-baseline {
   display: inline-flex !important;
   align-items: baseline;
@@ -354,13 +359,20 @@ td.big-spacer-top {
 }
 
 .vertical-separator {
-  margin-left: calc(2 * var(--gridSize));
-  margin-right: calc(2 * var(--gridSize));
+  width: 1px;
+  min-height: 16px;
+  flex-grow: 1;
+  background-color: var(--barBorderColor);
+}
+
+.vertical-pipe-separator {
+  display: flex;
+  flex-direction: column;
+  margin-right: 60px;
 }
 
-.vertical-separator:after {
-  content: '|';
-  color: var(--barBorderColor);
+.vertical-pipe-separator > .vertical-separator {
+  margin: 4px auto;
 }
 
 .capitalize {
index 01b41f6dbfdfc2dd92bfeac7dcf14472d6e69f23..e5b615e42d59c3287c86a47151e8560bb55a34f3 100644 (file)
@@ -26,6 +26,7 @@ export type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
 export interface AlmApplication extends IdentityProvider {
   installationUrl: string;
 }
+
 export interface AlmOrganization extends OrganizationBase {
   key: string;
   personal: boolean;
@@ -38,6 +39,11 @@ export interface AlmRepository {
   linkedProjectName?: string;
 }
 
+export interface AlmUnboundApplication {
+  installationId: string;
+  name: string;
+}
+
 export interface Analysis {
   date: string;
   events: AnalysisEvent[];
index 92de554862d650685b8cfd358dfaad3481910301..f24b6c44f928ca9fd00651c2dc961c66a9a35115 100644 (file)
@@ -27,8 +27,9 @@ import RadioToggle from '../../../components/controls/RadioToggle';
 import {
   AlmApplication,
   AlmOrganization,
-  OrganizationBase,
-  Organization
+  AlmUnboundApplication,
+  Organization,
+  OrganizationBase
 } from '../../../app/types';
 import { bindAlmOrganization } from '../../../api/alm-integration';
 import { sanitizeAlmId } from '../../../helpers/almIntegrations';
@@ -45,6 +46,7 @@ interface Props {
   almApplication: AlmApplication;
   almInstallId?: string;
   almOrganization?: AlmOrganization;
+  almUnboundApplications: AlmUnboundApplication[];
   createOrganization: (
     organization: OrganizationBase & { installationId?: string }
   ) => Promise<Organization>;
@@ -166,6 +168,7 @@ export default class AutoOrganizationCreate extends React.PureComponent<Props, S
       <ChooseRemoteOrganizationStep
         almApplication={this.props.almApplication}
         almInstallId={almInstallId}
+        almUnboundApplications={this.props.almUnboundApplications}
       />
     );
   }
index 80e80a5f1932748b3b7d696a9e25dfa042e5c7fc..9a562150fecb05aa5d621449b483c86d2aff3fb0 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { WithRouterProps, withRouter } from 'react-router';
+import { sortBy } from 'lodash';
+import { serializeQuery } from './utils';
 import IdentityProviderLink from '../../../components/ui/IdentityProviderLink';
+import Select from '../../../components/controls/Select';
 import Step from '../../tutorials/components/Step';
-import { translate } from '../../../helpers/l10n';
-import { AlmApplication } from '../../../app/types';
 import { Alert } from '../../../components/ui/Alert';
+import { SubmitButton } from '../../../components/ui/buttons';
+import { AlmApplication, AlmUnboundApplication } from '../../../app/types';
+import { getBaseUrl } from '../../../helpers/urls';
+import { sanitizeAlmId } from '../../../helpers/almIntegrations';
+import { translate } from '../../../helpers/l10n';
 
 interface Props {
   almApplication: AlmApplication;
   almInstallId?: string;
+  almUnboundApplications: AlmUnboundApplication[];
+}
+
+interface State {
+  unboundInstallationId: string;
 }
 
-export default class ChooseRemoteOrganizationStep extends React.PureComponent<Props> {
+export class ChooseRemoteOrganizationStep extends React.PureComponent<
+  Props & WithRouterProps,
+  State
+> {
+  state: State = { unboundInstallationId: '' };
+
+  handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+    event.preventDefault();
+
+    const { unboundInstallationId } = this.state;
+    if (unboundInstallationId) {
+      this.props.router.push({
+        pathname: '/create-organization',
+        query: serializeQuery({
+          almInstallId: unboundInstallationId,
+          almKey: this.props.almApplication.key
+        })
+      });
+    }
+  };
+
+  handleInstallationChange = ({ installationId }: AlmUnboundApplication) => {
+    this.setState({ unboundInstallationId: installationId });
+  };
+
+  renderOption = (organization: AlmUnboundApplication) => {
+    const { almApplication } = this.props;
+    return (
+      <span>
+        <img
+          alt={almApplication.name}
+          className="spacer-right"
+          height={14}
+          src={`${getBaseUrl()}/images/sonarcloud/${sanitizeAlmId(almApplication.key)}.svg`}
+        />
+        {organization.name}
+      </span>
+    );
+  };
+
   renderForm = () => {
-    const { almApplication, almInstallId } = this.props;
+    const { almApplication, almInstallId, almUnboundApplications } = this.props;
+    const { unboundInstallationId } = this.state;
     return (
       <div className="boxed-group-inner">
         {almInstallId && (
@@ -43,16 +95,55 @@ export default class ChooseRemoteOrganizationStep extends React.PureComponent<Pr
             </ul>
           </Alert>
         )}
-        <IdentityProviderLink
-          className="display-inline-block"
-          identityProvider={almApplication}
-          small={true}
-          url={almApplication.installationUrl}>
-          {translate(
-            'onboarding.import_organization.choose_organization_button',
-            almApplication.key
+        <div className="display-flex-center">
+          <div className="display-inline-block abs-width-400">
+            <IdentityProviderLink
+              className="display-inline-block"
+              identityProvider={almApplication}
+              small={true}
+              url={almApplication.installationUrl}>
+              {translate(
+                'onboarding.import_organization.choose_organization_button',
+                almApplication.key
+              )}
+            </IdentityProviderLink>
+          </div>
+          {almUnboundApplications.length > 0 && (
+            <div className="display-flex-stretch">
+              <div className="vertical-pipe-separator">
+                <div className="vertical-separator " />
+                <span className="note">{translate('or')}</span>
+                <div className="vertical-separator" />
+              </div>
+              <form className="big-spacer-top big-spacer-bottom" onSubmit={this.handleSubmit}>
+                <div className="form-field abs-width-400">
+                  <label htmlFor="select-unbound-installation">
+                    {translate(
+                      'onboarding.import_organization.choose_unbound_installation',
+                      almApplication.key
+                    )}
+                  </label>
+                  <Select
+                    className="input-super-large"
+                    clearable={false}
+                    id="select-unbound-installation"
+                    labelKey="name"
+                    onChange={this.handleInstallationChange}
+                    optionRenderer={this.renderOption}
+                    options={sortBy(almUnboundApplications, o => o.name.toLowerCase())}
+                    placeholder={translate('onboarding.import_organization.choose_organization')}
+                    value={unboundInstallationId}
+                    valueKey="installationId"
+                    valueRenderer={this.renderOption}
+                  />
+                </div>
+                <SubmitButton disabled={!unboundInstallationId}>
+                  {translate('continue')}
+                </SubmitButton>
+              </form>
+            </div>
           )}
-        </IdentityProviderLink>
+        </div>
       </div>
     );
   };
@@ -75,3 +166,5 @@ export default class ChooseRemoteOrganizationStep extends React.PureComponent<Pr
     );
   }
 }
+
+export default withRouter(ChooseRemoteOrganizationStep);
index 1023408535c638edd4230c5eae0520889e90805d..763ebef5e173ba4e7d2efe23ea477abce80330a8 100644 (file)
@@ -34,18 +34,20 @@ import Tabs from '../../../components/controls/Tabs';
 import { whenLoggedIn } from '../../../components/hoc/whenLoggedIn';
 import { withUserOrganizations } from '../../../components/hoc/withUserOrganizations';
 import {
+  bindAlmOrganization,
   getAlmAppInfo,
   getAlmOrganization,
-  bindAlmOrganization
+  listUnboundApplications
 } from '../../../api/alm-integration';
 import { getSubscriptionPlans } from '../../../api/billing';
 import {
-  LoggedInUser,
-  Organization,
-  SubscriptionPlan,
   AlmApplication,
   AlmOrganization,
-  OrganizationBase
+  AlmUnboundApplication,
+  LoggedInUser,
+  Organization,
+  OrganizationBase,
+  SubscriptionPlan
 } from '../../../app/types';
 import { hasAdvancedALMIntegration, isPersonal } from '../../../helpers/almIntegrations';
 import { translate } from '../../../helpers/l10n';
@@ -72,6 +74,7 @@ interface State {
   almApplication?: AlmApplication;
   almOrganization?: AlmOrganization;
   almOrgLoading: boolean;
+  almUnboundApplications: AlmUnboundApplication[];
   loading: boolean;
   organization?: Organization;
   subscriptionPlans?: SubscriptionPlan[];
@@ -86,7 +89,7 @@ interface LocationState {
 
 export class CreateOrganization extends React.PureComponent<Props & WithRouterProps, State> {
   mounted = false;
-  state: State = { almOrgLoading: false, loading: true };
+  state: State = { almOrgLoading: false, almUnboundApplications: [], loading: true };
 
   componentDidMount() {
     this.mounted = true;
@@ -101,11 +104,26 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
       const query = parseQuery(this.props.location.query);
       if (query.almInstallId) {
         this.fetchAlmOrganization(query.almInstallId);
+      } else {
+        initRequests.push(this.fetchAlmUnboundApplications());
       }
     }
     Promise.all(initRequests).then(this.stopLoading, this.stopLoading);
   }
 
+  componentDidUpdate(prevProps: WithRouterProps) {
+    const prevQuery = parseQuery(prevProps.location.query);
+    const query = parseQuery(this.props.location.query);
+    if (this.state.almApplication && prevQuery.almInstallId !== query.almInstallId) {
+      if (query.almInstallId) {
+        this.fetchAlmOrganization(query.almInstallId);
+      } else {
+        this.setState({ almOrganization: undefined, loading: true });
+        this.fetchAlmUnboundApplications().then(this.stopLoading, this.stopLoading);
+      }
+    }
+  }
+
   componentWillUnmount() {
     this.mounted = false;
     document.body.classList.remove('white-page');
@@ -119,6 +137,14 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
     });
   };
 
+  fetchAlmUnboundApplications = () => {
+    return listUnboundApplications().then(({ applications }) => {
+      if (this.mounted) {
+        this.setState({ almUnboundApplications: applications });
+      }
+    });
+  };
+
   fetchValidOrgKey = (almOrganization: AlmOrganization) => {
     const key = slugify(almOrganization.key);
     const keys = [key, ...times(9, i => `${key}-${i + 1}`)];
@@ -237,6 +263,7 @@ export class CreateOrganization extends React.PureComponent<Props & WithRouterPr
             almApplication={almApplication}
             almInstallId={almInstallId}
             almOrganization={almOrganization}
+            almUnboundApplications={this.state.almUnboundApplications}
             createOrganization={this.props.createOrganization}
             onOrgCreated={this.handleOrgCreated}
             unboundOrganizations={this.props.userOrganizations.filter(
index b58cd5adf3d830ac88a9152a177a330a79210c78..6cd346021e2a726355b064d20a4afac859ff854e 100644 (file)
@@ -105,6 +105,7 @@ function shallowRender(props: Partial<AutoOrganizationCreate['props']> = {}) {
         key: 'bitbucket',
         name: 'BitBucket'
       }}
+      almUnboundApplications={[]}
       createOrganization={jest.fn()}
       onOrgCreated={jest.fn()}
       unboundOrganizations={[]}
index c11537a0ef466c85bfd9f01c3875662b3eefefda..c9fa537806c51b46bd67ae60f8ea7a8d3c856e6a 100644 (file)
@@ -19,7 +19,8 @@
  */
 import * as React from 'react';
 import { shallow } from 'enzyme';
-import ChooseRemoteOrganizationStep from '../ChooseRemoteOrganizationStep';
+import { ChooseRemoteOrganizationStep } from '../ChooseRemoteOrganizationStep';
+import { mockRouter, submit } from '../../../../helpers/testUtils';
 
 it('should render', () => {
   expect(shallowRender()).toMatchSnapshot();
@@ -29,8 +30,26 @@ it('should display an alert message', () => {
   expect(shallowRender({ almInstallId: 'foo' }).find('Alert')).toMatchSnapshot();
 });
 
+it('should display unbound installations', () => {
+  const installation = { installationId: '12345', name: 'Foo' };
+  const push = jest.fn();
+  const wrapper = shallowRender({
+    almUnboundApplications: [installation],
+    router: mockRouter({ push })
+  });
+  expect(wrapper).toMatchSnapshot();
+
+  wrapper.find('Select').prop<Function>('onChange')(installation);
+  submit(wrapper.find('form'));
+  expect(push).toHaveBeenCalledWith({
+    pathname: '/create-organization',
+    query: { installation_id: installation.installationId } // eslint-disable-line camelcase
+  });
+});
+
 function shallowRender(props: Partial<ChooseRemoteOrganizationStep['props']> = {}) {
   return shallow(
+    // @ts-ignore avoid passing everything from WithRouterProps
     <ChooseRemoteOrganizationStep
       almApplication={{
         backgroundColor: 'blue',
@@ -39,6 +58,8 @@ function shallowRender(props: Partial<ChooseRemoteOrganizationStep['props']> = {
         key: 'github',
         name: 'GitHub'
       }}
+      almUnboundApplications={[]}
+      router={mockRouter()}
       {...props}
     />
   ).dive();
index aa8b0b6c155686d9d017898ecde72817d852fbdf..b73259b90767e77718735ec6cad718f658c52de1 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { times } from 'lodash';
 import { Location } from 'history';
 import { shallow } from 'enzyme';
 import { CreateOrganization } from '../CreateOrganization';
 import { mockRouter, waitAndUpdate } from '../../../../helpers/testUtils';
 import { LoggedInUser } from '../../../../app/types';
-import { getAlmOrganization } from '../../../../api/alm-integration';
+import {
+  getAlmAppInfo,
+  getAlmOrganization,
+  listUnboundApplications
+} from '../../../../api/alm-integration';
+import { getSubscriptionPlans } from '../../../../api/billing';
+import { getOrganizations } from '../../../../api/organizations';
 
 jest.mock('../../../../api/billing', () => ({
   getSubscriptionPlans: jest
@@ -42,17 +49,18 @@ jest.mock('../../../../api/alm-integration', () => ({
     }
   }),
   getAlmOrganization: jest.fn().mockResolvedValue({
-    avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
+    avatar: 'my-avatar',
     description: 'Continuous Code Quality',
     key: 'sonarsource',
     name: 'SonarSource',
     personal: false,
     url: 'https://www.sonarsource.com'
-  })
+  }),
+  listUnboundApplications: jest.fn().mockResolvedValue({ applications: [] })
 }));
 
 jest.mock('../../../../api/organizations', () => ({
-  getOrganization: jest.fn().mockResolvedValue(undefined)
+  getOrganizations: jest.fn().mockResolvedValue({ organizations: [] })
 }));
 
 const user: LoggedInUser = {
@@ -64,10 +72,20 @@ const user: LoggedInUser = {
   showOnboardingTutorial: false
 };
 
+beforeEach(() => {
+  (getAlmAppInfo as jest.Mock<any>).mockClear();
+  (getAlmOrganization as jest.Mock<any>).mockClear();
+  (listUnboundApplications as jest.Mock<any>).mockClear();
+  (getSubscriptionPlans as jest.Mock<any>).mockClear();
+  (getOrganizations as jest.Mock<any>).mockClear();
+});
+
 it('should render with manual tab displayed', async () => {
   const wrapper = shallowRender();
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
+  expect(getSubscriptionPlans).toHaveBeenCalled();
+  expect(getAlmAppInfo).not.toHaveBeenCalled();
 });
 
 it('should preselect paid plan on manual creation', async () => {
@@ -82,6 +100,8 @@ it('should render with auto tab displayed', async () => {
   const wrapper = shallowRender({ currentUser: { ...user, externalProvider: 'github' } });
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
+  expect(getAlmAppInfo).toHaveBeenCalled();
+  expect(listUnboundApplications).toHaveBeenCalled();
 });
 
 it('should render with auto tab selected and manual disabled', async () => {
@@ -92,13 +112,16 @@ it('should render with auto tab selected and manual disabled', async () => {
   expect(wrapper).toMatchSnapshot();
   await waitAndUpdate(wrapper);
   expect(wrapper).toMatchSnapshot();
+  expect(getAlmAppInfo).toHaveBeenCalled();
+  expect(getAlmOrganization).toHaveBeenCalled();
+  expect(getOrganizations).toHaveBeenCalled();
 });
 
 it('should render with auto personal organization bind page', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
     key: 'foo',
     name: 'Foo',
-    avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
+    avatar: 'my-avatar',
     personal: true
   });
   const wrapper = shallowRender({
@@ -112,18 +135,24 @@ it('should render with auto personal organization bind page', async () => {
 
 it('should slugify and find a uniq organization key', async () => {
   (getAlmOrganization as jest.Mock<any>).mockResolvedValueOnce({
+    avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
     key: 'Foo&Bar',
     name: 'Foo & Bar',
-    avatar: 'https://avatars3.githubusercontent.com/u/37629810?v=4',
-    type: 'USER'
+    personal: true
+  });
+  (getOrganizations as jest.Mock<any>).mockResolvedValueOnce({
+    organizations: [{ key: 'foo-and-bar' }, { key: 'foo-and-bar-1' }]
   });
   const wrapper = shallowRender({
     currentUser: { ...user, externalProvider: 'github' },
     location: { query: { installation_id: 'foo' } } as Location // eslint-disable-line camelcase
   });
   await waitAndUpdate(wrapper);
+  expect(getOrganizations).toHaveBeenCalledWith({
+    organizations: ['foo-and-bar', ...times(9, i => `foo-and-bar-${i + 1}`)].join(',')
+  });
   expect(wrapper.find('AutoOrganizationCreate').prop('almOrganization')).toMatchObject({
-    key: 'foo-and-bar'
+    key: 'foo-and-bar-2'
   });
 });
 
@@ -147,6 +176,17 @@ it('should switch tabs', async () => {
   expect(wrapper.find('AutoOrganizationCreate').exists()).toBeTruthy();
 });
 
+it('should reload the alm organization when the url query changes', async () => {
+  const wrapper = shallowRender({ currentUser: { ...user, externalProvider: 'github' } });
+  await waitAndUpdate(wrapper);
+  expect(getAlmOrganization).not.toHaveBeenCalled();
+  wrapper.setProps({ location: { query: { installation_id: 'foo' } } }); // eslint-disable-line camelcase
+  expect(getAlmOrganization).toHaveBeenCalledWith({ installationId: 'foo' });
+  wrapper.setProps({ location: { query: {} } });
+  expect(wrapper.state('almOrganization')).toBeUndefined();
+  expect(listUnboundApplications).toHaveBeenCalledTimes(2);
+});
+
 function shallowRender(props: Partial<CreateOrganization['props']> = {}) {
   return shallow(
     <CreateOrganization
index 56a6fe28fbb55ad36ed9bc11d092525490c08068..b0aa2e02a1a1b1ece4dac17182c0bf8a2dcba11e 100644 (file)
@@ -56,7 +56,7 @@ exports[`should display choice between import or creation 1`] = `
           },
         ]
       }
-      value="none"
+      value={null}
     />
   </div>
 </OrganizationDetailsStep>
@@ -121,7 +121,7 @@ exports[`should render prefilled and create org 1`] = `
 `;
 
 exports[`should render with import org button 1`] = `
-<ChooseRemoteOrganizationStep
+<withRouter(ChooseRemoteOrganizationStep)
   almApplication={
     Object {
       "backgroundColor": "#0052CC",
@@ -131,5 +131,6 @@ exports[`should render with import org button 1`] = `
       "name": "BitBucket",
     }
   }
+  almUnboundApplications={Array []}
 />
 `;
index 6a1c633b1a2e01bd5f27f0c29ae7d1c66dc922ca..77778ab5130ea4fa9ede0dbff15886a8ebecb296 100644 (file)
@@ -17,6 +17,115 @@ exports[`should display an alert message 1`] = `
 </Alert>
 `;
 
+exports[`should display unbound installations 1`] = `
+<div
+  className="boxed-group onboarding-step is-open"
+>
+  <div
+    className="onboarding-step-number"
+  >
+    1
+  </div>
+  <div
+    className="boxed-group-header"
+  >
+    <h2>
+      onboarding.import_organization.import_org_details
+    </h2>
+  </div>
+  <div
+    className=""
+  >
+    <div
+      className="boxed-group-inner"
+    >
+      <div
+        className="display-flex-center"
+      >
+        <div
+          className="display-inline-block abs-width-400"
+        >
+          <IdentityProviderLink
+            className="display-inline-block"
+            identityProvider={
+              Object {
+                "backgroundColor": "blue",
+                "iconPath": "icon/path",
+                "installationUrl": "https://alm.application.url",
+                "key": "github",
+                "name": "GitHub",
+              }
+            }
+            small={true}
+            url="https://alm.application.url"
+          >
+            onboarding.import_organization.choose_organization_button.github
+          </IdentityProviderLink>
+        </div>
+        <div
+          className="display-flex-stretch"
+        >
+          <div
+            className="vertical-pipe-separator"
+          >
+            <div
+              className="vertical-separator "
+            />
+            <span
+              className="note"
+            >
+              or
+            </span>
+            <div
+              className="vertical-separator"
+            />
+          </div>
+          <form
+            className="big-spacer-top big-spacer-bottom"
+            onSubmit={[Function]}
+          >
+            <div
+              className="form-field abs-width-400"
+            >
+              <label
+                htmlFor="select-unbound-installation"
+              >
+                onboarding.import_organization.choose_unbound_installation.github
+              </label>
+              <Select
+                className="input-super-large"
+                clearable={false}
+                id="select-unbound-installation"
+                labelKey="name"
+                onChange={[Function]}
+                optionRenderer={[Function]}
+                options={
+                  Array [
+                    Object {
+                      "installationId": "12345",
+                      "name": "Foo",
+                    },
+                  ]
+                }
+                placeholder="onboarding.import_organization.choose_organization"
+                value=""
+                valueKey="installationId"
+                valueRenderer={[Function]}
+              />
+            </div>
+            <SubmitButton
+              disabled={true}
+            >
+              continue
+            </SubmitButton>
+          </form>
+        </div>
+      </div>
+    </div>
+  </div>
+</div>
+`;
+
 exports[`should render 1`] = `
 <div
   className="boxed-group onboarding-step is-open"
@@ -39,22 +148,30 @@ exports[`should render 1`] = `
     <div
       className="boxed-group-inner"
     >
-      <IdentityProviderLink
-        className="display-inline-block"
-        identityProvider={
-          Object {
-            "backgroundColor": "blue",
-            "iconPath": "icon/path",
-            "installationUrl": "https://alm.application.url",
-            "key": "github",
-            "name": "GitHub",
-          }
-        }
-        small={true}
-        url="https://alm.application.url"
+      <div
+        className="display-flex-center"
       >
-        onboarding.import_organization.choose_organization_button.github
-      </IdentityProviderLink>
+        <div
+          className="display-inline-block abs-width-400"
+        >
+          <IdentityProviderLink
+            className="display-inline-block"
+            identityProvider={
+              Object {
+                "backgroundColor": "blue",
+                "iconPath": "icon/path",
+                "installationUrl": "https://alm.application.url",
+                "key": "github",
+                "name": "GitHub",
+              }
+            }
+            small={true}
+            url="https://alm.application.url"
+          >
+            onboarding.import_organization.choose_organization_button.github
+          </IdentityProviderLink>
+        </div>
+      </div>
     </div>
   </div>
 </div>
index 11acf9d7abaf3d38fd9a59cc3cd74d3d6ca27316..741ed46784e196cf0f3578e0f0420b9882e649b4 100644 (file)
@@ -61,7 +61,7 @@ exports[`should render with auto personal organization bind page 2`] = `
       almInstallId="foo"
       almOrganization={
         Object {
-          "avatar": "https://avatars3.githubusercontent.com/u/37629810?v=4",
+          "avatar": "my-avatar",
           "key": "foo",
           "name": "Foo",
           "personal": true,
@@ -148,6 +148,7 @@ exports[`should render with auto tab displayed 1`] = `
           "name": "GitHub",
         }
       }
+      almUnboundApplications={Array []}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
@@ -240,7 +241,7 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
       almInstallId="foo"
       almOrganization={
         Object {
-          "avatar": "https://avatars3.githubusercontent.com/u/37629810?v=4",
+          "avatar": "my-avatar",
           "description": "Continuous Code Quality",
           "key": "sonarsource",
           "name": "SonarSource",
@@ -248,6 +249,7 @@ exports[`should render with auto tab selected and manual disabled 2`] = `
           "url": "https://www.sonarsource.com",
         }
       }
+      almUnboundApplications={Array []}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
@@ -392,6 +394,7 @@ exports[`should switch tabs 1`] = `
           "name": "GitHub",
         }
       }
+      almUnboundApplications={Array []}
       onOrgCreated={[Function]}
       unboundOrganizations={
         Array [
index 71795b85c7876a621950e4ae9f2c9ab23bac70d0..e5c92d2816480017e768bdc9bbe8324d022fe6d2 100644 (file)
 import { memoize } from 'lodash';
 import { translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure } from '../../../helpers/measures';
-import { RawQuery, parseAsOptionalString } from '../../../helpers/query';
+import {
+  RawQuery,
+  parseAsOptionalString,
+  cleanQuery,
+  serializeString
+} from '../../../helpers/query';
+import { isBitbucket, isGithub } from '../../../helpers/almIntegrations';
 
 export function formatPrice(price?: number, noSign?: boolean) {
   const priceFormatted = formatMeasure(price, 'FLOAT')
@@ -47,3 +53,10 @@ export const parseQuery = memoize(
     };
   }
 );
+
+export const serializeQuery = (query: Query): RawQuery =>
+  cleanQuery({
+    // eslint-disable-next-line camelcase
+    installation_id: isGithub(query.almKey) ? serializeString(query.almInstallId) : undefined,
+    clientKey: isBitbucket(query.almKey) ? serializeString(query.almInstallId) : undefined
+  });
index 4b9175d39f884aaa3a903aa650e5686ed398468e..b8f7aec17ba0760a1bbe555207657b40927ebc96 100644 (file)
@@ -112,6 +112,7 @@ no_tags=No tags
 not_now=Not now
 off=Off
 on=On
+or=Or
 organization_key=Organization Key
 open=Open
 optional=Optional
@@ -2754,6 +2755,8 @@ onboarding.create_organization.enter_your_coupon=Enter your coupon
 onboarding.create_organization.create_and_upgrade=Create Organization and Upgrade
 onboarding.create_organization.ready=All set! Your organization is now ready to go
 onboarding.import_organization.bind=Bind Organization
+onboarding.import_organization.choose_unbound_installation.bitbucket=Choose one of your Bitbucket teams that already have the SonarCloud application installed:
+onboarding.import_organization.choose_unbound_installation.github=Choose one of your GitHub organizations that already have the SonarCloud application installed:
 onboarding.import_organization.import=Import Organization
 onboarding.import_organization.import_org_details=Import organization details
 onboarding.import_organization.org_not_found=We were not able to find the requested organization, here are a few tips to help you troubleshoot the issue: