]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-19168 New Tags component
authorJeremy Davis <jeremy.davis@sonarsource.com>
Mon, 8 May 2023 13:06:33 +0000 (15:06 +0200)
committersonartech <sonartech@sonarsource.com>
Wed, 10 May 2023 20:05:28 +0000 (20:05 +0000)
server/sonar-web/design-system/src/components/DropdownToggler.tsx
server/sonar-web/design-system/src/components/FocusOutHandler.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/InputSearch.tsx
server/sonar-web/design-system/src/components/MultiSelect.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/MultiSelectOption.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/Tags.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/TagsSelector.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx [new file with mode: 0644]
server/sonar-web/design-system/src/components/buttons.tsx
server/sonar-web/design-system/src/components/index.ts

index f4217e42ef705d405df69240bc97955800a1d3e7..14ed580b209edb9914c31982cfc136d9a8ad8938 100644 (file)
@@ -18,6 +18,7 @@
  * Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
  */
 import EscKeydownHandler from './EscKeydownHandler';
+import FocusOutHandler from './FocusOutHandler';
 import OutsideClickHandler from './OutsideClickHandler';
 import { Popup } from './popups';
 
@@ -36,7 +37,9 @@ export function DropdownToggler(props: Props) {
       overlay={
         open ? (
           <OutsideClickHandler onClickOutside={onRequestClose}>
-            <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+            <FocusOutHandler onFocusOut={onRequestClose}>
+              <EscKeydownHandler onKeydown={onRequestClose}>{overlay}</EscKeydownHandler>
+            </FocusOutHandler>
           </OutsideClickHandler>
         ) : undefined
       }
diff --git a/server/sonar-web/design-system/src/components/FocusOutHandler.tsx b/server/sonar-web/design-system/src/components/FocusOutHandler.tsx
new file mode 100644 (file)
index 0000000..dbd700e
--- /dev/null
@@ -0,0 +1,60 @@
+/*
+ * 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 * as React from 'react';
+
+interface Props extends React.BaseHTMLAttributes<HTMLDivElement> {
+  innerRef?: (instance: HTMLDivElement) => void;
+  onFocusOut: () => void;
+}
+
+export default class FocusOutHandler extends React.PureComponent<React.PropsWithChildren<Props>> {
+  ref?: HTMLDivElement;
+
+  componentDidMount() {
+    setTimeout(() => {
+      document.addEventListener('focusin', this.handleFocusOut);
+    }, 0);
+  }
+
+  componentWillUnmount() {
+    document.removeEventListener('focusin', this.handleFocusOut);
+  }
+
+  nodeRef = (node: HTMLDivElement) => {
+    const { innerRef } = this.props;
+    this.ref = node;
+    innerRef?.(node);
+  };
+
+  handleFocusOut = () => {
+    if (this.ref?.querySelector(':focus') === null) {
+      this.props.onFocusOut();
+    }
+  };
+
+  render() {
+    const { onFocusOut, innerRef, children, ...props } = this.props;
+    return (
+      <div ref={this.nodeRef} {...props}>
+        {children}
+      </div>
+    );
+  }
+}
index d87c384446895fd1bcfdc472d21e5bcfccc13f2d..0bf61f9cae1f31ce5817f35d67aeec13798fb706 100644 (file)
@@ -96,7 +96,7 @@ export function InputSearch({
     if (autoFocus && input.current) {
       input.current.focus();
     }
-  });
+  }, [autoFocus]);
 
   const changeValue = (newValue: string) => {
     if (newValue.length === 0 || !minLength || minLength <= newValue.length) {
diff --git a/server/sonar-web/design-system/src/components/MultiSelect.tsx b/server/sonar-web/design-system/src/components/MultiSelect.tsx
new file mode 100644 (file)
index 0000000..33b2037
--- /dev/null
@@ -0,0 +1,336 @@
+/*
+ * 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={true}
+            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={true}
+              />
+            ))}
+          {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={true}
+              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/MultiSelectOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectOption.tsx
new file mode 100644 (file)
index 0000000..6258187
--- /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 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={true} 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/Tags.tsx b/server/sonar-web/design-system/src/components/Tags.tsx
new file mode 100644 (file)
index 0000000..8d312ae
--- /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 styled from '@emotion/styled';
+import classNames from 'classnames';
+import * as React from 'react';
+import tw from 'twin.macro';
+import { PopupPlacement, PopupZLevel } from '../helpers';
+import { themeColor, themeContrast } from '../helpers/theme';
+import { Dropdown } from './Dropdown';
+import { LightLabel } from './Text';
+import { WrapperButton } from './buttons';
+
+interface Props {
+  allowUpdate?: boolean;
+  ariaTagsListLabel: string;
+  className?: string;
+  emptyText: string;
+  menuId?: string;
+  overlay?: React.ReactNode;
+  popupPlacement?: PopupPlacement;
+  tags: string[];
+  tagsToDisplay?: number;
+}
+
+export function Tags({
+  allowUpdate = false,
+  ariaTagsListLabel,
+  className,
+  emptyText,
+  menuId = '',
+  overlay,
+  popupPlacement,
+  tags,
+  tagsToDisplay = 3,
+}: Props) {
+  const displayedTags = tags.slice(0, tagsToDisplay);
+  const extraTags = tags.slice(tagsToDisplay);
+
+  const displayedTagsContent = () => (
+    <span className="sw-inline-flex sw-items-center sw-gap-1" title={tags.join(', ')}>
+      {/* Display first 3 (tagsToDisplay) tags */}
+      {displayedTags.map((tag) => (
+        <TagLabel key={tag}>{tag}</TagLabel>
+      ))}
+
+      {/* Show ellipsis if there are more tags */}
+      {extraTags.length > 0 ? <TagLabel>...</TagLabel> : null}
+
+      {/* Handle no tags with its own styling */}
+      {tags.length === 0 && <LightLabel>{emptyText}</LightLabel>}
+    </span>
+  );
+
+  return (
+    <span
+      aria-label={`${ariaTagsListLabel}: ${tags.join(', ')}`}
+      className={classNames('sw-cursor-default sw-flex sw-items-center', className)}
+    >
+      {allowUpdate ? (
+        <Dropdown
+          allowResizing={true}
+          closeOnClick={false}
+          id={menuId}
+          overlay={overlay}
+          placement={popupPlacement}
+          zLevel={PopupZLevel.Global}
+        >
+          {({ a11yAttrs, onToggleClick }) => (
+            <WrapperButton
+              className="sw-flex sw-items-center sw-gap-1 sw-p-1 sw-h-auto sw-rounded-0"
+              onClick={onToggleClick}
+              {...a11yAttrs}
+            >
+              {displayedTagsContent()}
+              <TagLabel className="sw-cursor-pointer">+</TagLabel>
+            </WrapperButton>
+          )}
+        </Dropdown>
+      ) : (
+        <span>{displayedTagsContent()}</span>
+      )}
+    </span>
+  );
+}
+
+const TagLabel = styled.span`
+  color: ${themeContrast('tag')};
+  background: ${themeColor('tag')};
+
+  ${tw`sw-body-sm`}
+  ${tw`sw-box-border`}
+  ${tw`sw-truncate`}
+  ${tw`sw-rounded-1/2`}
+  ${tw`sw-px-1 sw-py-1/2`}
+  ${tw`sw-max-w-32`}
+`;
diff --git a/server/sonar-web/design-system/src/components/TagsSelector.tsx b/server/sonar-web/design-system/src/components/TagsSelector.tsx
new file mode 100644 (file)
index 0000000..5c4ad8d
--- /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 { MultiSelect } from './MultiSelect';
+
+interface Props {
+  clearIconAriaLabel: string;
+  createElementLabel: string;
+  headerLabel: string;
+  listSize: number;
+  noResultsLabel: string;
+  onSearch: (query: string) => Promise<void>;
+  onSelect: (item: string) => void;
+  onUnselect: (item: string) => void;
+  searchInputAriaLabel: string;
+  selectedTags: string[];
+  tags: string[];
+}
+
+export function TagsSelector(props: Props) {
+  const {
+    clearIconAriaLabel,
+    createElementLabel,
+    headerLabel,
+    listSize,
+    noResultsLabel,
+    searchInputAriaLabel,
+    selectedTags,
+    tags,
+  } = props;
+
+  return (
+    <MultiSelect
+      clearIconAriaLabel={clearIconAriaLabel}
+      createElementLabel={createElementLabel}
+      elements={tags}
+      headerNode={<div className="sw-mt-4 sw-font-semibold">{headerLabel}</div>}
+      listSize={listSize}
+      noResultsLabel={noResultsLabel}
+      onSearch={props.onSearch}
+      onSelect={props.onSelect}
+      onUnselect={props.onUnselect}
+      placeholder={searchInputAriaLabel}
+      searchInputAriaLabel={searchInputAriaLabel}
+      selectedElements={selectedTags}
+      validateSearchInput={validateTag}
+    />
+  );
+}
+
+export function validateTag(value: string) {
+  // Allow only a-z, 0-9, '+', '-', '#', '.'
+  return value.toLowerCase().replace(/[^-a-z0-9+#.]/gi, '');
+}
diff --git a/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx
new file mode 100644 (file)
index 0000000..2e79eb0
--- /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 { 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__/Tags-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx
new file mode 100644 (file)
index 0000000..4749a34
--- /dev/null
@@ -0,0 +1,116 @@
+/*
+ * 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.
+ */
+
+/* eslint-disable import/no-extraneous-dependencies */
+
+import { render, screen } from '@testing-library/react';
+import userEvent from '@testing-library/user-event';
+import { useState } from 'react';
+import { FCProps } from '../../types/misc';
+import { Tags } from '../Tags';
+import { TagsSelector } from '../TagsSelector';
+
+it('should display "no tags"', () => {
+  renderTags({ tags: [] });
+
+  expect(screen.getByText('no tags')).toBeInTheDocument();
+});
+
+it('should display tags', () => {
+  const tags = ['tag1', 'tag2'];
+  renderTags({ tags });
+
+  expect(screen.getByText('tag1')).toBeInTheDocument();
+  expect(screen.getByText('tag2')).toBeInTheDocument();
+});
+
+it('should handle more tags than the max to display', () => {
+  const tags = ['tag1', 'tag2', 'tag3'];
+  renderTags({ tags, tagsToDisplay: 2 });
+
+  expect(screen.getByText('tag1')).toBeInTheDocument();
+  expect(screen.getByText('tag2')).toBeInTheDocument();
+  expect(screen.queryByText('tag3')).not.toBeInTheDocument();
+  expect(screen.getByText('...')).toBeInTheDocument();
+});
+
+it('should allow editing tags', async () => {
+  const user = userEvent.setup();
+  renderTags({ allowUpdate: true });
+
+  const plusButton = screen.getByText('+');
+  expect(plusButton).toBeInTheDocument();
+  await user.click(plusButton);
+
+  expect(await screen.findByLabelText('search')).toBeInTheDocument();
+  await user.click(screen.getByText('tag3'));
+  await user.keyboard('{Escape}');
+
+  expect(screen.getByText('tag1')).toBeInTheDocument();
+  expect(screen.getByText('tag3')).toBeInTheDocument();
+
+  await user.click(plusButton);
+  await user.click(screen.getByRole('checkbox', { name: 'tag1' }));
+  await user.keyboard('{Escape}');
+
+  expect(screen.queryByText('tag1')).not.toBeInTheDocument();
+  expect(screen.getByText('tag3')).toBeInTheDocument();
+});
+
+function renderTags(overrides: Partial<FCProps<typeof Tags>> = {}) {
+  render(<Wrapper {...overrides} />);
+}
+
+function Wrapper(overrides: Partial<FCProps<typeof Tags>> = {}) {
+  const [selectedTags, setSelectedTags] = useState<string[]>(overrides.tags ?? ['tag1']);
+
+  const overlay = (
+    <TagsSelector
+      clearIconAriaLabel="clear"
+      createElementLabel="create new tag"
+      headerLabel="edit tags"
+      listSize={4}
+      noResultsLabel="no results"
+      onSearch={jest.fn().mockResolvedValue(undefined)}
+      onSelect={(tag) => {
+        setSelectedTags([...selectedTags, tag]);
+      }}
+      onUnselect={(tag) => {
+        const i = selectedTags.indexOf(tag);
+        if (i > -1) {
+          setSelectedTags([...selectedTags.slice(0, i), ...selectedTags.slice(i + 1)]);
+        }
+      }}
+      searchInputAriaLabel="search"
+      selectedTags={selectedTags}
+      tags={['tag1', 'tag2', 'tag3']}
+    />
+  );
+
+  return (
+    <Tags
+      ariaTagsListLabel="list"
+      emptyText="no tags"
+      overlay={overlay}
+      tags={selectedTags}
+      {...overrides}
+    />
+  );
+}
index 13304f9215ccf468efddfe0973664219c80abd48..18030488b64365eaa7bfaa19c3e8e69061dcc00b 100644 (file)
@@ -190,6 +190,14 @@ export const DangerButtonSecondary: React.FC<ButtonProps> = styled(Button)`
   --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')};
 `;
 
+export const WrapperButton: React.FC<ButtonProps> = styled(Button)`
+  --background: none;
+  --backgroundHover: none;
+  --color: none;
+  --focus: ${themeColor('button', OPACITY_20_PERCENT)};
+  --border: none;
+`;
+
 interface ThirdPartyProps extends Omit<ButtonProps, 'Icon'> {
   iconPath: string;
   name: string;
index 3a244823b8f1d77e245ae2f9145556e74cddfb53..a4af02dcab0d061bc314a20c42505d1f99dafc48 100644 (file)
@@ -51,6 +51,8 @@ export * from './SelectionCard';
 export * from './Separator';
 export * from './SizeIndicator';
 export * from './SonarQubeLogo';
+export * from './Tags';
+export * from './TagsSelector';
 export * from './Text';
 export { ToggleButton } from './ToggleButton';
 export { TopBar } from './TopBar';