]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14059 Enable import of Azure repositories
authorJeremy Davis <jeremy.davis@sonarsource.com>
Tue, 17 Nov 2020 16:17:26 +0000 (17:17 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 25 Nov 2020 20:06:27 +0000 (20:06 +0000)
13 files changed:
server/sonar-web/src/main/js/api/alm-integrations.ts
server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreate.tsx
server/sonar-web/src/main/js/apps/create/project/AzureProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/AzureProjectsList.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectAccordion-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreate-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectCreateRenderer-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectsList-test.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectAccordion-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreate-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectCreateRenderer-test.tsx.snap
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectsList-test.tsx.snap

index 7aed2350b283b6f686161fe1b5a033c45a57fdbe..dc780b2fdef49454acc3fdd98b9a26958fddbb8e 100644 (file)
@@ -70,6 +70,18 @@ export function searchAzureRepositories(
   );
 }
 
+export function importAzureRepository(
+  almSetting: string,
+  projectName: string,
+  repositoryName: string
+): Promise<{ project: ProjectBase }> {
+  return postJSON('/api/alm_integrations/import_azure_project', {
+    almSetting,
+    projectName,
+    repositoryName
+  }).catch(throwGlobalError);
+}
+
 export function getBitbucketServerProjects(
   almSetting: string
 ): Promise<{ projects: BitbucketProject[] }> {
index 20c4e07924352cba68ae6831e5f14f1896aa22aa..87d2f5a1d8fbb37ad1b489815744441e4cfb702a 100644 (file)
@@ -23,6 +23,7 @@ import { FormattedMessage } from 'react-intl';
 import { Link } from 'react-router';
 import BoxedGroupAccordion from 'sonar-ui-common/components/controls/BoxedGroupAccordion';
 import ListFooter from 'sonar-ui-common/components/controls/ListFooter';
+import Radio from 'sonar-ui-common/components/controls/Radio';
 import { Alert } from 'sonar-ui-common/components/ui/Alert';
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { translate } from 'sonar-ui-common/helpers/l10n';
@@ -30,17 +31,20 @@ import { AzureProject, AzureRepository } from '../../../types/alm-integration';
 import { CreateProjectModes } from './types';
 
 export interface AzureProjectAccordionProps {
+  importing: boolean;
   loading: boolean;
   onOpen: (key: string) => void;
-  startsOpen: boolean;
+  onSelectRepository: (repository: AzureRepository) => void;
   project: AzureProject;
   repositories?: AzureRepository[];
+  selectedRepository?: AzureRepository;
+  startsOpen: boolean;
 }
 
 const PAGE_SIZE = 30;
 
 export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
-  const { loading, startsOpen, project, repositories = [] } = props;
+  const { importing, loading, startsOpen, project, repositories = [], selectedRepository } = props;
 
   const [open, setOpen] = React.useState(startsOpen);
   const handleClick = () => {
@@ -86,13 +90,19 @@ export default function AzureProjectAccordion(props: AzureProjectAccordionProps)
             <>
               <div className="display-flex-wrap">
                 {limitedRepositories.map(repo => (
-                  <div
-                    className="abs-width-400 overflow-hidden spacer-top spacer-bottom"
-                    key={repo.name}>
+                  <Radio
+                    checked={selectedRepository?.name === repo.name}
+                    className={classNames(
+                      'display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden',
+                      importing && ['disabled', 'text-muted', 'link-no-underline']
+                    )}
+                    key={repo.name}
+                    onCheck={() => !importing && props.onSelectRepository(repo)}
+                    value={repo.name}>
                     <strong className="text-ellipsis" title={repo.name}>
                       {repo.name}
                     </strong>
-                  </div>
+                  </Radio>
                 ))}
               </div>
               <ListFooter
index 50decd8e76dd6a27ae5739beee6fb888fd0d2b56..840cdd5b83c297be37e1b66e2e792d9283e36748 100644 (file)
@@ -24,6 +24,7 @@ import {
   checkPersonalAccessTokenIsValid,
   getAzureProjects,
   getAzureRepositories,
+  importAzureRepository,
   searchAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../api/alm-integrations';
@@ -39,6 +40,7 @@ interface Props extends Pick<WithRouterProps, 'location' | 'router'> {
 }
 
 interface State {
+  importing: boolean;
   loading: boolean;
   loadingRepositories: T.Dict<boolean>;
   patIsValid?: boolean;
@@ -46,6 +48,7 @@ interface State {
   repositories: T.Dict<AzureRepository[]>;
   searching?: boolean;
   searchResults?: T.Dict<AzureRepository[]>;
+  selectedRepository?: AzureRepository;
   settings?: AlmSettingsInstance;
   submittingToken?: boolean;
   tokenValidationFailed: boolean;
@@ -60,6 +63,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
       // For now, we only handle a single instance. So we always use the first
       // one from the list.
       settings: props.settings[0],
+      importing: false,
       loading: false,
       loadingRepositories: {},
       repositories: {},
@@ -195,6 +199,35 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
     }
   };
 
+  handleImportRepository = async () => {
+    const { selectedRepository, settings } = this.state;
+
+    if (!settings || !selectedRepository) {
+      return;
+    }
+
+    this.setState({ importing: true });
+
+    const createdProject = await importAzureRepository(
+      settings.key,
+      selectedRepository.projectName,
+      selectedRepository.name
+    )
+      .then(({ project }) => project)
+      .catch(() => undefined);
+
+    if (this.mounted) {
+      this.setState({ importing: false });
+      if (createdProject) {
+        this.props.onProjectCreate([createdProject.key]);
+      }
+    }
+  };
+
+  handleSelectRepository = (selectedRepository: AzureRepository) => {
+    this.setState({ selectedRepository });
+  };
+
   checkPersonalAccessToken = () => {
     const { settings } = this.state;
 
@@ -236,6 +269,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
   render() {
     const { canAdmin, loadingBindings, location } = this.props;
     const {
+      importing,
       loading,
       loadingRepositories,
       patIsValid,
@@ -243,6 +277,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
       repositories,
       searching,
       searchResults,
+      selectedRepository,
       settings,
       submittingToken,
       tokenValidationFailed
@@ -251,15 +286,19 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
     return (
       <AzureCreateProjectRenderer
         canAdmin={canAdmin}
+        importing={importing}
         loading={loading || loadingBindings}
         loadingRepositories={loadingRepositories}
+        onImportRepository={this.handleImportRepository}
         onOpenProject={this.handleOpenProject}
         onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
         onSearch={this.handleSearchRepositories}
+        onSelectRepository={this.handleSelectRepository}
         projects={projects}
         repositories={repositories}
         searching={searching}
         searchResults={searchResults}
+        selectedRepository={selectedRepository}
         settings={settings}
         showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
         submittingToken={submittingToken}
index af23dc3ff9ea53707f3a43f71efb611bdc923ebe..58b810243ed18a0baf769a6c00cf58cc641e3f87 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { Button } from 'sonar-ui-common/components/controls/buttons';
 import SearchBox from 'sonar-ui-common/components/controls/SearchBox';
 import DeferredSpinner from 'sonar-ui-common/components/ui/DeferredSpinner';
 import { translate } from 'sonar-ui-common/helpers/l10n';
@@ -31,15 +32,19 @@ import WrongBindingCountAlert from './WrongBindingCountAlert';
 
 export interface AzureProjectCreateRendererProps {
   canAdmin?: boolean;
+  importing: boolean;
   loading: boolean;
   loadingRepositories: T.Dict<boolean>;
+  onImportRepository: () => void;
   onOpenProject: (key: string) => void;
   onPersonalAccessTokenCreate: (token: string) => void;
   onSearch: (query: string) => void;
+  onSelectRepository: (repository: AzureRepository) => void;
   projects?: AzureProject[];
   repositories: T.Dict<AzureRepository[]>;
   searching?: boolean;
   searchResults?: T.Dict<AzureRepository[]>;
+  selectedRepository?: AzureRepository;
   settings?: AlmSettingsInstance;
   showPersonalAccessTokenForm?: boolean;
   submittingToken?: boolean;
@@ -49,14 +54,16 @@ export interface AzureProjectCreateRendererProps {
 export default function AzureProjectCreateRenderer(props: AzureProjectCreateRendererProps) {
   const {
     canAdmin,
+    importing,
     loading,
     loadingRepositories,
     projects,
     repositories,
     searching,
     searchResults,
-    showPersonalAccessTokenForm,
+    selectedRepository,
     settings,
+    showPersonalAccessTokenForm,
     submittingToken,
     tokenValidationFailed
   } = props;
@@ -64,6 +71,19 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
   return (
     <>
       <CreateProjectPageHeader
+        additionalActions={
+          !showPersonalAccessTokenForm && (
+            <div className="display-flex-center pull-right">
+              <DeferredSpinner className="spacer-right" loading={importing} />
+              <Button
+                className="button-large button-primary"
+                disabled={!selectedRepository || importing}
+                onClick={props.onImportRepository}>
+                {translate('onboarding.create_project.import_selected_repo')}
+              </Button>
+            </div>
+          )
+        }
         title={
           <span className="text-middle">
             <img
@@ -104,11 +124,14 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
             </div>
             <DeferredSpinner loading={Boolean(searching)}>
               <AzureProjectsList
+                importing={importing}
                 loadingRepositories={loadingRepositories}
                 onOpenProject={props.onOpenProject}
+                onSelectRepository={props.onSelectRepository}
                 projects={projects}
                 repositories={repositories}
                 searchResults={searchResults}
+                selectedRepository={selectedRepository}
               />
             </DeferredSpinner>
           </>
index 344e3e204f0880b4191973731fcf6a764e5f25ba..86e109f3326d4886511799aa2e37a67aa08329c6 100644 (file)
@@ -28,17 +28,27 @@ import AzureProjectAccordion from './AzureProjectAccordion';
 import { CreateProjectModes } from './types';
 
 export interface AzureProjectsListProps {
+  importing: boolean;
   loadingRepositories: T.Dict<boolean>;
   onOpenProject: (key: string) => void;
+  onSelectRepository: (repository: AzureRepository) => void;
   projects?: AzureProject[];
   repositories: T.Dict<AzureRepository[]>;
   searchResults?: T.Dict<AzureRepository[]>;
+  selectedRepository?: AzureRepository;
 }
 
 const PAGE_SIZE = 10;
 
 export default function AzureProjectsList(props: AzureProjectsListProps) {
-  const { loadingRepositories, projects = [], repositories, searchResults } = props;
+  const {
+    importing,
+    loadingRepositories,
+    projects = [],
+    repositories,
+    searchResults,
+    selectedRepository
+  } = props;
 
   const [page, setPage] = React.useState(1);
 
@@ -83,10 +93,13 @@ export default function AzureProjectsList(props: AzureProjectsListProps) {
       {displayedProjects.map((p, i) => (
         <AzureProjectAccordion
           key={`${p.key}${keySuffix}`}
+          importing={importing}
           loading={Boolean(loadingRepositories[p.key])}
           onOpen={props.onOpenProject}
+          onSelectRepository={props.onSelectRepository}
           project={p}
           repositories={searchResults ? searchResults[p.key] : repositories[p.key]}
+          selectedRepository={selectedRepository}
           startsOpen={searchResults !== undefined || i === 0}
         />
       ))}
index b9e7f3012b6060d87187ff247dddda24a3f2af5e..394205a78522b1e752e6849d5510973414e03b27 100644 (file)
@@ -30,6 +30,9 @@ it('should render correctly', () => {
   expect(shallowRender({ repositories: [mockAzureRepository()] })).toMatchSnapshot(
     'with a repository'
   );
+  expect(shallowRender({ importing: true, repositories: [mockAzureRepository()] })).toMatchSnapshot(
+    'importing'
+  );
 });
 
 it('should open when clicked', () => {
@@ -95,7 +98,9 @@ it('should close when clicked', () => {
 function shallowRender(overrides: Partial<AzureProjectAccordionProps> = {}) {
   return shallow(
     <AzureProjectAccordion
+      importing={false}
       loading={false}
+      onSelectRepository={jest.fn()}
       onOpen={jest.fn()}
       project={mockAzureProject()}
       startsOpen={true}
index c67d4dcc691a4dd71e4e7e1190a95dbc84f653b7..b636e0d0a8c884996736dbcc2647f260a36f7d30 100644 (file)
@@ -25,6 +25,7 @@ import {
   checkPersonalAccessTokenIsValid,
   getAzureProjects,
   getAzureRepositories,
+  importAzureRepository,
   searchAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../../api/alm-integrations';
@@ -40,7 +41,8 @@ jest.mock('../../../../api/alm-integrations', () => {
     setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null),
     getAzureProjects: jest.fn().mockResolvedValue({ projects: [] }),
     getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }),
-    searchAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] })
+    searchAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }),
+    importAzureRepository: jest.fn().mockResolvedValue({ project: { key: 'baz' } })
   };
 });
 
@@ -169,6 +171,43 @@ it('should handle searching for repositories', async () => {
   expect(wrapper.state().searchResults).toBeUndefined();
 });
 
+it('should select and import a repository', async () => {
+  const onProjectCreate = jest.fn();
+  const repository = mockAzureRepository();
+  const wrapper = shallowRender({ onProjectCreate });
+  await waitAndUpdate(wrapper);
+
+  expect(wrapper.state().selectedRepository).toBeUndefined();
+  wrapper.instance().handleSelectRepository(repository);
+  expect(wrapper.state().selectedRepository).toBe(repository);
+
+  wrapper.instance().handleImportRepository();
+  expect(wrapper.state().importing).toBe(true);
+  expect(importAzureRepository).toBeCalledWith('foo', repository.projectName, repository.name);
+  await waitAndUpdate(wrapper);
+
+  expect(onProjectCreate).toBeCalledWith(['baz']);
+  expect(wrapper.state().importing).toBe(false);
+});
+
+it('should handle no settings', () => {
+  const wrapper = shallowRender({ settings: [] });
+
+  wrapper.instance().fetchAzureProjects();
+  wrapper.instance().fetchAzureRepositories('whatever');
+  wrapper.instance().handleSearchRepositories('query');
+  wrapper.instance().handleImportRepository();
+  wrapper.instance().checkPersonalAccessToken();
+  wrapper.instance().handlePersonalAccessTokenCreate('');
+
+  expect(getAzureProjects).not.toBeCalled();
+  expect(getAzureRepositories).not.toBeCalled();
+  expect(searchAzureRepositories).not.toBeCalled();
+  expect(importAzureRepository).not.toBeCalled();
+  expect(checkPersonalAccessTokenIsValid).not.toBeCalled();
+  expect(setAlmPersonalAccessToken).not.toBeCalled();
+});
+
 function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) {
   return shallow<AzureProjectCreate>(
     <AzureProjectCreate
index 904ca4c0397e40602a7c5db0f28d3d6d337f9af7..9621da1322df6b67530b3853edbd3c0c4969ce9b 100644 (file)
@@ -40,11 +40,14 @@ function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) {
   return shallow(
     <AzureProjectCreateRenderer
       canAdmin={true}
+      importing={false}
       loading={false}
       loadingRepositories={{}}
+      onImportRepository={jest.fn()}
       onOpenProject={jest.fn()}
       onPersonalAccessTokenCreate={jest.fn()}
       onSearch={jest.fn()}
+      onSelectRepository={jest.fn()}
       projects={[project]}
       repositories={{ [project.key]: [mockAzureRepository()] }}
       tokenValidationFailed={false}
index ec8d2f2629e88e2b7ced553ce0aa9dbedb0fcaa3..e98f073ce542bc0ad1f63208515b4584251bb665 100644 (file)
@@ -62,8 +62,10 @@ function shallowRender(overrides: Partial<AzureProjectsListProps> = {}) {
 
   return shallow(
     <AzureProjectsList
+      importing={false}
       loadingRepositories={{}}
       onOpenProject={jest.fn()}
+      onSelectRepository={jest.fn()}
       projects={[project]}
       repositories={{ [project.key]: [] }}
       {...overrides}
index 0d0e57cdbde1e52499c9e69f57d870db1c78c287..15116a710babca24975ba9106e31e284ef79d6c2 100644 (file)
@@ -13,6 +13,47 @@ exports[`should render correctly: closed 1`] = `
 />
 `;
 
+exports[`should render correctly: importing 1`] = `
+<BoxedGroupAccordion
+  className="big-spacer-bottom open"
+  onClick={[Function]}
+  open={true}
+  title={
+    <h3>
+      Azure Project
+    </h3>
+  }
+>
+  <DeferredSpinner
+    loading={false}
+  >
+    <div
+      className="display-flex-wrap"
+    >
+      <Radio
+        checked={false}
+        className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden disabled text-muted link-no-underline"
+        key="Azure repo 1"
+        onCheck={[Function]}
+        value="Azure repo 1"
+      >
+        <strong
+          className="text-ellipsis"
+          title="Azure repo 1"
+        >
+          Azure repo 1
+        </strong>
+      </Radio>
+    </div>
+    <ListFooter
+      count={1}
+      loadMore={[Function]}
+      total={1}
+    />
+  </DeferredSpinner>
+</BoxedGroupAccordion>
+`;
+
 exports[`should render correctly: loading 1`] = `
 <BoxedGroupAccordion
   className="big-spacer-bottom open"
@@ -56,9 +97,12 @@ exports[`should render correctly: with a repository 1`] = `
     <div
       className="display-flex-wrap"
     >
-      <div
-        className="abs-width-400 overflow-hidden spacer-top spacer-bottom"
+      <Radio
+        checked={false}
+        className="display-flex-start spacer-right spacer-bottom create-project-import-bbs-repo overflow-hidden"
         key="Azure repo 1"
+        onCheck={[Function]}
+        value="Azure repo 1"
       >
         <strong
           className="text-ellipsis"
@@ -66,7 +110,7 @@ exports[`should render correctly: with a repository 1`] = `
         >
           Azure repo 1
         </strong>
-      </div>
+      </Radio>
     </div>
     <ListFooter
       count={1}
index d9116ea8b5d25044ff81572feecbf710bb9c688e..a3c1a9a74d752ac1dcac27553aa8e29befbd50dd 100644 (file)
@@ -3,11 +3,14 @@
 exports[`should render correctly 1`] = `
 <AzureProjectCreateRenderer
   canAdmin={true}
+  importing={false}
   loading={true}
   loadingRepositories={Object {}}
+  onImportRepository={[Function]}
   onOpenProject={[Function]}
   onPersonalAccessTokenCreate={[Function]}
   onSearch={[Function]}
+  onSelectRepository={[Function]}
   repositories={Object {}}
   settings={
     Object {
index 4d6058f355aa80a94b9d26bea555b2b8d0d79ad2..cc34d8163eea497336bc81c3785e7ea7af5039ec 100644 (file)
@@ -3,6 +3,23 @@
 exports[`should render correctly: loading 1`] = `
 <Fragment>
   <CreateProjectPageHeader
+    additionalActions={
+      <div
+        className="display-flex-center pull-right"
+      >
+        <DeferredSpinner
+          className="spacer-right"
+          loading={false}
+        />
+        <Button
+          className="button-large button-primary"
+          disabled={true}
+          onClick={[MockFunction]}
+        >
+          onboarding.create_project.import_selected_repo
+        </Button>
+      </div>
+    }
     title={
       <span
         className="text-middle"
@@ -26,6 +43,23 @@ exports[`should render correctly: loading 1`] = `
 exports[`should render correctly: no settings 1`] = `
 <Fragment>
   <CreateProjectPageHeader
+    additionalActions={
+      <div
+        className="display-flex-center pull-right"
+      >
+        <DeferredSpinner
+          className="spacer-right"
+          loading={false}
+        />
+        <Button
+          className="button-large button-primary"
+          disabled={true}
+          onClick={[MockFunction]}
+        >
+          onboarding.create_project.import_selected_repo
+        </Button>
+      </div>
+    }
     title={
       <span
         className="text-middle"
@@ -50,6 +84,23 @@ exports[`should render correctly: no settings 1`] = `
 exports[`should render correctly: project list 1`] = `
 <Fragment>
   <CreateProjectPageHeader
+    additionalActions={
+      <div
+        className="display-flex-center pull-right"
+      >
+        <DeferredSpinner
+          className="spacer-right"
+          loading={false}
+        />
+        <Button
+          className="button-large button-primary"
+          disabled={true}
+          onClick={[MockFunction]}
+        >
+          onboarding.create_project.import_selected_repo
+        </Button>
+      </div>
+    }
     title={
       <span
         className="text-middle"
@@ -76,8 +127,10 @@ exports[`should render correctly: project list 1`] = `
     loading={false}
   >
     <AzureProjectsList
+      importing={false}
       loadingRepositories={Object {}}
       onOpenProject={[MockFunction]}
+      onSelectRepository={[MockFunction]}
       projects={
         Array [
           Object {
@@ -104,6 +157,7 @@ exports[`should render correctly: project list 1`] = `
 exports[`should render correctly: token form 1`] = `
 <Fragment>
   <CreateProjectPageHeader
+    additionalActions={false}
     title={
       <span
         className="text-middle"
index 28b54f922c403839b2ce6ee9f61f148a358955be..0b83bb8c566cab684c221a35b81c17d93470b3e8 100644 (file)
@@ -3,9 +3,11 @@
 exports[`should render correctly: default 1`] = `
 <div>
   <AzureProjectAccordion
+    importing={false}
     key="azure-project-1"
     loading={false}
     onOpen={[MockFunction]}
+    onSelectRepository={[MockFunction]}
     project={
       Object {
         "key": "azure-project-1",
@@ -57,9 +59,11 @@ exports[`should render correctly: empty 1`] = `
 exports[`should render search results correctly: default 1`] = `
 <div>
   <AzureProjectAccordion
+    importing={false}
     key="p2 - result"
     loading={false}
     onOpen={[MockFunction]}
+    onSelectRepository={[MockFunction]}
     project={
       Object {
         "key": "p2",