--- /dev/null
+/*
+ * 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')};
+ }
+`;
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',
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',
+++ /dev/null
-/*
- * 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>
- );
- }
-}
--- /dev/null
+/*
+ * 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>
+ );
+ }
+}
--- /dev/null
+/*
+ * 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>
+ );
+}
+++ /dev/null
-/*
- * 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>
- );
-}
};
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}
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';
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;
}
Group extends GroupBase<Option> = GroupBase<Option>
>(props: SearchSelectDropdownProps<V, Option, IsMulti, Group>) {
const {
+ className,
isDiscreet,
value,
loadOptions,
controlLabel,
+ controlSize,
isDisabled,
minLength,
controlAriaLabel,
<DropdownToggler
allowResizing
className="sw-overflow-visible sw-border-none"
- isPortal
onRequestClose={() => {
toggleDropdown(false);
}}
</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>
);
interface SearchSelectDropdownControlProps {
ariaLabel?: string;
+ className?: string;
disabled?: boolean;
isDiscreet?: boolean;
label?: React.ReactNode | string;
}
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();
}
}}
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>
);
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`};
`;
const InputValue = styled.span`
- width: var(--inputSize);
+ width: 100%;
color: ${themeContrast('inputBackground')};
${tw`sw-truncate`};
* 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;
tags: string[];
}
+const LIST_SIZE = 10;
+
export function TagsSelector(props: Props) {
const {
+ allowNewElements,
clearIconAriaLabel,
createElementLabel,
headerLabel,
- listSize,
noResultsLabel,
searchInputAriaLabel,
selectedTags,
} = 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}
--- /dev/null
+/*
+ * 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} />);
+}
+++ /dev/null
-/*
- * 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}
- />
- );
-}
--- /dev/null
+/*
+ * 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}
+ />
+ );
+}
clearIconAriaLabel="clear"
createElementLabel="create new tag"
headerLabel="edit tags"
- listSize={4}
noResultsLabel="no results"
onSearch={jest.fn().mockResolvedValue(undefined)}
onSelect={(tag) => {
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 {
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';
export { HotspotRating } from './HotspotRating';
export * from './HtmlFormatter';
export * from './InputField';
+export * from './InputMultiSelect';
export { InputSearch } from './InputSearch';
export * from './InputSelect';
export * from './InteractiveIcon';
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';
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`}
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;
* 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 {
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}
* 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')}
+ />
+ );
}
* 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;
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() {
}
this.setState({
- initialTags: tags.map((tag) => ({ label: tag, value: tag })),
+ initialTags: tags,
issues,
loading: false,
paging,
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 });
};
};
handleSelectFieldChange =
- (field: 'severity' | 'type') => (data: LabelValueSelectOption | null) => {
+ (field: 'severity' | 'type') => (data: LabelValueSelectOption<string> | null) => {
if (data) {
this.setState<keyof FormFields>({ [field]: data.value });
} else {
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,
);
};
- 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;
const input = (
<AssigneeSelect
+ assignee={assignee}
+ className="sw-max-w-abs-300"
inputId={`issues-bulk-change-${field}`}
- currentUser={currentUser}
issues={issues}
onAssigneeSelect={this.handleAssigneeSelect}
/>
}
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"
/>
);
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"
/>
);
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);
}
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>
}
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()}
{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')}
+ />
);
}
}
function hasAction(action: string) {
return (issue: Issue) => issue.actions && issue.actions.includes(action);
}
-
-function createTagPrompt(label: string) {
- return translateWithParameters('issue.create_tag_x', label);
-}
};
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;
{bulkChangeModal && (
<BulkChangeModal
- component={component}
- currentUser={currentUser}
fetchIssues={checkAll ? this.fetchIssues : this.getCheckedIssues}
onClose={this.handleCloseBulkChange}
onDone={this.handleBulkChangeDone}
--- /dev/null
+/*
+ * 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();
+ }
+}
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', () => ({
}));
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();
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();
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();
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();
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>
);
}
* 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', () => ({
],
{
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' }), [
});
});
-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>,
+ ''
);
}
* 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';
return (
<>
<Avatar className="sw-mr-1" hash={user.avatar} name={userName} size="xs" />
-
{isUserActive(user) ? userName : translateWithParameters('user.x_deleted', userName)}
</>
);
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';
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} />
)}
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';
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} />
)}
*/
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';
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>
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';
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>
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}
* 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';
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';
<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>
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';
<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}
--- /dev/null
+/*
+ * 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;
+}
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';
* 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 {
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}
* 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';
return (
<li className="issue-comment">
<div className="issue-comment-author" title={displayName}>
- <Avatar
+ <LegacyAvatar
className="little-spacer-right"
hash={comment.authorAvatar}
name={author}
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 {
<div>
{userName && (
<>
- <Avatar
+ <LegacyAvatar
className="little-spacer-right"
hash={item.avatar}
name={userName}
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 {
<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}
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;
{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}
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;
{assignee ? (
<span>
{translate('assigned_to')}
- <Avatar
+ <LegacyAvatar
className="little-spacer-left little-spacer-right"
hash={issue.assigneeAvatar}
name={assignee}
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 {
<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}
* 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);
--- /dev/null
+/*
+ * 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);
* 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');
});
--- /dev/null
+/*
+ * 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();
+});
+++ /dev/null
-// 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}
-/>
-`;
--- /dev/null
+// 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}
+/>
+`;
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
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
#------------------------------------------------------------------------------
#