aboutsummaryrefslogtreecommitdiffstats
path: root/server
diff options
context:
space:
mode:
authorGrégoire Aubert <gregaubert@users.noreply.github.com>2017-03-22 13:40:13 +0100
committerGitHub <noreply@github.com>2017-03-22 13:40:13 +0100
commit685a373cc4a9028fbdd09ec2765074e8ef72e408 (patch)
tree4081f070b8e704f80626740cd1650a5eb97efea0 /server
parent926e6e3a8a76efd342b51c511426af6e4a15b765 (diff)
downloadsonarqube-685a373cc4a9028fbdd09ec2765074e8ef72e408.tar.gz
sonarqube-685a373cc4a9028fbdd09ec2765074e8ef72e408.zip
SONAR-8844 Add tags editor on the project homepage (#1821)
Diffstat (limited to 'server')
-rw-r--r--server/sonar-web/src/main/js/api/components.js2
-rw-r--r--server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js12
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/Meta.js13
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js140
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js61
-rw-r--r--server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap105
-rw-r--r--server/sonar-web/src/main/js/apps/overview/styles.css4
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js4
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js89
-rw-r--r--server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap1
-rw-r--r--server/sonar-web/src/main/js/apps/projects/store/actions.js19
-rw-r--r--server/sonar-web/src/main/js/components/common/BubblePopup.js45
-rw-r--r--server/sonar-web/src/main/js/components/common/MultiSelect.js256
-rw-r--r--server/sonar-web/src/main/js/components/common/MultiSelectOption.js72
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js32
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js51
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js47
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap16
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap164
-rw-r--r--server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap64
-rw-r--r--server/sonar-web/src/main/js/components/tags/TagsList.css (renamed from server/sonar-web/src/main/js/components/ui/TagsList.css)15
-rw-r--r--server/sonar-web/src/main/js/components/tags/TagsList.js (renamed from server/sonar-web/src/main/js/components/ui/TagsList.js)9
-rw-r--r--server/sonar-web/src/main/js/components/tags/TagsSelector.js61
-rw-r--r--server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js (renamed from server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js)0
-rw-r--r--server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js50
-rw-r--r--server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap47
-rw-r--r--server/sonar-web/src/main/js/helpers/testUtils.js3
-rw-r--r--server/sonar-web/src/main/js/store/components/actions.js7
-rw-r--r--server/sonar-web/src/main/js/store/components/reducer.js9
-rw-r--r--server/sonar-web/src/main/less/components/menu.less1
-rw-r--r--server/sonar-web/src/main/less/components/search.less2
31 files changed, 1362 insertions, 39 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;
};
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;
}
-