]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19345 new UI for the issue Bulk Change Modal
authorJay <jeremy.davis@sonarsource.com>
Thu, 1 Jun 2023 15:58:15 +0000 (17:58 +0200)
committersonartech <sonartech@sonarsource.com>
Fri, 9 Jun 2023 20:03:09 +0000 (20:03 +0000)
51 files changed:
server/sonar-web/design-system/src/components/InputMultiSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/InputSelect.tsx
server/sonar-web/design-system/src/components/MultiSelect.tsx [deleted file]
server/sonar-web/design-system/src/components/MultiSelectMenu.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MultiSelectOption.tsx [deleted file]
server/sonar-web/design-system/src/components/RadioButton.tsx
server/sonar-web/design-system/src/components/SearchSelectDropdown.tsx
server/sonar-web/design-system/src/components/SearchSelectDropdownControl.tsx
server/sonar-web/design-system/src/components/TagsSelector.tsx
server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx [deleted file]
server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx
server/sonar-web/design-system/src/components/buttons.tsx
server/sonar-web/design-system/src/components/icons/index.ts
server/sonar-web/design-system/src/components/index.ts
server/sonar-web/design-system/src/components/modal/ModalBody.tsx
server/sonar-web/design-system/src/components/modal/__tests__/__snapshots__/ModalBody-test.tsx.snap
server/sonar-web/src/main/js/apps/account/components/UserCard.tsx
server/sonar-web/src/main/js/apps/issues/components/AssigneeSelect.tsx
server/sonar-web/src/main/js/apps/issues/components/BulkChangeModal.tsx
server/sonar-web/src/main/js/apps/issues/components/IssuesApp.tsx
server/sonar-web/src/main/js/apps/issues/components/TagsSelect.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/issues/components/__tests__/AssigneeSelect-test.tsx
server/sonar-web/src/main/js/apps/issues/components/__tests__/BulkChangeModal-it.tsx
server/sonar-web/src/main/js/apps/issues/sidebar/AssigneeFacet.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/PermissionItem.tsx
server/sonar-web/src/main/js/apps/quality-gates/components/QualityGatePermissionsAddModalRenderer.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsFormSelect.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/ProfilePermissionsUser.tsx
server/sonar-web/src/main/js/apps/quality-profiles/details/__tests__/__snapshots__/ProfilePermissionsUser-test.tsx.snap
server/sonar-web/src/main/js/apps/security-hotspots/components/Assignee.tsx
server/sonar-web/src/main/js/apps/security-hotspots/components/HotspotReviewHistory.tsx
server/sonar-web/src/main/js/apps/users/components/UserListItem.tsx
server/sonar-web/src/main/js/components/icon-mappers/IssueSeverityIcon.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/icon-mappers/IssueTypeIcon.tsx
server/sonar-web/src/main/js/components/issue/components/IssueAssign.tsx
server/sonar-web/src/main/js/components/issue/components/IssueCommentLine.tsx
server/sonar-web/src/main/js/components/issue/popups/ChangelogPopup.tsx
server/sonar-web/src/main/js/components/issue/popups/CommentTile.tsx
server/sonar-web/src/main/js/components/issue/popups/SetAssigneePopup.tsx
server/sonar-web/src/main/js/components/issue/popups/SimilarIssuesPopup.tsx
server/sonar-web/src/main/js/components/permissions/UserHolder.tsx
server/sonar-web/src/main/js/components/ui/Avatar.tsx
server/sonar-web/src/main/js/components/ui/LegacyAvatar.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/Avatar-test.tsx
server/sonar-web/src/main/js/components/ui/__tests__/LegacyAvatar-test.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.tsx.snap [deleted file]
server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/LegacyAvatar-test.tsx.snap [new file with mode: 0644]
sonar-core/src/main/resources/org/sonar/l10n/core.properties

diff --git a/server/sonar-web/design-system/src/components/InputMultiSelect.tsx b/server/sonar-web/design-system/src/components/InputMultiSelect.tsx
new file mode 100644 (file)
index 0000000..6a7eca7
--- /dev/null
@@ -0,0 +1,70 @@
+/*
+ * 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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import { themeBorder } from '../helpers';
+import { Badge } from './Badge';
+import { LightLabel } from './Text';
+import { ButtonProps, WrapperButton } from './buttons';
+import { ChevronDownIcon } from './icons';
+
+interface Props extends Pick<ButtonProps, 'onClick'> {
+  className?: string;
+  count?: number;
+  id?: string;
+  placeholder: string;
+  selectedLabel: string;
+}
+
+export function InputMultiSelect(props: Props) {
+  const { className, count, id, placeholder, selectedLabel } = props;
+
+  return (
+    <StyledWrapper
+      className={classNames('sw-flex sw-justify-between sw-px-2 sw-body-sm', className)}
+      id={id}
+      onClick={props.onClick}
+      role="combobox"
+    >
+      {count ? selectedLabel : <LightLabel>{placeholder}</LightLabel>}
+
+      <div>
+        {count !== undefined && count > 0 && <Badge variant="counter">{count}</Badge>}
+        <ChevronDownIcon className="sw-ml-2" />
+      </div>
+    </StyledWrapper>
+  );
+}
+
+const StyledWrapper = styled(WrapperButton)`
+  border: ${themeBorder('default', 'inputBorder')};
+
+  &:hover {
+    border: ${themeBorder('default', 'inputFocus')};
+  }
+
+  &:active,
+  &:focus,
+  &:focus-within,
+  &:focus-visible {
+    border: ${themeBorder('default', 'inputFocus')};
+    outline: ${themeBorder('focus', 'inputFocus')};
+  }
+`;
index 3b51e2a9a4f7dab809f98d32d752dcedea523922..1498b2edca81906a327e7d48474f0b67b0a32623 100644 (file)
@@ -111,11 +111,11 @@ export function InputSelect<
   Option extends LabelValueSelectOption<V>,
   IsMulti extends boolean = false,
   Group extends GroupBase<Option> = GroupBase<Option>
->({ size = 'medium', ...props }: SelectProps<V, Option, IsMulti, Group>) {
+>({ size = 'medium', className, ...props }: SelectProps<V, Option, IsMulti, Group>) {
   return (
     <ReactSelect<Option, IsMulti, Group>
       {...omit(props, 'className', 'large')}
-      className={classNames('react-select', props.className)}
+      className={classNames('react-select', className)}
       classNamePrefix="react-select"
       classNames={{
         container: () => 'sw-relative sw-inline-block sw-align-middle',
@@ -156,13 +156,10 @@ export function selectStyle<
   const theme = themeInfo();
 
   return {
-    container: (base) => ({
-      ...base,
-      width: INPUT_SIZES[size],
-    }),
     control: (base, { isFocused, menuIsOpen, isDisabled }) => ({
       ...base,
       color: themeContrast('inputBackground')({ theme }),
+      cursor: 'pointer',
       background: themeColor('inputBackground')({ theme }),
       transition: 'border 0.2s ease, outline 0.2s ease',
       outline: isFocused && !menuIsOpen ? themeBorder('focus', 'inputFocus')({ theme }) : 'none',
diff --git a/server/sonar-web/design-system/src/components/MultiSelect.tsx b/server/sonar-web/design-system/src/components/MultiSelect.tsx
deleted file mode 100644 (file)
index 8766b05..0000000
+++ /dev/null
@@ -1,336 +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 classNames from 'classnames';
-import { difference } from 'lodash';
-import { PureComponent } from 'react';
-import { Key } from '../helpers/keyboard';
-import { ItemDivider, ItemHeader } from './DropdownMenu';
-import { InputSearch } from './InputSearch';
-import { MultiSelectOption } from './MultiSelectOption';
-
-interface Props {
-  allowNewElements?: boolean;
-  allowSelection?: boolean;
-  clearIconAriaLabel: string;
-  createElementLabel: string;
-  elements: string[];
-  footerNode?: React.ReactNode;
-  headerNode?: React.ReactNode;
-  listSize: number;
-  noResultsLabel: string;
-  onSearch: (query: string) => Promise<void>;
-  onSelect: (item: string) => void;
-  onUnselect: (item: string) => void;
-  placeholder: string;
-  searchInputAriaLabel: string;
-  selectedElements: string[];
-  validateSearchInput?: (value: string) => string;
-}
-
-interface State {
-  activeIdx: number;
-  loading: boolean;
-  query: string;
-  selectedElements: string[];
-  unselectedElements: string[];
-}
-
-interface DefaultProps {
-  filterSelected: (query: string, selectedElements: string[]) => string[];
-  renderLabel: (element: string) => React.ReactNode;
-  validateSearchInput: (value: string) => string;
-}
-
-type PropsWithDefault = Props & DefaultProps;
-
-export class MultiSelect extends PureComponent<Props, State> {
-  container?: HTMLDivElement | null;
-  searchInput?: HTMLInputElement | null;
-  mounted = false;
-
-  static defaultProps: DefaultProps = {
-    filterSelected: (query: string, selectedElements: string[]) =>
-      selectedElements.filter((elem) => elem.includes(query)),
-    renderLabel: (element: string) => element,
-    validateSearchInput: (value: string) => value,
-  };
-
-  constructor(props: Props) {
-    super(props);
-    this.state = {
-      activeIdx: 0,
-      loading: true,
-      query: '',
-      selectedElements: [],
-      unselectedElements: [],
-    };
-  }
-
-  componentDidMount() {
-    this.mounted = true;
-    this.onSearchQuery('');
-    this.updateSelectedElements(this.props as PropsWithDefault);
-    this.updateUnselectedElements();
-    if (this.container) {
-      this.container.addEventListener('keydown', this.handleKeyboard, true);
-    }
-  }
-
-  componentDidUpdate(prevProps: Props) {
-    if (this.searchInput) {
-      this.searchInput.focus();
-    }
-
-    if (
-      prevProps.elements !== this.props.elements ||
-      prevProps.selectedElements !== this.props.selectedElements
-    ) {
-      this.updateSelectedElements(this.props as PropsWithDefault);
-      this.updateUnselectedElements();
-
-      const totalElements = this.getAllElements(this.props, this.state).length;
-
-      if (this.state.activeIdx >= totalElements) {
-        this.setState({ activeIdx: totalElements - 1 });
-      }
-    }
-  }
-
-  componentWillUnmount() {
-    this.mounted = false;
-    if (this.container) {
-      this.container.removeEventListener('keydown', this.handleKeyboard);
-    }
-  }
-
-  handleSelectChange = (selected: boolean, item: string) => {
-    if (selected) {
-      this.onSelectItem(item);
-    } else {
-      this.onUnselectItem(item);
-    }
-  };
-
-  handleSearchChange = (value: string) => {
-    this.onSearchQuery((this.props as PropsWithDefault).validateSearchInput(value));
-  };
-
-  handleElementHover = (element: string) => {
-    this.setState((prevState, props) => {
-      return { activeIdx: this.getAllElements(props, prevState).indexOf(element) };
-    });
-  };
-
-  handleKeyboard = (evt: KeyboardEvent) => {
-    switch (evt.key) {
-      case Key.ArrowDown:
-        evt.stopPropagation();
-        evt.preventDefault();
-        this.setState(this.selectNextElement);
-        break;
-      case Key.ArrowUp:
-        evt.stopPropagation();
-        evt.preventDefault();
-        this.setState(this.selectPreviousElement);
-        break;
-      case Key.ArrowLeft:
-      case Key.ArrowRight:
-        evt.stopPropagation();
-        break;
-      case Key.Enter: {
-        const allElements = this.getAllElements(this.props, this.state);
-        if (this.state.activeIdx >= 0 && this.state.activeIdx < allElements.length) {
-          this.toggleSelect(allElements[this.state.activeIdx]);
-        }
-        break;
-      }
-    }
-  };
-
-  onSearchQuery = (query: string) => {
-    this.setState({ activeIdx: 0, loading: true, query });
-    this.props.onSearch(query).then(this.stopLoading, this.stopLoading);
-  };
-
-  onSelectItem = (item: string) => {
-    if (this.isNewElement(item, this.props)) {
-      this.onSearchQuery('');
-    }
-    this.props.onSelect(item);
-  };
-
-  onUnselectItem = (item: string) => this.props.onUnselect(item);
-
-  isNewElement = (elem: string, { selectedElements, elements }: Props) =>
-    elem.length > 0 && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1;
-
-  updateSelectedElements = (props: PropsWithDefault) => {
-    this.setState((state: State) => {
-      if (state.query) {
-        return {
-          selectedElements: props.filterSelected(state.query, props.selectedElements),
-        };
-      }
-      return { selectedElements: [...props.selectedElements] };
-    });
-  };
-
-  updateUnselectedElements = () => {
-    const { listSize } = this.props;
-    this.setState((state: State) => {
-      if (listSize === 0) {
-        return { unselectedElements: difference(this.props.elements, this.props.selectedElements) };
-      } else if (listSize < state.selectedElements.length) {
-        return { unselectedElements: [] };
-      }
-      return {
-        unselectedElements: difference(this.props.elements, this.props.selectedElements).slice(
-          0,
-          listSize - state.selectedElements.length
-        ),
-      };
-    });
-  };
-
-  getAllElements = (props: Props, state: State) => {
-    const { allowNewElements = true } = props;
-    if (allowNewElements && this.isNewElement(state.query, props)) {
-      return [...state.selectedElements, ...state.unselectedElements, state.query];
-    }
-    return [...state.selectedElements, ...state.unselectedElements];
-  };
-
-  selectNextElement = (state: State, props: Props) => {
-    const { activeIdx } = state;
-    const allElements = this.getAllElements(props, state);
-    if (activeIdx < 0 || activeIdx >= allElements.length - 1) {
-      return { activeIdx: 0 };
-    }
-    return { activeIdx: activeIdx + 1 };
-  };
-
-  selectPreviousElement = (state: State, props: Props) => {
-    const { activeIdx } = state;
-    const allElements = this.getAllElements(props, state);
-    if (activeIdx <= 0) {
-      const lastIdx = allElements.length - 1;
-      return { activeIdx: lastIdx };
-    }
-    return { activeIdx: activeIdx - 1 };
-  };
-
-  stopLoading = () => {
-    if (this.mounted) {
-      this.setState({ loading: false });
-    }
-  };
-
-  toggleSelect = (item: string) => {
-    if (!this.props.selectedElements.includes(item)) {
-      this.onSelectItem(item);
-      this.setState(this.selectNextElement);
-    } else {
-      this.onUnselectItem(item);
-    }
-  };
-
-  render() {
-    const {
-      allowSelection = true,
-      allowNewElements = true,
-      createElementLabel,
-      headerNode = '',
-      footerNode = '',
-      clearIconAriaLabel,
-      noResultsLabel,
-      searchInputAriaLabel,
-    } = this.props;
-    const { query, activeIdx, selectedElements, unselectedElements } = this.state;
-    const activeElement = this.getAllElements(this.props, this.state)[activeIdx];
-    const showNewElement = allowNewElements && this.isNewElement(query, this.props);
-    const isFixedHeight = this.props.listSize === 0;
-    const hasFooter = Boolean(footerNode);
-
-    return (
-      <div ref={(div) => (this.container = div)}>
-        <div className="sw-px-3">
-          <InputSearch
-            autoFocus
-            className="sw-mt-1"
-            clearIconAriaLabel={clearIconAriaLabel}
-            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', {
-            'sw-max-h-abs-200 sw-overflow-y-auto': isFixedHeight,
-          })}
-        >
-          {selectedElements.length > 0 &&
-            selectedElements.map((element) => (
-              <MultiSelectOption
-                active={activeElement === element}
-                createElementLabel={createElementLabel}
-                element={element}
-                key={element}
-                onHover={this.handleElementHover}
-                onSelectChange={this.handleSelectChange}
-                selected
-              />
-            ))}
-          {unselectedElements.length > 0 &&
-            unselectedElements.map((element) => (
-              <MultiSelectOption
-                active={activeElement === element}
-                createElementLabel={createElementLabel}
-                disabled={!allowSelection}
-                element={element}
-                key={element}
-                onHover={this.handleElementHover}
-                onSelectChange={this.handleSelectChange}
-              />
-            ))}
-          {showNewElement && (
-            <MultiSelectOption
-              active={activeElement === query}
-              createElementLabel={createElementLabel}
-              custom
-              element={query}
-              key={query}
-              onHover={this.handleElementHover}
-              onSelectChange={this.handleSelectChange}
-            />
-          )}
-          {!showNewElement && selectedElements.length < 1 && unselectedElements.length < 1 && (
-            <li className="sw-ml-2">{noResultsLabel}</li>
-          )}
-        </ul>
-        {hasFooter && <ItemDivider className="sw-mt-2" />}
-        <div className="sw-px-3">{footerNode}</div>
-      </div>
-    );
-  }
-}
diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenu.tsx
new file mode 100644 (file)
index 0000000..b0c77bf
--- /dev/null
@@ -0,0 +1,339 @@
+/*
+ * 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 classNames from 'classnames';
+import { difference } from 'lodash';
+import { PureComponent } from 'react';
+import { Key } from '../helpers/keyboard';
+import { ItemDivider, ItemHeader } from './DropdownMenu';
+import { InputSearch } from './InputSearch';
+import { MultiSelectMenuOption } from './MultiSelectMenuOption';
+
+interface Props {
+  allowNewElements?: boolean;
+  allowSelection?: boolean;
+  clearIconAriaLabel: string;
+  createElementLabel: string;
+  elements: string[];
+  footerNode?: React.ReactNode;
+  headerNode?: React.ReactNode;
+  inputId?: string;
+  listSize: number;
+  noResultsLabel: string;
+  onSearch: (query: string) => Promise<void>;
+  onSelect: (item: string) => void;
+  onUnselect: (item: string) => void;
+  placeholder: string;
+  searchInputAriaLabel: string;
+  selectedElements: string[];
+  validateSearchInput?: (value: string) => string;
+}
+
+interface State {
+  activeIdx: number;
+  loading: boolean;
+  query: string;
+  selectedElements: string[];
+  unselectedElements: string[];
+}
+
+interface DefaultProps {
+  filterSelected: (query: string, selectedElements: string[]) => string[];
+  renderLabel: (element: string) => React.ReactNode;
+  validateSearchInput: (value: string) => string;
+}
+
+type PropsWithDefault = Props & DefaultProps;
+
+export class MultiSelectMenu extends PureComponent<Props, State> {
+  container?: HTMLDivElement | null;
+  searchInput?: HTMLInputElement | null;
+  mounted = false;
+
+  static defaultProps: DefaultProps = {
+    filterSelected: (query: string, selectedElements: string[]) =>
+      selectedElements.filter((elem) => elem.includes(query)),
+    renderLabel: (element: string) => element,
+    validateSearchInput: (value: string) => value,
+  };
+
+  constructor(props: Props) {
+    super(props);
+    this.state = {
+      activeIdx: 0,
+      loading: true,
+      query: '',
+      selectedElements: [],
+      unselectedElements: [],
+    };
+  }
+
+  componentDidMount() {
+    this.mounted = true;
+    this.onSearchQuery('');
+    this.updateSelectedElements(this.props as PropsWithDefault);
+    this.updateUnselectedElements();
+    if (this.container) {
+      this.container.addEventListener('keydown', this.handleKeyboard, true);
+    }
+  }
+
+  componentDidUpdate(prevProps: Props) {
+    if (this.searchInput) {
+      this.searchInput.focus();
+    }
+
+    if (
+      prevProps.elements !== this.props.elements ||
+      prevProps.selectedElements !== this.props.selectedElements
+    ) {
+      this.updateSelectedElements(this.props as PropsWithDefault);
+      this.updateUnselectedElements();
+
+      const totalElements = this.getAllElements(this.props, this.state).length;
+
+      if (this.state.activeIdx >= totalElements) {
+        this.setState({ activeIdx: totalElements - 1 });
+      }
+    }
+  }
+
+  componentWillUnmount() {
+    this.mounted = false;
+    if (this.container) {
+      this.container.removeEventListener('keydown', this.handleKeyboard);
+    }
+  }
+
+  handleSelectChange = (selected: boolean, item: string) => {
+    if (selected) {
+      this.onSelectItem(item);
+    } else {
+      this.onUnselectItem(item);
+    }
+  };
+
+  handleSearchChange = (value: string) => {
+    this.onSearchQuery((this.props as PropsWithDefault).validateSearchInput(value));
+  };
+
+  handleElementHover = (element: string) => {
+    this.setState((prevState, props) => {
+      return { activeIdx: this.getAllElements(props, prevState).indexOf(element) };
+    });
+  };
+
+  handleKeyboard = (evt: KeyboardEvent) => {
+    switch (evt.key) {
+      case Key.ArrowDown:
+        evt.stopPropagation();
+        evt.preventDefault();
+        this.setState(this.selectNextElement);
+        break;
+      case Key.ArrowUp:
+        evt.stopPropagation();
+        evt.preventDefault();
+        this.setState(this.selectPreviousElement);
+        break;
+      case Key.ArrowLeft:
+      case Key.ArrowRight:
+        evt.stopPropagation();
+        break;
+      case Key.Enter: {
+        const allElements = this.getAllElements(this.props, this.state);
+        if (this.state.activeIdx >= 0 && this.state.activeIdx < allElements.length) {
+          this.toggleSelect(allElements[this.state.activeIdx]);
+        }
+        break;
+      }
+    }
+  };
+
+  onSearchQuery = (query: string) => {
+    this.setState({ activeIdx: 0, loading: true, query });
+    this.props.onSearch(query).then(this.stopLoading, this.stopLoading);
+  };
+
+  onSelectItem = (item: string) => {
+    if (this.isNewElement(item, this.props)) {
+      this.onSearchQuery('');
+    }
+    this.props.onSelect(item);
+  };
+
+  onUnselectItem = (item: string) => this.props.onUnselect(item);
+
+  isNewElement = (elem: string, { selectedElements, elements }: Props) =>
+    elem.length > 0 && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1;
+
+  updateSelectedElements = (props: PropsWithDefault) => {
+    this.setState((state: State) => {
+      if (state.query) {
+        return {
+          selectedElements: props.filterSelected(state.query, props.selectedElements),
+        };
+      }
+      return { selectedElements: [...props.selectedElements] };
+    });
+  };
+
+  updateUnselectedElements = () => {
+    const { listSize } = this.props;
+    this.setState((state: State) => {
+      if (listSize === 0) {
+        return { unselectedElements: difference(this.props.elements, this.props.selectedElements) };
+      } else if (listSize < state.selectedElements.length) {
+        return { unselectedElements: [] };
+      }
+      return {
+        unselectedElements: difference(this.props.elements, this.props.selectedElements).slice(
+          0,
+          listSize - state.selectedElements.length
+        ),
+      };
+    });
+  };
+
+  getAllElements = (props: Props, state: State) => {
+    const { allowNewElements = true } = props;
+    if (allowNewElements && this.isNewElement(state.query, props)) {
+      return [...state.selectedElements, ...state.unselectedElements, state.query];
+    }
+    return [...state.selectedElements, ...state.unselectedElements];
+  };
+
+  selectNextElement = (state: State, props: Props) => {
+    const { activeIdx } = state;
+    const allElements = this.getAllElements(props, state);
+    if (activeIdx < 0 || activeIdx >= allElements.length - 1) {
+      return { activeIdx: 0 };
+    }
+    return { activeIdx: activeIdx + 1 };
+  };
+
+  selectPreviousElement = (state: State, props: Props) => {
+    const { activeIdx } = state;
+    const allElements = this.getAllElements(props, state);
+    if (activeIdx <= 0) {
+      const lastIdx = allElements.length - 1;
+      return { activeIdx: lastIdx };
+    }
+    return { activeIdx: activeIdx - 1 };
+  };
+
+  stopLoading = () => {
+    if (this.mounted) {
+      this.setState({ loading: false });
+    }
+  };
+
+  toggleSelect = (item: string) => {
+    if (!this.props.selectedElements.includes(item)) {
+      this.onSelectItem(item);
+      this.setState(this.selectNextElement);
+    } else {
+      this.onUnselectItem(item);
+    }
+  };
+
+  render() {
+    const {
+      allowSelection = true,
+      allowNewElements = true,
+      createElementLabel,
+      headerNode = '',
+      footerNode = '',
+      inputId,
+      clearIconAriaLabel,
+      noResultsLabel,
+      searchInputAriaLabel,
+    } = this.props;
+    const { query, activeIdx, selectedElements, unselectedElements } = this.state;
+    const activeElement = this.getAllElements(this.props, this.state)[activeIdx];
+    const showNewElement = allowNewElements && this.isNewElement(query, this.props);
+    const isFixedHeight = this.props.listSize === 0;
+    const hasFooter = Boolean(footerNode);
+
+    return (
+      <div ref={(div) => (this.container = div)}>
+        <div className="sw-px-3">
+          <InputSearch
+            autoFocus
+            className="sw-mt-1"
+            clearIconAriaLabel={clearIconAriaLabel}
+            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', {
+            'sw-max-h-abs-200 sw-overflow-y-auto': isFixedHeight,
+          })}
+        >
+          {selectedElements.length > 0 &&
+            selectedElements.map((element) => (
+              <MultiSelectMenuOption
+                active={activeElement === element}
+                createElementLabel={createElementLabel}
+                element={element}
+                key={element}
+                onHover={this.handleElementHover}
+                onSelectChange={this.handleSelectChange}
+                selected
+              />
+            ))}
+          {unselectedElements.length > 0 &&
+            unselectedElements.map((element) => (
+              <MultiSelectMenuOption
+                active={activeElement === element}
+                createElementLabel={createElementLabel}
+                disabled={!allowSelection}
+                element={element}
+                key={element}
+                onHover={this.handleElementHover}
+                onSelectChange={this.handleSelectChange}
+              />
+            ))}
+          {showNewElement && (
+            <MultiSelectMenuOption
+              active={activeElement === query}
+              createElementLabel={createElementLabel}
+              custom
+              element={query}
+              key={query}
+              onHover={this.handleElementHover}
+              onSelectChange={this.handleSelectChange}
+            />
+          )}
+          {!showNewElement && selectedElements.length < 1 && unselectedElements.length < 1 && (
+            <li className="sw-ml-2">{noResultsLabel}</li>
+          )}
+        </ul>
+        {hasFooter && <ItemDivider className="sw-mt-2" />}
+        <div className="sw-px-3">{footerNode}</div>
+      </div>
+    );
+  }
+}
diff --git a/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectMenuOption.tsx
new file mode 100644 (file)
index 0000000..2714770
--- /dev/null
@@ -0,0 +1,64 @@
+/*
+ * 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 classNames from 'classnames';
+import { ItemCheckbox } from './DropdownMenu';
+
+export interface MultiSelectOptionProps {
+  active?: boolean;
+  createElementLabel: string;
+  custom?: boolean;
+  disabled?: boolean;
+  element: string;
+  onHover: (element: string) => void;
+  onSelectChange: (selected: boolean, element: string) => void;
+  selected?: boolean;
+}
+
+export function MultiSelectMenuOption(props: MultiSelectOptionProps) {
+  const { active, createElementLabel, custom, disabled, element, onSelectChange, selected } = props;
+  const onHover = () => props.onHover(element);
+
+  return (
+    <ItemCheckbox
+      checked={Boolean(selected)}
+      className={classNames('sw-flex sw-py-2 sw-px-4', { active })}
+      disabled={disabled}
+      id={element}
+      onCheck={onSelectChange}
+      onFocus={onHover}
+      onPointerEnter={onHover}
+    >
+      {custom ? (
+        <span
+          aria-label={`${createElementLabel}: ${element}`}
+          className="sw-ml-3"
+          title={createElementLabel}
+        >
+          <span aria-hidden className="sw-mr-1">
+            +
+          </span>
+          {element}
+        </span>
+      ) : (
+        <span className="sw-ml-3">{element}</span>
+      )}
+    </ItemCheckbox>
+  );
+}
diff --git a/server/sonar-web/design-system/src/components/MultiSelectOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectOption.tsx
deleted file mode 100644 (file)
index ccabda0..0000000
+++ /dev/null
@@ -1,64 +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 classNames from 'classnames';
-import { ItemCheckbox } from './DropdownMenu';
-
-export interface MultiSelectOptionProps {
-  active?: boolean;
-  createElementLabel: string;
-  custom?: boolean;
-  disabled?: boolean;
-  element: string;
-  onHover: (element: string) => void;
-  onSelectChange: (selected: boolean, element: string) => void;
-  selected?: boolean;
-}
-
-export function MultiSelectOption(props: MultiSelectOptionProps) {
-  const { active, createElementLabel, custom, disabled, element, onSelectChange, selected } = props;
-  const onHover = () => props.onHover(element);
-
-  return (
-    <ItemCheckbox
-      checked={Boolean(selected)}
-      className={classNames('sw-flex sw-py-2 sw-px-4', { active })}
-      disabled={disabled}
-      id={element}
-      onCheck={onSelectChange}
-      onFocus={onHover}
-      onPointerEnter={onHover}
-    >
-      {custom ? (
-        <span
-          aria-label={`${createElementLabel}: ${element}`}
-          className="sw-ml-3"
-          title={createElementLabel}
-        >
-          <span aria-hidden className="sw-mr-1">
-            +
-          </span>
-          {element}
-        </span>
-      ) : (
-        <span className="sw-ml-3">{element}</span>
-      )}
-    </ItemCheckbox>
-  );
-}
index 5dd58756d73408dd8718e49c1b746e9bd2774294..7bfd61f4bbcf88e11f0d12f6f184abbfc4d23191 100644 (file)
@@ -53,7 +53,7 @@ export function RadioButton({
   };
 
   return (
-    <label className={classNames('sw-flex sw-items-center', className)}>
+    <label className={classNames('sw-flex sw-items-center sw-cursor-pointer', className)}>
       <RadioButtonStyled
         aria-disabled={disabled}
         checked={checked}
index 13f719a91ab812f2f317b7f4b5927f7e312e4f12..8212eefd90c31bb0f715864291b5bd5d8f42042e 100644 (file)
@@ -30,7 +30,8 @@ import {
 import { AsyncProps } from 'react-select/async';
 import Select from 'react-select/dist/declarations/src/Select';
 import tw from 'twin.macro';
-import { DEBOUNCE_DELAY, themeBorder } from '../helpers';
+import { DEBOUNCE_DELAY, PopupPlacement, PopupZLevel, themeBorder } from '../helpers';
+import { InputSizeKeys } from '../types/theme';
 import { DropdownToggler } from './DropdownToggler';
 import { IconOption, LabelValueSelectOption, SelectProps } from './InputSelect';
 import { SearchHighlighterContext } from './SearchHighlighter';
@@ -52,8 +53,10 @@ export interface SearchSelectDropdownProps<
   Group extends GroupBase<Option> = GroupBase<Option>
 > extends SelectProps<V, Option, IsMulti, Group>,
     AsyncProps<Option, IsMulti, Group> {
+  className?: string;
   controlAriaLabel?: string;
   controlLabel?: React.ReactNode | string;
+  controlSize?: InputSizeKeys;
   isDiscreet?: boolean;
 }
 
@@ -64,10 +67,12 @@ export function SearchSelectDropdown<
   Group extends GroupBase<Option> = GroupBase<Option>
 >(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
   const {
+    className,
     isDiscreet,
     value,
     loadOptions,
     controlLabel,
+    controlSize,
     isDisabled,
     minLength,
     controlAriaLabel,
@@ -122,7 +127,6 @@ export function SearchSelectDropdown<
     <DropdownToggler
       allowResizing
       className="sw-overflow-visible sw-border-none"
-      isPortal
       onRequestClose={() => {
         toggleDropdown(false);
       }}
@@ -149,15 +153,19 @@ export function SearchSelectDropdown<
           </StyledSearchSelectWrapper>
         </SearchHighlighterContext.Provider>
       }
+      placement={PopupPlacement.BottomLeft}
+      zLevel={PopupZLevel.Global}
     >
       <SearchSelectDropdownControl
         ariaLabel={controlAriaLabel}
+        className={className}
         disabled={isDisabled}
         isDiscreet={isDiscreet}
         label={controlLabel}
         onClick={() => {
           toggleDropdown(true);
         }}
+        size={controlSize}
       />
     </DropdownToggler>
   );
index e0b50db401c7546be6efc60222e7a5a92ac1a11d..838737961343ba24eb3a1a4bae174aeec7b8a017 100644 (file)
@@ -28,6 +28,7 @@ import { ChevronDownIcon } from './icons';
 
 interface SearchSelectDropdownControlProps {
   ariaLabel?: string;
+  className?: string;
   disabled?: boolean;
   isDiscreet?: boolean;
   label?: React.ReactNode | string;
@@ -36,11 +37,11 @@ interface SearchSelectDropdownControlProps {
 }
 
 export function SearchSelectDropdownControl(props: SearchSelectDropdownControlProps) {
-  const { disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
+  const { className, disabled, label, isDiscreet, onClick, size = 'full', ariaLabel = '' } = props;
   return (
     <StyledControl
       aria-label={ariaLabel}
-      className={classNames({ 'is-discreet': isDiscreet })}
+      className={classNames(className, { 'is-discreet': isDiscreet })}
       onClick={() => {
         if (!disabled) {
           onClick();
@@ -52,17 +53,17 @@ export function SearchSelectDropdownControl(props: SearchSelectDropdownControlPr
         }
       }}
       role="combobox"
+      style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
       tabIndex={disabled ? -1 : 0}
     >
       <InputValue
-        className={classNames('js-search-input-value', {
+        className={classNames('it__js-search-input-value sw-flex sw-justify-between', {
           'is-disabled': disabled,
           'is-placeholder': !label,
         })}
-        style={{ '--inputSize': isDiscreet ? 'auto' : INPUT_SIZES[size] }}
       >
-        {label}
-        <ChevronDownIcon />
+        <span className="sw-truncate">{label}</span>
+        <ChevronDownIcon className="sw-ml-1" />
       </InputValue>
     </StyledControl>
   );
@@ -72,13 +73,14 @@ const StyledControl = styled.div`
   color: ${themeContrast('inputBackground')};
   background: ${themeColor('inputBackground')};
   border: ${themeBorder('default', 'inputBorder')};
+  width: var(--inputSize);
 
   ${tw`sw-flex sw-justify-between sw-items-center`};
   ${tw`sw-rounded-2`};
   ${tw`sw-box-border`};
   ${tw`sw-px-3 sw-py-2`};
   ${tw`sw-body-sm`};
-  ${tw`sw-w-full sw-h-control`};
+  ${tw`sw-h-control`};
   ${tw`sw-leading-4`};
   ${tw`sw-cursor-pointer`};
 
@@ -113,7 +115,7 @@ const StyledControl = styled.div`
 `;
 
 const InputValue = styled.span`
-  width: var(--inputSize);
+  width: 100%;
   color: ${themeContrast('inputBackground')};
 
   ${tw`sw-truncate`};
index 5c4ad8d52b394764d064ad92ac848ffd5e81cbbf..9a7480751385c22efae7e74f940e7372444bb941 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 { MultiSelect } from './MultiSelect';
+import { MultiSelectMenu } from './MultiSelectMenu';
 
 interface Props {
+  allowNewElements?: boolean;
   clearIconAriaLabel: string;
   createElementLabel: string;
   headerLabel: string;
-  listSize: number;
   noResultsLabel: string;
   onSearch: (query: string) => Promise<void>;
   onSelect: (item: string) => void;
@@ -33,12 +33,14 @@ interface Props {
   tags: string[];
 }
 
+const LIST_SIZE = 10;
+
 export function TagsSelector(props: Props) {
   const {
+    allowNewElements,
     clearIconAriaLabel,
     createElementLabel,
     headerLabel,
-    listSize,
     noResultsLabel,
     searchInputAriaLabel,
     selectedTags,
@@ -46,12 +48,13 @@ export function TagsSelector(props: Props) {
   } = props;
 
   return (
-    <MultiSelect
+    <MultiSelectMenu
+      allowNewElements={allowNewElements}
       clearIconAriaLabel={clearIconAriaLabel}
       createElementLabel={createElementLabel}
       elements={tags}
       headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
-      listSize={listSize}
+      listSize={LIST_SIZE}
       noResultsLabel={noResultsLabel}
       onSearch={props.onSearch}
       onSelect={props.onSelect}
diff --git a/server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/InputMultiSelect-test.tsx
new file mode 100644 (file)
index 0000000..d40de11
--- /dev/null
@@ -0,0 +1,40 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import { FCProps } from '../../types/misc';
+import { InputMultiSelect } from '../InputMultiSelect';
+
+it('should render correctly', () => {
+  renderInputMultiSelect();
+  expect(screen.getByText('select')).toBeInTheDocument();
+  expect(screen.queryByText('selected')).not.toBeInTheDocument();
+  expect(screen.queryByText(/\d+/)).not.toBeInTheDocument();
+});
+
+it('should render correctly with a counter', () => {
+  renderInputMultiSelect({ count: 42 });
+  expect(screen.queryByText('select')).not.toBeInTheDocument();
+  expect(screen.getByText('selected')).toBeInTheDocument();
+  expect(screen.getByText('42')).toBeInTheDocument();
+});
+
+function renderInputMultiSelect(props: Partial<FCProps<typeof InputMultiSelect>> = {}) {
+  render(<InputMultiSelect placeholder="select" selectedLabel="selected" {...props} />);
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx
deleted file mode 100644 (file)
index 2e79eb0..0000000
+++ /dev/null
@@ -1,113 +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 { render, screen } from '@testing-library/react';
-import userEvent from '@testing-library/user-event';
-import { MultiSelect } from '../MultiSelect';
-
-const elements = ['foo', 'bar', 'baz'];
-
-beforeAll(() => {
-  jest.useFakeTimers();
-});
-
-afterAll(() => {
-  jest.useRealTimers();
-});
-
-it('should allow selecting and deselecting a new option', async () => {
-  const user = userEvent.setup({ delay: null });
-  const onSelect = jest.fn();
-  const onUnselect = jest.fn();
-  renderMultiselect({ elements, onSelect, onUnselect, allowNewElements: true });
-
-  await user.keyboard('new option');
-  jest.runAllTimers(); // skip the debounce
-
-  expect(screen.getByText('new option')).toBeInTheDocument();
-
-  await user.click(screen.getByText('new option'));
-
-  expect(onSelect).toHaveBeenCalledWith('new option');
-
-  renderMultiselect({
-    elements,
-    onUnselect,
-    allowNewElements: true,
-    selectedElements: ['new option'],
-  });
-
-  await user.click(screen.getByText('new option'));
-  expect(onUnselect).toHaveBeenCalledWith('new option');
-});
-
-it('should ignore the left and right arrow keys', async () => {
-  const user = userEvent.setup({ delay: null });
-  const onSelect = jest.fn();
-  renderMultiselect({ elements, onSelect });
-
-  /* eslint-disable testing-library/no-node-access */
-  await user.keyboard('{arrowdown}');
-  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
-
-  await user.keyboard('{arrowleft}');
-  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
-
-  await user.keyboard('{arrowright}');
-  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
-
-  await user.keyboard('{arrowdown}');
-  expect(screen.getAllByRole('checkbox')[1].parentElement).not.toHaveClass('active');
-  expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
-
-  await user.keyboard('{arrowup}');
-  await user.keyboard('{arrowup}');
-  expect(screen.getAllByRole('checkbox')[0].parentElement).toHaveClass('active');
-  await user.keyboard('{arrowup}');
-  expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
-
-  expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked();
-  await user.keyboard('{enter}');
-  expect(onSelect).toHaveBeenCalledWith('baz');
-});
-
-it('should show no results', () => {
-  renderMultiselect();
-  expect(screen.getByText('no results')).toBeInTheDocument();
-});
-
-function renderMultiselect(props: Partial<MultiSelect['props']> = {}) {
-  return render(
-    <MultiSelect
-      clearIconAriaLabel="clear"
-      createElementLabel="create thing"
-      elements={[]}
-      filterSelected={jest.fn()}
-      listSize={10}
-      noResultsLabel="no results"
-      onSearch={jest.fn(() => Promise.resolve())}
-      onSelect={jest.fn()}
-      onUnselect={jest.fn()}
-      placeholder=""
-      searchInputAriaLabel="search"
-      selectedElements={[]}
-      {...props}
-    />
-  );
-}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MultiSelectMenu-test.tsx
new file mode 100644 (file)
index 0000000..e2077ec
--- /dev/null
@@ -0,0 +1,113 @@
+/*
+ * 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 { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { MultiSelectMenu } from '../MultiSelectMenu';
+
+const elements = ['foo', 'bar', 'baz'];
+
+beforeAll(() => {
+  jest.useFakeTimers();
+});
+
+afterAll(() => {
+  jest.useRealTimers();
+});
+
+it('should allow selecting and deselecting a new option', async () => {
+  const user = userEvent.setup({ delay: null });
+  const onSelect = jest.fn();
+  const onUnselect = jest.fn();
+  renderMultiselect({ elements, onSelect, onUnselect, allowNewElements: true });
+
+  await user.keyboard('new option');
+  jest.runAllTimers(); // skip the debounce
+
+  expect(screen.getByText('new option')).toBeInTheDocument();
+
+  await user.click(screen.getByText('new option'));
+
+  expect(onSelect).toHaveBeenCalledWith('new option');
+
+  renderMultiselect({
+    elements,
+    onUnselect,
+    allowNewElements: true,
+    selectedElements: ['new option'],
+  });
+
+  await user.click(screen.getByText('new option'));
+  expect(onUnselect).toHaveBeenCalledWith('new option');
+});
+
+it('should ignore the left and right arrow keys', async () => {
+  const user = userEvent.setup({ delay: null });
+  const onSelect = jest.fn();
+  renderMultiselect({ elements, onSelect });
+
+  /* eslint-disable testing-library/no-node-access */
+  await user.keyboard('{arrowdown}');
+  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+  await user.keyboard('{arrowleft}');
+  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+  await user.keyboard('{arrowright}');
+  expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active');
+
+  await user.keyboard('{arrowdown}');
+  expect(screen.getAllByRole('checkbox')[1].parentElement).not.toHaveClass('active');
+  expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
+
+  await user.keyboard('{arrowup}');
+  await user.keyboard('{arrowup}');
+  expect(screen.getAllByRole('checkbox')[0].parentElement).toHaveClass('active');
+  await user.keyboard('{arrowup}');
+  expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active');
+
+  expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked();
+  await user.keyboard('{enter}');
+  expect(onSelect).toHaveBeenCalledWith('baz');
+});
+
+it('should show no results', () => {
+  renderMultiselect();
+  expect(screen.getByText('no results')).toBeInTheDocument();
+});
+
+function renderMultiselect(props: Partial<MultiSelectMenu['props']> = {}) {
+  return render(
+    <MultiSelectMenu
+      clearIconAriaLabel="clear"
+      createElementLabel="create thing"
+      elements={[]}
+      filterSelected={jest.fn()}
+      listSize={10}
+      noResultsLabel="no results"
+      onSearch={jest.fn(() => Promise.resolve())}
+      onSelect={jest.fn()}
+      onUnselect={jest.fn()}
+      placeholder=""
+      searchInputAriaLabel="search"
+      selectedElements={[]}
+      {...props}
+    />
+  );
+}
index 4749a34cb4017a51c2265779addde969cbd53dd9..7192c8d91e07b6a484a28e2c0e9118be2e54d6af 100644 (file)
@@ -86,7 +86,6 @@ function Wrapper(overrides: Partial<FCProps<typeof Tags>> = {}) {
       clearIconAriaLabel="clear"
       createElementLabel="create new tag"
       headerLabel="edit tags"
-      listSize={4}
       noResultsLabel="no results"
       onSearch={jest.fn().mockResolvedValue(undefined)}
       onSelect={(tag) => {
index dd679d3ae563bed1bbae26ed36c7144aa624a218..6cb9c86f756a553501214c42fe2d8f1d0bc91f08 100644 (file)
@@ -29,7 +29,7 @@ import { BaseLink, LinkProps } from './Link';
 
 type AllowedButtonAttributes = Pick<
   React.ButtonHTMLAttributes<HTMLButtonElement>,
-  'aria-label' | 'autoFocus' | 'id' | 'name' | 'role' | 'style' | 'title' | 'type'
+  'aria-label' | 'autoFocus' | 'id' | 'name' | 'role' | 'style' | 'title' | 'type' | 'form'
 >;
 
 export interface ButtonProps extends AllowedButtonAttributes {
index 898e9a46890923a7a3169a2c51aacec82c6628df..5112f869cfd6a8920d720946d022e2aec4603565 100644 (file)
@@ -41,6 +41,7 @@ export { FlagWarningIcon } from './FlagWarningIcon';
 export { HelperHintIcon } from './HelperHintIcon';
 export { HomeFillIcon } from './HomeFillIcon';
 export { HomeIcon } from './HomeIcon';
+export * from './Icon';
 export { IssueLocationIcon } from './IssueLocationIcon';
 export { LinkIcon } from './LinkIcon';
 export { LockIcon } from './LockIcon';
index 27126e7a328f66f283b6dd7f41a9c4cbc9a6677b..9cd9b19018467f5909e86eaddb2fb3225e03e35a 100644 (file)
@@ -50,6 +50,7 @@ export { Histogram } from './Histogram';
 export { HotspotRating } from './HotspotRating';
 export * from './HtmlFormatter';
 export * from './InputField';
+export * from './InputMultiSelect';
 export { InputSearch } from './InputSearch';
 export * from './InputSelect';
 export * from './InteractiveIcon';
@@ -61,10 +62,12 @@ export * from './MainAppBar';
 export * from './MainMenu';
 export * from './MainMenuItem';
 export * from './MetricsRatingBadge';
+export * from './MultiSelectMenu';
 export * from './NavBarTabs';
 export * from './NewCodeLegend';
 export * from './OutsideClickHandler';
 export { QualityGateIndicator } from './QualityGateIndicator';
+export * from './RadioButton';
 export * from './SearchSelect';
 export * from './SearchSelectDropdown';
 export * from './SelectionCard';
index 1d5af12d9cfe926dc204e99b1588bab3b5370dbf..6b7ead1f8db767d9e776e5273c3f87542e17bdf9 100644 (file)
@@ -35,7 +35,8 @@ export function ModalBody({ children, isScrollable = true }: Props) {
 
 const StyledMain = styled.div`
   ${tw`sw-body-sm`}
-  ${tw`sw-pr-3`} // to accomodate a possible scrollbar
+  ${tw`sw-px-3`} // to accomodate a possible scrollbar
+  ${tw`-sw-mx-3`}
   ${tw`sw-my-12`}
   ${tw`sw-overflow-x-hidden`}
 
index 6c86070b3b49995b4ae8ac52f30101b99470d9c5..4d41d0c95e2d7b2f622a48eaa23c0782bd42229a 100644 (file)
@@ -6,7 +6,10 @@ exports[`renders with children 1`] = `
   font-size: 0.875rem;
   line-height: 1.25rem;
   font-weight: 400;
+  padding-left: 0.75rem;
   padding-right: 0.75rem;
+  margin-left: -0.75rem;
+  margin-right: -0.75rem;
   margin-top: 3rem;
   margin-bottom: 3rem;
   overflow-x: hidden;
index 6e72459c32628e96815c21b2a89ce64917203b02..7479073a8c6c78eb1837ccf12a86c9777b6e2f49 100644 (file)
@@ -18,7 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { LoggedInUser } from '../../../types/users';
 
 interface Props {
@@ -29,7 +29,7 @@ export default function UserCard({ user }: Props) {
   return (
     <div className="account-user">
       <div className="pull-left account-user-avatar" id="avatar">
-        <Avatar hash={user.avatar} name={user.name} size={60} />
+        <LegacyAvatar hash={user.avatar} name={user.name} size={60} />
       </div>
       <h1 className="pull-left" id="name">
         {user.name}
index 5da901f7a48e5a6633ec12845bc8396474fa4d5d..5bca99c6339c2279e484655e1f5656dbd9ed7c61 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 { debounce } from 'lodash';
+import { LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
 import * as React from 'react';
-import { components, OptionProps, SingleValueProps } from 'react-select';
-import { LabelValueSelectOption, SearchSelect } from '../../../components/controls/Select';
+import { Options, SingleValue } from 'react-select';
+import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
 import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Issue } from '../../../types/types';
-import { CurrentUser, isLoggedIn, isUserActive } from '../../../types/users';
+import { UserActive, isLoggedIn, isUserActive } from '../../../types/users';
 import { searchAssignees } from '../utils';
 
-const DEBOUNCE_DELAY = 250;
 // exported for test
 export const MIN_QUERY_LENGTH = 2;
 
-export interface AssigneeOption extends LabelValueSelectOption {
-  avatar?: string;
-  email?: string;
-  label: string;
-  value: string;
-}
+const UNASSIGNED = { value: '', label: translate('unassigned') };
 
 export interface AssigneeSelectProps {
-  currentUser: CurrentUser;
+  assignee?: SingleValue<LabelValueSelectOption<string>>;
+  className?: string;
   issues: Issue[];
-  onAssigneeSelect: (assignee: AssigneeOption) => void;
+  onAssigneeSelect: (assignee: SingleValue<LabelValueSelectOption<string>>) => void;
   inputId: string;
 }
 
-export default class AssigneeSelect extends React.Component<AssigneeSelectProps> {
-  constructor(props: AssigneeSelectProps) {
-    super(props);
-
-    this.handleAssigneeSearch = debounce(this.handleAssigneeSearch, DEBOUNCE_DELAY);
-  }
-
-  getDefaultAssignee = () => {
-    const { currentUser, issues } = this.props;
-    const options = [];
-
-    if (isLoggedIn(currentUser)) {
-      const canBeAssignedToMe =
-        issues.filter((issue) => issue.assignee !== currentUser.login).length > 0;
-      if (canBeAssignedToMe) {
-        options.push({
-          avatar: currentUser.avatar,
-          label: currentUser.name,
-          value: currentUser.login,
-        });
-      }
-    }
-
-    const canBeUnassigned = issues.filter((issue) => issue.assignee).length > 0;
-    if (canBeUnassigned) {
-      options.push({ label: translate('unassigned'), value: '' });
-    }
-
-    return options;
+function userToOption(user: UserActive) {
+  const userInfo = user.name || user.login;
+  return {
+    value: user.login,
+    label: isUserActive(user) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
+    Icon: <Avatar hash={user.avatar} name={user.name} size="xs" />,
   };
+}
 
-  handleAssigneeSearch = (query: string, resolve: (options: AssigneeOption[]) => void) => {
-    if (query.length < MIN_QUERY_LENGTH) {
-      resolve([]);
-      return;
-    }
+export default function AssigneeSelect(props: AssigneeSelectProps) {
+  const { assignee, className, issues, inputId } = props;
 
-    searchAssignees(query)
-      .then(({ results }) =>
-        results.map((r) => {
-          const userInfo = r.name ?? r.login;
+  const { currentUser } = React.useContext(CurrentUserContext);
 
-          return {
-            avatar: r.avatar,
-            label: isUserActive(r) ? userInfo : translateWithParameters('user.x_deleted', userInfo),
-            value: r.login,
-          };
-        })
-      )
-      .then(resolve)
-      .catch(() => resolve([]));
-  };
+  const allowCurrentUserSelection =
+    isLoggedIn(currentUser) && issues.some((issue) => currentUser.login !== issue.assignee);
 
-  renderAssignee = (option: AssigneeOption) => {
-    return (
-      <div className="display-flex-center">
-        {option.avatar !== undefined && (
-          <Avatar className="spacer-right" hash={option.avatar} name={option.label} size={16} />
-        )}
-        {option.label}
-      </div>
-    );
-  };
+  const defaultOptions = allowCurrentUserSelection
+    ? [UNASSIGNED, userToOption(currentUser)]
+    : [UNASSIGNED];
 
-  renderAssigneeOption = (props: OptionProps<AssigneeOption, false>) => (
-    <components.Option {...props}>{this.renderAssignee(props.data)}</components.Option>
+  const controlLabel = assignee ? (
+    <>
+      {assignee.Icon} {assignee.label}
+    </>
+  ) : (
+    translate('select_verb')
   );
 
-  renderSingleAssignee = (props: SingleValueProps<AssigneeOption, false>) => (
-    <components.SingleValue {...props}>{this.renderAssignee(props.data)}</components.SingleValue>
+  const handleAssigneeSearch = React.useCallback(
+    (query: string, resolve: (options: Options<LabelValueSelectOption<string>>) => void) => {
+      if (query.length < MIN_QUERY_LENGTH) {
+        resolve([]);
+        return;
+      }
+
+      searchAssignees(query)
+        .then(({ results }) => results.map(userToOption))
+        .then(resolve)
+        .catch(() => resolve([]));
+    },
+    []
   );
 
-  render() {
-    const { inputId } = this.props;
-    return (
-      <SearchSelect
-        className="input-super-large"
-        inputId={inputId}
-        components={{
-          Option: this.renderAssigneeOption,
-          SingleValue: this.renderSingleAssignee,
-        }}
-        isClearable
-        defaultOptions={this.getDefaultAssignee()}
-        loadOptions={this.handleAssigneeSearch}
-        onChange={this.props.onAssigneeSelect}
-        noOptionsMessage={({ inputValue }) =>
-          inputValue.length < MIN_QUERY_LENGTH
-            ? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
-            : translate('select2.noMatches')
-        }
-      />
-    );
-  }
+  return (
+    <SearchSelectDropdown
+      aria-label={translate('search.search_for_users')}
+      className={className}
+      size="full"
+      controlSize="full"
+      inputId={inputId}
+      isClearable
+      defaultOptions={defaultOptions}
+      loadOptions={handleAssigneeSearch}
+      onChange={props.onAssigneeSelect}
+      noOptionsMessage={({ inputValue }) =>
+        inputValue.length < MIN_QUERY_LENGTH
+          ? translateWithParameters('select2.tooShort', MIN_QUERY_LENGTH)
+          : translate('select2.noMatches')
+      }
+      tooShortText={translateWithParameters('search.tooShort', MIN_QUERY_LENGTH)}
+      placeholder={translate('search.search_for_users')}
+      controlLabel={controlLabel}
+      controlAriaLabel={translate('issue_bulk_change.assignee.change')}
+    />
+  );
 }
index 8c49f77e4e7ced48d6a7dcd0f3ab4ce0a06be00a..5e47667161a50edc1eeeeb8063b3f44de7a5626a 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 { debounce, pickBy, sortBy } from 'lodash';
+import {
+  ButtonPrimary,
+  Checkbox,
+  DeferredSpinner,
+  FlagMessage,
+  FormField,
+  HelperHintIcon,
+  Highlight,
+  InputSelect,
+  LabelValueSelectOption,
+  LightLabel,
+  Modal,
+  RadioButton,
+} from 'design-system';
+import { pickBy, sortBy } from 'lodash';
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
-import { components, OptionProps, SingleValueProps } from 'react-select';
+import { SingleValue } from 'react-select';
 import { bulkChangeIssues, searchIssueTags } from '../../../api/issues';
 import FormattingTips from '../../../components/common/FormattingTips';
-import { ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
-import Checkbox from '../../../components/controls/Checkbox';
-import HelpTooltip from '../../../components/controls/HelpTooltip';
-import Modal from '../../../components/controls/Modal';
-import Radio from '../../../components/controls/Radio';
-import Select, {
-  CreatableSelect,
-  LabelValueSelectOption,
-  SearchSelect,
-} from '../../../components/controls/Select';
-import IssueTypeIcon from '../../../components/icons/IssueTypeIcon';
-import SeverityHelper from '../../../components/shared/SeverityHelper';
-import { Alert } from '../../../components/ui/Alert';
-import DeferredSpinner from '../../../components/ui/DeferredSpinner';
+import IssueSeverityIcon from '../../../components/icon-mappers/IssueSeverityIcon';
+import IssueTypeIcon from '../../../components/icon-mappers/IssueTypeIcon';
 import { SEVERITIES } from '../../../helpers/constants';
 import { throwGlobalError } from '../../../helpers/error';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
-import { Component, Dict, Issue, IssueType, Paging } from '../../../types/types';
-import { CurrentUser } from '../../../types/users';
-import AssigneeSelect, { AssigneeOption } from './AssigneeSelect';
-
-const DEBOUNCE_DELAY = 250;
-
-interface TagOption extends LabelValueSelectOption {
-  label: string;
-  value: string;
-}
+import { IssueSeverity } from '../../../types/issues';
+import { Dict, Issue, IssueType, Paging } from '../../../types/types';
+import AssigneeSelect from './AssigneeSelect';
+import TagsSelect from './TagsSelect';
 
 interface Props {
-  component: Component | undefined;
-  currentUser: CurrentUser;
   fetchIssues: (x: {}) => Promise<{ issues: Issue[]; paging: Paging }>;
   onClose: () => void;
   onDone: () => void;
 }
 
 interface FormFields {
-  addTags?: Array<{ label: string; value: string }>;
-  assignee?: AssigneeOption;
+  addTags?: Array<string>;
+  assignee?: SingleValue<LabelValueSelectOption<string>>;
   comment?: string;
   notifications?: boolean;
-  removeTags?: Array<{ label: string; value: string }>;
+  removeTags?: Array<string>;
   severity?: string;
   transition?: string;
   type?: string;
 }
 
 interface State extends FormFields {
-  initialTags: Array<{ label: string; value: string }>;
+  initialTags: Array<string>;
   issues: Issue[];
   // used for initial loading of issues
   loading: boolean;
@@ -90,49 +84,12 @@ enum InputField {
 
 export const MAX_PAGE_SIZE = 500;
 
-function typeFieldTypeRenderer(option: LabelValueSelectOption) {
-  return (
-    <div className="display-flex-center">
-      <IssueTypeIcon query={option.value} />
-      <span className="little-spacer-left">{option.label}</span>
-    </div>
-  );
-}
-
-function TypeFieldOptionComponent(props: OptionProps<LabelValueSelectOption, false>) {
-  return <components.Option {...props}>{typeFieldTypeRenderer(props.data)}</components.Option>;
-}
-
-function TypeFieldSingleValueComponent(props: SingleValueProps<LabelValueSelectOption, false>) {
-  return (
-    <components.SingleValue {...props}>{typeFieldTypeRenderer(props.data)}</components.SingleValue>
-  );
-}
-
-function SeverityFieldOptionComponent(props: OptionProps<LabelValueSelectOption, false>) {
-  return (
-    <components.Option {...props}>
-      {<SeverityHelper className="display-flex-center" severity={props.data.value} />}
-    </components.Option>
-  );
-}
-
-function SeverityFieldSingleValueComponent(props: SingleValueProps<LabelValueSelectOption, false>) {
-  return (
-    <components.SingleValue {...props}>
-      {<SeverityHelper className="display-flex-center" severity={props.data.value} />}
-    </components.SingleValue>
-  );
-}
-
 export default class BulkChangeModal extends React.PureComponent<Props, State> {
   mounted = false;
 
   constructor(props: Props) {
     super(props);
     this.state = { initialTags: [], issues: [], loading: true, submitting: false };
-
-    this.handleTagsSearch = debounce(this.handleTagsSearch, DEBOUNCE_DELAY);
   }
 
   componentDidMount() {
@@ -146,7 +103,7 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
           }
 
           this.setState({
-            initialTags: tags.map((tag) => ({ label: tag, value: tag })),
+            initialTags: tags,
             issues,
             loading: false,
             paging,
@@ -165,19 +122,18 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     return this.props.fetchIssues({ additionalFields: 'actions,transitions', ps: MAX_PAGE_SIZE });
   };
 
-  handleAssigneeSelect = (assignee: AssigneeOption) => {
+  handleAssigneeSelect = (assignee: SingleValue<LabelValueSelectOption<string>>) => {
     this.setState({ assignee });
   };
 
-  handleTagsSearch = (query: string, resolve: (option: TagOption[]) => void) => {
-    searchIssueTags({ q: query })
-      .then((tags) => tags.map((tag) => ({ label: tag, value: tag })))
-      .then(resolve)
-      .catch(() => resolve([]));
+  handleTagsSearch = (query: string): Promise<string[]> => {
+    return searchIssueTags({ q: query })
+      .then((tags) => tags)
+      .catch(() => []);
   };
 
   handleTagsSelect =
-    (field: InputField.addTags | InputField.removeTags) => (options: TagOption[]) => {
+    (field: InputField.addTags | InputField.removeTags) => (options: Array<string>) => {
       this.setState<keyof FormFields>({ [field]: options });
     };
 
@@ -198,7 +154,7 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
   };
 
   handleSelectFieldChange =
-    (field: 'severity' | 'type') => (data: LabelValueSelectOption | null) => {
+    (field: 'severity' | 'type') => (data: LabelValueSelectOption<string> | null) => {
       if (data) {
         this.setState<keyof FormFields>({ [field]: data.value });
       } else {
@@ -211,11 +167,11 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
 
     const query = pickBy(
       {
-        add_tags: this.state.addTags && this.state.addTags.map((t) => t.value).join(),
+        add_tags: this.state.addTags?.join(),
         assign: this.state.assignee ? this.state.assignee.value : null,
         comment: this.state.comment,
         do_transition: this.state.transition,
-        remove_tags: this.state.removeTags && this.state.removeTags.map((t) => t.value).join(),
+        remove_tags: this.state.removeTags?.join(),
         sendNotifications: this.state.notifications,
         set_severity: this.state.severity,
         set_type: this.state.type,
@@ -270,48 +226,26 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     );
   };
 
-  renderLoading = () => (
-    <div>
-      <div className="modal-head">
-        <h2>{translate('bulk_change')}</h2>
-      </div>
-      <div className="modal-body">
-        <div className="text-center">
-          <DeferredSpinner
-            timeout={0}
-            className="spacer"
-            ariaLabel={translate('issues.loading_issues')}
-          />
-        </div>
-      </div>
-      <div className="modal-foot">
-        <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
-      </div>
-    </div>
-  );
-
-  renderAffected = (affected: number) => (
-    <div className="pull-right note">
-      ({translateWithParameters('issue_bulk_change.x_issues', affected)})
-    </div>
-  );
-
   renderField = (
     field: InputField,
     label: string,
     affected: number | undefined,
     input: React.ReactNode
   ) => (
-    <div className="modal-field">
-      <label htmlFor={`issues-bulk-change-${field}`}>{translate(label)}</label>
-      {input}
-      {affected !== undefined && this.renderAffected(affected)}
-    </div>
+    <FormField htmlFor={`issues-bulk-change-${field}`} label={translate(label)}>
+      <div className="sw-flex sw-items-center sw-justify-between">
+        {input}
+        {affected !== undefined && (
+          <LightLabel>
+            ({translateWithParameters('issue_bulk_change.x_issues', affected)})
+          </LightLabel>
+        )}
+      </div>
+    </FormField>
   );
 
   renderAssigneeField = () => {
-    const { currentUser } = this.props;
-    const { issues } = this.state;
+    const { assignee, issues } = this.state;
     const affected = this.state.issues.filter(hasAction('assign')).length;
     const field = InputField.assignee;
 
@@ -321,8 +255,9 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
 
     const input = (
       <AssigneeSelect
+        assignee={assignee}
+        className="sw-max-w-abs-300"
         inputId={`issues-bulk-change-${field}`}
-        currentUser={currentUser}
         issues={issues}
         onAssigneeSelect={this.handleAssigneeSelect}
       />
@@ -340,23 +275,21 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     }
 
     const types: IssueType[] = ['BUG', 'VULNERABILITY', 'CODE_SMELL'];
-    const options: LabelValueSelectOption[] = types.map((type) => ({
+    const options: LabelValueSelectOption<IssueType>[] = types.map((type) => ({
       label: translate('issue.type', type),
       value: type,
+      Icon: <IssueTypeIcon height={16} type={type} />,
     }));
 
     const input = (
-      <Select
-        className="input-super-large"
+      <InputSelect
+        className="sw-w-abs-300"
         inputId={`issues-bulk-change-${field}`}
         isClearable
         isSearchable={false}
-        components={{
-          Option: TypeFieldOptionComponent,
-          SingleValue: TypeFieldSingleValueComponent,
-        }}
         onChange={this.handleSelectFieldChange('type')}
         options={options}
+        size="full"
       />
     );
 
@@ -371,23 +304,21 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
       return null;
     }
 
-    const options: LabelValueSelectOption[] = SEVERITIES.map((severity) => ({
+    const options: LabelValueSelectOption<IssueSeverity>[] = SEVERITIES.map((severity) => ({
       label: translate('severity', severity),
       value: severity,
+      Icon: <IssueSeverityIcon height={16} severity={severity} />,
     }));
 
     const input = (
-      <Select
-        className="input-super-large"
+      <InputSelect
+        className="sw-w-abs-300"
         inputId={`issues-bulk-change-${field}`}
         isClearable
         isSearchable={false}
         onChange={this.handleSelectFieldChange('severity')}
-        components={{
-          Option: SeverityFieldOptionComponent,
-          SingleValue: SeverityFieldSingleValueComponent,
-        }}
         options={options}
+        size="full"
       />
     );
 
@@ -400,26 +331,21 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     allowCreate: boolean
   ) => {
     const { initialTags } = this.state;
+    const tags = this.state[field] ?? [];
     const affected = this.state.issues.filter(hasAction('set_tags')).length;
 
     if (initialTags === undefined || affected === 0) {
       return null;
     }
 
-    const props = {
-      className: 'input-super-large',
-      inputId: `issues-bulk-change-${field}`,
-      isClearable: true,
-      defaultOptions: this.state.initialTags,
-      isMulti: true,
-      onChange: this.handleTagsSelect(field),
-      loadOptions: this.handleTagsSearch,
-    };
-
-    const input = allowCreate ? (
-      <CreatableSelect {...props} formatCreateLabel={createTagPrompt} />
-    ) : (
-      <SearchSelect {...props} />
+    const input = (
+      <TagsSelect
+        allowCreation={allowCreate}
+        inputId={`issues-bulk-change-${field}`}
+        onChange={this.handleTagsSelect(field)}
+        selectedTags={tags}
+        onSearch={this.handleTagsSearch}
+      />
     );
 
     return this.renderField(field, label, affected, input);
@@ -433,23 +359,27 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     }
 
     return (
-      <div className="modal-field">
+      <div className="sw-mb-6">
         <fieldset>
-          <legend>{translate('issue.transition')}</legend>
+          <Highlight as="legend" className="sw-mb-2">
+            {translate('issue.transition')}
+          </Highlight>
           {transitions.map((transition) => (
-            <span
-              className="bulk-change-radio-button display-flex-center display-flex-space-between"
+            <div
+              className="sw-mb-1 sw-flex sw-items-center sw-justify-between"
               key={transition.transition}
             >
-              <Radio
+              <RadioButton
                 checked={this.state.transition === transition.transition}
                 onCheck={this.handleRadioTransitionChange}
                 value={transition.transition}
               >
                 {translate('issue.transition', transition.transition)}
-              </Radio>
-              {this.renderAffected(transition.count)}
-            </span>
+              </RadioButton>
+              <LightLabel>
+                ({translateWithParameters('issue_bulk_change.x_issues', transition.count)})
+              </LightLabel>
+            </div>
           ))}
         </fieldset>
       </div>
@@ -464,58 +394,62 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
     }
 
     return (
-      <div className="modal-field">
-        <label htmlFor="comment">
-          <span className="text-middle">{translate('issue.comment.formlink')}</span>
-          <HelpTooltip
-            className="spacer-left"
-            overlay={translate('issue_bulk_change.comment.help')}
-          />
-        </label>
+      <FormField
+        label={translate('issue.comment.formlink')}
+        htmlFor="comment"
+        help={
+          <div className="-sw-mt-1" title={translate('issue_bulk_change.comment.help')}>
+            <HelperHintIcon />
+          </div>
+        }
+      >
         <textarea
           id="comment"
           onChange={this.handleCommentChange}
           rows={4}
-          value={this.state.comment || ''}
+          value={this.state.comment ?? ''}
         />
-        <FormattingTips className="modal-field-descriptor text-right" />
-      </div>
+        <FormattingTips className="sw-text-right" />
+      </FormField>
     );
   };
 
   renderNotificationsField = () => (
-    <Checkbox
-      checked={this.state.notifications !== undefined}
-      className="display-inline-block spacer-top"
-      id="send-notifications"
-      onCheck={this.handleFieldCheck('notifications')}
-      right
-    >
-      <strong className="little-spacer-right">{translate('issue.send_notifications')}</strong>
-    </Checkbox>
+    <div>
+      <Checkbox
+        checked={this.state.notifications !== undefined}
+        className="sw-my-2 sw-gap-1/2"
+        id="send-notifications"
+        onCheck={this.handleFieldCheck('notifications')}
+        right
+      >
+        {translate('issue.send_notifications')}
+      </Checkbox>
+    </div>
   );
 
   renderForm = () => {
-    const { issues, paging, submitting } = this.state;
+    const { issues, loading, paging } = this.state;
 
     const limitReached = paging && paging.total > MAX_PAGE_SIZE;
-    const canSubmit = this.canSubmit();
 
     return (
-      <form id="bulk-change-form" onSubmit={this.handleSubmit}>
-        <div className="modal-head">
-          <h2>{translateWithParameters('issue_bulk_change.form.title', issues.length)}</h2>
-        </div>
-
-        <div className="modal-body modal-container">
+      <DeferredSpinner loading={loading}>
+        <form id="bulk-change-form" onSubmit={this.handleSubmit}>
           {limitReached && (
-            <Alert variant="warning">
-              <FormattedMessage
-                defaultMessage={translate('issue_bulk_change.max_issues_reached')}
-                id="issue_bulk_change.max_issues_reached"
-                values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
-              />
-            </Alert>
+            <FlagMessage
+              ariaLabel={translate('alert.tooltip.warning')}
+              className="sw-mb-4"
+              variant="warning"
+            >
+              <span>
+                <FormattedMessage
+                  defaultMessage={translate('issue_bulk_change.max_issues_reached')}
+                  id="issue_bulk_change.max_issues_reached"
+                  values={{ max: <strong>{MAX_PAGE_SIZE}</strong> }}
+                />
+              </span>
+            </FlagMessage>
           )}
 
           {this.renderAssigneeField()}
@@ -527,29 +461,43 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
           {this.renderCommentField()}
           {issues.length > 0 && this.renderNotificationsField()}
           {issues.length === 0 && (
-            <Alert variant="warning">{translate('issue_bulk_change.no_match')}</Alert>
+            <FlagMessage ariaLabel={translate('alert.tooltip.warning')} variant="warning">
+              {translate('issue_bulk_change.no_match')}
+            </FlagMessage>
           )}
-        </div>
-
-        <div className="modal-foot">
-          {submitting && <i className="spinner spacer-right" />}
-          <SubmitButton
-            disabled={!canSubmit || submitting || issues.length === 0}
-            id="bulk-change-submit"
-          >
-            {translate('apply')}
-          </SubmitButton>
-          <ResetButtonLink onClick={this.props.onClose}>{translate('cancel')}</ResetButtonLink>
-        </div>
-      </form>
+        </form>
+      </DeferredSpinner>
     );
   };
 
   render() {
+    const { issues, loading, submitting } = this.state;
+
+    const canSubmit = this.canSubmit();
+
     return (
-      <Modal onRequestClose={this.props.onClose} size="small">
-        {this.state.loading ? this.renderLoading() : this.renderForm()}
-      </Modal>
+      <Modal
+        onClose={this.props.onClose}
+        headerTitle={
+          loading
+            ? translate('bulk_change')
+            : translateWithParameters('issue_bulk_change.form.title', issues.length)
+        }
+        isScrollable={true}
+        loading={submitting}
+        body={this.renderForm()}
+        primaryButton={
+          <ButtonPrimary
+            id="bulk-change-submit"
+            form="bulk-change-form"
+            type="submit"
+            disabled={!canSubmit || submitting || issues.length === 0}
+          >
+            {translate('apply')}
+          </ButtonPrimary>
+        }
+        secondaryButtonLabel={translate('cancel')}
+      />
     );
   }
 }
@@ -557,7 +505,3 @@ export default class BulkChangeModal extends React.PureComponent<Props, State> {
 function hasAction(action: string) {
   return (issue: Issue) => issue.actions && issue.actions.includes(action);
 }
-
-function createTagPrompt(label: string) {
-  return translateWithParameters('issue.create_tag_x', label);
-}
index 2326968dc26eb8b9ed3b9b88efd7ccdeb8f5287d..ac4a86b744ed4162739cbea675e1182d8add3021 100644 (file)
@@ -901,7 +901,7 @@ export class App extends React.PureComponent<Props, State> {
   };
 
   renderBulkChange() {
-    const { component, currentUser } = this.props;
+    const { currentUser } = this.props;
     const { checkAll, bulkChangeModal, checked, issues, paging } = this.state;
 
     const isAllChecked = checked.length > 0 && issues.length === checked.length;
@@ -935,8 +935,6 @@ export class App extends React.PureComponent<Props, State> {
 
         {bulkChangeModal && (
           <BulkChangeModal
-            component={component}
-            currentUser={currentUser}
             fetchIssues={checkAll ? this.fetchIssues : this.getCheckedIssues}
             onClose={this.handleCloseBulkChange}
             onDone={this.handleBulkChangeDone}
diff --git a/server/sonar-web/src/main/js/apps/issues/components/TagsSelect.tsx b/server/sonar-web/src/main/js/apps/issues/components/TagsSelect.tsx
new file mode 100644 (file)
index 0000000..19a5d7d
--- /dev/null
@@ -0,0 +1,112 @@
+/*
+ * 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,
+  PopupPlacement,
+  PopupZLevel,
+  TagsSelector,
+} from 'design-system';
+import * as React from 'react';
+import { translate, translateWithParameters } from '../../../helpers/l10n';
+
+interface Props {
+  allowCreation: boolean;
+  inputId?: string;
+  onChange: (selected: string[]) => void;
+  onSearch: (query: string) => Promise<string[]>;
+  selectedTags: string[];
+}
+
+export default function TagsSelect(props: Props) {
+  const { allowCreation, inputId, onSearch, onChange, selectedTags } = props;
+  const [searchResults, setSearchResults] = React.useState<string[]>([]);
+
+  const doSearch = React.useCallback(
+    async (query: string) => {
+      const results = await onSearch(query);
+      setSearchResults(results);
+    },
+    [onSearch, setSearchResults]
+  );
+
+  const onSelect = React.useCallback(
+    (newTag: string) => {
+      onChange([...selectedTags, newTag]);
+    },
+    [onChange, selectedTags]
+  );
+
+  const onUnselect = React.useCallback(
+    (toRemove: string) => {
+      onChange(selectedTags.filter((tag) => tag !== toRemove));
+    },
+    [onChange, selectedTags]
+  );
+
+  return (
+    <Dropdown
+      allowResizing={true}
+      closeOnClick={false}
+      id="tag-selector"
+      overlay={
+        // eslint-disable-next-line jsx-a11y/no-static-element-interactions
+        <div onMouseDown={handleMousedown}>
+          <TagsSelector
+            allowNewElements={allowCreation}
+            createElementLabel={translateWithParameters('issue.create_tag')}
+            clearIconAriaLabel={translate('clear')}
+            headerLabel={translate('issue_bulk_change.select_tags')}
+            noResultsLabel={translate('no_results')}
+            onSelect={onSelect}
+            onUnselect={onUnselect}
+            searchInputAriaLabel={translate('search.search_for_tags')}
+            selectedTags={selectedTags}
+            onSearch={doSearch}
+            tags={searchResults}
+          />
+        </div>
+      }
+      placement={PopupPlacement.BottomLeft}
+      zLevel={PopupZLevel.Global}
+    >
+      {({ onToggleClick }) => (
+        <InputMultiSelect
+          className="sw-w-abs-300"
+          id={inputId}
+          onClick={onToggleClick}
+          placeholder={translate('select_verb')}
+          selectedLabel={translate('issue_bulk_change.selected_tags')}
+          count={selectedTags.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 48474e56cf37ee27e72a5600762e9cc6622225fc..b471b83dc05febf65aa17c28ab8c0e8f0168db92 100644 (file)
@@ -21,10 +21,12 @@ import { screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import { act } from 'react-dom/test-utils';
-import { byRole } from 'testing-library-selector';
+import { byLabelText } from 'testing-library-selector';
+import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider';
 import { mockUserBase } from '../../../../helpers/mocks/users';
 import { mockCurrentUser, mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks';
 import { renderComponent } from '../../../../helpers/testReactTestingUtils';
+import { CurrentUser } from '../../../../types/users';
 import AssigneeSelect, { AssigneeSelectProps, MIN_QUERY_LENGTH } from '../AssigneeSelect';
 
 jest.mock('../../utils', () => ({
@@ -52,15 +54,18 @@ jest.mock('../../utils', () => ({
 }));
 
 const ui = {
-  combobox: byRole('combobox'),
+  combobox: byLabelText('issue_bulk_change.assignee.change'),
+  searchbox: byLabelText('search.search_for_users'),
 };
 
 it('should show correct suggestions when there is assignable issue for the current user', async () => {
   const user = userEvent.setup();
-  renderAssigneeSelect({
-    currentUser: mockLoggedInUser({ name: 'Skywalker' }),
-    issues: [mockIssue(false, { assignee: 'someone' })],
-  });
+  renderAssigneeSelect(
+    {
+      issues: [mockIssue(false, { assignee: 'someone' })],
+    },
+    mockLoggedInUser({ name: 'Skywalker' })
+  );
 
   await user.click(ui.combobox.get());
   expect(await screen.findByText('Skywalker')).toBeInTheDocument();
@@ -68,10 +73,12 @@ it('should show correct suggestions when there is assignable issue for the curre
 
 it('should show correct suggestions when all issues are already assigned to current user', async () => {
   const user = userEvent.setup();
-  renderAssigneeSelect({
-    currentUser: mockLoggedInUser({ login: 'luke', name: 'Skywalker' }),
-    issues: [mockIssue(false, { assignee: 'luke' })],
-  });
+  renderAssigneeSelect(
+    {
+      issues: [mockIssue(false, { assignee: 'luke' })],
+    },
+    mockLoggedInUser({ login: 'luke', name: 'Skywalker' })
+  );
 
   await user.click(ui.combobox.get());
   expect(screen.queryByText('Skywalker')).not.toBeInTheDocument();
@@ -79,9 +86,7 @@ it('should show correct suggestions when all issues are already assigned to curr
 
 it('should show correct suggestions when there is no assigneable issue', async () => {
   const user = userEvent.setup();
-  renderAssigneeSelect({
-    currentUser: mockLoggedInUser({ name: 'Skywalker' }),
-  });
+  renderAssigneeSelect({}, mockLoggedInUser({ name: 'Skywalker' }));
 
   await user.click(ui.combobox.get());
   expect(screen.queryByText('Skywalker')).not.toBeInTheDocument();
@@ -92,11 +97,17 @@ it('should handle assignee search correctly', async () => {
   renderAssigneeSelect();
 
   // Minimum MIN_QUERY_LENGTH charachters to trigger search
-  await user.type(ui.combobox.get(), 'a');
+  await act(async () => {
+    await user.click(ui.combobox.get());
+    await user.type(ui.searchbox.get(), 'a');
+  });
   expect(await screen.findByText(`select2.tooShort.${MIN_QUERY_LENGTH}`)).toBeInTheDocument();
 
   // Trigger search
-  await user.type(ui.combobox.get(), 'someone');
+  await act(async () => {
+    await user.click(ui.combobox.get());
+    await user.type(ui.searchbox.get(), 'someone');
+  });
   expect(await screen.findByText('toto')).toBeInTheDocument();
   expect(await screen.findByText('user.x_deleted.tata')).toBeInTheDocument();
   expect(await screen.findByText('user.x_deleted.titi@titi')).toBeInTheDocument();
@@ -108,25 +119,25 @@ it('should handle assignee selection', async () => {
   renderAssigneeSelect({ onAssigneeSelect });
 
   await act(async () => {
-    await user.type(ui.combobox.get(), 'tot');
+    await user.click(ui.combobox.get());
+    await user.type(ui.searchbox.get(), 'tot');
   });
 
   // Do not select assignee until suggestion is selected
   expect(onAssigneeSelect).not.toHaveBeenCalled();
 
   // Select assignee when suggestion is selected
-  await user.click(screen.getByText('toto'));
+  await user.click(screen.getByLabelText('toto'));
   expect(onAssigneeSelect).toHaveBeenCalledTimes(1);
 });
 
-function renderAssigneeSelect(overrides: Partial<AssigneeSelectProps> = {}) {
+function renderAssigneeSelect(
+  overrides: Partial<AssigneeSelectProps> = {},
+  currentUser: CurrentUser = mockCurrentUser()
+) {
   return renderComponent(
-    <AssigneeSelect
-      inputId="id"
-      currentUser={mockCurrentUser()}
-      issues={[]}
-      onAssigneeSelect={jest.fn()}
-      {...overrides}
-    />
+    <CurrentUserContextProvider currentUser={currentUser}>
+      <AssigneeSelect inputId="id" issues={[]} onAssigneeSelect={jest.fn()} {...overrides} />
+    </CurrentUserContextProvider>
   );
 }
index d629ed13c86ea3ed23ea12dc12c320cca4673b06..b964c4f117243f7b02ba6bfeacf0cae3ec64641f 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 { screen } from '@testing-library/react';
+import { act, screen } from '@testing-library/react';
 import userEvent from '@testing-library/user-event';
 import * as React from 'react';
 import selectEvent from 'react-select-event';
 import { bulkChangeIssues } from '../../../../api/issues';
+import CurrentUserContextProvider from '../../../../app/components/current-user/CurrentUserContextProvider';
 import { SEVERITIES } from '../../../../helpers/constants';
 import { mockIssue, mockLoggedInUser } from '../../../../helpers/testMocks';
 import { renderComponent } from '../../../../helpers/testReactTestingUtils';
 import { IssueType } from '../../../../types/issues';
 import { Issue } from '../../../../types/types';
+import { CurrentUser } from '../../../../types/users';
 import BulkChangeModal, { MAX_PAGE_SIZE } from '../BulkChangeModal';
 
 jest.mock('../../../../api/issues', () => ({
@@ -120,28 +122,31 @@ it('should properly submit', async () => {
     ],
     {
       onDone,
-      currentUser: mockLoggedInUser({
-        login: 'toto',
-        name: 'Toto',
-      }),
-    }
+    },
+    mockLoggedInUser({
+      login: 'toto',
+      name: 'Toto',
+    })
   );
 
   expect(bulkChangeIssues).toHaveBeenCalledTimes(0);
   expect(onDone).toHaveBeenCalledTimes(0);
 
   // Assign
-  await user.click(await screen.findByRole('combobox', { name: 'issue.assign.formlink' }));
+  await user.click(
+    await screen.findByRole('combobox', { name: 'issue_bulk_change.assignee.change' })
+  );
   await user.click(await screen.findByText('Toto'));
 
   // Transition
   await user.click(await screen.findByText('issue.transition.Transition2'));
 
   // Add a tag
-  await selectEvent.select(screen.getByRole('combobox', { name: 'issue.add_tags' }), [
-    'tag1',
-    'tag2',
-  ]);
+  await act(async () => {
+    await user.click(screen.getByRole('combobox', { name: 'issue.add_tags' }));
+    await user.click(screen.getByText('tag1'));
+    await user.click(screen.getByText('tag2'));
+  });
 
   // Select a type
   await selectEvent.select(screen.getByRole('combobox', { name: 'issue.set_type' }), [
@@ -180,24 +185,29 @@ it('should properly submit', async () => {
   });
 });
 
-function renderBulkChangeModal(issues: Issue[], props: Partial<BulkChangeModal['props']> = {}) {
+function renderBulkChangeModal(
+  issues: Issue[],
+  props: Partial<BulkChangeModal['props']> = {},
+  currentUser: CurrentUser = mockLoggedInUser()
+) {
   return renderComponent(
-    <BulkChangeModal
-      component={undefined}
-      currentUser={{ isLoggedIn: true, dismissedNotices: {} }}
-      fetchIssues={() =>
-        Promise.resolve({
-          issues,
-          paging: {
-            pageIndex: issues.length,
-            pageSize: issues.length,
-            total: issues.length,
-          },
-        })
-      }
-      onClose={() => {}}
-      onDone={() => {}}
-      {...props}
-    />
+    <CurrentUserContextProvider currentUser={currentUser}>
+      <BulkChangeModal
+        fetchIssues={() =>
+          Promise.resolve({
+            issues,
+            paging: {
+              pageIndex: issues.length,
+              pageSize: issues.length,
+              total: issues.length,
+            },
+          })
+        }
+        onClose={() => {}}
+        onDone={() => {}}
+        {...props}
+      />
+    </CurrentUserContextProvider>,
+    ''
   );
 }
index 95d4efd241040dc1eb0035bb0d23accb0009f5e4..e02b0abe46354ad21b40dbbb5b0d969026b9a530 100644 (file)
@@ -18,9 +18,9 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 
-import { Avatar } from 'design-system';
 import { omit, sortBy, without } from 'lodash';
 import * as React from 'react';
+import Avatar from '../../../components/ui/Avatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { highlightTerm } from '../../../helpers/search';
 import { Facet } from '../../../types/issues';
@@ -120,7 +120,6 @@ export class AssigneeFacet extends React.PureComponent<Props> {
     return (
       <>
         <Avatar className="sw-mr-1" hash={user.avatar} name={userName} size="xs" />
-
         {isUserActive(user) ? userName : translateWithParameters('user.x_deleted', userName)}
       </>
     );
index 7f2ba88edb339fc5969cafc2768a766f3c60ee2a..ec30c1f8039e113ea961f90175f3e018f7dd2b5e 100644 (file)
@@ -20,7 +20,7 @@
 import * as React from 'react';
 import { DeleteButton } from '../../../components/controls/buttons';
 import GroupIcon from '../../../components/icons/GroupIcon';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { Group, isUser } from '../../../types/quality-gates';
 import { UserBase } from '../../../types/users';
 
@@ -35,7 +35,7 @@ export default function PermissionItem(props: PermissionItemProps) {
   return (
     <div className="display-flex-center permission-list-item padded">
       {isUser(item) ? (
-        <Avatar className="spacer-right" hash={item.avatar} name={item.name} size={32} />
+        <LegacyAvatar className="spacer-right" hash={item.avatar} name={item.name} size={32} />
       ) : (
         <GroupIcon className="pull-left spacer-right" size={32} />
       )}
index 6bb9d8b78c61f42234940fb3b1a30d4647f1cfd2..d5dfb77954e06483826ec3ccea011d14eeebb27e 100644 (file)
@@ -24,7 +24,7 @@ import { ResetButtonLink, SubmitButton } from '../../../components/controls/butt
 import Modal from '../../../components/controls/Modal';
 import { SearchSelect } from '../../../components/controls/Select';
 import GroupIcon from '../../../components/icons/GroupIcon';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { translate } from '../../../helpers/l10n';
 import { Group, isUser } from '../../../types/quality-gates';
 import { UserBase } from '../../../types/users';
@@ -92,7 +92,7 @@ export function customOptions(option: OptionWithValue) {
   return (
     <span className="display-flex-center" data-testid="qg-add-permission-option">
       {isUser(option) ? (
-        <Avatar hash={option.avatar} name={option.name} size={16} />
+        <LegacyAvatar hash={option.avatar} name={option.name} size={16} />
       ) : (
         <GroupIcon size={16} />
       )}
index 7e99bc501e6f1ade9385dc4285e76dc604e5e588..3d1f8ee2f2b185a4e1282374e1648e9acfbf2a58 100644 (file)
  */
 import { debounce, omit } from 'lodash';
 import * as React from 'react';
-import { components, ControlProps, OptionProps, SingleValueProps } from 'react-select';
+import { ControlProps, OptionProps, SingleValueProps, components } from 'react-select';
 import {
+  SearchUsersGroupsParameters,
   searchGroups,
   searchUsers,
-  SearchUsersGroupsParameters,
 } from '../../../api/quality-profiles';
 import { SearchSelect } from '../../../components/controls/Select';
 import GroupIcon from '../../../components/icons/GroupIcon';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { translate } from '../../../helpers/l10n';
 import { UserSelected } from '../../../types/types';
 import { Group } from './ProfilePermissions';
@@ -115,7 +115,7 @@ function getStringValue(option: Option) {
 function customOptions(option: OptionWithValue) {
   return isUser(option) ? (
     <span className="display-flex-center">
-      <Avatar hash={option.avatar} name={option.name} size={16} />
+      <LegacyAvatar hash={option.avatar} name={option.name} size={16} />
       <strong className="spacer-left">{option.name}</strong>
       <span className="note little-spacer-left">{option.login}</span>
     </span>
index b460024d93e47b7f08e05209146c9c2d1b6a60d3..fc343d6e1e265b269ce3cc2867d70edcaea1d883 100644 (file)
@@ -20,9 +20,9 @@
 import * as React from 'react';
 import { FormattedMessage } from 'react-intl';
 import { removeUser } from '../../../api/quality-profiles';
-import { DeleteButton, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
 import SimpleModal, { ChildrenProps } from '../../../components/controls/SimpleModal';
-import Avatar from '../../../components/ui/Avatar';
+import { DeleteButton, ResetButtonLink, SubmitButton } from '../../../components/controls/buttons';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { translate } from '../../../helpers/l10n';
 import { UserSelected } from '../../../types/types';
 
@@ -110,7 +110,12 @@ export default class ProfilePermissionsUser extends React.PureComponent<Props, S
           className="pull-right spacer-top spacer-left spacer-right button-small"
           onClick={this.handleDeleteClick}
         />
-        <Avatar className="pull-left spacer-right" hash={user.avatar} name={user.name} size={32} />
+        <LegacyAvatar
+          className="pull-left spacer-right"
+          hash={user.avatar}
+          name={user.name}
+          size={32}
+        />
         <div className="overflow-hidden">
           <strong>{user.name}</strong>
           <div className="note">{user.login}</div>
index fc1970f5c723fe3e6be8e28a027a1e14b3f27d90..a1fb25cecb654dc3cfe19913c3cf30ec8bc258b4 100644 (file)
@@ -8,7 +8,7 @@ exports[`renders 1`] = `
     className="pull-right spacer-top spacer-left spacer-right button-small"
     onClick={[Function]}
   />
-  <withAppStateContext(Avatar)
+  <withAppStateContext(LegacyAvatar)
     className="pull-left spacer-right"
     name="Luke Skywalker"
     size={32}
index 4f670dfc33b707f261a45e30c65f6afecd4cd928..8631310d85d5c0e2b01778dd0a4da4a653ee8a10 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 { Avatar, LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
+import { LabelValueSelectOption, SearchSelectDropdown } from 'design-system';
 import { noop } from 'lodash';
 import * as React from 'react';
 import { Options, SingleValue } from 'react-select';
 import { assignSecurityHotspot } from '../../../api/security-hotspots';
 import { searchUsers } from '../../../api/users';
 import { CurrentUserContext } from '../../../app/components/current-user/CurrentUserContext';
+import Avatar from '../../../components/ui/Avatar';
 import { addGlobalSuccessMessage } from '../../../helpers/globalMessages';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Hotspot, HotspotResolution, HotspotStatus } from '../../../types/security-hotspots';
index 5bb9fccbc3c05aef065fd1dc71b33f13191ca2c8..659f919326420d1b028f1248587e2ff6af4ec33e 100644 (file)
@@ -32,7 +32,7 @@ import {
 import * as React from 'react';
 import DateTimeFormatter from '../../../components/intl/DateTimeFormatter';
 import IssueChangelogDiff from '../../../components/issue/components/IssueChangelogDiff';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { Hotspot, ReviewHistoryType } from '../../../types/security-hotspots';
@@ -63,7 +63,7 @@ export default function HotspotReviewHistory(props: HotspotReviewHistoryProps) {
             <LightLabel as="div" className="sw-flex sw-gap-2">
               {user.name && (
                 <div className="sw-flex sw-items-center sw-gap-1">
-                  <Avatar hash={user.avatar} name={user.name} size={20} />
+                  <LegacyAvatar hash={user.avatar} name={user.name} size={20} />
                   <span className="sw-body-sm-highlight">
                     {user.active ? user.name : translateWithParameters('user.x_deleted', user.name)}
                   </span>
index 54f39d8ce6987cf7240fd5be1c1cf9c9a73d0cad..a3840b67f3a5244f709163513128d9cadfb4b472 100644 (file)
@@ -21,7 +21,7 @@ import * as React from 'react';
 import { ButtonIcon } from '../../../components/controls/buttons';
 import BulletListIcon from '../../../components/icons/BulletListIcon';
 import DateFromNow from '../../../components/intl/DateFromNow';
-import Avatar from '../../../components/ui/Avatar';
+import LegacyAvatar from '../../../components/ui/LegacyAvatar';
 import { translateWithParameters } from '../../../helpers/l10n';
 import { IdentityProvider } from '../../../types/types';
 import { User } from '../../../types/users';
@@ -56,7 +56,12 @@ export default function UserListItem(props: UserListItemProps) {
     <tr>
       <td className="thin text-middle">
         <div className="sw-flex sw-items-center">
-          <Avatar className="sw-shrink-0 sw-mr-4" hash={user.avatar} name={user.name} size={36} />
+          <LegacyAvatar
+            className="sw-shrink-0 sw-mr-4"
+            hash={user.avatar}
+            name={user.name}
+            size={36}
+          />
           <UserListItemIdentity
             identityProvider={identityProvider}
             user={user}
diff --git a/server/sonar-web/src/main/js/components/icon-mappers/IssueSeverityIcon.tsx b/server/sonar-web/src/main/js/components/icon-mappers/IssueSeverityIcon.tsx
new file mode 100644 (file)
index 0000000..6659db6
--- /dev/null
@@ -0,0 +1,51 @@
+/*
+ * 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 {
+  IconProps,
+  SeverityBlockerIcon,
+  SeverityCriticalIcon,
+  SeverityInfoIcon,
+  SeverityMajorIcon,
+  SeverityMinorIcon,
+} from 'design-system';
+import React from 'react';
+import { IssueSeverity } from '../../types/issues';
+import { Dict } from '../../types/types';
+
+interface Props extends IconProps {
+  severity: IssueSeverity | undefined;
+}
+
+const severityIcons: Dict<(props: IconProps) => React.ReactElement> = {
+  blocker: SeverityBlockerIcon,
+  critical: SeverityCriticalIcon,
+  major: SeverityMajorIcon,
+  minor: SeverityMinorIcon,
+  info: SeverityInfoIcon,
+};
+
+export default function IssueSeverityIcon({ severity, ...iconProps }: Props) {
+  if (!severity) {
+    return null;
+  }
+
+  const IconComponent = severityIcons[severity.toLowerCase()];
+  return IconComponent ? <IconComponent {...iconProps} /> : null;
+}
index d035d4aa4009d11946b031f22536edc14eea9132..4863731e28492228f887756c033c930d66f3cbf7 100644 (file)
@@ -23,12 +23,12 @@ import classNames from 'classnames';
 import {
   BugIcon,
   CodeSmellIcon,
+  IconProps,
   SecurityHotspotIcon,
   VulnerabilityIcon,
   themeColor,
   themeContrast,
 } from 'design-system';
-import { IconProps } from 'design-system/lib/components/icons/Icon';
 import React from 'react';
 import { IssueType } from '../../types/issues';
 
index 0c86f616a331c6669639bb9d33be63192d3c942b..4b739af115620114be19e99e5f47209f12ad37d6 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { ButtonLink } from '../../../components/controls/buttons';
 import Toggler from '../../../components/controls/Toggler';
+import { ButtonLink } from '../../../components/controls/buttons';
 import DropdownIcon from '../../../components/icons/DropdownIcon';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Issue } from '../../../types/types';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 import SetAssigneePopup from '../popups/SetAssigneePopup';
 
 interface Props {
@@ -55,7 +55,12 @@ export default class IssueAssign extends React.PureComponent<Props> {
       return (
         <>
           <span className="text-top">
-            <Avatar className="little-spacer-right" hash={issue.assigneeAvatar} name="" size={16} />
+            <LegacyAvatar
+              className="little-spacer-right"
+              hash={issue.assigneeAvatar}
+              name=""
+              size={16}
+            />
           </span>
           <span className="issue-meta-label" title={assigneeDisplay}>
             {assigneeDisplay}
index 08ec3edc3cf3c02da069185750e243c7c43e9546..748283724e10f18f9618c686b4b14ab40274747a 100644 (file)
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import * as React from 'react';
-import { DeleteButton, EditButton } from '../../../components/controls/buttons';
 import Toggler from '../../../components/controls/Toggler';
+import { DeleteButton, EditButton } from '../../../components/controls/buttons';
 import { PopupPlacement } from '../../../components/ui/popups';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { IssueComment } from '../../../types/types';
 import DateFromNow from '../../intl/DateFromNow';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 import CommentDeletePopup from '../popups/CommentDeletePopup';
 import CommentPopup from '../popups/CommentPopup';
 
@@ -87,7 +87,7 @@ export default class IssueCommentLine extends React.PureComponent<Props, State>
     return (
       <li className="issue-comment">
         <div className="issue-comment-author" title={displayName}>
-          <Avatar
+          <LegacyAvatar
             className="little-spacer-right"
             hash={comment.authorAvatar}
             name={author}
index 3cd9bcbb419c904b23ae48ad51f13b8c9713a8aa..eb4e580156cef19699cc33e2ff3ded99f5070543 100644 (file)
@@ -24,7 +24,7 @@ import { PopupPlacement } from '../../../components/ui/popups';
 import { translate, translateWithParameters } from '../../../helpers/l10n';
 import { Issue, IssueChangelog } from '../../../types/types';
 import DateTimeFormatter from '../../intl/DateTimeFormatter';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 import IssueChangelogDiff from '../components/IssueChangelogDiff';
 
 interface Props {
@@ -88,7 +88,7 @@ export default class ChangelogPopup extends React.PureComponent<Props, State> {
                       <div>
                         {userName && (
                           <>
-                            <Avatar
+                            <LegacyAvatar
                               className="little-spacer-right"
                               hash={item.avatar}
                               name={userName}
index 2efd8d9ea1c463ae8833806bb56564c53a8fe009..c280798cf429a3a4b329a7d4db4edac82ade75f9 100644 (file)
@@ -23,7 +23,7 @@ import { sanitizeUserInput } from '../../../helpers/sanitize';
 import { IssueComment } from '../../../types/types';
 import { DeleteButton, EditButton } from '../../controls/buttons';
 import DateTimeFormatter from '../../intl/DateTimeFormatter';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 import CommentForm from './CommentForm';
 
 interface CommentTileProps {
@@ -68,7 +68,7 @@ export default class CommentTile extends React.PureComponent<CommentTileProps, C
       <div className="issue-comment-tile spacer-bottom padded">
         <div className="display-flex-center">
           <div className="issue-comment-author display-flex-center" title={displayName}>
-            <Avatar
+            <LegacyAvatar
               className="little-spacer-right"
               hash={comment.authorAvatar}
               name={author}
index 1dc729aed53968138b61c31dd9d19b9030282e1d..181dc2989e8590d334a2e11b7f2e3970930c947c 100644 (file)
@@ -27,7 +27,7 @@ import { translate } from '../../../helpers/l10n';
 import { CurrentUser, isLoggedIn, isUserActive, UserActive, UserBase } from '../../../types/users';
 import SelectList from '../../common/SelectList';
 import SelectListItem from '../../common/SelectListItem';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 
 interface Props {
   currentUser: CurrentUser;
@@ -107,7 +107,12 @@ export class SetAssigneePopup extends React.PureComponent<Props, State> {
             {this.state.users.map((user) => (
               <SelectListItem item={user.login} key={user.login}>
                 {!!user.login && (
-                  <Avatar className="spacer-right" hash={user.avatar} name={user.name} size={16} />
+                  <LegacyAvatar
+                    className="spacer-right"
+                    hash={user.avatar}
+                    name={user.name}
+                    size={16}
+                  />
                 )}
                 <span className="text-middle" style={{ marginLeft: user.login ? 24 : undefined }}>
                   {user.name}
index 9662a5bad95c28d2a7018edd4d2f6bb0f5a61fd9..6da1f303387a92e4d4bb736fcbc5c65c3c206b4a 100644 (file)
@@ -30,7 +30,7 @@ import SelectList from '../../common/SelectList';
 import SelectListItem from '../../common/SelectListItem';
 import SeverityHelper from '../../shared/SeverityHelper';
 import StatusHelper from '../../shared/StatusHelper';
-import Avatar from '../../ui/Avatar';
+import LegacyAvatar from '../../ui/LegacyAvatar';
 
 interface SimilarIssuesPopupProps {
   issue: Issue;
@@ -95,7 +95,7 @@ export default function SimilarIssuesPopup(props: SimilarIssuesPopupProps) {
           {assignee ? (
             <span>
               {translate('assigned_to')}
-              <Avatar
+              <LegacyAvatar
                 className="little-spacer-left little-spacer-right"
                 hash={issue.assigneeAvatar}
                 name={assignee}
index 7336a716a63ede731995ed04b29fd30f82138cc5..ada77a541cf6d2c10ecc51962270929e11942baa 100644 (file)
@@ -22,7 +22,7 @@ import * as React from 'react';
 import { translate } from '../../helpers/l10n';
 import { isPermissionDefinitionGroup } from '../../helpers/permissions';
 import { PermissionDefinitions, PermissionUser } from '../../types/types';
-import Avatar from '../ui/Avatar';
+import LegacyAvatar from '../ui/LegacyAvatar';
 import PermissionCell from './PermissionCell';
 
 interface Props {
@@ -97,7 +97,7 @@ export default class UserHolder extends React.PureComponent<Props, State> {
       <tr>
         <td className="nowrap text-middle">
           <div className="display-flex-center">
-            <Avatar
+            <LegacyAvatar
               className="text-middle big-spacer-right flex-0"
               hash={user.avatar}
               name={user.name}
index e59bf061b3bcd62284834172d23580860bc1f081..affb330f8ccf47a5374e038e6fa6fee6844de381 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 classNames from 'classnames';
+import { Avatar as BaseAvatar } from 'design-system';
 import * as React from 'react';
-import withAppStateContext from '../../app/components/app-state/withAppStateContext';
-import GenericAvatar from '../../components/ui/GenericAvatar';
-import { AppState } from '../../types/appstate';
+import { AppStateContext } from '../../app/components/app-state/AppStateContext';
+import { FCProps } from '../../types/misc';
 import { GlobalSettingKeys } from '../../types/settings';
 
-const GRAVATAR_SIZE_MULTIPLIER = 2;
+type ExcludedProps =
+  | 'enableGravatar'
+  | 'gravatarServerUrl'
+  | 'organizationAvatar'
+  | 'organizationName';
 
-interface Props {
-  appState: AppState;
-  className?: string;
-  hash?: string;
-  name?: string;
-  size: number;
-}
+type Props = Omit<FCProps<typeof BaseAvatar>, ExcludedProps>;
 
-export function Avatar(props: Props) {
-  const {
-    appState: { settings },
-    className,
-    hash,
-    name,
-    size,
-  } = props;
+export default function Avatar(props: Props) {
+  const { settings } = React.useContext(AppStateContext);
 
   const enableGravatar = settings[GlobalSettingKeys.EnableGravatar] === 'true';
-
-  if (!enableGravatar || !hash) {
-    if (!name) {
-      return null;
-    }
-    return <GenericAvatar className={className} name={name} size={size} />;
-  }
-
   const gravatarServerUrl = settings[GlobalSettingKeys.GravatarServerUrl] ?? '';
-  const url = gravatarServerUrl
-    .replace('{EMAIL_MD5}', hash)
-    .replace('{SIZE}', String(size * GRAVATAR_SIZE_MULTIPLIER));
 
   return (
-    <img
-      alt={name}
-      className={classNames(className, 'rounded')}
-      height={size}
-      src={url}
-      width={size}
-    />
+    <BaseAvatar enableGravatar={enableGravatar} gravatarServerUrl={gravatarServerUrl} {...props} />
   );
 }
-
-export default withAppStateContext(Avatar);
diff --git a/server/sonar-web/src/main/js/components/ui/LegacyAvatar.tsx b/server/sonar-web/src/main/js/components/ui/LegacyAvatar.tsx
new file mode 100644 (file)
index 0000000..aa1a8e3
--- /dev/null
@@ -0,0 +1,74 @@
+/*
+ * 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 classNames from 'classnames';
+import * as React from 'react';
+import withAppStateContext from '../../app/components/app-state/withAppStateContext';
+import { AppState } from '../../types/appstate';
+import { GlobalSettingKeys } from '../../types/settings';
+import GenericAvatar from './GenericAvatar';
+
+const GRAVATAR_SIZE_MULTIPLIER = 2;
+
+interface Props {
+  appState: AppState;
+  className?: string;
+  hash?: string;
+  name?: string;
+  size: number;
+}
+
+/**
+ * @deprecated Use Avatar instead
+ */
+export function LegacyAvatar(props: Props) {
+  const {
+    appState: { settings },
+    className,
+    hash,
+    name,
+    size,
+  } = props;
+
+  const enableGravatar = settings[GlobalSettingKeys.EnableGravatar] === 'true';
+
+  if (!enableGravatar || !hash) {
+    if (!name) {
+      return null;
+    }
+    return <GenericAvatar className={className} name={name} size={size} />;
+  }
+
+  const gravatarServerUrl = settings[GlobalSettingKeys.GravatarServerUrl] ?? '';
+  const url = gravatarServerUrl
+    .replace('{EMAIL_MD5}', hash)
+    .replace('{SIZE}', String(size * GRAVATAR_SIZE_MULTIPLIER));
+
+  return (
+    <img
+      alt={name}
+      className={classNames(className, 'rounded')}
+      height={size}
+      src={url}
+      width={size}
+    />
+  );
+}
+
+export default withAppStateContext(LegacyAvatar);
index c8f2bec7ca457eed26e6400c5521039a9bc64a4e..79956d51acb357e2d94725c79d95214857257e2e 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 { shallow } from 'enzyme';
+import { screen } from '@testing-library/react';
 import * as React from 'react';
 import { mockAppState } from '../../../helpers/testMocks';
-import { GlobalSettingKeys } from '../../../types/settings';
-import { Avatar } from '../Avatar';
+import { renderComponent } from '../../../helpers/testReactTestingUtils';
+import Avatar from '../Avatar';
 
 const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
 
-it('should be able to render with hash only', () => {
-  const avatar = shallow(
-    <Avatar
-      appState={mockAppState({
-        settings: {
-          [GlobalSettingKeys.EnableGravatar]: 'true',
-          [GlobalSettingKeys.GravatarServerUrl]: gravatarServerUrl,
-        },
-      })}
-      hash="7daf6c79d4802916d83f6266e24850af"
-      name="Foo"
-      size={30}
-    />
-  );
-  expect(avatar).toMatchSnapshot();
-});
-
-it('falls back to dummy avatar', () => {
-  const avatar = shallow(
-    <Avatar appState={mockAppState({ settings: {} })} name="Foo Bar" size={30} />
-  );
-  expect(avatar).toMatchSnapshot();
-});
-
-it('do not fail when name is missing', () => {
-  const avatar = shallow(
-    <Avatar appState={mockAppState({ settings: {} })} name={undefined} size={30} />
-  );
-  expect(avatar.getElement()).toBeNull();
+it('renders correctly', () => {
+  renderComponent(<Avatar name="John Doe" hash="johndoe" />, '', {
+    appState: mockAppState({
+      settings: {
+        'sonar.lf.enableGravatar': 'true',
+        'sonar.lf.gravatarServerUrl': gravatarServerUrl,
+      },
+    }),
+  });
+  const image = screen.getByAltText('John Doe');
+  expect(image).toBeInTheDocument();
+  expect(image).toHaveAttribute('src', 'http://example.com/johndoe.jpg?s=48');
 });
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/LegacyAvatar-test.tsx b/server/sonar-web/src/main/js/components/ui/__tests__/LegacyAvatar-test.tsx
new file mode 100644 (file)
index 0000000..46337a6
--- /dev/null
@@ -0,0 +1,57 @@
+/*
+ * 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 { shallow } from 'enzyme';
+import * as React from 'react';
+import { mockAppState } from '../../../helpers/testMocks';
+import { GlobalSettingKeys } from '../../../types/settings';
+import { LegacyAvatar } from '../LegacyAvatar';
+
+const gravatarServerUrl = 'http://example.com/{EMAIL_MD5}.jpg?s={SIZE}';
+
+it('should be able to render with hash only', () => {
+  const avatar = shallow(
+    <LegacyAvatar
+      appState={mockAppState({
+        settings: {
+          [GlobalSettingKeys.EnableGravatar]: 'true',
+          [GlobalSettingKeys.GravatarServerUrl]: gravatarServerUrl,
+        },
+      })}
+      hash="7daf6c79d4802916d83f6266e24850af"
+      name="Foo"
+      size={30}
+    />
+  );
+  expect(avatar).toMatchSnapshot();
+});
+
+it('falls back to dummy avatar', () => {
+  const avatar = shallow(
+    <LegacyAvatar appState={mockAppState({ settings: {} })} name="Foo Bar" size={30} />
+  );
+  expect(avatar).toMatchSnapshot();
+});
+
+it('do not fail when name is missing', () => {
+  const avatar = shallow(
+    <LegacyAvatar appState={mockAppState({ settings: {} })} name={undefined} size={30} />
+  );
+  expect(avatar.getElement()).toBeNull();
+});
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/Avatar-test.tsx.snap
deleted file mode 100644 (file)
index 0083612..0000000
+++ /dev/null
@@ -1,18 +0,0 @@
-// Jest Snapshot v1, https://goo.gl/fbAQLP
-
-exports[`falls back to dummy avatar 1`] = `
-<GenericAvatar
-  name="Foo Bar"
-  size={30}
-/>
-`;
-
-exports[`should be able to render with hash only 1`] = `
-<img
-  alt="Foo"
-  className="rounded"
-  height={30}
-  src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60"
-  width={30}
-/>
-`;
diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/LegacyAvatar-test.tsx.snap b/server/sonar-web/src/main/js/components/ui/__tests__/__snapshots__/LegacyAvatar-test.tsx.snap
new file mode 100644 (file)
index 0000000..0083612
--- /dev/null
@@ -0,0 +1,18 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`falls back to dummy avatar 1`] = `
+<GenericAvatar
+  name="Foo Bar"
+  size={30}
+/>
+`;
+
+exports[`should be able to render with hash only 1`] = `
+<img
+  alt="Foo"
+  className="rounded"
+  height={30}
+  src="http://example.com/7daf6c79d4802916d83f6266e24850af.jpg?s=60"
+  width={30}
+/>
+`;
index c63734107ca3dc52cdc1b89e100524974b61cf60..f0d3add55d78e674830a3720db4c22d7453830e7 100644 (file)
@@ -883,7 +883,7 @@ issues.on_file_x=Issues on file {0}
 issue.add_tags=Add Tags
 issue.remove_tags=Remove Tags
 issue.no_tag=No tags
-issue.create_tag_x=Create Tag '{0}'
+issue.create_tag=Create Tag
 issue.assign.assigned_to_x_click_to_change=Assigned to {0}, click to change
 issue.assign.unassigned_click_to_assign=Unassigned, click to assign issue
 issue.assign.formlink=Assign
@@ -1097,6 +1097,9 @@ issue_bulk_change.comment.help=This comment will be applied only to issues that
 issue_bulk_change.max_issues_reached=There are more issues available than can be treated by a single bulk action. Your changes will only be applied to the first {max} issues.
 issue_bulk_change.x_issues={0} issues
 issue_bulk_change.no_match=There is no issue matching your filter selection
+issue_bulk_change.assignee.change=Assign the selected issues to a user
+issue_bulk_change.select_tags=Select tags
+issue_bulk_change.selected_tags=Selected tags
 
 #------------------------------------------------------------------------------
 #