]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14057 Display Azure Projects and Repositories
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 16 Nov 2020 14:00:59 +0000 (15:00 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 25 Nov 2020 20:06:26 +0000 (20:06 +0000)
19 files changed:
server/sonar-web/src/main/js/api/alm-integrations.ts
server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx [new file with mode: 0644]
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/CreateProjectPage.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectAccordion-test.tsx [new file with mode: 0644]
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 [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectAccordion-test.tsx.snap [new file with mode: 0644]
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 [new file with mode: 0644]
server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/CreateProjectPage-test.tsx.snap
server/sonar-web/src/main/js/apps/settings/components/almIntegration/__tests__/__snapshots__/AzureForm-test.tsx.snap
server/sonar-web/src/main/js/helpers/mocks/alm-integrations.ts
server/sonar-web/src/main/js/types/alm-integration.ts
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index e013a3721df6e371f4f75f4de4157f970d24fbab..3824f97bb2c7c5ab4b699db948612a8f4fce5107 100644 (file)
@@ -20,6 +20,8 @@
 import { get, getJSON, post, postJSON } from 'sonar-ui-common/helpers/request';
 import throwGlobalError from '../app/utils/throwGlobalError';
 import {
+  AzureProject,
+  AzureRepository,
   BitbucketProject,
   BitbucketRepository,
   GithubOrganization,
@@ -44,6 +46,21 @@ export function checkPersonalAccessTokenIsValid(almSetting: string): Promise<boo
     });
 }
 
+export function getAzureProjects(almSetting: string): Promise<{ projects: AzureProject[] }> {
+  return getJSON('/api/alm_integrations/list_azure_projects', { almSetting }).catch(
+    throwGlobalError
+  );
+}
+
+export function getAzureRepositories(
+  almSetting: string,
+  projectName: string
+): Promise<{ repositories: AzureRepository[] }> {
+  return getJSON('/api/alm_integrations/search_azure_repos', { almSetting, projectName }).catch(
+    throwGlobalError
+  );
+}
+
 export function getBitbucketServerProjects(
   almSetting: string
 ): Promise<{ projects: BitbucketProject[] }> {
diff --git a/server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx b/server/sonar-web/src/main/js/apps/create/project/AzureProjectAccordion.tsx
new file mode 100644 (file)
index 0000000..20c4e07
--- /dev/null
@@ -0,0 +1,109 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 classNames from 'classnames';
+import * as React from 'react';
+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 { 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';
+import { AzureProject, AzureRepository } from '../../../types/alm-integration';
+import { CreateProjectModes } from './types';
+
+export interface AzureProjectAccordionProps {
+  loading: boolean;
+  onOpen: (key: string) => void;
+  startsOpen: boolean;
+  project: AzureProject;
+  repositories?: AzureRepository[];
+}
+
+const PAGE_SIZE = 30;
+
+export default function AzureProjectAccordion(props: AzureProjectAccordionProps) {
+  const { loading, startsOpen, project, repositories = [] } = props;
+
+  const [open, setOpen] = React.useState(startsOpen);
+  const handleClick = () => {
+    if (!open) {
+      props.onOpen(project.key);
+    }
+    setOpen(!open);
+  };
+
+  const [page, setPage] = React.useState(1);
+  const limitedRepositories = repositories.slice(0, page * PAGE_SIZE);
+
+  return (
+    <BoxedGroupAccordion
+      className={classNames('big-spacer-bottom', {
+        open
+      })}
+      onClick={handleClick}
+      open={open}
+      title={<h3>{project.name}</h3>}>
+      {open && (
+        <DeferredSpinner loading={loading}>
+          {/* The extra loading guard is to prevent the flash of the Alert */}
+          {!loading && repositories.length === 0 ? (
+            <Alert variant="warning">
+              <FormattedMessage
+                defaultMessage={translate('onboarding.create_project.azure.no_repositories')}
+                id="onboarding.create_project.azure.no_repositories"
+                values={{
+                  link: (
+                    <Link
+                      to={{
+                        pathname: '/projects/create',
+                        query: { mode: CreateProjectModes.AzureDevOps, resetPat: 1 }
+                      }}>
+                      {translate('onboarding.create_project.update_your_token')}
+                    </Link>
+                  )
+                }}
+              />
+            </Alert>
+          ) : (
+            <>
+              <div className="display-flex-wrap">
+                {limitedRepositories.map(repo => (
+                  <div
+                    className="abs-width-400 overflow-hidden spacer-top spacer-bottom"
+                    key={repo.name}>
+                    <strong className="text-ellipsis" title={repo.name}>
+                      {repo.name}
+                    </strong>
+                  </div>
+                ))}
+              </div>
+              <ListFooter
+                count={limitedRepositories.length}
+                total={repositories.length}
+                loadMore={() => setPage(p => p + 1)}
+              />
+            </>
+          )}
+        </DeferredSpinner>
+      )}
+    </BoxedGroupAccordion>
+  );
+}
index 292abc2e54821cdcbadc4525c4905c549f533ec8..7cd34c6a42ab15b1c0d8cb8cd2e162f036be733a 100644 (file)
@@ -21,12 +21,15 @@ import * as React from 'react';
 import { WithRouterProps } from 'react-router';
 import {
   checkPersonalAccessTokenIsValid,
+  getAzureProjects,
+  getAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../api/alm-integrations';
+import { AzureProject, AzureRepository } from '../../../types/alm-integration';
 import { AlmSettingsInstance } from '../../../types/alm-settings';
 import AzureCreateProjectRenderer from './AzureProjectCreateRenderer';
 
-interface Props extends Pick<WithRouterProps, 'location'> {
+interface Props extends Pick<WithRouterProps, 'location' | 'router'> {
   canAdmin: boolean;
   loadingBindings: boolean;
   onProjectCreate: (projectKeys: string[]) => void;
@@ -35,7 +38,10 @@ interface Props extends Pick<WithRouterProps, 'location'> {
 
 interface State {
   loading: boolean;
+  loadingRepositories: T.Dict<boolean>;
   patIsValid?: boolean;
+  projects?: AzureProject[];
+  repositories: T.Dict<AzureRepository[]>;
   settings?: AlmSettingsInstance;
   submittingToken?: boolean;
   tokenValidationFailed: boolean;
@@ -51,6 +57,8 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
       // one from the list.
       settings: props.settings[0],
       loading: false,
+      loadingRepositories: {},
+      repositories: {},
       tokenValidationFailed: false
     };
   }
@@ -78,14 +86,84 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
 
     const patIsValid = await this.checkPersonalAccessToken().catch(() => false);
 
+    let projects: AzureProject[] | undefined;
+    if (patIsValid) {
+      projects = await this.fetchAzureProjects();
+    }
+
+    const { repositories } = this.state;
+
+    let firstProjectKey: string;
+
+    if (projects && projects.length > 0) {
+      firstProjectKey = projects[0].key;
+
+      this.setState(({ loadingRepositories }) => ({
+        loadingRepositories: { ...loadingRepositories, [firstProjectKey]: true }
+      }));
+
+      const repos = await this.fetchAzureRepositories(firstProjectKey);
+      repositories[firstProjectKey] = repos;
+    }
+
     if (this.mounted) {
-      this.setState({
-        patIsValid,
-        loading: false
+      this.setState(({ loadingRepositories }) => {
+        if (firstProjectKey) {
+          loadingRepositories[firstProjectKey] = false;
+        }
+
+        return {
+          patIsValid,
+          loading: false,
+          loadingRepositories: { ...loadingRepositories },
+          projects,
+          repositories
+        };
       });
     }
   };
 
+  fetchAzureProjects = (): Promise<AzureProject[] | undefined> => {
+    const { settings } = this.state;
+
+    if (!settings) {
+      return Promise.resolve(undefined);
+    }
+
+    return getAzureProjects(settings.key).then(({ projects }) => projects);
+  };
+
+  fetchAzureRepositories = (projectKey: string): Promise<AzureRepository[]> => {
+    const { settings } = this.state;
+
+    if (!settings) {
+      return Promise.resolve([]);
+    }
+
+    return getAzureRepositories(settings.key, projectKey)
+      .then(({ repositories }) => repositories)
+      .catch(() => []);
+  };
+
+  cleanUrl = () => {
+    const { location, router } = this.props;
+    delete location.query.resetPat;
+    router.replace(location);
+  };
+
+  handleOpenProject = async (projectKey: string) => {
+    this.setState(({ loadingRepositories }) => ({
+      loadingRepositories: { ...loadingRepositories, [projectKey]: true }
+    }));
+
+    const projectRepos = await this.fetchAzureRepositories(projectKey);
+
+    this.setState(({ loadingRepositories, repositories }) => ({
+      loadingRepositories: { ...loadingRepositories, [projectKey]: false },
+      repositories: { ...repositories, [projectKey]: projectRepos }
+    }));
+  };
+
   checkPersonalAccessToken = () => {
     const { settings } = this.state;
 
@@ -114,7 +192,7 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
 
         if (patIsValid) {
           this.cleanUrl();
-          await this.fetchInitialData();
+          this.fetchInitialData();
         }
       }
     } catch (e) {
@@ -126,13 +204,26 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
 
   render() {
     const { canAdmin, loadingBindings, location } = this.props;
-    const { loading, patIsValid, settings, submittingToken, tokenValidationFailed } = this.state;
+    const {
+      loading,
+      loadingRepositories,
+      patIsValid,
+      projects,
+      repositories,
+      settings,
+      submittingToken,
+      tokenValidationFailed
+    } = this.state;
 
     return (
       <AzureCreateProjectRenderer
         canAdmin={canAdmin}
         loading={loading || loadingBindings}
+        loadingRepositories={loadingRepositories}
+        onOpenProject={this.handleOpenProject}
         onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
+        projects={projects}
+        repositories={repositories}
         settings={settings}
         showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
         submittingToken={submittingToken}
index 2d43a8bb26ab4e917f0aeb1d0107426dcfd6fb05..70a16ef811323d282315a5954e60cc7e4b8a573e 100644 (file)
@@ -20,6 +20,7 @@
 import * as React from 'react';
 import { translate } from 'sonar-ui-common/helpers/l10n';
 import { getBaseUrl } from 'sonar-ui-common/helpers/urls';
+import { AzureProject, AzureRepository } from '../../../types/alm-integration';
 import { AlmKeys, AlmSettingsInstance } from '../../../types/alm-settings';
 import AzurePersonalAccessTokenForm from './AzurePersonalAccessTokenForm';
 import AzureProjectsList from './AzureProjectsList';
@@ -29,7 +30,11 @@ import WrongBindingCountAlert from './WrongBindingCountAlert';
 export interface AzureProjectCreateRendererProps {
   canAdmin?: boolean;
   loading: boolean;
+  loadingRepositories: T.Dict<boolean>;
+  onOpenProject: (key: string) => void;
   onPersonalAccessTokenCreate: (token: string) => void;
+  projects?: AzureProject[];
+  repositories: T.Dict<AzureRepository[]>;
   settings?: AlmSettingsInstance;
   showPersonalAccessTokenForm?: boolean;
   submittingToken?: boolean;
@@ -40,6 +45,9 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
   const {
     canAdmin,
     loading,
+    loadingRepositories,
+    projects,
+    repositories,
     showPersonalAccessTokenForm,
     settings,
     submittingToken,
@@ -80,7 +88,12 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
             />
           </div>
         ) : (
-          <AzureProjectsList />
+          <AzureProjectsList
+            loadingRepositories={loadingRepositories}
+            onOpenProject={props.onOpenProject}
+            projects={projects}
+            repositories={repositories}
+          />
         ))}
     </>
   );
index c6f34ede827ec88667d3e81a1f76892a25f842d5..f61257b57116f3b7b255e35d5db446448394e19d 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+import { FormattedMessage } from 'react-intl';
+import { Link } from 'react-router';
+import ListFooter from 'sonar-ui-common/components/controls/ListFooter';
 import { Alert } from 'sonar-ui-common/components/ui/Alert';
+import { translate } from 'sonar-ui-common/helpers/l10n';
+import { AzureProject, AzureRepository } from '../../../types/alm-integration';
+import AzureProjectAccordion from './AzureProjectAccordion';
+import { CreateProjectModes } from './types';
 
-export interface AzureProjectsListProps {}
+export interface AzureProjectsListProps {
+  loadingRepositories: T.Dict<boolean>;
+  onOpenProject: (key: string) => void;
+  projects?: AzureProject[];
+  repositories: T.Dict<AzureRepository[]>;
+}
+
+const PAGE_SIZE = 10;
+
+export default function AzureProjectsList(props: AzureProjectsListProps) {
+  const { loadingRepositories, projects = [], repositories } = props;
+
+  const [page, setPage] = React.useState(1);
+
+  if (projects.length === 0) {
+    return (
+      <Alert className="spacer-top" variant="warning">
+        <FormattedMessage
+          defaultMessage={translate('onboarding.create_project.azure.no_projects')}
+          id="onboarding.create_project.azure.no_projects"
+          values={{
+            link: (
+              <Link
+                to={{
+                  pathname: '/projects/create',
+                  query: { mode: CreateProjectModes.AzureDevOps, resetPat: 1 }
+                }}>
+                {translate('onboarding.create_project.update_your_token')}
+              </Link>
+            )
+          }}
+        />
+      </Alert>
+    );
+  }
+
+  const filteredProjects = projects.slice(0, page * PAGE_SIZE);
 
-export default function AzureProjectsList(_props: AzureProjectsListProps) {
   return (
     <div>
-      <Alert variant="warning">Coming soon!</Alert>
+      {filteredProjects.map((p, i) => (
+        <AzureProjectAccordion
+          key={p.key}
+          loading={Boolean(loadingRepositories[p.key])}
+          onOpen={props.onOpenProject}
+          project={p}
+          repositories={repositories[p.key]}
+          startsOpen={i === 0}
+        />
+      ))}
+
+      <ListFooter
+        count={filteredProjects.length}
+        loadMore={() => setPage(p => p + 1)}
+        total={projects.length}
+      />
     </div>
   );
 }
index a2cbadb2912bea7ef7cb5c0fc262f8d392a3a76d..60c5abe3ce4668736deefc1be1c0ff14890d6862 100644 (file)
@@ -130,6 +130,7 @@ export class CreateProjectPage extends React.PureComponent<Props, State> {
             loadingBindings={loading}
             location={location}
             onProjectCreate={this.handleProjectCreate}
+            router={router}
             settings={azureSettings}
           />
         );
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectAccordion-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectAccordion-test.tsx
new file mode 100644 (file)
index 0000000..b9e7f30
--- /dev/null
@@ -0,0 +1,105 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import BoxedGroupAccordion from 'sonar-ui-common/components/controls/BoxedGroupAccordion';
+import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations';
+import AzureProjectAccordion, { AzureProjectAccordionProps } from '../AzureProjectAccordion';
+
+it('should render correctly', () => {
+  expect(shallowRender({ loading: true })).toMatchSnapshot('loading');
+  expect(shallowRender({ startsOpen: false })).toMatchSnapshot('closed');
+  expect(shallowRender({ repositories: [mockAzureRepository()] })).toMatchSnapshot(
+    'with a repository'
+  );
+});
+
+it('should open when clicked', () => {
+  const onOpen = jest.fn();
+
+  const wrapper = shallowRender({
+    onOpen,
+    repositories: [mockAzureRepository()],
+    startsOpen: false
+  });
+  expect(
+    wrapper
+      .find(BoxedGroupAccordion)
+      .children()
+      .exists()
+  ).toBe(false);
+
+  wrapper
+    .find(BoxedGroupAccordion)
+    .props()
+    .onClick();
+
+  expect(onOpen).toBeCalled();
+
+  expect(
+    wrapper
+      .find(BoxedGroupAccordion)
+      .children()
+      .exists()
+  ).toBe(true);
+});
+
+it('should close when clicked', () => {
+  const onOpen = jest.fn();
+
+  const wrapper = shallowRender({
+    onOpen,
+    repositories: [mockAzureRepository()]
+  });
+
+  expect(
+    wrapper
+      .find(BoxedGroupAccordion)
+      .children()
+      .exists()
+  ).toBe(true);
+
+  wrapper
+    .find(BoxedGroupAccordion)
+    .props()
+    .onClick();
+
+  expect(onOpen).not.toBeCalled();
+
+  expect(
+    wrapper
+      .find(BoxedGroupAccordion)
+      .children()
+      .exists()
+  ).toBe(false);
+});
+
+function shallowRender(overrides: Partial<AzureProjectAccordionProps> = {}) {
+  return shallow(
+    <AzureProjectAccordion
+      loading={false}
+      onOpen={jest.fn()}
+      project={mockAzureProject()}
+      startsOpen={true}
+      {...overrides}
+    />
+  );
+}
index 7ce89327f6e45e271d53dec6eab44c7b5e9743dc..c40406179e020cf246c21757aa9db86277310eb3 100644 (file)
@@ -23,17 +23,22 @@ import * as React from 'react';
 import { waitAndUpdate } from 'sonar-ui-common/helpers/testUtils';
 import {
   checkPersonalAccessTokenIsValid,
+  getAzureProjects,
+  getAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../../api/alm-integrations';
+import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations';
 import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
-import { mockLocation } from '../../../../helpers/testMocks';
+import { mockLocation, mockRouter } from '../../../../helpers/testMocks';
 import { AlmKeys } from '../../../../types/alm-settings';
 import AzureProjectCreate from '../AzureProjectCreate';
 
 jest.mock('../../../../api/alm-integrations', () => {
   return {
     checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue(true),
-    setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null)
+    setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null),
+    getAzureProjects: jest.fn().mockResolvedValue({ projects: [] }),
+    getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] })
   };
 });
 
@@ -66,7 +71,8 @@ it('should correctly handle an invalid PAT', async () => {
 });
 
 it('should correctly handle setting a new PAT', async () => {
-  const wrapper = shallowRender();
+  const router = mockRouter();
+  const wrapper = shallowRender({ router });
   wrapper.instance().handlePersonalAccessTokenCreate('token');
   expect(setAlmPersonalAccessToken).toBeCalledWith('foo', 'token');
   expect(wrapper.state().submittingToken).toBe(true);
@@ -76,6 +82,59 @@ it('should correctly handle setting a new PAT', async () => {
   expect(checkPersonalAccessTokenIsValid).toBeCalled();
   expect(wrapper.state().submittingToken).toBe(false);
   expect(wrapper.state().tokenValidationFailed).toBe(true);
+
+  // Try again, this time with a correct token:
+
+  wrapper.instance().handlePersonalAccessTokenCreate('correct token');
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().tokenValidationFailed).toBe(false);
+  expect(router.replace).toBeCalled();
+});
+
+it('should correctly fetch projects and repositories on mount', async () => {
+  const project = mockAzureProject();
+  (getAzureProjects as jest.Mock).mockResolvedValueOnce({ projects: [project] });
+  (getAzureRepositories as jest.Mock).mockResolvedValueOnce({
+    repositories: [mockAzureRepository()]
+  });
+
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+  expect(getAzureProjects).toBeCalled();
+  expect(getAzureRepositories).toBeCalledTimes(1);
+  expect(getAzureRepositories).toBeCalledWith('foo', project.key);
+});
+
+it('should handle opening a project', async () => {
+  const projects = [
+    mockAzureProject(),
+    mockAzureProject({ key: 'project2', name: 'Project to open' })
+  ];
+
+  const firstProjectRepos = [mockAzureRepository()];
+  const secondProjectRepos = [mockAzureRepository({ projectName: projects[1].name })];
+
+  (getAzureProjects as jest.Mock).mockResolvedValueOnce({ projects });
+  (getAzureRepositories as jest.Mock)
+    .mockResolvedValueOnce({
+      repositories: firstProjectRepos
+    })
+    .mockResolvedValueOnce({
+      repositories: secondProjectRepos
+    });
+
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  wrapper.instance().handleOpenProject(projects[1].key);
+  await waitAndUpdate(wrapper);
+
+  expect(getAzureRepositories).toBeCalledWith('foo', projects[1].key);
+
+  expect(wrapper.state().repositories).toEqual({
+    [projects[0].key]: firstProjectRepos,
+    [projects[1].key]: secondProjectRepos
+  });
 });
 
 function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) {
@@ -85,6 +144,7 @@ function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) {
       loadingBindings={false}
       location={mockLocation()}
       onProjectCreate={jest.fn()}
+      router={mockRouter()}
       settings={[mockAlmSettingsInstance({ alm: AlmKeys.Azure, key: 'foo' })]}
       {...overrides}
     />
index d38e64d922e37b75f49a53efab1f810b65a32702..9e09b0a1810b94db93b28ad87491a074dd29490d 100644 (file)
@@ -20,6 +20,7 @@
 /* eslint-disable sonarjs/no-duplicate-string */
 import { shallow } from 'enzyme';
 import * as React from 'react';
+import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations';
 import { mockAlmSettingsInstance } from '../../../../helpers/mocks/alm-settings';
 import { AlmKeys } from '../../../../types/alm-settings';
 import AzureProjectCreateRenderer, {
@@ -34,11 +35,17 @@ it('should render correctly', () => {
 });
 
 function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) {
+  const project = mockAzureProject();
+
   return shallow(
     <AzureProjectCreateRenderer
       canAdmin={true}
       loading={false}
+      loadingRepositories={{}}
+      onOpenProject={jest.fn()}
       onPersonalAccessTokenCreate={jest.fn()}
+      projects={[project]}
+      repositories={{ [project.key]: [mockAzureRepository()] }}
       tokenValidationFailed={false}
       settings={mockAlmSettingsInstance({ alm: AlmKeys.Azure })}
       showPersonalAccessTokenForm={false}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectsList-test.tsx b/server/sonar-web/src/main/js/apps/create/project/__tests__/AzureProjectsList-test.tsx
new file mode 100644 (file)
index 0000000..7ee191f
--- /dev/null
@@ -0,0 +1,59 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2020 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import ListFooter from 'sonar-ui-common/components/controls/ListFooter';
+import { mockAzureProject } from '../../../../helpers/mocks/alm-integrations';
+import AzureProjectAccordion from '../AzureProjectAccordion';
+import AzureProjectsList, { AzureProjectsListProps } from '../AzureProjectsList';
+
+it('should render correctly', () => {
+  expect(shallowRender({})).toMatchSnapshot('default');
+  expect(shallowRender({ projects: [] })).toMatchSnapshot('empty');
+});
+
+it('should handle pagination', () => {
+  const projects = new Array(21)
+    .fill(1)
+    .map((_, i) => mockAzureProject({ key: `project-${i}`, name: `Project #${i}` }));
+
+  const wrapper = shallowRender({ projects });
+
+  expect(wrapper.find(AzureProjectAccordion)).toHaveLength(10);
+
+  wrapper.find(ListFooter).props().loadMore!();
+
+  expect(wrapper.find(AzureProjectAccordion)).toHaveLength(20);
+});
+
+function shallowRender(overrides: Partial<AzureProjectsListProps> = {}) {
+  const project = mockAzureProject();
+
+  return shallow(
+    <AzureProjectsList
+      loadingRepositories={{}}
+      onOpenProject={jest.fn()}
+      projects={[project]}
+      repositories={{ [project.key]: [] }}
+      {...overrides}
+    />
+  );
+}
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectAccordion-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectAccordion-test.tsx.snap
new file mode 100644 (file)
index 0000000..0d0e57c
--- /dev/null
@@ -0,0 +1,78 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: closed 1`] = `
+<BoxedGroupAccordion
+  className="big-spacer-bottom"
+  onClick={[Function]}
+  open={false}
+  title={
+    <h3>
+      Azure Project
+    </h3>
+  }
+/>
+`;
+
+exports[`should render correctly: loading 1`] = `
+<BoxedGroupAccordion
+  className="big-spacer-bottom open"
+  onClick={[Function]}
+  open={true}
+  title={
+    <h3>
+      Azure Project
+    </h3>
+  }
+>
+  <DeferredSpinner
+    loading={true}
+  >
+    <div
+      className="display-flex-wrap"
+    />
+    <ListFooter
+      count={0}
+      loadMore={[Function]}
+      total={0}
+    />
+  </DeferredSpinner>
+</BoxedGroupAccordion>
+`;
+
+exports[`should render correctly: with a repository 1`] = `
+<BoxedGroupAccordion
+  className="big-spacer-bottom open"
+  onClick={[Function]}
+  open={true}
+  title={
+    <h3>
+      Azure Project
+    </h3>
+  }
+>
+  <DeferredSpinner
+    loading={false}
+  >
+    <div
+      className="display-flex-wrap"
+    >
+      <div
+        className="abs-width-400 overflow-hidden spacer-top spacer-bottom"
+        key="Azure repo 1"
+      >
+        <strong
+          className="text-ellipsis"
+          title="Azure repo 1"
+        >
+          Azure repo 1
+        </strong>
+      </div>
+    </div>
+    <ListFooter
+      count={1}
+      loadMore={[Function]}
+      total={1}
+    />
+  </DeferredSpinner>
+</BoxedGroupAccordion>
+`;
index 47f70559c9fcddd153a10716ffdf7a6d0b1063ae..40a022b1e4cc40e7105dad3df33aca9a1eb1e4bc 100644 (file)
@@ -4,7 +4,10 @@ exports[`should render correctly 1`] = `
 <AzureProjectCreateRenderer
   canAdmin={true}
   loading={true}
+  loadingRepositories={Object {}}
+  onOpenProject={[Function]}
   onPersonalAccessTokenCreate={[Function]}
+  repositories={Object {}}
   settings={
     Object {
       "alm": "azure",
index dc15c4a504a81371a46d84af3ac2ec531f7b9183..dee9ad781a1abfb7b0b722ca2fc5a57508eb415d 100644 (file)
@@ -64,7 +64,28 @@ exports[`should render correctly: project list 1`] = `
       </span>
     }
   />
-  <AzureProjectsList />
+  <AzureProjectsList
+    loadingRepositories={Object {}}
+    onOpenProject={[MockFunction]}
+    projects={
+      Array [
+        Object {
+          "key": "azure-project-1",
+          "name": "Azure Project",
+        },
+      ]
+    }
+    repositories={
+      Object {
+        "azure-project-1": Array [
+          Object {
+            "name": "Azure repo 1",
+            "projectName": "Azure Project",
+          },
+        ],
+      }
+    }
+  />
 </Fragment>
 `;
 
diff --git a/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectsList-test.tsx.snap b/server/sonar-web/src/main/js/apps/create/project/__tests__/__snapshots__/AzureProjectsList-test.tsx.snap
new file mode 100644 (file)
index 0000000..8711c8e
--- /dev/null
@@ -0,0 +1,55 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`should render correctly: default 1`] = `
+<div>
+  <AzureProjectAccordion
+    key="azure-project-1"
+    loading={false}
+    onOpen={[MockFunction]}
+    project={
+      Object {
+        "key": "azure-project-1",
+        "name": "Azure Project",
+      }
+    }
+    repositories={Array []}
+    startsOpen={true}
+  />
+  <ListFooter
+    count={1}
+    loadMore={[Function]}
+    total={1}
+  />
+</div>
+`;
+
+exports[`should render correctly: empty 1`] = `
+<Alert
+  className="spacer-top"
+  variant="warning"
+>
+  <FormattedMessage
+    defaultMessage="onboarding.create_project.azure.no_projects"
+    id="onboarding.create_project.azure.no_projects"
+    values={
+      Object {
+        "link": <Link
+          onlyActiveOnIndex={false}
+          style={Object {}}
+          to={
+            Object {
+              "pathname": "/projects/create",
+              "query": Object {
+                "mode": "azure",
+                "resetPat": 1,
+              },
+            }
+          }
+        >
+          onboarding.create_project.update_your_token
+        </Link>,
+      }
+    }
+  />
+</Alert>
+`;
index 7f37890764ce5c81db54b7133e40c51e04413c19..970ce4d6c591c2982a17e19ca494356da8a2dffb 100644 (file)
@@ -85,6 +85,19 @@ exports[`should render correctly if the Azure method is selected 1`] = `
         }
       }
       onProjectCreate={[Function]}
+      router={
+        Object {
+          "createHref": [MockFunction],
+          "createPath": [MockFunction],
+          "go": [MockFunction],
+          "goBack": [MockFunction],
+          "goForward": [MockFunction],
+          "isActive": [MockFunction],
+          "push": [MockFunction],
+          "replace": [MockFunction],
+          "setRouteLeaveHook": [MockFunction],
+        }
+      }
       settings={Array []}
     />
   </div>
index 99c2341cba2e1272cd346a97caf2461092ca83c4..48b2949cad4f852565cd29f558b140324dd08a63 100644 (file)
@@ -16,7 +16,7 @@ exports[`should render correctly: create 1`] = `
         settings.almintegration.form.url.azure.help
         <br />
         <em>
-          https://ado.your-company.com/
+          https://ado.your-company.com/DefaultCollection
         </em>
       </React.Fragment>
     }
@@ -53,7 +53,7 @@ exports[`should render correctly: edit 1`] = `
         settings.almintegration.form.url.azure.help
         <br />
         <em>
-          https://ado.your-company.com/
+          https://ado.your-company.com/DefaultCollection
         </em>
       </React.Fragment>
     }
index 1fdd37225e6e75989f71e18fff97abe85212137e..1196150f46464b0eb4fe712943afa275368ea939 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import {
+  AzureProject,
+  AzureRepository,
   BitbucketProject,
   BitbucketRepository,
   GithubRepository,
   GitlabProject
 } from '../../types/alm-integration';
 
+export function mockAzureProject(overrides: Partial<AzureProject> = {}): AzureProject {
+  return {
+    key: 'azure-project-1',
+    name: 'Azure Project',
+    ...overrides
+  };
+}
+
+export function mockAzureRepository(overrides: Partial<AzureRepository> = {}): AzureRepository {
+  return {
+    name: 'Azure repo 1',
+    projectName: 'Azure Project',
+    ...overrides
+  };
+}
+
 export function mockBitbucketProject(overrides: Partial<BitbucketProject> = {}): BitbucketProject {
   return {
     id: 1,
index cf70f73017a915b4bbce6c56288188fae5dad21a..fca78f50149c2243f42ec26d8dc1718c632443bd 100644 (file)
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+
+export interface AzureProject {
+  key: string;
+  name: string;
+}
+
+export interface AzureRepository {
+  name: string;
+  projectName: string;
+}
+
 export interface BitbucketProject {
   id: number;
   key: string;
index 02ee1bf0c49cf455945e02ffdfa23a0b72976242..36cda900b13c6ccad024467e89bd54080790ca97 100644 (file)
@@ -3287,6 +3287,8 @@ onboarding.create_project.import_selected_repo=Set up selected repository
 onboarding.create_project.go_to_project=Go to project
 
 onboarding.create_project.azure.title=Which Azure DevOps Server repository do you want to set up?
+onboarding.create_project.azure.no_projects=No projects could be fetched from Azure DevOps Server. Contact your system administrator, or {link}.
+onboarding.create_project.azure.no_repositories=Could not fetch repositories for this project. Contact your system administrator, or {link}.
 onboarding.create_project.github.title=Which GitHub repository do you want to set up?
 onboarding.create_project.github.choose_organization=Choose organization
 onboarding.create_project.github.warning.title=Could not connect to GitHub