]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20708: Allow to select multiple projects on GitHub porject import
authorViktor Vorona <viktor.vorona@sonarsource.com>
Wed, 11 Oct 2023 13:49:08 +0000 (15:49 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 18 Oct 2023 20:03:05 +0000 (20:03 +0000)
16 files changed:
server/sonar-web/design-system/src/components/input/Checkbox.tsx
server/sonar-web/src/main/js/apps/create/project/Azure/AzureProjectAccordion.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketCloud/BitbucketCloudSearchForm.tsx
server/sonar-web/src/main/js/apps/create/project/BitbucketServer/BitbucketProjectAccordion.tsx
server/sonar-web/src/main/js/apps/create/project/Github/GitHubProjectCreateRenderer.tsx
server/sonar-web/src/main/js/apps/create/project/Gitlab/GitlabProjectSelectionForm.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Azure-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/Bitbucket-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/BitbucketCloud-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitHub-it.tsx
server/sonar-web/src/main/js/apps/create/project/__tests__/GitLab-it.tsx
server/sonar-web/src/main/js/apps/create/project/components/AlmRepoItem.tsx
server/sonar-web/src/main/js/apps/create/project/components/__tests__/AlmRepoItem-test.tsx
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 fa5014411da1ccc0d07361f2ee1589e88fc827da..f4c917c51cf91756e29672ffcc16336e0e4b885b 100644 (file)
@@ -35,7 +35,7 @@ interface Props {
   label?: string;
   loading?: boolean;
   onCheck: (checked: boolean, id?: string) => void;
-  onClick?: (event: React.MouseEvent<HTMLInputElement>) => void;
+  onClick?: (event: React.MouseEvent<HTMLLabelElement>) => void;
   onFocus?: VoidFunction;
   right?: boolean;
   thirdState?: boolean;
@@ -64,7 +64,7 @@ export function Checkbox({
   };
 
   return (
-    <CheckboxContainer className={className} disabled={disabled}>
+    <CheckboxContainer className={className} disabled={disabled} onClick={onClick}>
       {right && children}
       <AccessibleCheckbox
         aria-label={label ?? title}
@@ -72,7 +72,6 @@ export function Checkbox({
         disabled={disabled ?? loading}
         id={id}
         onChange={handleChange}
-        onClick={onClick}
         onFocus={onFocus}
         type="checkbox"
       />
index 5d4001510d68d528aee090b570ce3b90231782fc..50ee7533376c1c6a9deb50036581de7c365f238e 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { Accordion, Spinner, FlagMessage, Link, SearchHighlighter } from 'design-system';
+import { Accordion, FlagMessage, Link, SearchHighlighter, Spinner } from 'design-system';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import ListFooter from '../../../../components/controls/ListFooter';
@@ -93,7 +93,7 @@ export default function AzureProjectAccordion(props: AzureProjectAccordionProps)
             </FlagMessage>
           ) : (
             <>
-              <div className="sw-flex sw-flex-col sw-gap-3">
+              <ul className="sw-flex sw-flex-col sw-gap-3">
                 {limitedRepositories.map((r) => (
                   <AlmRepoItem
                     key={r.name}
@@ -108,7 +108,7 @@ export default function AzureProjectAccordion(props: AzureProjectAccordionProps)
                     }
                   />
                 ))}
-              </div>
+              </ul>
               <ListFooter
                 count={limitedRepositories.length}
                 total={repositories.length}
index 727b24720c7cea5d251a6037f380665fee7af8d3..4263c52e3441402c63b9762eddfc8481783ba51d 100644 (file)
@@ -90,7 +90,7 @@ export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchForm
           <LightPrimary className="sw-body-sm">{translate('no_results')}</LightPrimary>
         </div>
       ) : (
-        <div className="sw-flex sw-flex-col sw-gap-3">
+        <ul className="sw-flex sw-flex-col sw-gap-3">
           {repositories.map((r) => (
             <AlmRepoItem
               key={r.uuid}
@@ -104,7 +104,7 @@ export default function BitbucketCloudSearchForm(props: BitbucketCloudSearchForm
               secondaryTextNode={<span title={r.projectKey}>{r.projectKey}</span>}
             />
           ))}
-        </div>
+        </ul>
       )}
 
       <ListFooter
index 07cc3d9825d86090e62ff5670a6084f7da93d06d..3a4e1d30a44d975e4c1deb6f96250eaec7b58568 100644 (file)
@@ -85,7 +85,7 @@ export default function BitbucketProjectAccordion(props: BitbucketProjectAccordi
               </FlagMessage>
             )}
 
-            <div className="sw-flex sw-flex-col sw-gap-3">
+            <ul className="sw-flex sw-flex-col sw-gap-3">
               {repositories.map((r) => (
                 <AlmRepoItem
                   key={r.name}
@@ -96,7 +96,7 @@ export default function BitbucketProjectAccordion(props: BitbucketProjectAccordi
                   primaryTextNode={<span>{r.name}</span>}
                 />
               ))}
-            </div>
+            </ul>
           </div>
 
           {!showingAllRepositories && repositoryCount > 0 && (
index 9cb78a8a90cab31a36d9c2c84815b68853e6f0e8..35847eb760e538a5e480867e5a2479339728e871 100644 (file)
  */
 /* eslint-disable react/no-unused-prop-types */
 
+import styled from '@emotion/styled';
 import {
+  ButtonPrimary,
+  Checkbox,
   DarkLabel,
   FlagMessage,
   InputSearch,
@@ -28,8 +31,10 @@ import {
   Link,
   Spinner,
   Title,
+  themeBorder,
+  themeColor,
 } from 'design-system';
-import * as React from 'react';
+import React, { useState } from 'react';
 import { FormattedMessage } from 'react-intl';
 import ListFooter from '../../../../components/controls/ListFooter';
 import { LabelValueSelectOption } from '../../../../components/controls/Select';
@@ -65,55 +70,97 @@ function orgToOption({ key, name }: GithubOrganization) {
   return { value: key, label: name };
 }
 
-function renderRepositoryList(props: GitHubProjectCreateRendererProps) {
-  const { loadingRepositories, repositories, repositoryPaging, searchQuery, selectedOrganization } =
-    props;
+function RepositoryList(
+  props: GitHubProjectCreateRendererProps & {
+    selected: Set<string>;
+    checkAll: () => void;
+    uncheckAll: () => void;
+    onCheck: (key: string) => void;
+  },
+) {
+  const {
+    loadingRepositories,
+    repositories,
+    repositoryPaging,
+    searchQuery,
+    selectedOrganization,
+    selected,
+  } = props;
+
+  const areAllRepositoriesChecked = () => {
+    const nonImportedRepos = repositories?.filter((r) => !r.sqProjectKey) ?? [];
+    return nonImportedRepos.length > 0 && selected.size === nonImportedRepos.length;
+  };
+
+  const onCheckAllRepositories = () => {
+    const allSelected = areAllRepositoriesChecked();
+    if (allSelected) {
+      props.uncheckAll();
+    } else {
+      props.checkAll();
+    }
+  };
+
+  if (!selectedOrganization || !repositories) {
+    return null;
+  }
 
   return (
-    selectedOrganization &&
-    repositories && (
-      <div>
-        <div className="sw-flex sw-items-center sw-mb-6">
-          <InputSearch
-            size="large"
-            loading={loadingRepositories}
-            onChange={props.onSearch}
-            placeholder={translate('onboarding.create_project.search_repositories')}
-            value={searchQuery}
-          />
+    <div>
+      <div className="sw-mb-2 sw-py-2 sw-flex sw-items-center sw-justify-between sw-w-full">
+        <div>
+          <Checkbox
+            className="sw-ml-5"
+            checked={areAllRepositoriesChecked()}
+            disabled={repositories.length === 0}
+            onCheck={onCheckAllRepositories}
+          >
+            <span className="sw-ml-2">
+              {translate('onboarding.create_project.select_all_repositories')}
+            </span>
+          </Checkbox>
         </div>
-
-        {repositories.length === 0 ? (
-          <div className="sw-py-6 sw-px-2">
-            <LightPrimary className="sw-body-sm">{translate('no_results')}</LightPrimary>
-          </div>
-        ) : (
-          <div className="sw-flex sw-flex-col sw-gap-3">
-            {repositories.map((r) => (
-              <AlmRepoItem
-                key={r.key}
-                almKey={r.key}
-                almUrl={r.url}
-                almUrlText={translate('onboarding.create_project.see_on_github')}
-                almIconSrc={`${getBaseUrl()}/images/tutorials/github-actions.svg`}
-                sqProjectKey={r.sqProjectKey}
-                onImport={props.onImportRepository}
-                primaryTextNode={<span title={r.name}>{r.name}</span>}
-              />
-            ))}
-          </div>
-        )}
-
-        <ListFooter
-          className="sw-mb-10"
-          count={repositories.length}
-          total={repositoryPaging.total}
-          loadMore={props.onLoadMore}
+        <InputSearch
+          size="medium"
           loading={loadingRepositories}
-          useMIUIButtons
+          onChange={props.onSearch}
+          placeholder={translate('onboarding.create_project.search_repositories')}
+          value={searchQuery}
         />
       </div>
-    )
+
+      {repositories.length === 0 ? (
+        <div className="sw-py-6 sw-px-2">
+          <LightPrimary className="sw-body-sm">{translate('no_results')}</LightPrimary>
+        </div>
+      ) : (
+        <ul className="sw-flex sw-flex-col sw-gap-3">
+          {repositories.map(({ key, url, sqProjectKey, name }) => (
+            <AlmRepoItem
+              key={key}
+              almKey={key}
+              almUrl={url}
+              almUrlText={translate('onboarding.create_project.see_on_github')}
+              almIconSrc={`${getBaseUrl()}/images/tutorials/github-actions.svg`}
+              sqProjectKey={sqProjectKey}
+              multiple
+              selected={selected.has(key)}
+              onCheck={(key: string) => props.onCheck(key)}
+              primaryTextNode={<span title={name}>{name}</span>}
+            />
+          ))}
+        </ul>
+      )}
+
+      <ListFooter
+        className="sw-mb-10"
+        count={repositories.length}
+        total={repositoryPaging.total}
+        loadMore={props.onLoadMore}
+        loading={loadingRepositories}
+        useMIUIButtons
+      />
+    </div>
   );
 }
 
@@ -127,12 +174,30 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
     selectedOrganization,
     almInstances,
     selectedAlmInstance,
+    repositories,
   } = props;
+  const [selected, setSelected] = useState<Set<string>>(new Set());
 
   if (loadingBindings) {
     return <Spinner />;
   }
 
+  const handleImport = () => {
+    props.onImportRepository(Array.from(selected).toString()); // TBD
+  };
+
+  const handleCheckAll = () => {
+    setSelected(new Set(repositories?.filter((r) => !r.sqProjectKey).map((r) => r.key) ?? []));
+  };
+
+  const handleUncheckAll = () => {
+    setSelected(new Set());
+  };
+
+  const handleCheck = (key: string) => {
+    setSelected((prev) => new Set(prev.delete(key) ? prev : prev.add(key)));
+  };
+
   return (
     <>
       <header className="sw-mb-10">
@@ -171,52 +236,112 @@ export default function GitHubProjectCreateRenderer(props: GitHubProjectCreateRe
         </FlagMessage>
       )}
 
-      <Spinner loading={loadingOrganizations && !error}>
-        {!error && (
-          <div className="sw-flex sw-flex-col">
-            <DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
-              {translate('onboarding.create_project.github.choose_organization')}
-            </DarkLabel>
-            {organizations.length > 0 ? (
-              <InputSelect
-                className="sw-w-abs-300 sw-mb-9"
-                size="full"
-                isSearchable
-                inputId="github-choose-organization"
-                options={organizations.map(orgToOption)}
-                onChange={({ value }: LabelValueSelectOption) => props.onSelectOrganization(value)}
-                value={selectedOrganization ? orgToOption(selectedOrganization) : null}
-              />
-            ) : (
-              !loadingOrganizations && (
-                <FlagMessage variant="error" className="sw-mb-2">
-                  <span>
-                    {canAdmin ? (
-                      <FormattedMessage
-                        id="onboarding.create_project.github.no_orgs_admin"
-                        defaultMessage={translate('onboarding.create_project.github.no_orgs_admin')}
-                        values={{
-                          link: (
-                            <Link to="/admin/settings?category=almintegration">
-                              {translate(
-                                'onboarding.create_project.github.warning.message_admin.link',
-                              )}
-                            </Link>
-                          ),
-                        }}
-                      />
-                    ) : (
-                      translate('onboarding.create_project.github.no_orgs')
-                    )}
-                  </span>
-                </FlagMessage>
-              )
+      <div className="sw-flex sw-gap-12">
+        <LargeColumn>
+          <Spinner loading={loadingOrganizations && !error}>
+            {!error && (
+              <div className="sw-flex sw-flex-col">
+                <DarkLabel htmlFor="github-choose-organization" className="sw-mb-2">
+                  {translate('onboarding.create_project.github.choose_organization')}
+                </DarkLabel>
+                {organizations.length > 0 ? (
+                  <InputSelect
+                    className="sw-w-full sw-mb-9"
+                    size="full"
+                    isSearchable
+                    inputId="github-choose-organization"
+                    options={organizations.map(orgToOption)}
+                    onChange={({ value }: LabelValueSelectOption) =>
+                      props.onSelectOrganization(value)
+                    }
+                    value={selectedOrganization ? orgToOption(selectedOrganization) : null}
+                  />
+                ) : (
+                  !loadingOrganizations && (
+                    <FlagMessage variant="error" className="sw-mb-2">
+                      <span>
+                        {canAdmin ? (
+                          <FormattedMessage
+                            id="onboarding.create_project.github.no_orgs_admin"
+                            defaultMessage={translate(
+                              'onboarding.create_project.github.no_orgs_admin',
+                            )}
+                            values={{
+                              link: (
+                                <Link to="/admin/settings?category=almintegration">
+                                  {translate(
+                                    'onboarding.create_project.github.warning.message_admin.link',
+                                  )}
+                                </Link>
+                              ),
+                            }}
+                          />
+                        ) : (
+                          translate('onboarding.create_project.github.no_orgs')
+                        )}
+                      </span>
+                    </FlagMessage>
+                  )
+                )}
+              </div>
             )}
-          </div>
-        )}
-      </Spinner>
-
-      {renderRepositoryList(props)}
+          </Spinner>
+          <RepositoryList
+            {...props}
+            selected={selected}
+            checkAll={handleCheckAll}
+            uncheckAll={handleUncheckAll}
+            onCheck={handleCheck}
+          />
+        </LargeColumn>
+        <SideColumn>
+          {selected.size > 0 && (
+            <SetupBox className="sw-rounded-2 sw-p-8 sw-mb-0">
+              <SetupBoxTitle className="sw-mb-2 sw-heading-md">
+                <FormattedMessage
+                  id="onboarding.create_project.x_repositories_selected"
+                  values={{ count: selected.size }}
+                />
+              </SetupBoxTitle>
+              <div>
+                <SetupBoxContent className="sw-pb-4">
+                  <FormattedMessage
+                    id="onboarding.create_project.x_repository_created"
+                    values={{ count: selected.size }}
+                  />
+                </SetupBoxContent>
+                <div className="sw-mt-4">
+                  <ButtonPrimary onClick={handleImport} className="js-set-up-projects">
+                    {translate('onboarding.create_project.import')}
+                  </ButtonPrimary>
+                </div>
+              </div>
+            </SetupBox>
+          )}
+        </SideColumn>
+      </div>
     </>
   );
 }
+
+const LargeColumn = styled.div`
+  flex: 6;
+`;
+
+const SideColumn = styled.div`
+  flex: 4;
+`;
+
+const SetupBox = styled.form`
+  max-height: 280px;
+  background: ${themeColor('highlightedSection')};
+  border: ${themeBorder('default', 'highlightedSectionBorder')};
+`;
+
+const SetupBoxTitle = styled.h2`
+  color: ${themeColor('pageTitle')};
+`;
+
+const SetupBoxContent = styled.div`
+  border-bottom: ${themeBorder('default')};
+`;
index e5bc6c1860a1635960e03ce044ebd2450bebb996..503646c36bb5565cba441edddbb88290a14ba2ef 100644 (file)
@@ -86,7 +86,7 @@ export default function GitlabProjectSelectionForm(props: GitlabProjectSelection
           <LightPrimary className="sw-body-sm">{translate('no_results')}</LightPrimary>
         </div>
       ) : (
-        <div className="sw-flex sw-flex-col sw-gap-3">
+        <ul className="sw-flex sw-flex-col sw-gap-3">
           {projects.map((project) => (
             <AlmRepoItem
               key={project.id}
@@ -108,7 +108,7 @@ export default function GitlabProjectSelectionForm(props: GitlabProjectSelection
               }
             />
           ))}
-        </div>
+        </ul>
       )}
       <ListFooter
         className="sw-mb-10"
index 47ee1d8c54df0accfc59172d2cce64bde751ec77..b5b52e25470f4f2542835071bc6c2181292dcd78 100644 (file)
@@ -101,14 +101,14 @@ it('should show import project feature when PAT is already set', async () => {
   expect(screen.getByText('Azure project 2')).toBeInTheDocument();
 
   expect(
-    screen.getByRole('row', {
-      name: 'Azure repo 1 onboarding.create_project.repository_imported',
+    screen.getByRole('listitem', {
+      name: 'Azure repo 1',
     }),
   ).toBeInTheDocument();
 
   expect(
-    screen.getByRole('row', {
-      name: 'Azure repo 2 onboarding.create_project.import',
+    screen.getByRole('listitem', {
+      name: 'Azure repo 2',
     }),
   ).toBeInTheDocument();
 
index cd517e202ba3d2888c78dc4fbf06a18087dd96c9..85b5ad18a8fc6ca67a056ec7b7ad6a1e3ff39b16 100644 (file)
@@ -121,14 +121,14 @@ it('should show import project feature when PAT is already set', async () => {
   await user.click(projectItem);
 
   expect(
-    screen.getByRole('row', {
-      name: 'Bitbucket Repo 1 onboarding.create_project.repository_imported',
+    screen.getByRole('listitem', {
+      name: 'Bitbucket Repo 1',
     }),
   ).toBeInTheDocument();
 
   expect(
-    screen.getByRole('row', {
-      name: 'Bitbucket Repo 2 onboarding.create_project.import',
+    screen.getByRole('listitem', {
+      name: 'Bitbucket Repo 2',
     }),
   ).toBeInTheDocument();
 
index 9886b30d6bed240cd0a62f67c5d1bb0b52d1a80b..d7644408fa7c891248b62ce80dc9afb27ea73f67 100644 (file)
@@ -131,7 +131,7 @@ it('should show import project feature when PAT is already set', async () => {
   expect(screen.getByText('BitbucketCloud Repo 1')).toBeInTheDocument();
   expect(screen.getByText('BitbucketCloud Repo 2')).toBeInTheDocument();
 
-  projectItem = screen.getByRole('row', { name: /BitbucketCloud Repo 1/ });
+  projectItem = screen.getByRole('listitem', { name: /BitbucketCloud Repo 1/ });
   expect(
     within(projectItem).getByText('onboarding.create_project.repository_imported'),
   ).toBeInTheDocument();
@@ -144,7 +144,7 @@ it('should show import project feature when PAT is already set', async () => {
     '/dashboard?id=key',
   );
 
-  projectItem = screen.getByRole('row', { name: /BitbucketCloud Repo 2/ });
+  projectItem = screen.getByRole('listitem', { name: /BitbucketCloud Repo 2/ });
   const setupButton = within(projectItem).getByRole('button', {
     name: 'onboarding.create_project.import',
   });
index af3002d3eb2e6cfd5856d239047fe495ed2f1f96..4311a26b0543c8b9e70304609f0b33b42139f120 100644 (file)
@@ -17,7 +17,7 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { screen, within } from '@testing-library/react';
+import { screen, waitFor } from '@testing-library/react';
 
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
@@ -26,8 +26,9 @@ import { getGithubRepositories } from '../../../../api/alm-integrations';
 import AlmIntegrationsServiceMock from '../../../../api/mocks/AlmIntegrationsServiceMock';
 import AlmSettingsServiceMock from '../../../../api/mocks/AlmSettingsServiceMock';
 import NewCodeDefinitionServiceMock from '../../../../api/mocks/NewCodeDefinitionServiceMock';
+import { mockGitHubRepository } from '../../../../helpers/mocks/alm-integrations';
 import { renderApp } from '../../../../helpers/testReactTestingUtils';
-import { byLabelText, byText } from '../../../../helpers/testSelector';
+import { byLabelText, byRole, byText } from '../../../../helpers/testSelector';
 import CreateProjectPage from '../CreateProjectPage';
 
 jest.mock('../../../../api/alm-integrations');
@@ -43,6 +44,19 @@ const ui = {
   githubCreateProjectButton: byText('onboarding.create_project.select_method.github'),
   instanceSelector: byLabelText(/alm.configuration.selector.label/),
   organizationSelector: byLabelText('onboarding.create_project.github.choose_organization'),
+  project1: byRole('listitem', { name: 'Github repo 1' }),
+  project1Checkbox: byRole('listitem', { name: 'Github repo 1' }).byRole('checkbox'),
+  project2: byRole('listitem', { name: 'Github repo 2' }),
+  project2Checkbox: byRole('listitem', { name: 'Github repo 2' }).byRole('checkbox'),
+  project3: byRole('listitem', { name: 'Github repo 3' }),
+  project3Checkbox: byRole('listitem', { name: 'Github repo 3' }).byRole('checkbox'),
+  checkAll: byRole('checkbox', { name: 'onboarding.create_project.select_all_repositories' }),
+  importButton: byRole('button', { name: 'onboarding.create_project.import' }),
+  newCodeTitle: byRole('heading', { name: 'onboarding.create_project.new_code_definition.title' }),
+  createProjectButton: byRole('button', {
+    name: 'onboarding.create_project.new_code_definition.create_project',
+  }),
+  globalSettingRadio: byRole('radio', { name: 'new_code_definition.global_setting' }),
 };
 
 beforeAll(() => {
@@ -92,7 +106,6 @@ it('should not redirect to github when url is malformated', async () => {
 
 it('should show import project feature when the authentication is successfull', async () => {
   const user = userEvent.setup();
-  let repoItem;
 
   renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
 
@@ -100,44 +113,100 @@ it('should show import project feature when the authentication is successfull',
 
   await selectEvent.select(ui.organizationSelector.get(), [/org-1/]);
 
-  expect(screen.getByText('Github repo 1')).toBeInTheDocument();
-  expect(screen.getByText('Github repo 2')).toBeInTheDocument();
+  expect(await ui.project1.find()).toBeInTheDocument();
+  expect(ui.project2.get()).toBeInTheDocument();
+  expect(ui.checkAll.get()).not.toBeChecked();
 
-  repoItem = screen.getByRole('row', {
-    name: 'Github repo 1 onboarding.create_project.see_on_github onboarding.create_project.repository_imported',
-  });
+  expect(ui.project1Checkbox.get()).toBeChecked();
+  expect(ui.project1Checkbox.get()).toBeDisabled();
 
   expect(
-    within(repoItem).getByText('onboarding.create_project.repository_imported'),
+    ui.project1.byText('onboarding.create_project.repository_imported').get(),
   ).toBeInTheDocument();
 
-  expect(within(repoItem).getByRole('link', { name: /Github repo 1/ })).toBeInTheDocument();
-  expect(within(repoItem).getByRole('link', { name: /Github repo 1/ })).toHaveAttribute(
+  expect(ui.project1.byRole('link', { name: /Github repo 1/ }).get()).toBeInTheDocument();
+  expect(ui.project1.byRole('link', { name: /Github repo 1/ }).get()).toHaveAttribute(
     'href',
     '/dashboard?id=key123',
   );
 
-  repoItem = screen.getByRole('row', {
-    name: 'Github repo 2 onboarding.create_project.see_on_github onboarding.create_project.import',
-  });
+  expect(ui.project2Checkbox.get()).not.toBeChecked();
+  expect(ui.project2Checkbox.get()).toBeEnabled();
 
-  const importButton = screen.getByText('onboarding.create_project.import');
-  await user.click(importButton);
+  expect(ui.importButton.query()).not.toBeInTheDocument();
+  await user.click(ui.project2Checkbox.get());
+  await waitFor(() => expect(ui.checkAll.get()).toBeChecked());
 
-  expect(
-    screen.getByRole('heading', { name: 'onboarding.create_project.new_code_definition.title' }),
-  ).toBeInTheDocument();
+  expect(ui.importButton.get()).toBeInTheDocument();
+  await user.click(ui.importButton.get());
 
-  await user.click(screen.getByRole('radio', { name: 'new_code_definition.global_setting' }));
-  await user.click(
-    screen.getByRole('button', {
-      name: 'onboarding.create_project.new_code_definition.create_project',
-    }),
-  );
+  expect(await ui.newCodeTitle.find()).toBeInTheDocument();
+
+  await user.click(ui.globalSettingRadio.get());
+
+  expect(ui.createProjectButton.get()).toBeEnabled();
+  await user.click(ui.createProjectButton.get());
 
   expect(await screen.findByText('/dashboard?id=key')).toBeInTheDocument();
 });
 
+it('should import several projects', async () => {
+  const user = userEvent.setup();
+
+  almIntegrationHandler.setGithubRepositories([
+    mockGitHubRepository({ name: 'Github repo 1', key: 'key1' }),
+    mockGitHubRepository({ name: 'Github repo 2', key: 'key2' }),
+    mockGitHubRepository({ name: 'Github repo 3', key: 'key3' }),
+  ]);
+
+  renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
+
+  expect(await ui.instanceSelector.find()).toBeInTheDocument();
+
+  await selectEvent.select(ui.organizationSelector.get(), [/org-1/]);
+
+  expect(await ui.project1.find()).toBeInTheDocument();
+  expect(ui.project1Checkbox.get()).not.toBeChecked();
+  expect(ui.project2Checkbox.get()).not.toBeChecked();
+  expect(ui.project3Checkbox.get()).not.toBeChecked();
+  expect(ui.checkAll.get()).not.toBeChecked();
+  expect(ui.importButton.query()).not.toBeInTheDocument();
+
+  await user.click(ui.project1Checkbox.get());
+
+  expect(ui.project1Checkbox.get()).toBeChecked();
+  expect(ui.project2Checkbox.get()).not.toBeChecked();
+  expect(ui.project3Checkbox.get()).not.toBeChecked();
+  expect(ui.checkAll.get()).not.toBeChecked();
+  expect(ui.importButton.get()).toBeInTheDocument();
+
+  await user.click(ui.checkAll.get());
+
+  expect(ui.project1Checkbox.get()).toBeChecked();
+  expect(ui.project2Checkbox.get()).toBeChecked();
+  expect(ui.project3Checkbox.get()).toBeChecked();
+  expect(ui.checkAll.get()).toBeChecked();
+  expect(ui.importButton.get()).toBeInTheDocument();
+
+  await user.click(ui.checkAll.get());
+
+  expect(ui.project1Checkbox.get()).not.toBeChecked();
+  expect(ui.project2Checkbox.get()).not.toBeChecked();
+  expect(ui.project3Checkbox.get()).not.toBeChecked();
+  expect(ui.checkAll.get()).not.toBeChecked();
+  expect(ui.importButton.query()).not.toBeInTheDocument();
+
+  await user.click(ui.project1Checkbox.get());
+  await user.click(ui.project2Checkbox.get());
+
+  expect(ui.importButton.get()).toBeInTheDocument();
+  await user.click(ui.importButton.get());
+
+  expect(await ui.newCodeTitle.find()).toBeInTheDocument();
+
+  // TBD
+});
+
 it('should show search filter when the authentication is successful', async () => {
   const user = userEvent.setup();
   renderCreateProject('project/create?mode=github&almInstance=conf-github-2&code=213321213');
index b0eb033ec8dec26e944f439e532fd5cee750e811..442abc088b872aee13380f101e24ae986b73026a 100644 (file)
@@ -105,7 +105,7 @@ it('should show import project feature when PAT is already set', async () => {
   expect(screen.getByText('Gitlab project 1')).toBeInTheDocument();
   expect(screen.getByText('Gitlab project 2')).toBeInTheDocument();
 
-  projectItem = screen.getByRole('row', { name: /Gitlab project 1/ });
+  projectItem = screen.getByRole('listitem', { name: /Gitlab project 1/ });
   expect(
     within(projectItem).getByText('onboarding.create_project.repository_imported'),
   ).toBeInTheDocument();
@@ -115,7 +115,7 @@ it('should show import project feature when PAT is already set', async () => {
     '/dashboard?id=key',
   );
 
-  projectItem = screen.getByRole('row', { name: /Gitlab project 2/ });
+  projectItem = screen.getByRole('listitem', { name: /Gitlab project 2/ });
   const importButton = within(projectItem).getByRole('button', {
     name: 'onboarding.create_project.import',
   });
index 4b341da893b9fe977da92a796809d8974c5b0ad5..4ae059d21c13b7f2b2fa43e5a325eff818555da9 100644 (file)
@@ -22,28 +22,40 @@ import styled from '@emotion/styled';
 import classNames from 'classnames';
 import {
   ButtonSecondary,
-  Card,
   CheckIcon,
+  Checkbox,
   DiscreetLink,
   LightLabel,
   LightPrimary,
   Link,
-  themeColor,
+  themeBorder,
 } from 'design-system';
 import React from 'react';
 import { translate } from '../../../../helpers/l10n';
 import { getProjectUrl } from '../../../../helpers/urls';
 
-interface AlmRepoItemProps {
+type AlmRepoItemProps = {
   primaryTextNode: React.ReactNode;
   secondaryTextNode?: React.ReactNode;
   sqProjectKey?: string;
   almKey: string;
   almUrl?: string;
   almUrlText?: string;
-  onImport: (key: string) => void;
   almIconSrc: string;
-}
+} & (
+  | {
+      multiple: true;
+      onCheck: (key: string) => void;
+      selected: boolean;
+      onImport?: never;
+    }
+  | {
+      multiple?: false;
+      onCheck?: never;
+      selected?: never;
+      onImport: (key: string) => void;
+    }
+);
 
 export default function AlmRepoItem({
   almKey,
@@ -53,19 +65,34 @@ export default function AlmRepoItem({
   almUrl,
   almUrlText,
   almIconSrc,
+  multiple,
+  selected,
+  onCheck,
   onImport,
 }: AlmRepoItemProps) {
+  const labelId = `${almKey.replace(/\s/g, '_')}-label`;
   return (
-    <StyledCard
-      key={almKey}
-      role="row"
-      className={classNames('sw-flex sw-px-4', {
-        'sw-py-4': sqProjectKey !== undefined,
-        'sw-py-2': sqProjectKey === undefined,
+    <RepositoryItem
+      selected={selected}
+      imported={sqProjectKey !== undefined}
+      aria-labelledby={labelId}
+      onClick={() => multiple && sqProjectKey === undefined && onCheck(almKey)}
+      className={classNames('sw-flex sw-items-center sw-w-full sw-p-4 sw-rounded-1', {
+        'sw-py-4': multiple || sqProjectKey !== undefined,
+        'sw-py-2': !multiple && sqProjectKey === undefined,
       })}
     >
+      {multiple && (
+        <Checkbox
+          checked={selected || sqProjectKey !== undefined}
+          className="sw-p-1 sw-mr-2"
+          disabled={sqProjectKey !== undefined}
+          onCheck={() => onCheck(almKey)}
+          onClick={(e: React.MouseEvent<HTMLLabelElement>) => e.stopPropagation()}
+        />
+      )}
       <div className="sw-w-[70%] sw-min-w-0 sw-flex sw-mr-1">
-        <div className="sw-max-w-[50%] sw-flex sw-items-center">
+        <div id={labelId} className="sw-max-w-[50%] sw-flex sw-items-center">
           <img
             alt="" // Should be ignored by screen readers
             className="sw-h-4 sw-w-4 sw-mr-2"
@@ -109,19 +136,26 @@ export default function AlmRepoItem({
             </LightPrimary>
           </div>
         ) : (
-          <ButtonSecondary
-            onClick={() => {
-              onImport(almKey);
-            }}
-          >
-            {translate('onboarding.create_project.import')}
-          </ButtonSecondary>
+          <>
+            {!multiple && (
+              <ButtonSecondary
+                onClick={() => {
+                  onImport(almKey);
+                }}
+              >
+                {translate('onboarding.create_project.import')}
+              </ButtonSecondary>
+            )}
+          </>
         )}
       </div>
-    </StyledCard>
+    </RepositoryItem>
   );
 }
 
-const StyledCard = styled(Card)`
-  border-color: ${themeColor('almCardBorder')};
+const RepositoryItem = styled.li<{ selected?: boolean; imported?: boolean }>`
+  box-sizing: border-box;
+  border: ${({ selected }) =>
+    selected ? themeBorder('default', 'primary') : themeBorder('default')};
+  cursor: ${({ imported }) => imported && 'default'};
 `;
index a1cd2bb6b41918eccb2b3bcc202bc7e5c3b42b6f..4edbbc37d80e8d37b2f0972f52b067e01e35284e 100644 (file)
@@ -31,6 +31,7 @@ it('render the component correctly when sqProjectKey is not present', () => {
   expect(
     screen.getByRole('button', { name: 'onboarding.create_project.import' }),
   ).toBeInTheDocument();
+  expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
 });
 
 it('render the component correctly when sqProjectKey is present', () => {
@@ -41,18 +42,56 @@ it('render the component correctly when sqProjectKey is present', () => {
   expect(
     screen.queryByRole('button', { name: 'onboarding.create_project.import' }),
   ).not.toBeInTheDocument();
+  expect(screen.queryByRole('checkbox')).not.toBeInTheDocument();
 });
 
-function renderAlmRepoItem(props?: Partial<FCProps<typeof AlmRepoItem>>) {
+it('render the component correctly with checkboxes when sqProjectKey is not present', () => {
+  renderAlmRepoItem(undefined, true);
+  expect(screen.getByText('test1')).toBeInTheDocument();
+  expect(screen.getByText('url text')).toHaveAttribute('href', '/url');
+  expect(
+    screen.queryByText('onboarding.create_project.repository_imported'),
+  ).not.toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'onboarding.create_project.import' }),
+  ).not.toBeInTheDocument();
+  expect(screen.getByRole('checkbox')).toBeInTheDocument();
+  expect(screen.getByRole('checkbox')).not.toBeChecked();
+  expect(screen.getByRole('checkbox')).toBeEnabled();
+});
+
+it('render the component correctly with checkboxes when sqProjectKey is present', () => {
+  renderAlmRepoItem({ sqProjectKey: 'sqkey' }, true);
+  expect(screen.getByText('test1')).toBeInTheDocument();
+  expect(screen.getByText('url text')).toHaveAttribute('href', '/url');
+  expect(screen.getByText('onboarding.create_project.repository_imported')).toBeInTheDocument();
+  expect(
+    screen.queryByRole('button', { name: 'onboarding.create_project.import' }),
+  ).not.toBeInTheDocument();
+  expect(screen.getByRole('checkbox')).toBeInTheDocument();
+  expect(screen.getByRole('checkbox')).toBeChecked();
+  expect(screen.getByRole('checkbox')).toBeDisabled();
+});
+
+function renderAlmRepoItem(
+  props?: Omit<
+    Partial<FCProps<typeof AlmRepoItem>>,
+    'multiple' | 'onCheck' | 'onImport' | 'selected'
+  >,
+  multiple?: boolean,
+) {
+  const commonProps = {
+    primaryTextNode: 'test1',
+    almKey: 'key',
+    almUrl: 'url',
+    almUrlText: 'url text',
+    almIconSrc: 'src',
+  };
   return renderComponent(
-    <AlmRepoItem
-      primaryTextNode="test1"
-      almKey="key"
-      almUrl="url"
-      almUrlText="url text"
-      almIconSrc="src"
-      onImport={jest.fn()}
-      {...props}
-    />,
+    multiple ? (
+      <AlmRepoItem {...commonProps} multiple onCheck={jest.fn()} selected={false} {...props} />
+    ) : (
+      <AlmRepoItem {...commonProps} onImport={jest.fn()} {...props} />
+    ),
   );
 }
index d1ce7474ad1244f8e5b595688c6fd2295ae0967d..2e5bec68af09b46cbe8614c55863d109574b794f 100644 (file)
@@ -82,7 +82,6 @@ export function mockGitHubRepository(overrides: Partial<GithubRepository> = {}):
     id: 'id1234',
     key: 'key3456',
     name: 'repository 1',
-    sqProjectKey: '',
     url: 'https://github.com/owner/repo1',
     ...overrides,
   };
@@ -95,7 +94,6 @@ export function mockGitlabProject(overrides: Partial<GitlabProject> = {}): Gitla
     slug: 'awesome-project-exclamation',
     pathName: 'Company / Best Projects',
     pathSlug: 'company/best-projects',
-    sqProjectKey: '',
     url: 'https://gitlab.company.com/best-projects/awesome-project-exclamation',
     ...overrides,
   };
index d6ff515944c555c9b77d3f509b4e7fef1f2629c1..9fed4ae0c75edea9577c2ebeb60e36fdd62405bf 100644 (file)
@@ -69,7 +69,7 @@ export interface GithubRepository {
   key: string;
   name: string;
   url: string;
-  sqProjectKey: string;
+  sqProjectKey?: string;
 }
 
 export interface GitlabProject {
index 0d9db6f1dc961f95a26717d08906a444cd18dc43..bad1ff52ecbd638f250359174f900977fdc3f170 100644 (file)
@@ -4188,6 +4188,8 @@ onboarding.create_project.gitlab.no_projects=No projects could be fetched from G
 onboarding.create_project.gitlab.link=See on GitLab
 onboarding.create_project.bitbucket.title=Bitbucket Server project onboarding
 onboarding.create_project.bitbucket.subtitle=Import projects from one of your Bitbucket server workspaces
+onboarding.create_project.x_repositories_selected={count} {count, plural, one {repository} other {repositories}} selected
+onboarding.create_project.x_repository_created={count} {count, plural, one {repository} other {repositories}} will be created as a project on SonarQube
 
 onboarding.create_project.new_code_definition.title=Set up project for Clean as You Code
 onboarding.create_project.new_code_definition.description=The new code definition sets which part of your code will be considered new code. This helps you focus attention on the most recent changes to your project, enabling you to follow the Clean as You Code methodology. Learn more: {link}