diff options
author | Grégoire Aubert <gregaubert@users.noreply.github.com> | 2017-03-22 13:40:13 +0100 |
---|---|---|
committer | GitHub <noreply@github.com> | 2017-03-22 13:40:13 +0100 |
commit | 685a373cc4a9028fbdd09ec2765074e8ef72e408 (patch) | |
tree | 4081f070b8e704f80626740cd1650a5eb97efea0 /server/sonar-web/src/main/js | |
parent | 926e6e3a8a76efd342b51c511426af6e4a15b765 (diff) | |
download | sonarqube-685a373cc4a9028fbdd09ec2765074e8ef72e408.tar.gz sonarqube-685a373cc4a9028fbdd09ec2765074e8ef72e408.zip |
SONAR-8844 Add tags editor on the project homepage (#1821)
Diffstat (limited to 'server/sonar-web/src/main/js')
29 files changed, 1361 insertions, 37 deletions
diff --git a/server/sonar-web/src/main/js/api/components.js b/server/sonar-web/src/main/js/api/components.js index 75fa13cb001..512f3a1f7ef 100644 --- a/server/sonar-web/src/main/js/api/components.js +++ b/server/sonar-web/src/main/js/api/components.js @@ -65,7 +65,7 @@ export function searchProjectTags(data?: { ps?: number, q?: string }) { export function setProjectTags(data: { project: string, tags: string }) { const url = '/api/project_tags/set'; - return postJSON(url, data); + return post(url, data); } export function getComponentTree( diff --git a/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js b/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js index eefe20f3438..c19ef680cc6 100644 --- a/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js +++ b/server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js @@ -90,16 +90,16 @@ export default class OverviewApp extends React.Component { componentDidMount() { this.mounted = true; document.querySelector('html').classList.add('dashboard-page'); - this.loadMeasures(this.props.component).then(() => this.loadHistory(this.props.component)); + this.loadMeasures(this.props.component.key).then(() => this.loadHistory(this.props.component)); } shouldComponentUpdate(nextProps, nextState) { return shallowCompare(this, nextProps, nextState); } - componentDidUpdate(nextProps) { - if (this.props.component !== nextProps.component) { - this.loadMeasures(nextProps.component).then(() => this.loadHistory(nextProps.component)); + componentDidUpdate(prevProps) { + if (this.props.component.key !== prevProps.component.key) { + this.loadMeasures(this.props.component.key).then(() => this.loadHistory(this.props.component)); } } @@ -108,10 +108,10 @@ export default class OverviewApp extends React.Component { document.querySelector('html').classList.remove('dashboard-page'); } - loadMeasures(component) { + loadMeasures(componentKey) { this.setState({ loading: true }); - return getMeasuresAndMeta(component.key, METRICS, { + return getMeasuresAndMeta(componentKey, METRICS, { additionalFields: 'metrics,periods' }).then(r => { if (this.mounted) { diff --git a/server/sonar-web/src/main/js/apps/overview/meta/Meta.js b/server/sonar-web/src/main/js/apps/overview/meta/Meta.js index c5dfaa64ecb..3fdaa3bcdcc 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/Meta.js +++ b/server/sonar-web/src/main/js/apps/overview/meta/Meta.js @@ -26,9 +26,8 @@ import MetaQualityGate from './MetaQualityGate'; import MetaQualityProfiles from './MetaQualityProfiles'; import AnalysesList from '../events/AnalysesList'; import MetaSize from './MetaSize'; -import TagsList from '../../../components/ui/TagsList'; +import MetaTags from './MetaTags'; import { areThereCustomOrganizations } from '../../../store/rootReducer'; -import { translate } from '../../../helpers/l10n'; const Meta = ({ component, measures, areThereCustomOrganizations }) => { const { qualifier, description, qualityProfiles, qualityGate } = component; @@ -45,8 +44,6 @@ const Meta = ({ component, measures, areThereCustomOrganizations }) => { const shouldShowQualityGate = !isView && !isDeveloper && hasQualityGate; const shouldShowOrganizationKey = component.organization != null && areThereCustomOrganizations; - const configuration = component.configuration || {}; - return ( <div className="overview-meta"> {hasDescription && @@ -56,13 +53,7 @@ const Meta = ({ component, measures, areThereCustomOrganizations }) => { <MetaSize component={component} measures={measures} /> - <div className="overview-meta-card"> - <TagsList - tags={component.tags.length ? component.tags : [translate('no_tags')]} - allowUpdate={configuration.showSettings} - allowMultiLine={true} - /> - </div> + <MetaTags component={component} /> {shouldShowQualityGate && <MetaQualityGate gate={qualityGate} />} diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js b/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js new file mode 100644 index 00000000000..04c6650341f --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js @@ -0,0 +1,140 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +//@flow +import React from 'react'; +import { translate } from '../../../helpers/l10n'; +import TagsList from '../../../components/tags/TagsList'; +import ProjectTagsSelectorContainer from '../../projects/components/ProjectTagsSelectorContainer'; + +type Props = { + component: { + key: string, + tags: Array<string>, + configuration?: { + showSettings?: boolean + } + } +}; + +type State = { + popupOpen: boolean, + popupPosition: { top: number, right: number } +}; + +export default class MetaTags extends React.PureComponent { + card: HTMLElement; + tagsList: HTMLElement; + tagsSelector: HTMLElement; + props: Props; + state: State = { + popupOpen: false, + popupPosition: { + top: 0, + right: 0 + } + }; + + componentDidMount() { + if (this.canUpdateTags()) { + const buttonPos = this.tagsList.getBoundingClientRect(); + const cardPos = this.card.getBoundingClientRect(); + this.setState({ popupPosition: this.getPopupPos(buttonPos, cardPos) }); + + window.addEventListener('keydown', this.handleKey, false); + window.addEventListener('click', this.handleOutsideClick, false); + } + } + + componentWillUnmount() { + window.removeEventListener('keydown', this.handleKey); + window.removeEventListener('click', this.handleOutsideClick); + } + + handleKey = (evt: KeyboardEvent) => { + // Escape key + if (evt.keyCode === 27) { + this.setState({ popupOpen: false }); + } + }; + + handleOutsideClick = (evt: SyntheticInputEvent) => { + if (!this.tagsSelector || !this.tagsSelector.contains(evt.target)) { + this.setState({ popupOpen: false }); + } + }; + + handleClick = (evt: MouseEvent) => { + evt.stopPropagation(); + this.setState(state => ({ popupOpen: !state.popupOpen })); + }; + + canUpdateTags() { + const { configuration } = this.props.component; + return configuration && configuration.showSettings; + } + + getPopupPos(eltPos: { height: number, width: number }, containerPos: { width: number }) { + return { + top: eltPos.height, + right: containerPos.width - eltPos.width + }; + } + + render() { + const { tags, key } = this.props.component; + const { popupOpen, popupPosition } = this.state; + + if (this.canUpdateTags()) { + return ( + <div className="overview-meta-card overview-meta-tags" ref={card => this.card = card}> + <button + className="button-link" + onClick={this.handleClick} + ref={tagsList => this.tagsList = tagsList} + > + <TagsList + tags={tags.length ? tags : [translate('no_tags')]} + allowUpdate={true} + allowMultiLine={true} + /> + </button> + {popupOpen && + <div ref={tagsSelector => this.tagsSelector = tagsSelector}> + <ProjectTagsSelectorContainer + position={popupPosition} + project={key} + selectedTags={tags} + /> + </div>} + </div> + ); + } else { + return ( + <div className="overview-meta-card overview-meta-tags"> + <TagsList + tags={tags.length ? tags : [translate('no_tags')]} + allowUpdate={false} + allowMultiLine={true} + /> + </div> + ); + } + } +} diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js new file mode 100644 index 00000000000..eaf3dc5b669 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 React from 'react'; +import { shallow } from 'enzyme'; +import { click } from '../../../../helpers/testUtils'; +import MetaTags from '../MetaTags'; + +const component = { + key: 'my-project', + tags: [], + configuration: { + showSettings: false + } +}; + +const componentWithTags = { + key: 'my-second-project', + tags: ['foo', 'bar'], + configuration: { + showSettings: true + } +}; + +it('should render without tags and admin rights', () => { + expect(shallow(<MetaTags component={component} />)).toMatchSnapshot(); +}); + +it('should render with tags and admin rights', () => { + expect(shallow(<MetaTags component={componentWithTags} />)).toMatchSnapshot(); +}); + + +it('should open the tag selector on click', () => { + const wrapper = shallow(<MetaTags component={componentWithTags} />); + expect(wrapper).toMatchSnapshot(); + + // open + click(wrapper.find('button')); + expect(wrapper).toMatchSnapshot(); + + // close + click(wrapper.find('button')); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap new file mode 100644 index 00000000000..fdf142c3fc8 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap @@ -0,0 +1,105 @@ +exports[`test should open the tag selector on click 1`] = ` +<div + className="overview-meta-card overview-meta-tags"> + <button + className="button-link" + onClick={[Function]}> + <TagsList + allowMultiLine={true} + allowUpdate={true} + tags={ + Array [ + "foo", + "bar", + ] + } /> + </button> +</div> +`; + +exports[`test should open the tag selector on click 2`] = ` +<div + className="overview-meta-card overview-meta-tags"> + <button + className="button-link" + onClick={[Function]}> + <TagsList + allowMultiLine={true} + allowUpdate={true} + tags={ + Array [ + "foo", + "bar", + ] + } /> + </button> + <div> + <Connect(ProjectTagsSelectorContainer) + position={ + Object { + "right": 0, + "top": 0, + } + } + project="my-second-project" + selectedTags={ + Array [ + "foo", + "bar", + ] + } /> + </div> +</div> +`; + +exports[`test should open the tag selector on click 3`] = ` +<div + className="overview-meta-card overview-meta-tags"> + <button + className="button-link" + onClick={[Function]}> + <TagsList + allowMultiLine={true} + allowUpdate={true} + tags={ + Array [ + "foo", + "bar", + ] + } /> + </button> +</div> +`; + +exports[`test should render with tags and admin rights 1`] = ` +<div + className="overview-meta-card overview-meta-tags"> + <button + className="button-link" + onClick={[Function]}> + <TagsList + allowMultiLine={true} + allowUpdate={true} + tags={ + Array [ + "foo", + "bar", + ] + } /> + </button> +</div> +`; + +exports[`test should render without tags and admin rights 1`] = ` +<div + className="overview-meta-card overview-meta-tags"> + <TagsList + allowMultiLine={true} + allowUpdate={false} + tags={ + Array [ + "no_tags", + ] + } /> +</div> +`; diff --git a/server/sonar-web/src/main/js/apps/overview/styles.css b/server/sonar-web/src/main/js/apps/overview/styles.css index ffe231472b7..2db27b12117 100644 --- a/server/sonar-web/src/main/js/apps/overview/styles.css +++ b/server/sonar-web/src/main/js/apps/overview/styles.css @@ -313,6 +313,10 @@ white-space: nowrap; } +.overview-meta-tags { + position: relative; +} + .overview-meta-size-ncloc { display: inline-block; vertical-align: middle; diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js b/server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js index 6f3691c943d..537bdafbc63 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js +++ b/server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js @@ -26,7 +26,7 @@ import ProjectCardQualityGate from './ProjectCardQualityGate'; import ProjectCardMeasures from './ProjectCardMeasures'; import FavoriteContainer from '../../../components/controls/FavoriteContainer'; import Organization from '../../../components/shared/Organization'; -import TagsList from '../../../components/ui/TagsList'; +import TagsList from '../../../components/tags/TagsList'; import { translate, translateWithParameters } from '../../../helpers/l10n'; export default class ProjectCard extends React.PureComponent { @@ -78,7 +78,7 @@ export default class ProjectCard extends React.PureComponent { {project.name} </Link> </h2> - {project.tags.length > 0 && <TagsList tags={project.tags} />} + {project.tags.length > 0 && <TagsList tags={project.tags} customClass="spacer-left" />} </div> {isProjectAnalyzed diff --git a/server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js b/server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js new file mode 100644 index 00000000000..537bc3d4a8e --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js @@ -0,0 +1,89 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +//@flow +import React from 'react'; +import { connect } from 'react-redux'; +import debounce from 'lodash/debounce'; +import without from 'lodash/without'; +import TagsSelector from '../../../components/tags/TagsSelector'; +import { searchProjectTags } from '../../../api/components'; +import { setProjectTags } from '../store/actions'; + +type Props = { + open: boolean, + position: {}, + project: string, + selectedTags: Array<string>, + setProjectTags: (string, Array<string>) => void +}; + +type State = { + searchResult: Array<string> +}; + +const PAGE_SIZE = 20; + +class ProjectTagsSelectorContainer extends React.PureComponent { + props: Props; + state: State = { + searchResult: [] + }; + + constructor(props: Props) { + super(props); + this.onSearch = debounce(this.onSearch, 250); + } + + componentDidMount() { + this.onSearch(''); + } + + onSearch = (query: string) => { + searchProjectTags({ q: query || '', ps: PAGE_SIZE }).then(result => { + this.setState({ + searchResult: result.tags + }); + }); + }; + + onSelect = (tag: string) => { + this.props.setProjectTags(this.props.project, [...this.props.selectedTags, tag]); + }; + + onUnselect = (tag: string) => { + this.props.setProjectTags(this.props.project, without(this.props.selectedTags, tag)); + }; + + render() { + return ( + <TagsSelector + open={this.props.open} + position={this.props.position} + tags={this.state.searchResult} + selectedTags={this.props.selectedTags} + onSearch={this.onSearch} + onSelect={this.onSelect} + onUnselect={this.onUnselect} + /> + ); + } +} + +export default connect(null, { setProjectTags })(ProjectTagsSelectorContainer); diff --git a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap index b53a20d538b..8eda732fdbb 100644 --- a/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap +++ b/server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap @@ -62,6 +62,7 @@ exports[`test should display tags 1`] = ` <TagsList allowMultiLine={false} allowUpdate={false} + customClass="spacer-left" tags={ Array [ "foo", diff --git a/server/sonar-web/src/main/js/apps/projects/store/actions.js b/server/sonar-web/src/main/js/apps/projects/store/actions.js index 0bac164f8c2..dd1bb987851 100644 --- a/server/sonar-web/src/main/js/apps/projects/store/actions.js +++ b/server/sonar-web/src/main/js/apps/projects/store/actions.js @@ -19,13 +19,13 @@ */ import groupBy from 'lodash/groupBy'; import uniq from 'lodash/uniq'; -import { searchProjects } from '../../../api/components'; +import { searchProjects, setProjectTags as apiSetProjectTags } from '../../../api/components'; import { addGlobalErrorMessage } from '../../../store/globalMessages/duck'; import { parseError } from '../../code/utils'; -import { receiveComponents } from '../../../store/components/actions'; +import { receiveComponents, receiveProjectTags } from '../../../store/components/actions'; import { receiveProjects, receiveMoreProjects } from './projectsDuck'; import { updateState } from './stateDuck'; -import { getProjectsAppState } from '../../../store/rootReducer'; +import { getProjectsAppState, getComponent } from '../../../store/rootReducer'; import { getMeasuresForProjects } from '../../../api/measures'; import { receiveComponentsMeasures } from '../../../store/measures/actions'; import { convertToQueryData } from './utils'; @@ -180,3 +180,16 @@ export const fetchMoreProjects = (query, isFavorite, organization) => }); return searchProjects(data).then(onReceiveMoreProjects(dispatch), onFail(dispatch)); }; + +export const setProjectTags = (project, tags) => + (dispatch, getState) => { + const previousTags = getComponent(getState(), project).tags; + dispatch(receiveProjectTags(project, tags)); + return apiSetProjectTags({ project, tags: tags.join(',') }).then( + null, + error => { + dispatch(receiveProjectTags(project, previousTags)); + onFail(dispatch)(error); + } + ); + }; diff --git a/server/sonar-web/src/main/js/components/common/BubblePopup.js b/server/sonar-web/src/main/js/components/common/BubblePopup.js new file mode 100644 index 00000000000..b29ee147dab --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/BubblePopup.js @@ -0,0 +1,45 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 React from 'react'; +import classNames from 'classnames'; + +export default class BubblePopup extends React.PureComponent { + static propsType = { + children: React.PropTypes.object.isRequired, + position: React.PropTypes.object.isRequired, + customClass: React.PropTypes.string + }; + + static defaultProps = { + customClass: '' + }; + + render() { + const popupClass = classNames('bubble-popup', this.props.customClass); + const popupStyle = { ...this.props.position }; + + return ( + <div className={popupClass} style={popupStyle}> + {this.props.children} + <div className="bubble-popup-arrow" /> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/common/MultiSelect.js b/server/sonar-web/src/main/js/components/common/MultiSelect.js new file mode 100644 index 00000000000..ec8adb3ced9 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/MultiSelect.js @@ -0,0 +1,256 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +// @flow +import React from 'react'; +import difference from 'lodash/difference'; +import MultiSelectOption from './MultiSelectOption'; +import { translate } from '../../helpers/l10n'; + +type Props = { + selectedElements: Array<string>, + elements: Array<string>, + onSearch: (string) => void, + onSelect: (string) => void, + onUnselect: (string) => void, + validateSearchInput: (string) => string +}; + +type State = { + query: string, + selectedElements: Array<string>, + unselectedElements: Array<string>, + activeIdx: number +}; + +export default class MultiSelect extends React.PureComponent { + container: HTMLElement; + searchInput: HTMLInputElement; + props: Props; + state: State = { + query: '', + selectedElements: [], + unselectedElements: [], + activeIdx: 0 + }; + + static defaultProps = { + validateSearchInput: (value: string) => value + }; + + componentDidMount() { + this.updateSelectedElements(this.props); + this.updateUnselectedElements(this.props); + this.container.addEventListener('keydown', this.handleKeyboard, true); + } + + componentWillReceiveProps(nextProps: Props) { + if ( + this.props.elements !== nextProps.elements || + this.props.selectedElements !== nextProps.selectedElements + ) { + this.updateSelectedElements(nextProps); + this.updateUnselectedElements(nextProps); + + const totalElements = this.getAllElements(nextProps, this.state).length; + if (this.state.activeIdx >= totalElements) { + this.setState({ activeIdx: totalElements - 1 }); + } + } + } + + componentDidUpdate() { + this.searchInput && this.searchInput.focus(); + } + + componentWillUnmount() { + this.container.removeEventListener('keydown', this.handleKeyboard); + } + + handleSelectChange = (item: string, selected: boolean) => { + if (selected) { + this.onSelectItem(item); + } else { + this.onUnselectItem(item); + } + }; + + handleSearchChange = ({ target }: { target: HTMLInputElement }) => { + this.onSearchQuery(this.props.validateSearchInput(target.value)); + }; + + handleElementHover = (element: string) => { + this.setState((prevState, props) => { + return { activeIdx: this.getAllElements(props, prevState).indexOf(element) }; + }); + }; + + handleKeyboard = (evt: KeyboardEvent) => { + switch (evt.keyCode) { + case 40: // down + this.setState(this.selectNextElement); + evt.preventDefault(); + break; + case 38: // up + this.setState(this.selectPreviousElement); + evt.preventDefault(); + break; + case 13: // return + if (this.state.activeIdx >= 0) { + this.toggleSelect(this.getAllElements(this.props, this.state)[this.state.activeIdx]); + } + break; + } + }; + + onSearchQuery(query: string) { + this.setState({ query, activeIdx: 0 }); + this.props.onSearch(query); + } + + 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) { + return elem && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1; + } + + updateSelectedElements(props: Props) { + this.setState((state: State) => { + if (state.query) { + return { + selectedElements: [...props.selectedElements.filter(elem => elem.includes(state.query))] + }; + } else { + return { selectedElements: [...props.selectedElements] }; + } + }); + } + + updateUnselectedElements(props: Props) { + this.setState({ + unselectedElements: difference(props.elements, props.selectedElements) + }); + } + + getAllElements(props: Props, state: State) { + if (this.isNewElement(state.query, props)) { + return [...state.selectedElements, ...state.unselectedElements, state.query]; + } else { + return [...state.selectedElements, ...state.unselectedElements]; + } + } + + setElementActive(idx: number) { + this.setState({ activeIdx: idx }); + } + + selectNextElement = (state: State, props: Props) => { + const { activeIdx } = state; + const allElements = this.getAllElements(props, state); + if (activeIdx < 0 || activeIdx >= allElements.length - 1) { + return { activeIdx: 0 }; + } else { + 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 }; + } else { + return { activeIdx: activeIdx - 1 }; + } + }; + + toggleSelect(item: string) { + if (this.props.selectedElements.indexOf(item) === -1) { + this.onSelectItem(item); + } else { + this.onUnselectItem(item); + } + } + + render() { + const { query, activeIdx, selectedElements, unselectedElements } = this.state; + const activeElement = this.getAllElements(this.props, this.state)[activeIdx]; + + return ( + <div className="multi-select" ref={div => this.container = div}> + <div className="search-box menu-search"> + <button className="search-box-submit button-clean"> + <i className="icon-search-new" /> + </button> + <input + type="search" + value={query} + className="search-box-input" + placeholder={translate('search_verb')} + onChange={this.handleSearchChange} + autoComplete="off" + ref={input => this.searchInput = input} + /> + </div> + <ul className="menu"> + {selectedElements.length > 0 && + selectedElements.map(element => ( + <MultiSelectOption + key={element} + element={element} + selected={true} + active={activeElement === element} + onSelectChange={this.handleSelectChange} + onHover={this.handleElementHover} + /> + ))} + {unselectedElements.length > 0 && + unselectedElements.map(element => ( + <MultiSelectOption + key={element} + element={element} + active={activeElement === element} + onSelectChange={this.handleSelectChange} + onHover={this.handleElementHover} + /> + ))} + {this.isNewElement(query, this.props) && + <MultiSelectOption + key={query} + element={query} + custom={true} + active={activeElement === query} + onSelectChange={this.handleSelectChange} + onHover={this.handleElementHover} + />} + </ul> + </div> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/common/MultiSelectOption.js b/server/sonar-web/src/main/js/components/common/MultiSelectOption.js new file mode 100644 index 00000000000..fdd910ded06 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/MultiSelectOption.js @@ -0,0 +1,72 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +// @flow +import React from 'react'; +import classNames from 'classnames'; + +type Props = { + element: string, + selected: boolean, + custom: boolean, + active: boolean, + onSelectChange: (string, boolean) => void, + onHover: (string) => void +}; + +export default class MultiSelectOption extends React.PureComponent { + props: Props; + + static defaultProps = { + selected: false, + custom: false, + active: false + }; + + handleSelect = (evt: SyntheticInputEvent) => { + evt.stopPropagation(); + evt.target.blur(); + this.props.onSelectChange(this.props.element, !this.props.selected); + }; + + handleHover = () => { + this.props.onHover(this.props.element); + }; + + render() { + const className = classNames('icon-checkbox', { + 'icon-checkbox-checked': this.props.selected + }); + const activeClass = classNames({ active: this.props.active }); + + return ( + <li> + <a + href="#" + className={activeClass} + onClick={this.handleSelect} + onMouseOver={this.handleHover} + onFocus={this.handleHover} + > + <i className={className} />{' '}{this.props.custom && '+ '}{this.props.element} + </a> + </li> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js b/server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js new file mode 100644 index 00000000000..add930864e5 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js @@ -0,0 +1,32 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 React from 'react'; +import BubblePopup from '../BubblePopup'; + +const props = { + position: { top: 0, right: 0 }, + customClass: 'custom' +}; + +it('should render popup', () => { + const popup = shallow(<BubblePopup {...props}><span>test</span></BubblePopup>); + expect(popup).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js new file mode 100644 index 00000000000..1a52e06f0c3 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js @@ -0,0 +1,51 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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, mount } from 'enzyme'; +import React from 'react'; +import MultiSelect from '../MultiSelect'; + +const props = { + selectedElements: ['bar'], + elements: [], + onSearch: () => {}, + onSelect: () => {}, + onUnselect: () => {} +}; + +const elements = ['foo', 'bar', 'baz']; + +it('should render multiselect with selected elements', () => { + const multiselect = shallow(<MultiSelect {...props} />); + // Will not have any element in the list since its filled with componentDidMount the first time + expect(multiselect).toMatchSnapshot(); + + // Will have some elements + multiselect.setProps({ elements }); + expect(multiselect).toMatchSnapshot(); + multiselect.setState({ activeIdx: 2 }); + expect(multiselect).toMatchSnapshot(); + multiselect.setState({ query: 'test' }); + expect(multiselect).toMatchSnapshot(); +}); + +it('should render with the focus inside the search input', () => { + const multiselect = mount(<MultiSelect {...props} />); + expect(multiselect.find('input').node).toBe(document.activeElement); +}); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js new file mode 100644 index 00000000000..5e8719e10a7 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js @@ -0,0 +1,47 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 React from 'react'; +import MultiSelectOption from '../MultiSelectOption'; + +const props = { + element: 'mytag', + selected: false, + custom: false, + active: false, + onSelectChange: () => {}, + onHover: () => {} +}; + +it('should render standard tag', () => { + expect(shallow(<MultiSelectOption {...props} />)).toMatchSnapshot(); +}); + +it('should render selected tag', () => { + expect(shallow(<MultiSelectOption {...props} selected={true} />)).toMatchSnapshot(); +}); + +it('should render custom tag', () => { + expect(shallow(<MultiSelectOption {...props} custom={true} />)).toMatchSnapshot(); +}); + +it('should render active tag', () => { + expect(shallow(<MultiSelectOption {...props} selected={true} active={true} />)).toMatchSnapshot(); +}); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap new file mode 100644 index 00000000000..edb3882f441 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap @@ -0,0 +1,16 @@ +exports[`test should render popup 1`] = ` +<div + className="bubble-popup custom" + style={ + Object { + "right": 0, + "top": 0, + } + }> + <span> + test + </span> + <div + className="bubble-popup-arrow" /> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap new file mode 100644 index 00000000000..69d1756aac7 --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap @@ -0,0 +1,164 @@ +exports[`test should render multiselect with selected elements 1`] = ` +<div + className="multi-select"> + <div + className="search-box menu-search"> + <button + className="search-box-submit button-clean"> + <i + className="icon-search-new" /> + </button> + <input + autoComplete="off" + className="search-box-input" + onChange={[Function]} + placeholder="search_verb" + type="search" + value="" /> + </div> + <ul + className="menu" /> +</div> +`; + +exports[`test should render multiselect with selected elements 2`] = ` +<div + className="multi-select"> + <div + className="search-box menu-search"> + <button + className="search-box-submit button-clean"> + <i + className="icon-search-new" /> + </button> + <input + autoComplete="off" + className="search-box-input" + onChange={[Function]} + placeholder="search_verb" + type="search" + value="" /> + </div> + <ul + className="menu"> + <MultiSelectOption + active={false} + custom={false} + element="bar" + onHover={[Function]} + onSelectChange={[Function]} + selected={true} /> + <MultiSelectOption + active={false} + custom={false} + element="foo" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + <MultiSelectOption + active={false} + custom={false} + element="baz" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + </ul> +</div> +`; + +exports[`test should render multiselect with selected elements 3`] = ` +<div + className="multi-select"> + <div + className="search-box menu-search"> + <button + className="search-box-submit button-clean"> + <i + className="icon-search-new" /> + </button> + <input + autoComplete="off" + className="search-box-input" + onChange={[Function]} + placeholder="search_verb" + type="search" + value="" /> + </div> + <ul + className="menu"> + <MultiSelectOption + active={false} + custom={false} + element="bar" + onHover={[Function]} + onSelectChange={[Function]} + selected={true} /> + <MultiSelectOption + active={false} + custom={false} + element="foo" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + <MultiSelectOption + active={true} + custom={false} + element="baz" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + </ul> +</div> +`; + +exports[`test should render multiselect with selected elements 4`] = ` +<div + className="multi-select"> + <div + className="search-box menu-search"> + <button + className="search-box-submit button-clean"> + <i + className="icon-search-new" /> + </button> + <input + autoComplete="off" + className="search-box-input" + onChange={[Function]} + placeholder="search_verb" + type="search" + value="test" /> + </div> + <ul + className="menu"> + <MultiSelectOption + active={false} + custom={false} + element="bar" + onHover={[Function]} + onSelectChange={[Function]} + selected={true} /> + <MultiSelectOption + active={false} + custom={false} + element="foo" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + <MultiSelectOption + active={true} + custom={false} + element="baz" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + <MultiSelectOption + active={false} + custom={true} + element="test" + onHover={[Function]} + onSelectChange={[Function]} + selected={false} /> + </ul> +</div> +`; diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap new file mode 100644 index 00000000000..600bf81f86f --- /dev/null +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap @@ -0,0 +1,64 @@ +exports[`test should render active tag 1`] = ` +<li> + <a + className="active" + href="#" + onClick={[Function]} + onFocus={[Function]} + onMouseOver={[Function]}> + <i + className="icon-checkbox icon-checkbox-checked" /> + + mytag + </a> +</li> +`; + +exports[`test should render custom tag 1`] = ` +<li> + <a + className="" + href="#" + onClick={[Function]} + onFocus={[Function]} + onMouseOver={[Function]}> + <i + className="icon-checkbox" /> + + + + mytag + </a> +</li> +`; + +exports[`test should render selected tag 1`] = ` +<li> + <a + className="" + href="#" + onClick={[Function]} + onFocus={[Function]} + onMouseOver={[Function]}> + <i + className="icon-checkbox icon-checkbox-checked" /> + + mytag + </a> +</li> +`; + +exports[`test should render standard tag 1`] = ` +<li> + <a + className="" + href="#" + onClick={[Function]} + onFocus={[Function]} + onMouseOver={[Function]}> + <i + className="icon-checkbox" /> + + mytag + </a> +</li> +`; diff --git a/server/sonar-web/src/main/js/components/ui/TagsList.css b/server/sonar-web/src/main/js/components/tags/TagsList.css index bb48ef2bf21..5963611a683 100644 --- a/server/sonar-web/src/main/js/components/ui/TagsList.css +++ b/server/sonar-web/src/main/js/components/tags/TagsList.css @@ -1,20 +1,17 @@ -.tags-list { - padding-left: 6px; -} - -.tags-list i { - padding-left: 4px; -} - .tags-list i::before { font-size: 12px; } +.tags-list i.icon-dropdown::before { + top: 1px; +} + .tags-list span { display: inline-block; vertical-align: text-top; + text-align: left; max-width: 220px; padding-left: 4px; + padding-right: 4px; margin-top: 2px; - opacity: 0.6; } diff --git a/server/sonar-web/src/main/js/components/ui/TagsList.js b/server/sonar-web/src/main/js/components/tags/TagsList.js index 6f568df0357..6f5e9d29029 100644 --- a/server/sonar-web/src/main/js/components/ui/TagsList.js +++ b/server/sonar-web/src/main/js/components/tags/TagsList.js @@ -25,7 +25,8 @@ import './TagsList.css'; type Props = { tags: Array<string>, allowUpdate: boolean, - allowMultiLine: boolean + allowMultiLine: boolean, + customClass?: string }; export default class TagsList extends React.PureComponent { @@ -38,12 +39,14 @@ export default class TagsList extends React.PureComponent { render() { const { tags, allowUpdate } = this.props; - const spanClass = classNames('note', { + const spanClass = classNames({ + note: !allowUpdate, 'text-ellipsis': !this.props.allowMultiLine }); + const tagListClass = classNames('tags-list', this.props.customClass); return ( - <span className="tags-list" title={tags.join(', ')}> + <span className={tagListClass} title={tags.join(', ')}> <i className="icon-tags icon-half-transparent" /> <span className={spanClass}>{tags.join(', ')}</span> {allowUpdate && <i className="icon-dropdown icon-half-transparent" />} diff --git a/server/sonar-web/src/main/js/components/tags/TagsSelector.js b/server/sonar-web/src/main/js/components/tags/TagsSelector.js new file mode 100644 index 00000000000..f43e091981e --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/TagsSelector.js @@ -0,0 +1,61 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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. + */ +// @flow +import React from 'react'; +import BubblePopup from '../common/BubblePopup'; +import MultiSelect from '../common/MultiSelect'; +import './TagsList.css'; + +type Props = { + position: {}, + tags: Array<string>, + selectedTags: Array<string>, + onSearch: (string) => void, + onSelect: (string) => void, + onUnselect: (string) => void +}; + +export default class TagsSelector extends React.PureComponent { + validateTag: (string) => string; + props: Props; + + validateTag(value: string) { + // Allow only a-z, 0-9, '+', '-', '#', '.' + return value.toLowerCase().replace(/[^a-z0-9\+\-#.]/gi, ''); + } + + render() { + return ( + <BubblePopup + position={this.props.position} + customClass="bubble-popup-bottom-right bubble-popup-menu" + > + <MultiSelect + elements={this.props.tags} + selectedElements={this.props.selectedTags} + onSearch={this.props.onSearch} + onSelect={this.props.onSelect} + onUnselect={this.props.onUnselect} + validateSearchInput={this.validateTag} + /> + </BubblePopup> + ); + } +} diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js b/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js index 9eec4be8ed2..9eec4be8ed2 100644 --- a/server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js new file mode 100644 index 00000000000..6ef4a86dd89 --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js @@ -0,0 +1,50 @@ +/* + * SonarQube + * Copyright (C) 2009-2017 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 React from 'react'; +import TagsSelector from '../TagsSelector'; + +const props = { + position: { left: 0, top: 0 }, + tags: ['foo', 'bar', 'baz'], + selectedTags: ['bar'], + onSearch: () => {}, + onSelect: () => {}, + onUnselect: () => {} +}; + +it('should render with selected tags', () => { + const tagsSelector = shallow(<TagsSelector {...props} />); + expect(tagsSelector).toMatchSnapshot(); +}); + +it('should render without tags at all', () => { + expect(shallow(<TagsSelector {...props} tags={[]} selectedTags={[]} />)).toMatchSnapshot(); +}); + +it('should validate tags correctly', () => { + const validChars = 'abcdefghijklmnopqrstuvwxyz0123456789+-#.'; + const tagsSelector = shallow(<TagsSelector {...props} />).instance(); + expect(tagsSelector.validateTag('test')).toBe('test'); + expect(tagsSelector.validateTag(validChars)).toBe(validChars); + expect(tagsSelector.validateTag(validChars.toUpperCase())).toBe(validChars); + expect(tagsSelector.validateTag('T E$ST')).toBe('test'); + expect(tagsSelector.validateTag('T E$st!^àéèing1')).toBe('testing1'); +}); diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap new file mode 100644 index 00000000000..56264e5b0be --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap @@ -0,0 +1,47 @@ +exports[`test should render with selected tags 1`] = ` +<BubblePopup + customClass="bubble-popup-bottom-right bubble-popup-menu" + position={ + Object { + "left": 0, + "top": 0, + } + }> + <MultiSelect + elements={ + Array [ + "foo", + "bar", + "baz", + ] + } + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + selectedElements={ + Array [ + "bar", + ] + } + validateSearchInput={[Function]} /> +</BubblePopup> +`; + +exports[`test should render without tags at all 1`] = ` +<BubblePopup + customClass="bubble-popup-bottom-right bubble-popup-menu" + position={ + Object { + "left": 0, + "top": 0, + } + }> + <MultiSelect + elements={Array []} + onSearch={[Function]} + onSelect={[Function]} + onUnselect={[Function]} + selectedElements={Array []} + validateSearchInput={[Function]} /> +</BubblePopup> +`; diff --git a/server/sonar-web/src/main/js/helpers/testUtils.js b/server/sonar-web/src/main/js/helpers/testUtils.js index 6b6da781fb9..358df804d4f 100644 --- a/server/sonar-web/src/main/js/helpers/testUtils.js +++ b/server/sonar-web/src/main/js/helpers/testUtils.js @@ -21,7 +21,8 @@ export const click = element => { return element.simulate('click', { target: { blur() {} }, currentTarget: { blur() {} }, - preventDefault() {} + preventDefault() {}, + stopPropagation() {} }); }; diff --git a/server/sonar-web/src/main/js/store/components/actions.js b/server/sonar-web/src/main/js/store/components/actions.js index e59b2a8fa07..873640f7092 100644 --- a/server/sonar-web/src/main/js/store/components/actions.js +++ b/server/sonar-web/src/main/js/store/components/actions.js @@ -18,8 +18,15 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ export const RECEIVE_COMPONENTS = 'RECEIVE_COMPONENTS'; +export const RECEIVE_PROJECT_TAGS = 'RECEIVE_PROJECT_TAGS'; export const receiveComponents = components => ({ type: RECEIVE_COMPONENTS, components }); + +export const receiveProjectTags = (project, tags) => ({ + type: RECEIVE_PROJECT_TAGS, + project, + tags +}); diff --git a/server/sonar-web/src/main/js/store/components/reducer.js b/server/sonar-web/src/main/js/store/components/reducer.js index 473c288feb2..0f90ce90862 100644 --- a/server/sonar-web/src/main/js/store/components/reducer.js +++ b/server/sonar-web/src/main/js/store/components/reducer.js @@ -20,7 +20,7 @@ import { combineReducers } from 'redux'; import keyBy from 'lodash/keyBy'; import uniq from 'lodash/uniq'; -import { RECEIVE_COMPONENTS } from './actions'; +import { RECEIVE_COMPONENTS, RECEIVE_PROJECT_TAGS } from './actions'; const byKey = (state = {}, action = {}) => { if (action.type === RECEIVE_COMPONENTS) { @@ -28,6 +28,13 @@ const byKey = (state = {}, action = {}) => { return { ...state, ...changes }; } + if (action.type === RECEIVE_PROJECT_TAGS) { + const project = state[action.project]; + if (project) { + return { ...state, [action.project]: { ...project, tags: action.tags } }; + } + } + return state; }; |