From 685a373cc4a9028fbdd09ec2765074e8ef72e408 Mon Sep 17 00:00:00 2001 From: Grégoire Aubert Date: Wed, 22 Mar 2017 13:40:13 +0100 Subject: SONAR-8844 Add tags editor on the project homepage (#1821) --- server/sonar-web/src/main/js/api/components.js | 2 +- .../js/apps/overview/components/OverviewApp.js | 12 +- .../src/main/js/apps/overview/meta/Meta.js | 13 +- .../src/main/js/apps/overview/meta/MetaTags.js | 140 +++++++++++ .../apps/overview/meta/__tests__/MetaTags-test.js | 61 +++++ .../__tests__/__snapshots__/MetaTags-test.js.snap | 105 +++++++++ .../sonar-web/src/main/js/apps/overview/styles.css | 4 + .../js/apps/projects/components/ProjectCard.js | 4 +- .../components/ProjectTagsSelectorContainer.js | 89 +++++++ .../__snapshots__/ProjectCard-test.js.snap | 1 + .../src/main/js/apps/projects/store/actions.js | 19 +- .../src/main/js/components/common/BubblePopup.js | 45 ++++ .../src/main/js/components/common/MultiSelect.js | 256 +++++++++++++++++++++ .../main/js/components/common/MultiSelectOption.js | 72 ++++++ .../common/__tests__/BubblePopup-test.js | 32 +++ .../common/__tests__/MultiSelect-test.js | 51 ++++ .../common/__tests__/MultiSelectOption-test.js | 47 ++++ .../__snapshots__/BubblePopup-test.js.snap | 16 ++ .../__snapshots__/MultiSelect-test.js.snap | 164 +++++++++++++ .../__snapshots__/MultiSelectOption-test.js.snap | 64 ++++++ .../src/main/js/components/tags/TagsList.css | 17 ++ .../src/main/js/components/tags/TagsList.js | 56 +++++ .../src/main/js/components/tags/TagsSelector.js | 61 +++++ .../js/components/tags/__tests__/TagsList-test.js | 50 ++++ .../components/tags/__tests__/TagsSelector-test.js | 50 ++++ .../__snapshots__/TagsSelector-test.js.snap | 47 ++++ .../src/main/js/components/ui/TagsList.css | 20 -- .../src/main/js/components/ui/TagsList.js | 53 ----- .../js/components/ui/__tests__/TagsList-test.js | 50 ---- server/sonar-web/src/main/js/helpers/testUtils.js | 3 +- .../src/main/js/store/components/actions.js | 7 + .../src/main/js/store/components/reducer.js | 9 +- .../sonar-web/src/main/less/components/menu.less | 1 - .../sonar-web/src/main/less/components/search.less | 2 +- 34 files changed, 1473 insertions(+), 150 deletions(-) create mode 100644 server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js create mode 100644 server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js create mode 100644 server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap create mode 100644 server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js create mode 100644 server/sonar-web/src/main/js/components/common/BubblePopup.js create mode 100644 server/sonar-web/src/main/js/components/common/MultiSelect.js create mode 100644 server/sonar-web/src/main/js/components/common/MultiSelectOption.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap create mode 100644 server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap create mode 100644 server/sonar-web/src/main/js/components/tags/TagsList.css create mode 100644 server/sonar-web/src/main/js/components/tags/TagsList.js create mode 100644 server/sonar-web/src/main/js/components/tags/TagsSelector.js create mode 100644 server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js create mode 100644 server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js create mode 100644 server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap delete mode 100644 server/sonar-web/src/main/js/components/ui/TagsList.css delete mode 100644 server/sonar-web/src/main/js/components/ui/TagsList.js delete mode 100644 server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js (limited to 'server/sonar-web/src') 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 (
{hasDescription && @@ -56,13 +53,7 @@ const Meta = ({ component, measures, areThereCustomOrganizations }) => { -
- -
+ {shouldShowQualityGate && } 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, + 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 ( +
this.card = card}> + + {popupOpen && +
this.tagsSelector = tagsSelector}> + +
} +
+ ); + } else { + return ( +
+ +
+ ); + } + } +} 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()).toMatchSnapshot(); +}); + +it('should render with tags and admin rights', () => { + expect(shallow()).toMatchSnapshot(); +}); + + +it('should open the tag selector on click', () => { + const wrapper = shallow(); + 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`] = ` +
+ +
+`; + +exports[`test should open the tag selector on click 2`] = ` +
+ +
+ +
+
+`; + +exports[`test should open the tag selector on click 3`] = ` +
+ +
+`; + +exports[`test should render with tags and admin rights 1`] = ` +
+ +
+`; + +exports[`test should render without tags and admin rights 1`] = ` +
+ +
+`; 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} - {project.tags.length > 0 && } + {project.tags.length > 0 && }
{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, + setProjectTags: (string, Array) => void +}; + +type State = { + searchResult: Array +}; + +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 ( + + ); + } +} + +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`] = ` }); 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 ( +
+ {this.props.children} +
+
+ ); + } +} 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, + elements: Array, + onSearch: (string) => void, + onSelect: (string) => void, + onUnselect: (string) => void, + validateSearchInput: (string) => string +}; + +type State = { + query: string, + selectedElements: Array, + unselectedElements: Array, + 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 ( +
this.container = div}> +
+ + this.searchInput = input} + /> +
+
    + {selectedElements.length > 0 && + selectedElements.map(element => ( + + ))} + {unselectedElements.length > 0 && + unselectedElements.map(element => ( + + ))} + {this.isNewElement(query, this.props) && + } +
+
+ ); + } +} 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 ( +
  • + + {' '}{this.props.custom && '+ '}{this.props.element} + +
  • + ); + } +} 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(test); + 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(); + // 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(); + 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()).toMatchSnapshot(); +}); + +it('should render selected tag', () => { + expect(shallow()).toMatchSnapshot(); +}); + +it('should render custom tag', () => { + expect(shallow()).toMatchSnapshot(); +}); + +it('should render active tag', () => { + expect(shallow()).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`] = ` +
    + + test + +
    +
    +`; 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`] = ` +
    +
    + + +
    +
      +
    +`; + +exports[`test should render multiselect with selected elements 2`] = ` +
    +
    + + +
    +
      + + + +
    +
    +`; + +exports[`test should render multiselect with selected elements 3`] = ` +
    +
    + + +
    +
      + + + +
    +
    +`; + +exports[`test should render multiselect with selected elements 4`] = ` +
    +
    + + +
    +
      + + + + +
    +
    +`; 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`] = ` +
  • + + + + mytag + +
  • +`; + +exports[`test should render custom tag 1`] = ` +
  • + + + + + + mytag + +
  • +`; + +exports[`test should render selected tag 1`] = ` +
  • + + + + mytag + +
  • +`; + +exports[`test should render standard tag 1`] = ` +
  • + + + + mytag + +
  • +`; diff --git a/server/sonar-web/src/main/js/components/tags/TagsList.css b/server/sonar-web/src/main/js/components/tags/TagsList.css new file mode 100644 index 00000000000..5963611a683 --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/TagsList.css @@ -0,0 +1,17 @@ +.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; +} diff --git a/server/sonar-web/src/main/js/components/tags/TagsList.js b/server/sonar-web/src/main/js/components/tags/TagsList.js new file mode 100644 index 00000000000..6f5e9d29029 --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/TagsList.js @@ -0,0 +1,56 @@ +/* + * 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'; +import './TagsList.css'; + +type Props = { + tags: Array, + allowUpdate: boolean, + allowMultiLine: boolean, + customClass?: string +}; + +export default class TagsList extends React.PureComponent { + props: Props; + + static defaultProps = { + allowUpdate: false, + allowMultiLine: false + }; + + render() { + const { tags, allowUpdate } = this.props; + const spanClass = classNames({ + note: !allowUpdate, + 'text-ellipsis': !this.props.allowMultiLine + }); + const tagListClass = classNames('tags-list', this.props.customClass); + + return ( + + + {tags.join(', ')} + {allowUpdate && } + + ); + } +} 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, + selectedTags: Array, + 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 ( + + + + ); + } +} diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js b/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js new file mode 100644 index 00000000000..9eec4be8ed2 --- /dev/null +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-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 TagsList from '../TagsList'; + +const tags = ['foo', 'bar']; + +it('should render with a list of tag', () => { + const taglist = shallow(); + expect(taglist.text()).toBe(tags.join(', ')); + expect(taglist.find('i').length).toBe(1); + expect(taglist.find('span.note').hasClass('text-ellipsis')).toBe(true); +}); + +it('should FAIL to render without tags', () => { + expect(() => shallow()).toThrow(); +}); + +it('should correctly handle a lot of tags', () => { + const lotOfTags = []; + for (let i = 0; i < 20; i++) { + lotOfTags.push(tags); + } + const taglist = shallow(); + expect(taglist.text()).toBe(lotOfTags.join(', ')); + expect(taglist.find('span.note').hasClass('text-ellipsis')).toBe(false); +}); + +it('should render with a caret on the right if update is allowed', () => { + const taglist = shallow(); + expect(taglist.find('i').length).toBe(2); +}); 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(); + expect(tagsSelector).toMatchSnapshot(); +}); + +it('should render without tags at all', () => { + expect(shallow()).toMatchSnapshot(); +}); + +it('should validate tags correctly', () => { + const validChars = 'abcdefghijklmnopqrstuvwxyz0123456789+-#.'; + const tagsSelector = shallow().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`] = ` + + + +`; + +exports[`test should render without tags at all 1`] = ` + + + +`; diff --git a/server/sonar-web/src/main/js/components/ui/TagsList.css b/server/sonar-web/src/main/js/components/ui/TagsList.css deleted file mode 100644 index bb48ef2bf21..00000000000 --- a/server/sonar-web/src/main/js/components/ui/TagsList.css +++ /dev/null @@ -1,20 +0,0 @@ -.tags-list { - padding-left: 6px; -} - -.tags-list i { - padding-left: 4px; -} - -.tags-list i::before { - font-size: 12px; -} - -.tags-list span { - display: inline-block; - vertical-align: text-top; - max-width: 220px; - padding-left: 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/ui/TagsList.js deleted file mode 100644 index 6f568df0357..00000000000 --- a/server/sonar-web/src/main/js/components/ui/TagsList.js +++ /dev/null @@ -1,53 +0,0 @@ -/* - * 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'; -import './TagsList.css'; - -type Props = { - tags: Array, - allowUpdate: boolean, - allowMultiLine: boolean -}; - -export default class TagsList extends React.PureComponent { - props: Props; - - static defaultProps = { - allowUpdate: false, - allowMultiLine: false - }; - - render() { - const { tags, allowUpdate } = this.props; - const spanClass = classNames('note', { - 'text-ellipsis': !this.props.allowMultiLine - }); - - return ( - - - {tags.join(', ')} - {allowUpdate && } - - ); - } -} diff --git a/server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js b/server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js deleted file mode 100644 index 9eec4be8ed2..00000000000 --- a/server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js +++ /dev/null @@ -1,50 +0,0 @@ -/* - * 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 TagsList from '../TagsList'; - -const tags = ['foo', 'bar']; - -it('should render with a list of tag', () => { - const taglist = shallow(); - expect(taglist.text()).toBe(tags.join(', ')); - expect(taglist.find('i').length).toBe(1); - expect(taglist.find('span.note').hasClass('text-ellipsis')).toBe(true); -}); - -it('should FAIL to render without tags', () => { - expect(() => shallow()).toThrow(); -}); - -it('should correctly handle a lot of tags', () => { - const lotOfTags = []; - for (let i = 0; i < 20; i++) { - lotOfTags.push(tags); - } - const taglist = shallow(); - expect(taglist.text()).toBe(lotOfTags.join(', ')); - expect(taglist.find('span.note').hasClass('text-ellipsis')).toBe(false); -}); - -it('should render with a caret on the right if update is allowed', () => { - const taglist = shallow(); - expect(taglist.find('i').length).toBe(2); -}); 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; }; diff --git a/server/sonar-web/src/main/less/components/menu.less b/server/sonar-web/src/main/less/components/menu.less index c2241042386..17df7a8ec0c 100644 --- a/server/sonar-web/src/main/less/components/menu.less +++ b/server/sonar-web/src/main/less/components/menu.less @@ -59,7 +59,6 @@ &:hover, &:focus { text-decoration: none; color: @baseFontColor; - background-color: @barBackgroundColor; } } diff --git a/server/sonar-web/src/main/less/components/search.less b/server/sonar-web/src/main/less/components/search.less index b58bbe8722a..f76c61c340b 100644 --- a/server/sonar-web/src/main/less/components/search.less +++ b/server/sonar-web/src/main/less/components/search.less @@ -23,6 +23,7 @@ .search-box { position: relative; font-size: 0; + white-space: nowrap; } .search-box-input { @@ -56,4 +57,3 @@ font-size: @smallFontSize; white-space: nowrap; } - -- cgit v1.2.3