From: Jeremy Davis Date: Mon, 8 May 2023 13:06:33 +0000 (+0200) Subject: SONAR-19168 New Tags component X-Git-Tag: 10.1.0.73491~315 X-Git-Url: https://source.dussan.org/?a=commitdiff_plain;h=ef4dcc3b8dfe68ae85ae7c146fb1d670f537ee1a;p=sonarqube.git SONAR-19168 New Tags component --- diff --git a/server/sonar-web/design-system/src/components/DropdownToggler.tsx b/server/sonar-web/design-system/src/components/DropdownToggler.tsx index f4217e42ef7..14ed580b209 100644 --- a/server/sonar-web/design-system/src/components/DropdownToggler.tsx +++ b/server/sonar-web/design-system/src/components/DropdownToggler.tsx @@ -18,6 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import EscKeydownHandler from './EscKeydownHandler'; +import FocusOutHandler from './FocusOutHandler'; import OutsideClickHandler from './OutsideClickHandler'; import { Popup } from './popups'; @@ -36,7 +37,9 @@ export function DropdownToggler(props: Props) { overlay={ open ? ( - {overlay} + + {overlay} + ) : undefined } diff --git a/server/sonar-web/design-system/src/components/FocusOutHandler.tsx b/server/sonar-web/design-system/src/components/FocusOutHandler.tsx new file mode 100644 index 00000000000..dbd700e633a --- /dev/null +++ b/server/sonar-web/design-system/src/components/FocusOutHandler.tsx @@ -0,0 +1,60 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import * as React from 'react'; + +interface Props extends React.BaseHTMLAttributes { + innerRef?: (instance: HTMLDivElement) => void; + onFocusOut: () => void; +} + +export default class FocusOutHandler extends React.PureComponent> { + ref?: HTMLDivElement; + + componentDidMount() { + setTimeout(() => { + document.addEventListener('focusin', this.handleFocusOut); + }, 0); + } + + componentWillUnmount() { + document.removeEventListener('focusin', this.handleFocusOut); + } + + nodeRef = (node: HTMLDivElement) => { + const { innerRef } = this.props; + this.ref = node; + innerRef?.(node); + }; + + handleFocusOut = () => { + if (this.ref?.querySelector(':focus') === null) { + this.props.onFocusOut(); + } + }; + + render() { + const { onFocusOut, innerRef, children, ...props } = this.props; + return ( +
+ {children} +
+ ); + } +} diff --git a/server/sonar-web/design-system/src/components/InputSearch.tsx b/server/sonar-web/design-system/src/components/InputSearch.tsx index d87c3844468..0bf61f9cae1 100644 --- a/server/sonar-web/design-system/src/components/InputSearch.tsx +++ b/server/sonar-web/design-system/src/components/InputSearch.tsx @@ -96,7 +96,7 @@ export function InputSearch({ if (autoFocus && input.current) { input.current.focus(); } - }); + }, [autoFocus]); const changeValue = (newValue: string) => { if (newValue.length === 0 || !minLength || minLength <= newValue.length) { diff --git a/server/sonar-web/design-system/src/components/MultiSelect.tsx b/server/sonar-web/design-system/src/components/MultiSelect.tsx new file mode 100644 index 00000000000..33b2037afe1 --- /dev/null +++ b/server/sonar-web/design-system/src/components/MultiSelect.tsx @@ -0,0 +1,336 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import classNames from 'classnames'; +import { difference } from 'lodash'; +import { PureComponent } from 'react'; +import { Key } from '../helpers/keyboard'; +import { ItemDivider, ItemHeader } from './DropdownMenu'; +import { InputSearch } from './InputSearch'; +import { MultiSelectOption } from './MultiSelectOption'; + +interface Props { + allowNewElements?: boolean; + allowSelection?: boolean; + clearIconAriaLabel: string; + createElementLabel: string; + elements: string[]; + footerNode?: React.ReactNode; + headerNode?: React.ReactNode; + listSize: number; + noResultsLabel: string; + onSearch: (query: string) => Promise; + 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 { + 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 ( +
(this.container = div)}> +
+ +
+ {headerNode} +
    + {selectedElements.length > 0 && + selectedElements.map((element) => ( + + ))} + {unselectedElements.length > 0 && + unselectedElements.map((element) => ( + + ))} + {showNewElement && ( + + )} + {!showNewElement && selectedElements.length < 1 && unselectedElements.length < 1 && ( +
  • {noResultsLabel}
  • + )} +
+ {hasFooter && } +
{footerNode}
+
+ ); + } +} diff --git a/server/sonar-web/design-system/src/components/MultiSelectOption.tsx b/server/sonar-web/design-system/src/components/MultiSelectOption.tsx new file mode 100644 index 00000000000..6258187395e --- /dev/null +++ b/server/sonar-web/design-system/src/components/MultiSelectOption.tsx @@ -0,0 +1,64 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import classNames from 'classnames'; +import { ItemCheckbox } from './DropdownMenu'; + +export interface MultiSelectOptionProps { + active?: boolean; + createElementLabel: string; + custom?: boolean; + disabled?: boolean; + element: string; + onHover: (element: string) => void; + onSelectChange: (selected: boolean, element: string) => void; + selected?: boolean; +} + +export function MultiSelectOption(props: MultiSelectOptionProps) { + const { active, createElementLabel, custom, disabled, element, onSelectChange, selected } = props; + const onHover = () => props.onHover(element); + + return ( + + {custom ? ( + + + + + + {element} + + ) : ( + {element} + )} + + ); +} diff --git a/server/sonar-web/design-system/src/components/Tags.tsx b/server/sonar-web/design-system/src/components/Tags.tsx new file mode 100644 index 00000000000..8d312aea879 --- /dev/null +++ b/server/sonar-web/design-system/src/components/Tags.tsx @@ -0,0 +1,113 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import styled from '@emotion/styled'; +import classNames from 'classnames'; +import * as React from 'react'; +import tw from 'twin.macro'; +import { PopupPlacement, PopupZLevel } from '../helpers'; +import { themeColor, themeContrast } from '../helpers/theme'; +import { Dropdown } from './Dropdown'; +import { LightLabel } from './Text'; +import { WrapperButton } from './buttons'; + +interface Props { + allowUpdate?: boolean; + ariaTagsListLabel: string; + className?: string; + emptyText: string; + menuId?: string; + overlay?: React.ReactNode; + popupPlacement?: PopupPlacement; + tags: string[]; + tagsToDisplay?: number; +} + +export function Tags({ + allowUpdate = false, + ariaTagsListLabel, + className, + emptyText, + menuId = '', + overlay, + popupPlacement, + tags, + tagsToDisplay = 3, +}: Props) { + const displayedTags = tags.slice(0, tagsToDisplay); + const extraTags = tags.slice(tagsToDisplay); + + const displayedTagsContent = () => ( + + {/* Display first 3 (tagsToDisplay) tags */} + {displayedTags.map((tag) => ( + {tag} + ))} + + {/* Show ellipsis if there are more tags */} + {extraTags.length > 0 ? ... : null} + + {/* Handle no tags with its own styling */} + {tags.length === 0 && {emptyText}} + + ); + + return ( + + {allowUpdate ? ( + + {({ a11yAttrs, onToggleClick }) => ( + + {displayedTagsContent()} + + + + )} + + ) : ( + {displayedTagsContent()} + )} + + ); +} + +const TagLabel = styled.span` + color: ${themeContrast('tag')}; + background: ${themeColor('tag')}; + + ${tw`sw-body-sm`} + ${tw`sw-box-border`} + ${tw`sw-truncate`} + ${tw`sw-rounded-1/2`} + ${tw`sw-px-1 sw-py-1/2`} + ${tw`sw-max-w-32`} +`; diff --git a/server/sonar-web/design-system/src/components/TagsSelector.tsx b/server/sonar-web/design-system/src/components/TagsSelector.tsx new file mode 100644 index 00000000000..5c4ad8d52b3 --- /dev/null +++ b/server/sonar-web/design-system/src/components/TagsSelector.tsx @@ -0,0 +1,70 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { MultiSelect } from './MultiSelect'; + +interface Props { + clearIconAriaLabel: string; + createElementLabel: string; + headerLabel: string; + listSize: number; + noResultsLabel: string; + onSearch: (query: string) => Promise; + onSelect: (item: string) => void; + onUnselect: (item: string) => void; + searchInputAriaLabel: string; + selectedTags: string[]; + tags: string[]; +} + +export function TagsSelector(props: Props) { + const { + clearIconAriaLabel, + createElementLabel, + headerLabel, + listSize, + noResultsLabel, + searchInputAriaLabel, + selectedTags, + tags, + } = props; + + return ( + {headerLabel}} + listSize={listSize} + noResultsLabel={noResultsLabel} + onSearch={props.onSearch} + onSelect={props.onSelect} + onUnselect={props.onUnselect} + placeholder={searchInputAriaLabel} + searchInputAriaLabel={searchInputAriaLabel} + selectedElements={selectedTags} + validateSearchInput={validateTag} + /> + ); +} + +export function validateTag(value: string) { + // Allow only a-z, 0-9, '+', '-', '#', '.' + return value.toLowerCase().replace(/[^-a-z0-9+#.]/gi, ''); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx b/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx new file mode 100644 index 00000000000..2e79eb02cd0 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/MultiSelect-test.tsx @@ -0,0 +1,113 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { MultiSelect } from '../MultiSelect'; + +const elements = ['foo', 'bar', 'baz']; + +beforeAll(() => { + jest.useFakeTimers(); +}); + +afterAll(() => { + jest.useRealTimers(); +}); + +it('should allow selecting and deselecting a new option', async () => { + const user = userEvent.setup({ delay: null }); + const onSelect = jest.fn(); + const onUnselect = jest.fn(); + renderMultiselect({ elements, onSelect, onUnselect, allowNewElements: true }); + + await user.keyboard('new option'); + jest.runAllTimers(); // skip the debounce + + expect(screen.getByText('new option')).toBeInTheDocument(); + + await user.click(screen.getByText('new option')); + + expect(onSelect).toHaveBeenCalledWith('new option'); + + renderMultiselect({ + elements, + onUnselect, + allowNewElements: true, + selectedElements: ['new option'], + }); + + await user.click(screen.getByText('new option')); + expect(onUnselect).toHaveBeenCalledWith('new option'); +}); + +it('should ignore the left and right arrow keys', async () => { + const user = userEvent.setup({ delay: null }); + const onSelect = jest.fn(); + renderMultiselect({ elements, onSelect }); + + /* eslint-disable testing-library/no-node-access */ + await user.keyboard('{arrowdown}'); + expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active'); + + await user.keyboard('{arrowleft}'); + expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active'); + + await user.keyboard('{arrowright}'); + expect(screen.getAllByRole('checkbox')[1].parentElement).toHaveClass('active'); + + await user.keyboard('{arrowdown}'); + expect(screen.getAllByRole('checkbox')[1].parentElement).not.toHaveClass('active'); + expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active'); + + await user.keyboard('{arrowup}'); + await user.keyboard('{arrowup}'); + expect(screen.getAllByRole('checkbox')[0].parentElement).toHaveClass('active'); + await user.keyboard('{arrowup}'); + expect(screen.getAllByRole('checkbox')[2].parentElement).toHaveClass('active'); + + expect(screen.getAllByRole('checkbox')[2]).not.toBeChecked(); + await user.keyboard('{enter}'); + expect(onSelect).toHaveBeenCalledWith('baz'); +}); + +it('should show no results', () => { + renderMultiselect(); + expect(screen.getByText('no results')).toBeInTheDocument(); +}); + +function renderMultiselect(props: Partial = {}) { + return render( + Promise.resolve())} + onSelect={jest.fn()} + onUnselect={jest.fn()} + placeholder="" + searchInputAriaLabel="search" + selectedElements={[]} + {...props} + /> + ); +} diff --git a/server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx b/server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx new file mode 100644 index 00000000000..4749a34cb40 --- /dev/null +++ b/server/sonar-web/design-system/src/components/__tests__/Tags-test.tsx @@ -0,0 +1,116 @@ +/* + * SonarQube + * Copyright (C) 2009-2023 SonarSource SA + * mailto:info AT sonarsource DOT com + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; either + * version 3 of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * + * You should have received a copy of the GNU Lesser General Public License + * along with this program; if not, write to the Free Software Foundation, + * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + */ + +/* eslint-disable import/no-extraneous-dependencies */ + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { useState } from 'react'; +import { FCProps } from '../../types/misc'; +import { Tags } from '../Tags'; +import { TagsSelector } from '../TagsSelector'; + +it('should display "no tags"', () => { + renderTags({ tags: [] }); + + expect(screen.getByText('no tags')).toBeInTheDocument(); +}); + +it('should display tags', () => { + const tags = ['tag1', 'tag2']; + renderTags({ tags }); + + expect(screen.getByText('tag1')).toBeInTheDocument(); + expect(screen.getByText('tag2')).toBeInTheDocument(); +}); + +it('should handle more tags than the max to display', () => { + const tags = ['tag1', 'tag2', 'tag3']; + renderTags({ tags, tagsToDisplay: 2 }); + + expect(screen.getByText('tag1')).toBeInTheDocument(); + expect(screen.getByText('tag2')).toBeInTheDocument(); + expect(screen.queryByText('tag3')).not.toBeInTheDocument(); + expect(screen.getByText('...')).toBeInTheDocument(); +}); + +it('should allow editing tags', async () => { + const user = userEvent.setup(); + renderTags({ allowUpdate: true }); + + const plusButton = screen.getByText('+'); + expect(plusButton).toBeInTheDocument(); + await user.click(plusButton); + + expect(await screen.findByLabelText('search')).toBeInTheDocument(); + await user.click(screen.getByText('tag3')); + await user.keyboard('{Escape}'); + + expect(screen.getByText('tag1')).toBeInTheDocument(); + expect(screen.getByText('tag3')).toBeInTheDocument(); + + await user.click(plusButton); + await user.click(screen.getByRole('checkbox', { name: 'tag1' })); + await user.keyboard('{Escape}'); + + expect(screen.queryByText('tag1')).not.toBeInTheDocument(); + expect(screen.getByText('tag3')).toBeInTheDocument(); +}); + +function renderTags(overrides: Partial> = {}) { + render(); +} + +function Wrapper(overrides: Partial> = {}) { + const [selectedTags, setSelectedTags] = useState(overrides.tags ?? ['tag1']); + + const overlay = ( + { + setSelectedTags([...selectedTags, tag]); + }} + onUnselect={(tag) => { + const i = selectedTags.indexOf(tag); + if (i > -1) { + setSelectedTags([...selectedTags.slice(0, i), ...selectedTags.slice(i + 1)]); + } + }} + searchInputAriaLabel="search" + selectedTags={selectedTags} + tags={['tag1', 'tag2', 'tag3']} + /> + ); + + return ( + + ); +} diff --git a/server/sonar-web/design-system/src/components/buttons.tsx b/server/sonar-web/design-system/src/components/buttons.tsx index 13304f9215c..18030488b64 100644 --- a/server/sonar-web/design-system/src/components/buttons.tsx +++ b/server/sonar-web/design-system/src/components/buttons.tsx @@ -190,6 +190,14 @@ export const DangerButtonSecondary: React.FC = styled(Button)` --border: ${themeBorder('default', 'dangerButtonSecondaryBorder')}; `; +export const WrapperButton: React.FC = styled(Button)` + --background: none; + --backgroundHover: none; + --color: none; + --focus: ${themeColor('button', OPACITY_20_PERCENT)}; + --border: none; +`; + interface ThirdPartyProps extends Omit { iconPath: string; name: string; diff --git a/server/sonar-web/design-system/src/components/index.ts b/server/sonar-web/design-system/src/components/index.ts index 3a244823b8f..a4af02dcab0 100644 --- a/server/sonar-web/design-system/src/components/index.ts +++ b/server/sonar-web/design-system/src/components/index.ts @@ -51,6 +51,8 @@ export * from './SelectionCard'; export * from './Separator'; export * from './SizeIndicator'; export * from './SonarQubeLogo'; +export * from './Tags'; +export * from './TagsSelector'; export * from './Text'; export { ToggleButton } from './ToggleButton'; export { TopBar } from './TopBar';