]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-14057 Enable Search for Azure Repositories
authorJeremy Davis <jeremy.davis@sonarsource.com>
Tue, 17 Nov 2020 10:31:48 +0000 (11:31 +0100)
committersonartech <sonartech@sonarsource.com>
Wed, 25 Nov 2020 20:06:26 +0000 (20:06 +0000)
server/sonar-web/src/main/js/api/alm-integrations.ts
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__/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__/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
sonar-core/src/main/resources/org/sonar/l10n/core.properties

index 3824f97bb2c7c5ab4b699db948612a8f4fce5107..7aed2350b283b6f686161fe1b5a033c45a57fdbe 100644 (file)
@@ -61,6 +61,15 @@ export function getAzureRepositories(
   );
 }
 
+export function searchAzureRepositories(
+  almSetting: string,
+  repositoryName: string
+): Promise<{ repositories: AzureRepository[] }> {
+  return getJSON('/api/alm_integrations/search_azure_repos', { almSetting, repositoryName }).catch(
+    throwGlobalError
+  );
+}
+
 export function getBitbucketServerProjects(
   almSetting: string
 ): Promise<{ projects: BitbucketProject[] }> {
index 7cd34c6a42ab15b1c0d8cb8cd2e162f036be733a..50decd8e76dd6a27ae5739beee6fb888fd0d2b56 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.
  */
+import { groupBy } from 'lodash';
 import * as React from 'react';
 import { WithRouterProps } from 'react-router';
 import {
   checkPersonalAccessTokenIsValid,
   getAzureProjects,
   getAzureRepositories,
+  searchAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../api/alm-integrations';
 import { AzureProject, AzureRepository } from '../../../types/alm-integration';
@@ -42,6 +44,8 @@ interface State {
   patIsValid?: boolean;
   projects?: AzureProject[];
   repositories: T.Dict<AzureRepository[]>;
+  searching?: boolean;
+  searchResults?: T.Dict<AzureRepository[]>;
   settings?: AlmSettingsInstance;
   submittingToken?: boolean;
   tokenValidationFailed: boolean;
@@ -152,6 +156,10 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
   };
 
   handleOpenProject = async (projectKey: string) => {
+    if (this.state.searchResults) {
+      return;
+    }
+
     this.setState(({ loadingRepositories }) => ({
       loadingRepositories: { ...loadingRepositories, [projectKey]: true }
     }));
@@ -164,6 +172,29 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
     }));
   };
 
+  handleSearchRepositories = async (searchQuery: string) => {
+    const { settings } = this.state;
+
+    if (!settings) {
+      return;
+    }
+
+    if (searchQuery.length === 0) {
+      this.setState({ searchResults: undefined });
+      return;
+    }
+
+    this.setState({ searching: true });
+
+    const results: AzureRepository[] = await searchAzureRepositories(settings.key, searchQuery)
+      .then(({ repositories }) => repositories)
+      .catch(() => []);
+
+    if (this.mounted) {
+      this.setState({ searching: false, searchResults: groupBy(results, 'projectName') });
+    }
+  };
+
   checkPersonalAccessToken = () => {
     const { settings } = this.state;
 
@@ -210,6 +241,8 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
       patIsValid,
       projects,
       repositories,
+      searching,
+      searchResults,
       settings,
       submittingToken,
       tokenValidationFailed
@@ -222,8 +255,11 @@ export default class AzureProjectCreate extends React.PureComponent<Props, State
         loadingRepositories={loadingRepositories}
         onOpenProject={this.handleOpenProject}
         onPersonalAccessTokenCreate={this.handlePersonalAccessTokenCreate}
+        onSearch={this.handleSearchRepositories}
         projects={projects}
         repositories={repositories}
+        searching={searching}
+        searchResults={searchResults}
         settings={settings}
         showPersonalAccessTokenForm={!patIsValid || Boolean(location.query.resetPat)}
         submittingToken={submittingToken}
index 70a16ef811323d282315a5954e60cc7e4b8a573e..af23dc3ff9ea53707f3a43f71efb611bdc923ebe 100644 (file)
@@ -18,6 +18,8 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
+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';
 import { getBaseUrl } from 'sonar-ui-common/helpers/urls';
 import { AzureProject, AzureRepository } from '../../../types/alm-integration';
@@ -33,8 +35,11 @@ export interface AzureProjectCreateRendererProps {
   loadingRepositories: T.Dict<boolean>;
   onOpenProject: (key: string) => void;
   onPersonalAccessTokenCreate: (token: string) => void;
+  onSearch: (query: string) => void;
   projects?: AzureProject[];
   repositories: T.Dict<AzureRepository[]>;
+  searching?: boolean;
+  searchResults?: T.Dict<AzureRepository[]>;
   settings?: AlmSettingsInstance;
   showPersonalAccessTokenForm?: boolean;
   submittingToken?: boolean;
@@ -48,6 +53,8 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
     loadingRepositories,
     projects,
     repositories,
+    searching,
+    searchResults,
     showPersonalAccessTokenForm,
     settings,
     submittingToken,
@@ -88,12 +95,23 @@ export default function AzureProjectCreateRenderer(props: AzureProjectCreateRend
             />
           </div>
         ) : (
-          <AzureProjectsList
-            loadingRepositories={loadingRepositories}
-            onOpenProject={props.onOpenProject}
-            projects={projects}
-            repositories={repositories}
-          />
+          <>
+            <div className="huge-spacer-bottom">
+              <SearchBox
+                onChange={props.onSearch}
+                placeholder={translate('onboarding.create_project.search_repositories_by_name')}
+              />
+            </div>
+            <DeferredSpinner loading={Boolean(searching)}>
+              <AzureProjectsList
+                loadingRepositories={loadingRepositories}
+                onOpenProject={props.onOpenProject}
+                projects={projects}
+                repositories={repositories}
+                searchResults={searchResults}
+              />
+            </DeferredSpinner>
+          </>
         ))}
     </>
   );
index f61257b57116f3b7b255e35d5db446448394e19d..344e3e204f0880b4191973731fcf6a764e5f25ba 100644 (file)
@@ -32,56 +32,69 @@ export interface AzureProjectsListProps {
   onOpenProject: (key: string) => void;
   projects?: AzureProject[];
   repositories: T.Dict<AzureRepository[]>;
+  searchResults?: T.Dict<AzureRepository[]>;
 }
 
 const PAGE_SIZE = 10;
 
 export default function AzureProjectsList(props: AzureProjectsListProps) {
-  const { loadingRepositories, projects = [], repositories } = props;
+  const { loadingRepositories, projects = [], repositories, searchResults } = props;
 
   const [page, setPage] = React.useState(1);
 
-  if (projects.length === 0) {
+  const filteredProjects = searchResults
+    ? projects.filter(p => searchResults[p.key] !== undefined)
+    : projects;
+
+  if (filteredProjects.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>
-            )
-          }}
-        />
+        {searchResults ? (
+          translate('onboarding.create_project.azure.no_results')
+        ) : (
+          <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);
+  const displayedProjects = filteredProjects.slice(0, page * PAGE_SIZE);
+
+  // Add a suffix to the key to force react to not reuse AzureProjectAccordions between
+  // search results and project exploration
+  const keySuffix = searchResults ? ' - result' : '';
 
   return (
     <div>
-      {filteredProjects.map((p, i) => (
+      {displayedProjects.map((p, i) => (
         <AzureProjectAccordion
-          key={p.key}
+          key={`${p.key}${keySuffix}`}
           loading={Boolean(loadingRepositories[p.key])}
           onOpen={props.onOpenProject}
           project={p}
-          repositories={repositories[p.key]}
-          startsOpen={i === 0}
+          repositories={searchResults ? searchResults[p.key] : repositories[p.key]}
+          startsOpen={searchResults !== undefined || i === 0}
         />
       ))}
 
       <ListFooter
-        count={filteredProjects.length}
+        count={displayedProjects.length}
         loadMore={() => setPage(p => p + 1)}
-        total={projects.length}
+        total={filteredProjects.length}
       />
     </div>
   );
index c40406179e020cf246c21757aa9db86277310eb3..c67d4dcc691a4dd71e4e7e1190a95dbc84f653b7 100644 (file)
@@ -25,6 +25,7 @@ import {
   checkPersonalAccessTokenIsValid,
   getAzureProjects,
   getAzureRepositories,
+  searchAzureRepositories,
   setAlmPersonalAccessToken
 } from '../../../../api/alm-integrations';
 import { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations';
@@ -38,7 +39,8 @@ jest.mock('../../../../api/alm-integrations', () => {
     checkPersonalAccessTokenIsValid: jest.fn().mockResolvedValue(true),
     setAlmPersonalAccessToken: jest.fn().mockResolvedValue(null),
     getAzureProjects: jest.fn().mockResolvedValue({ projects: [] }),
-    getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] })
+    getAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] }),
+    searchAzureRepositories: jest.fn().mockResolvedValue({ repositories: [] })
   };
 });
 
@@ -137,6 +139,36 @@ it('should handle opening a project', async () => {
   });
 });
 
+it('should handle searching for repositories', async () => {
+  const wrapper = shallowRender();
+  await waitAndUpdate(wrapper);
+
+  const query = 'repo';
+  const repositories = [mockAzureRepository({ projectName: 'p2' })];
+  (searchAzureRepositories as jest.Mock).mockResolvedValueOnce({
+    repositories
+  });
+  wrapper.instance().handleSearchRepositories(query);
+  expect(wrapper.state().searching).toBe(true);
+
+  expect(searchAzureRepositories).toBeCalledWith('foo', query);
+  await waitAndUpdate(wrapper);
+  expect(wrapper.state().searching).toBe(false);
+  expect(wrapper.state().searchResults).toEqual({ [repositories[0].projectName]: repositories });
+
+  // Ignore opening a project when search results are displayed
+  (getAzureRepositories as jest.Mock).mockClear();
+  wrapper.instance().handleOpenProject('whatever');
+  expect(getAzureRepositories).not.toHaveBeenCalled();
+
+  // and reset the search field
+  (searchAzureRepositories as jest.Mock).mockClear();
+
+  wrapper.instance().handleSearchRepositories('');
+  expect(searchAzureRepositories).not.toBeCalled();
+  expect(wrapper.state().searchResults).toBeUndefined();
+});
+
 function shallowRender(overrides: Partial<AzureProjectCreate['props']> = {}) {
   return shallow<AzureProjectCreate>(
     <AzureProjectCreate
index 9e09b0a1810b94db93b28ad87491a074dd29490d..904ca4c0397e40602a7c5db0f28d3d6d337f9af7 100644 (file)
@@ -44,6 +44,7 @@ function shallowRender(overrides: Partial<AzureProjectCreateRendererProps>) {
       loadingRepositories={{}}
       onOpenProject={jest.fn()}
       onPersonalAccessTokenCreate={jest.fn()}
+      onSearch={jest.fn()}
       projects={[project]}
       repositories={{ [project.key]: [mockAzureRepository()] }}
       tokenValidationFailed={false}
index 7ee191fc84a282c1030effce5a74bc28b99accb2..ec8d2f2629e88e2b7ced553ce0aa9dbedb0fcaa3 100644 (file)
@@ -21,7 +21,7 @@
 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 { mockAzureProject, mockAzureRepository } from '../../../../helpers/mocks/alm-integrations';
 import AzureProjectAccordion from '../AzureProjectAccordion';
 import AzureProjectsList, { AzureProjectsListProps } from '../AzureProjectsList';
 
@@ -30,6 +30,19 @@ it('should render correctly', () => {
   expect(shallowRender({ projects: [] })).toMatchSnapshot('empty');
 });
 
+it('should render search results correctly', () => {
+  const projects = [
+    mockAzureProject({ key: 'p1', name: 'p1' }),
+    mockAzureProject({ key: 'p2', name: 'p2' }),
+    mockAzureProject({ key: 'p3', name: 'p3' })
+  ];
+  const searchResults = {
+    p2: [mockAzureRepository({ projectName: 'p2' })]
+  };
+  expect(shallowRender({ searchResults, projects })).toMatchSnapshot('default');
+  expect(shallowRender({ searchResults: {}, projects })).toMatchSnapshot('empty');
+});
+
 it('should handle pagination', () => {
   const projects = new Array(21)
     .fill(1)
index 40a022b1e4cc40e7105dad3df33aca9a1eb1e4bc..d9116ea8b5d25044ff81572feecbf710bb9c688e 100644 (file)
@@ -7,6 +7,7 @@ exports[`should render correctly 1`] = `
   loadingRepositories={Object {}}
   onOpenProject={[Function]}
   onPersonalAccessTokenCreate={[Function]}
+  onSearch={[Function]}
   repositories={Object {}}
   settings={
     Object {
index dee9ad781a1abfb7b0b722ca2fc5a57508eb415d..4d6058f355aa80a94b9d26bea555b2b8d0d79ad2 100644 (file)
@@ -64,28 +64,40 @@ exports[`should render correctly: project list 1`] = `
       </span>
     }
   />
-  <AzureProjectsList
-    loadingRepositories={Object {}}
-    onOpenProject={[MockFunction]}
-    projects={
-      Array [
-        Object {
-          "key": "azure-project-1",
-          "name": "Azure Project",
-        },
-      ]
-    }
-    repositories={
-      Object {
-        "azure-project-1": Array [
+  <div
+    className="huge-spacer-bottom"
+  >
+    <SearchBox
+      onChange={[MockFunction]}
+      placeholder="onboarding.create_project.search_repositories_by_name"
+    />
+  </div>
+  <DeferredSpinner
+    loading={false}
+  >
+    <AzureProjectsList
+      loadingRepositories={Object {}}
+      onOpenProject={[MockFunction]}
+      projects={
+        Array [
           Object {
-            "name": "Azure repo 1",
-            "projectName": "Azure Project",
+            "key": "azure-project-1",
+            "name": "Azure Project",
           },
-        ],
+        ]
       }
-    }
-  />
+      repositories={
+        Object {
+          "azure-project-1": Array [
+            Object {
+              "name": "Azure repo 1",
+              "projectName": "Azure Project",
+            },
+          ],
+        }
+      }
+    />
+  </DeferredSpinner>
 </Fragment>
 `;
 
index 8711c8e646b85f1bbd558aefc77b2a3103843295..28b54f922c403839b2ce6ee9f61f148a358955be 100644 (file)
@@ -53,3 +53,42 @@ exports[`should render correctly: empty 1`] = `
   />
 </Alert>
 `;
+
+exports[`should render search results correctly: default 1`] = `
+<div>
+  <AzureProjectAccordion
+    key="p2 - result"
+    loading={false}
+    onOpen={[MockFunction]}
+    project={
+      Object {
+        "key": "p2",
+        "name": "p2",
+      }
+    }
+    repositories={
+      Array [
+        Object {
+          "name": "Azure repo 1",
+          "projectName": "p2",
+        },
+      ]
+    }
+    startsOpen={true}
+  />
+  <ListFooter
+    count={1}
+    loadMore={[Function]}
+    total={1}
+  />
+</div>
+`;
+
+exports[`should render search results correctly: empty 1`] = `
+<Alert
+  className="spacer-top"
+  variant="warning"
+>
+  onboarding.create_project.azure.no_results
+</Alert>
+`;
index 36cda900b13c6ccad024467e89bd54080790ca97..2bf94b588ac5d07c7e82b9298e7d0d0b69b14bc7 100644 (file)
@@ -3289,6 +3289,7 @@ 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.azure.no_results=No repositories match your search query.
 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