]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-20500 Rules list header
authorKevin Silva <kevin.silva@sonarsource.com>
Wed, 27 Sep 2023 15:45:23 +0000 (17:45 +0200)
committersonartech <sonartech@sonarsource.com>
Thu, 5 Oct 2023 20:02:48 +0000 (20:02 +0000)
19 files changed:
server/sonar-web/design-system/src/components/MultiSelector.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/TagsSelector.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/components/input/MultiSelectMenu.tsx
server/sonar-web/src/main/js/apps/coding-rules/__tests__/CodingRules-it.ts
server/sonar-web/src/main/js/apps/coding-rules/components/BulkChange.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/CodingRulesApp.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/PageActions.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/QualityProfileSelector.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx
server/sonar-web/src/main/js/apps/coding-rules/utils-tests.tsx
server/sonar-web/src/main/js/apps/issues/components/TagsSelect.tsx
server/sonar-web/src/main/js/apps/projectInformation/about/components/MetaTags.tsx
server/sonar-web/src/main/js/components/common/PageCounter.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/PageCounter-test.tsx.snap
server/sonar-web/src/main/js/components/issue/popups/IssueTagsPopup.tsx
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/design-system/src/components/MultiSelector.tsx b/server/sonar-web/design-system/src/components/MultiSelector.tsx
new file mode 100644 (file)
index 0000000..dede9a7
--- /dev/null
@@ -0,0 +1,75 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 { MultiSelectMenu } from './input/MultiSelectMenu';
+
+interface Props {
+  allowNewElements?: boolean;
+  allowSearch?: boolean;
+  createElementLabel: string;
+  elements: string[];
+  headerLabel: string;
+  listSize?: number;
+  noResultsLabel: string;
+  onSearch?: (query: string) => Promise<void>;
+  onSelect: (item: string) => void;
+  onUnselect: (item: string) => void;
+  searchInputAriaLabel: string;
+  selectedElements: string[];
+}
+
+const LIST_SIZE = 10;
+
+export function MultiSelector(props: Readonly<Props>) {
+  const {
+    allowNewElements,
+    createElementLabel,
+    headerLabel,
+    noResultsLabel,
+    searchInputAriaLabel,
+    selectedElements,
+    elements,
+    allowSearch = true,
+    listSize = LIST_SIZE,
+  } = props;
+
+  return (
+    <MultiSelectMenu
+      allowNewElements={allowNewElements}
+      allowSearch={allowSearch}
+      createElementLabel={createElementLabel}
+      elements={elements}
+      headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
+      listSize={listSize}
+      noResultsLabel={noResultsLabel}
+      onSearch={props.onSearch}
+      onSelect={props.onSelect}
+      onUnselect={props.onUnselect}
+      placeholder={searchInputAriaLabel}
+      searchInputAriaLabel={searchInputAriaLabel}
+      selectedElements={selectedElements}
+      validateSearchInput={validateElement}
+    />
+  );
+}
+
+export function validateElement(value: string) {
+  // Allow only a-z, 0-9, '+', '-', '#', '.'
+  return value.toLowerCase().replace(/[^-a-z0-9+#.]/gi, '');
+}
diff --git a/server/sonar-web/design-system/src/components/TagsSelector.tsx b/server/sonar-web/design-system/src/components/TagsSelector.tsx
deleted file mode 100644 (file)
index 55eaac1..0000000
+++ /dev/null
@@ -1,70 +0,0 @@
-/*
- * SonarQube
- * Copyright (C) 2009-2023 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 { MultiSelectMenu } from './input/MultiSelectMenu';
-
-interface Props {
-  allowNewElements?: boolean;
-  createElementLabel: string;
-  headerLabel: string;
-  noResultsLabel: string;
-  onSearch: (query: string) => Promise<void>;
-  onSelect: (item: string) => void;
-  onUnselect: (item: string) => void;
-  searchInputAriaLabel: string;
-  selectedTags: string[];
-  tags: string[];
-}
-
-const LIST_SIZE = 10;
-
-export function TagsSelector(props: Props) {
-  const {
-    allowNewElements,
-    createElementLabel,
-    headerLabel,
-    noResultsLabel,
-    searchInputAriaLabel,
-    selectedTags,
-    tags,
-  } = props;
-
-  return (
-    <MultiSelectMenu
-      allowNewElements={allowNewElements}
-      createElementLabel={createElementLabel}
-      elements={tags}
-      headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
-      listSize={LIST_SIZE}
-      noResultsLabel={noResultsLabel}
-      onSearch={props.onSearch}
-      onSelect={props.onSelect}
-      onUnselect={props.onUnselect}
-      placeholder={searchInputAriaLabel}
-      searchInputAriaLabel={searchInputAriaLabel}
-      selectedElements={selectedTags}
-      validateSearchInput={validateTag}
-    />
-  );
-}
-
-export function validateTag(value: string) {
-  // Allow only a-z, 0-9, '+', '-', '#', '.'
-  return value.toLowerCase().replace(/[^-a-z0-9+#.]/gi, '');
-}
index 73a623cee341eea92fe8c1dbfa447d384d8e7f97..c6082251dbcfe6a5e3c0b9517673ef71e4d1ad1c 100644 (file)
@@ -25,8 +25,8 @@ import userEvent from '@testing-library/user-event';
 import { useState } from 'react';
 import { renderWithContext } from '../../helpers/testUtils';
 import { FCProps } from '../../types/misc';
+import { MultiSelector } from '../MultiSelector';
 import { Tags } from '../Tags';
-import { TagsSelector } from '../TagsSelector';
 
 it('should display "no tags"', () => {
   renderTags({ tags: [] });
@@ -83,8 +83,9 @@ function Wrapper(overrides: Partial<FCProps<typeof Tags>> = {}) {
   const [selectedTags, setSelectedTags] = useState<string[]>(overrides.tags ?? ['tag1']);
 
   const overlay = (
-    <TagsSelector
+    <MultiSelector
       createElementLabel="create new tag"
+      elements={['tag1', 'tag2', 'tag3']}
       headerLabel="edit tags"
       noResultsLabel="no results"
       onSearch={jest.fn().mockResolvedValue(undefined)}
@@ -98,8 +99,7 @@ function Wrapper(overrides: Partial<FCProps<typeof Tags>> = {}) {
         }
       }}
       searchInputAriaLabel="search"
-      selectedTags={selectedTags}
-      tags={['tag1', 'tag2', 'tag3']}
+      selectedElements={selectedTags}
     />
   );
 
index 34f72bf173b155a7aac8c45b1b5d6c2da9df1092..16e93fbc755fc2238ca53e4628526f8dd1f58308 100644 (file)
@@ -56,6 +56,7 @@ export * from './MainAppBar';
 export * from './MainMenu';
 export * from './MainMenuItem';
 export * from './MetricsRatingBadge';
+export * from './MultiSelector';
 export * from './NavBarTabs';
 export * from './NewCodeLegend';
 export * from './OutsideClickHandler';
@@ -71,7 +72,6 @@ export { Spinner } from './Spinner';
 export * from './SpotlightTour';
 export * from './Table';
 export * from './Tags';
-export * from './TagsSelector';
 export * from './Text';
 export * from './Title';
 export { ToggleButton } from './ToggleButton';
index bd73d4672ec27e96533e60b318e4be84176f5fc7..0a85dd0c09321f6ebfa6e36202db518ba6552fe6 100644 (file)
@@ -27,6 +27,7 @@ import { MultiSelectMenuOption } from './MultiSelectMenuOption';
 
 interface Props {
   allowNewElements?: boolean;
+  allowSearch?: boolean;
   allowSelection?: boolean;
   createElementLabel: string;
   elements: string[];
@@ -35,7 +36,7 @@ interface Props {
   inputId?: string;
   listSize: number;
   noResultsLabel: string;
-  onSearch: (query: string) => Promise<void>;
+  onSearch?: (query: string) => Promise<void>;
   onSelect: (item: string) => void;
   onUnselect: (item: string) => void;
   placeholder: string;
@@ -165,8 +166,10 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
   };
 
   onSearchQuery = (query: string) => {
-    this.setState({ activeIdx: 0, loading: true, query });
-    this.props.onSearch(query).then(this.stopLoading, this.stopLoading);
+    if (this.props.onSearch) {
+      this.setState({ activeIdx: 0, loading: true, query });
+      this.props.onSearch(query).then(this.stopLoading, this.stopLoading);
+    }
   };
 
   onSelectItem = (item: string) => {
@@ -205,7 +208,7 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
       return {
         unselectedElements: difference(this.props.elements, this.props.selectedElements).slice(
           0,
-          listSize - state.selectedElements.length
+          listSize - state.selectedElements.length,
         ),
       };
     });
@@ -255,6 +258,7 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
 
   render() {
     const {
+      allowSearch = true,
       allowSelection = true,
       allowNewElements = true,
       createElementLabel,
@@ -274,22 +278,27 @@ export class MultiSelectMenu extends PureComponent<Props, State> {
 
     return (
       <div ref={(div) => (this.container = div)}>
-        <div className="sw-px-3">
-          <InputSearch
-            autoFocus
-            className="sw-mt-1"
-            id={inputId}
-            loading={this.state.loading}
-            onChange={this.handleSearchChange}
-            placeholder={this.props.placeholder}
-            searchInputAriaLabel={searchInputAriaLabel}
-            size="full"
-            value={query}
-          />
-        </div>
-        <ItemHeader>{headerNode}</ItemHeader>
+        {allowSearch && (
+          <>
+            <div className="sw-px-3">
+              <InputSearch
+                autoFocus
+                className="sw-mt-1"
+                id={inputId}
+                loading={this.state.loading}
+                onChange={this.handleSearchChange}
+                placeholder={this.props.placeholder}
+                searchInputAriaLabel={searchInputAriaLabel}
+                size="full"
+                value={query}
+              />
+            </div>
+            <ItemHeader>{headerNode}</ItemHeader>
+          </>
+        )}
         <ul
-          className={classNames('sw-mt-2', {
+          className={classNames({
+            'sw-mt-2': allowSearch,
             'sw-max-h-abs-200 sw-overflow-y-auto': isFixedHeight,
           })}
         >
index f4e273180e63164de9abd289403b80e7cb6d7ad1..6279190231091cfcff386e17250d5202c0303ff7 100644 (file)
@@ -256,8 +256,7 @@ describe('Rules app list', () => {
       expect(ui.ruleListItem.getAll(ui.rulesList.get())).toHaveLength(11);
     });
 
-    // eslint-disable-next-line jest/no-disabled-tests
-    it.skip('filters by search', async () => {
+    it('filters by search', async () => {
       const { ui, user } = getPageObjects();
       renderCodingRulesApp(mockCurrentUser());
       await ui.appLoaded();
@@ -288,11 +287,12 @@ describe('Rules app list', () => {
       await user.click(ui.bulkChangeButton.get());
       await user.click(ui.activateIn.get());
 
-      const dialog = ui.bulkChangeDialog(1).get();
-      expect(dialog).toBeInTheDocument();
+      const dialog = ui.bulkChangeDialog(1);
+      expect(dialog.get()).toBeInTheDocument();
 
-      selectEvent.openMenu(ui.activateInSelect.get());
-      expect(ui.noQualityProfiles.get(dialog)).toBeInTheDocument();
+      await user.click(ui.activateInSelect.get());
+
+      expect(ui.noQualityProfiles.get(dialog.get())).toBeInTheDocument();
     });
 
     it('should be able to bulk activate quality profile', async () => {
index 9d8b36aed717b897a07e3a6c70e70114b49cb59e..bf8ff0c4caa06221e6aa0bd00dc2da8b1e7c76e7 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 {
+  ButtonPrimary,
+  ButtonSecondary,
+  ChevronDownIcon,
+  Dropdown,
+  ItemButton,
+  PopupPlacement,
+  PopupZLevel,
+} from 'design-system';
 import * as React from 'react';
 import { Profile } from '../../../api/quality-profiles';
-import Dropdown from '../../../components/controls/Dropdown';
 import Tooltip from '../../../components/controls/Tooltip';
-import { Button } from '../../../components/controls/buttons';
-import { PopupPlacement } from '../../../components/ui/popups';
 import { translate } from '../../../helpers/l10n';
 import { Dict } from '../../../types/types';
 import { Query } from '../query';
@@ -50,27 +56,19 @@ export default class BulkChange extends React.PureComponent<Props, State> {
 
   closeModal = () => this.setState({ action: undefined, modal: false, profile: undefined });
 
-  handleActivateClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
+  handleActivateClick = () => {
     this.setState({ action: 'activate', modal: true, profile: undefined });
   };
 
-  handleActivateInProfileClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
+  handleActivateInProfileClick = () => {
     this.setState({ action: 'activate', modal: true, profile: this.getSelectedProfile() });
   };
 
-  handleDeactivateClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
+  handleDeactivateClick = () => {
     this.setState({ action: 'deactivate', modal: true, profile: undefined });
   };
 
-  handleDeactivateInProfileClick = (event: React.SyntheticEvent<HTMLAnchorElement>) => {
-    event.preventDefault();
-    event.currentTarget.blur();
+  handleDeactivateInProfileClick = () => {
     this.setState({ action: 'deactivate', modal: true, profile: this.getSelectedProfile() });
   };
 
@@ -82,9 +80,7 @@ export default class BulkChange extends React.PureComponent<Props, State> {
     if (!canBulkChange) {
       return (
         <Tooltip overlay={translate('coding_rules.can_not_bulk_change')}>
-          <Button className="js-bulk-change" disabled>
-            {translate('bulk_change')}
-          </Button>
+          <ButtonPrimary disabled>{translate('bulk_change')}</ButtonPrimary>
         </Tooltip>
       );
     }
@@ -100,37 +96,38 @@ export default class BulkChange extends React.PureComponent<Props, State> {
     return (
       <>
         <Dropdown
-          overlayPlacement={PopupPlacement.BottomLeft}
+          id="issue-bulkaction-menu"
+          size="auto"
+          placement={PopupPlacement.BottomRight}
+          zLevel={PopupZLevel.Global}
           overlay={
-            <ul className="menu">
-              <li>
-                <a href="#" onClick={this.handleActivateClick}>
-                  {translate('coding_rules.activate_in')}…
-                </a>
-              </li>
+            <>
+              <ItemButton onClick={this.handleActivateClick}>
+                {translate('coding_rules.activate_in')}
+              </ItemButton>
+
               {allowActivateOnProfile && profile && (
-                <li>
-                  <a href="#" onClick={this.handleActivateInProfileClick}>
-                    {translate('coding_rules.activate_in')} <strong>{profile.name}</strong>
-                  </a>
-                </li>
+                <ItemButton onClick={this.handleActivateInProfileClick}>
+                  {translate('coding_rules.activate_in')} <strong>{profile.name}</strong>
+                </ItemButton>
               )}
-              <li>
-                <a href="#" onClick={this.handleDeactivateClick}>
-                  {translate('coding_rules.deactivate_in')}…
-                </a>
-              </li>
+
+              <ItemButton onClick={this.handleDeactivateClick}>
+                {translate('coding_rules.deactivate_in')}
+              </ItemButton>
+
               {allowDeactivateOnProfile && profile && (
-                <li>
-                  <a href="#" onClick={this.handleDeactivateInProfileClick}>
-                    {translate('coding_rules.deactivate_in')} <strong>{profile.name}</strong>
-                  </a>
-                </li>
+                <ItemButton onClick={this.handleDeactivateInProfileClick}>
+                  {translate('coding_rules.deactivate_in')} <strong>{profile.name}</strong>
+                </ItemButton>
               )}
-            </ul>
+            </>
           }
         >
-          <Button className="js-bulk-change">{translate('bulk_change')}</Button>
+          <ButtonSecondary>
+            {translate('bulk_change')}
+            <ChevronDownIcon className="sw-ml-1" />
+          </ButtonSecondary>
         </Dropdown>
         {this.state.modal && this.state.action && (
           <BulkChangeModal
index 65e6d4fee4dc82c5673697c48730036e98ebfbc8..c7c743591857b63ede81465ea41efbf68bdd92cf 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 { ButtonPrimary, FlagMessage, FormField, Modal, Spinner } from 'design-system';
 import * as React from 'react';
-import { bulkActivateRules, bulkDeactivateRules, Profile } from '../../../api/quality-profiles';
+import { Profile, bulkActivateRules, bulkDeactivateRules } from '../../../api/quality-profiles';
 import withLanguagesContext from '../../../app/components/languages/withLanguagesContext';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import Modal from '../../../components/controls/Modal';
-import Select from '../../../components/controls/Select';
-import { Alert } from '../../../components/ui/Alert';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { formatMeasure } from '../../../helpers/measures';
 import { Languages } from '../../../types/languages';
 import { MetricType } from '../../../types/metrics';
 import { Dict } from '../../../types/types';
 import { Query, serializeQuery } from '../query';
+import { QualityProfileSelector } from './QualityProfileSelector';
 
 interface Props {
   action: string;
@@ -49,7 +47,6 @@ interface ActivationResult {
 
 interface State {
   finished: boolean;
-  modalWrapperNode: HTMLDivElement | null;
   results: ActivationResult[];
   selectedProfiles: Profile[];
   submitting: boolean;
@@ -70,7 +67,6 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
 
     this.state = {
       finished: false,
-      modalWrapperNode: null,
       results: [],
       selectedProfiles,
       submitting: false,
@@ -85,10 +81,6 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
     this.mounted = false;
   }
 
-  setModalWrapperNode = (node: HTMLDivElement | null) => {
-    this.setState({ modalWrapperNode: node });
-  };
-
   handleProfileSelect = (selectedProfiles: Profile[]) => {
     this.setState({ selectedProfiles });
   };
@@ -168,7 +160,11 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
       ? languages[profile.language].name
       : profile.language;
     return (
-      <Alert key={result.profile} variant={result.failed === 0 ? 'success' : 'warning'}>
+      <FlagMessage
+        className="sw-mb-4"
+        key={result.profile}
+        variant={result.failed === 0 ? 'success' : 'warning'}
+      >
         {result.failed
           ? translateWithParameters(
               'coding_rules.bulk_change.warning',
@@ -183,27 +179,20 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
               language,
               result.succeeded,
             )}
-      </Alert>
+      </FlagMessage>
     );
   };
 
   renderProfileSelect = () => {
     const profiles = this.getAvailableQualityProfiles();
 
+    const { selectedProfiles } = this.state;
     return (
-      <Select
-        aria-labelledby="coding-rules-bulk-change-profile-header"
-        isMulti
-        isClearable={false}
-        isSearchable
-        menuPortalTarget={this.state.modalWrapperNode}
-        menuPosition="fixed"
-        noOptionsMessage={() => translate('coding_rules.bulk_change.no_quality_profile')}
-        getOptionLabel={(profile) => `${profile.name} - ${profile.languageName}`}
-        getOptionValue={(profile) => profile.key}
+      <QualityProfileSelector
+        inputId="coding-rules-bulk-change-profile-select"
+        profiles={profiles}
+        selectedProfiles={selectedProfiles}
         onChange={this.handleProfileSelect}
-        options={profiles}
-        value={this.state.selectedProfiles}
       />
     );
   };
@@ -221,53 +210,58 @@ export class BulkChangeModal extends React.PureComponent<Props, State> {
             MetricType.Integer,
           )} ${translate('coding_rules._rules')})`;
 
-    return (
-      <Modal contentLabel={header} onRequestClose={this.props.onClose} size="medium">
-        <div ref={this.setModalWrapperNode}>
-          <form onSubmit={this.handleFormSubmit}>
-            <header className="modal-head">
-              <h2>{header}</h2>
-            </header>
+    const FORM_ID = `coding-rules-bulk-change-form-${action}`;
 
-            <div className="modal-body modal-container">
-              {this.state.results.map(this.renderResult)}
-
-              {!this.state.finished && !this.state.submitting && (
-                <div className="modal-field huge-spacer-bottom">
-                  <h3>
-                    <label id="coding-rules-bulk-change-profile-header">
-                      {action === 'activate'
-                        ? translate('coding_rules.activate_in')
-                        : translate('coding_rules.deactivate_in')}
-                    </label>
-                  </h3>
-                  {profile ? (
-                    <span>
-                      {profile.name}
-                      {' — '}
-                      {translate('are_you_sure')}
-                    </span>
-                  ) : (
-                    this.renderProfileSelect()
-                  )}
-                </div>
-              )}
-            </div>
+    const formBody = (
+      <form id={FORM_ID} onSubmit={this.handleFormSubmit}>
+        <div>
+          {this.state.results.map(this.renderResult)}
 
-            <footer className="modal-foot">
-              {this.state.submitting && <i className="spinner spacer-right" />}
-              {!this.state.finished && (
-                <SubmitButton disabled={this.state.submitting} id="coding-rules-submit-bulk-change">
-                  {translate('apply')}
-                </SubmitButton>
+          {!this.state.finished && !this.state.submitting && (
+            <FormField
+              id="coding-rules-bulk-change-profile-header"
+              htmlFor="coding-rules-bulk-change-profile-select"
+              label={
+                action === 'activate'
+                  ? translate('coding_rules.activate_in')
+                  : translate('coding_rules.deactivate_in')
+              }
+            >
+              {profile ? (
+                <span>
+                  {profile.name}
+                  {' — '}
+                  {translate('are_you_sure')}
+                </span>
+              ) : (
+                this.renderProfileSelect()
               )}
-              <ResetButtonLink onClick={this.props.onClose}>
-                {this.state.finished ? translate('close') : translate('cancel')}
-              </ResetButtonLink>
-            </footer>
-          </form>
+            </FormField>
+          )}
         </div>
-      </Modal>
+      </form>
+    );
+
+    return (
+      <Modal
+        headerTitle={header}
+        isScrollable
+        onClose={this.props.onClose}
+        body={<Spinner loading={this.state.submitting}>{formBody}</Spinner>}
+        primaryButton={
+          !this.state.finished && (
+            <ButtonPrimary
+              autoFocus
+              type="submit"
+              disabled={this.state.submitting || this.state.selectedProfiles.length === 0}
+              form={FORM_ID}
+            >
+              {translate('apply')}
+            </ButtonPrimary>
+          )
+        }
+        secondaryButtonLabel={this.state.finished ? translate('close') : translate('cancel')}
+      />
     );
   }
 }
index 68fd8b60c8e7425019f4f12cd817c927784a37a4..ad4b71ea1510c953ebfeccea44923eb81aec13d3 100644 (file)
@@ -17,6 +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 { InputSearch } from 'design-system';
 import { keyBy } from 'lodash';
 import * as React from 'react';
 import { Helmet } from 'react-helmet-async';
@@ -74,6 +75,7 @@ import RuleDetails from './RuleDetails';
 import RuleListItem from './RuleListItem';
 
 const PAGE_SIZE = 100;
+const MAX_SEARCH_LENGTH = 200;
 const LIMIT_BEFORE_LOAD_MORE = 5;
 
 interface Props {
@@ -617,6 +619,17 @@ export class CodingRulesApp extends React.PureComponent<Props, State> {
                 <div className="layout-page-main-inner">
                   <A11ySkipTarget anchor="rules_main" />
                   <div className="display-flex-space-between">
+                    <InputSearch
+                      className="sw-min-w-abs-250 sw-max-w-abs-350 sw-mr-4"
+                      id="coding-rules-search"
+                      maxLength={MAX_SEARCH_LENGTH}
+                      minLength={2}
+                      onChange={this.handleSearch}
+                      placeholder={translate('search.search_for_rules')}
+                      value={query.searchQuery ?? ''}
+                      size="auto"
+                    />
+
                     {openRule ? (
                       <a
                         className="js-back display-inline-flex-center link-no-underline"
index faea863dfbca9a80459805f00a800b151628a979..556bab1faac37768a81989dbfd70192dfe2169bc 100644 (file)
@@ -17,9 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
+import { KeyboardHint } from 'design-system';
 import * as React from 'react';
 import PageCounter from '../../../components/common/PageCounter';
-import PageShortcutsTooltip from '../../../components/ui/PageShortcutsTooltip';
 import { translate } from '../../../helpers/l10n';
 import { Paging } from '../../../types/types';
 
@@ -30,16 +30,13 @@ export interface PageActionsProps {
 
 export default function PageActions(props: PageActionsProps) {
   return (
-    <div className="display-flex-center">
-      <PageShortcutsTooltip
-        className="big-spacer-right"
-        leftAndRightLabel={translate('issues.to_navigate')}
-        upAndDownLabel={translate('coding_rules.to_select_rules')}
-      />
+    <div className="sw-body-sm sw-flex sw-items-center sw-gap-6 sw-justify-end sw-flex-1">
+      <KeyboardHint title={translate('coding_rules.to_select_rules')} command="ArrowUp ArrowDown" />
+      <KeyboardHint title={translate('coding_rules.to_navigate')} command="ArrowLeft ArrowRight" />
 
       {props.paging && (
         <PageCounter
-          className="spacer-left"
+          className="sw-ml-2"
           current={props.selectedIndex}
           label={translate('coding_rules._rules')}
           total={props.paging.total}
diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/QualityProfileSelector.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/QualityProfileSelector.tsx
new file mode 100644 (file)
index 0000000..560edee
--- /dev/null
@@ -0,0 +1,114 @@
+/*
+ * SonarQube
+ * Copyright (C) 2009-2023 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 {
+  Dropdown,
+  InputMultiSelect,
+  MultiSelector,
+  PopupPlacement,
+  PopupZLevel,
+} from 'design-system';
+import * as React from 'react';
+import { Profile } from '../../../api/quality-profiles';
+import { translate } from '../../../helpers/l10n';
+
+interface Props {
+  inputId?: string;
+  profiles: Profile[];
+  onChange: (selected: Profile[]) => void;
+  selectedProfiles: Profile[];
+}
+
+const LIST_SIZE = 0;
+
+export function QualityProfileSelector(props: Readonly<Props>) {
+  const { inputId, onChange, selectedProfiles, profiles } = props;
+
+  const onSelect = React.useCallback(
+    (selected: string) => {
+      const profileFound = profiles.find(
+        (profile) => `${profile.name} - ${profile.languageName}` === selected,
+      );
+      if (profileFound) {
+        onChange([profileFound, ...selectedProfiles]);
+      }
+    },
+    [profiles, onChange, selectedProfiles],
+  );
+
+  const onUnselect = React.useCallback(
+    (selected: string) => {
+      const selectedProfilesWithoutUnselected = selectedProfiles.filter(
+        (profile) => `${profile.name} - ${profile.languageName}` !== selected,
+      );
+      onChange(selectedProfilesWithoutUnselected);
+    },
+    [onChange, selectedProfiles],
+  );
+
+  return (
+    <Dropdown
+      allowResizing
+      closeOnClick={false}
+      id="quality-profile-selector"
+      overlay={
+        // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+        <div onMouseDown={handleMousedown}>
+          <MultiSelector
+            allowSearch={false}
+            createElementLabel="" // Cannot create
+            headerLabel={translate('coding_rules.select_profile')}
+            noResultsLabel={translate('coding_rules.bulk_change.no_quality_profile')}
+            onSelect={onSelect}
+            onUnselect={onUnselect}
+            searchInputAriaLabel={translate('search.search_for_profiles')}
+            selectedElements={selectedProfiles.map(
+              (profile) => `${profile.name} - ${profile.languageName}`,
+            )}
+            elements={profiles.map((profile) => `${profile.name} - ${profile.languageName}`)}
+            listSize={LIST_SIZE}
+          />
+        </div>
+      }
+      placement={PopupPlacement.BottomLeft}
+      zLevel={PopupZLevel.Global}
+    >
+      {({ onToggleClick }): JSX.Element => (
+        <InputMultiSelect
+          className="sw-w-full sw-mb-2"
+          id={inputId}
+          onClick={onToggleClick}
+          placeholder={translate('select_verb')}
+          selectedLabel={translate('coding_rules.selected_profiles')}
+          count={selectedProfiles.length}
+        />
+      )}
+    </Dropdown>
+  );
+}
+
+/*
+ * Prevent click from triggering a change of focus that would close the dropdown
+ */
+function handleMousedown(e: React.MouseEvent) {
+  if ((e.target as HTMLElement).tagName !== 'INPUT') {
+    e.preventDefault();
+    e.stopPropagation();
+  }
+}
index 38d14f6d623bd23eb84dba785bbe70fdd4c19496..189bcc6e62534e3f31d010424a35bdadc2628807 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { TagsSelector } from 'design-system';
+import { MultiSelector } from 'design-system';
 import { difference, uniq, without } from 'lodash';
 import * as React from 'react';
 import { getRuleTags } from '../../../api/rules';
@@ -74,7 +74,7 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta
   render() {
     const availableTags = difference(this.state.searchResult, this.props.tags);
     return (
-      <TagsSelector
+      <MultiSelector
         createElementLabel={translate('coding_rules.create_tag')}
         headerLabel={translate('tags')}
         searchInputAriaLabel={translate('search.search_for_tags')}
@@ -82,8 +82,8 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta
         onSearch={this.onSearch}
         onSelect={this.onSelect}
         onUnselect={this.onUnselect}
-        selectedTags={this.props.tags}
-        tags={availableTags}
+        selectedElements={this.props.tags}
+        elements={availableTags}
       />
     );
   }
index aae6b059dc4d9fd75e013525bf09ef339a880d21..f119f456740deddeb6be2c481386a3a8d0c6ddd2 100644 (file)
@@ -75,8 +75,8 @@ const selectors = {
 
   // Bulk change
   bulkChangeButton: byRole('button', { name: 'bulk_change' }),
-  activateIn: byRole('link', { name: 'coding_rules.activate_in…' }),
-  deactivateIn: byRole('link', { name: 'coding_rules.deactivate_in…' }),
+  activateIn: byRole('menuitem', { name: 'coding_rules.activate_in' }),
+  deactivateIn: byRole('menuitem', { name: 'coding_rules.deactivate_in' }),
   bulkChangeDialog: (count: number, activate = true) =>
     byRole('dialog', {
       name: `coding_rules.${
index bebaf72fffe66ae47137d6d3ddd7afdcf28803d1..998772d76d01eb82bb72e6592765014ff11244ca 100644 (file)
@@ -20,9 +20,9 @@
 import {
   Dropdown,
   InputMultiSelect,
+  MultiSelector,
   PopupPlacement,
   PopupZLevel,
-  TagsSelector,
 } from 'design-system';
 import * as React from 'react';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
@@ -69,7 +69,7 @@ export default function TagsSelect(props: Props) {
       overlay={
         // eslint-disable-next-line jsx-a11y/no-static-element-interactions
         <div onMouseDown={handleMousedown}>
-          <TagsSelector
+          <MultiSelector
             allowNewElements={allowCreation}
             createElementLabel={translateWithParameters('issue.create_tag')}
             headerLabel={translate('issue_bulk_change.select_tags')}
@@ -77,9 +77,9 @@ export default function TagsSelect(props: Props) {
             onSelect={onSelect}
             onUnselect={onUnselect}
             searchInputAriaLabel={translate('search.search_for_tags')}
-            selectedTags={selectedTags}
+            selectedElements={selectedTags}
             onSearch={doSearch}
-            tags={searchResults}
+            elements={searchResults}
           />
         </div>
       }
index 52bd4d1494749d5fcdeb7f4866b4c40b0a3653a2..f23587b64d5e0718b85f6a539efe2c221fdb08d9 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 { Tags, TagsSelector } from 'design-system';
+import { MultiSelector, Tags } from 'design-system';
 import { difference, without } from 'lodash';
 import React, { useState } from 'react';
 import { searchProjectTags, setApplicationTags, setProjectTags } from '../../../../api/components';
@@ -116,7 +116,7 @@ function MetaTagsSelector({ selectedTags, setProjectTags }: MetaTagsSelectorProp
   };
 
   return (
-    <TagsSelector
+    <MultiSelector
       headerLabel={translate('tags')}
       searchInputAriaLabel={translate('search.search_for_tags')}
       createElementLabel={translate('issue.create_tag')}
@@ -124,8 +124,8 @@ function MetaTagsSelector({ selectedTags, setProjectTags }: MetaTagsSelectorProp
       onSearch={onSearch}
       onSelect={onSelect}
       onUnselect={onUnselect}
-      selectedTags={selectedTags}
-      tags={availableTags}
+      selectedElements={selectedTags}
+      elements={availableTags}
     />
   );
 }
index 1176f3752663455a14f9ebfc3946e81869a7cfe2..8ad2fa057e5101a8c38285a3024bae6d1244b07e 100644 (file)
@@ -17,9 +17,9 @@
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import classNames from 'classnames';
 import * as React from 'react';
 import { formatMeasure } from '../../helpers/measures';
+import { MetricType } from '../../types/metrics';
 
 export interface PageCounterProps {
   className?: string;
@@ -30,10 +30,10 @@ export interface PageCounterProps {
 
 export default function PageCounter({ className, current, label, total }: PageCounterProps) {
   return (
-    <div className={classNames('display-inline-block', className)}>
-      <strong className="little-spacer-right">
-        {current !== undefined && formatMeasure(current + 1, 'INT') + ' / '}
-        <span className="it__page-counter-total">{formatMeasure(total, 'INT')}</span>
+    <div className={className}>
+      <strong className="sw-ml-1">
+        {current !== undefined && formatMeasure(current + 1, MetricType.Integer) + ' / '}
+        <span className="it__page-counter-total">{formatMeasure(total, MetricType.Integer)}</span>
       </strong>
       {label}
     </div>
index 46cc3bca91cb7bef68f869b49f985756b90e216f..2ac7d76bea858c490ccf6c973270571bcd811bf5 100644 (file)
@@ -1,11 +1,9 @@
 // Jest Snapshot v1, https://goo.gl/fbAQLP
 
 exports[`should render correctly 1`] = `
-<div
-  className="display-inline-block"
->
+<div>
   <strong
-    className="little-spacer-right"
+    className="sw-ml-1"
   >
     124 / 
     <span
index 2e37c4848402342ab557e81bfc4e053bf46e4c0f..17d156129faf8d417ac4233be1b84b346049c63d 100644 (file)
@@ -19,7 +19,7 @@ import { searchIssueTags } from '../../../api/issues';
  * along with this program; if not, write to the Free Software Foundation,
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
-import { TagsSelector } from 'design-system';
+import { MultiSelector } from 'design-system';
 import { difference, noop, without } from 'lodash';
 import * as React from 'react';
 import { translate } from '../../../helpers/l10n';
@@ -53,7 +53,7 @@ function IssueTagsPopup({ selectedTags, setTags }: IssueTagsPopupProps) {
   const availableTags = difference(searchResult, selectedTags);
 
   return (
-    <TagsSelector
+    <MultiSelector
       headerLabel={translate('issue.tags')}
       searchInputAriaLabel={translate('search.search_for_tags')}
       createElementLabel={translate('issue.create_tag')}
@@ -61,8 +61,8 @@ function IssueTagsPopup({ selectedTags, setTags }: IssueTagsPopupProps) {
       onSearch={onSearch}
       onSelect={onSelect}
       onUnselect={onUnselect}
-      selectedTags={selectedTags}
-      tags={availableTags}
+      selectedElements={selectedTags}
+      elements={availableTags}
     />
   );
 }
index aacfb2952c8b63937ab7663bec292958b1a8e434..56f66c35befa304c58802356cd322a98842ab26a 100644 (file)
@@ -1704,6 +1704,7 @@ search.search_for_directories=Search for directories...
 search.search_for_files=Search for files...
 search.search_for_modules=Search for modules...
 search.search_for_metrics=Search for metrics...
+search.search_for_profiles=Search for Quality Profiles...
 search.tooShort=Please enter at least {0} characters
 
 global_search.shortcut_hint=Hint: Press 'S' from anywhere to open this search bar.
@@ -2303,7 +2304,8 @@ coding_rules.rule_template.title=This rule can be used as a template to create c
 coding_rules._rules=rules
 coding_rules.show_template=Show Template
 coding_rules.skip_to_filters=Skip to rules filters
-coding_rules.to_select_rules=to select rules
+coding_rules.to_select_rules=Select rules
+coding_rules.to_navigate=Navtigate to rule
 coding_rules.type.tooltip.CODE_SMELL=Code Smell Detection Rule
 coding_rules.type.tooltip.BUG=Bug Detection Rule
 coding_rules.type.tooltip.VULNERABILITY=Vulnerability Detection Rule
@@ -2407,6 +2409,9 @@ coding_rules.more_info.scroll_message=Scroll down to Code Quality principles
 coding_rules.detail.extend_description.form=Extend this rule's description
 coding_rules.create_tag=Create Tag
 
+coding_rules.select_profile=Select Profile
+coding_rules.selected_profiles=Selected Profiles
+
 rule.impact.severity.tooltip=Issues found for this rule will have a {severity} impact on the {quality} of your software.
 
 rule.clean_code_attribute_category.CONSISTENT=Consistency