]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-8844 Add tags editor on the project homepage (#1821)
authorGrégoire Aubert <gregaubert@users.noreply.github.com>
Wed, 22 Mar 2017 12:40:13 +0000 (13:40 +0100)
committerGitHub <noreply@github.com>
Wed, 22 Mar 2017 12:40:13 +0000 (13:40 +0100)
36 files changed:
it/it-tests/src/test/java/it/measure/ProjectDashboardTest.java
it/it-tests/src/test/java/pageobjects/ProjectDashboardPage.java
server/sonar-web/src/main/js/api/components.js
server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js
server/sonar-web/src/main/js/apps/overview/meta/Meta.js
server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/apps/overview/styles.css
server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js
server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap
server/sonar-web/src/main/js/apps/projects/store/actions.js
server/sonar-web/src/main/js/components/common/BubblePopup.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/MultiSelect.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/MultiSelectOption.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/TagsList.css [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/TagsList.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/TagsSelector.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js [new file with mode: 0644]
server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap [new file with mode: 0644]
server/sonar-web/src/main/js/components/ui/TagsList.css [deleted file]
server/sonar-web/src/main/js/components/ui/TagsList.js [deleted file]
server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js [deleted file]
server/sonar-web/src/main/js/helpers/testUtils.js
server/sonar-web/src/main/js/store/components/actions.js
server/sonar-web/src/main/js/store/components/reducer.js
server/sonar-web/src/main/less/components/menu.less
server/sonar-web/src/main/less/components/search.less

index ab5326e41a28646f5e3e9e910d188431f2053cae..269acc7d9d333a4699930f6d63b87e977992d073 100644 (file)
@@ -25,22 +25,32 @@ import it.Category1Suite;
 import org.junit.Before;
 import org.junit.ClassRule;
 import org.junit.Ignore;
+import org.junit.Rule;
 import org.junit.Test;
+import org.openqa.selenium.Keys;
+import org.sonarqube.ws.client.PostRequest;
+import org.sonarqube.ws.client.WsClient;
 import pageobjects.Navigation;
 import pageobjects.ProjectDashboardPage;
 
 import static com.codeborne.selenide.Condition.hasText;
 import static com.codeborne.selenide.Condition.text;
+import static util.ItUtils.newAdminWsClient;
 import static util.ItUtils.projectDir;
 import static util.selenium.Selenese.runSelenese;
 
 public class ProjectDashboardTest {
-
   @ClassRule
   public static Orchestrator orchestrator = Category1Suite.ORCHESTRATOR;
 
+  @Rule
+  public Navigation nav = Navigation.get(orchestrator);
+
+  private static WsClient wsClient;
+
   @Before
-  public void resetData() throws Exception {
+  public void setUp() throws Exception {
+    wsClient = newAdminWsClient(orchestrator);
     orchestrator.resetData();
   }
 
@@ -55,17 +65,61 @@ public class ProjectDashboardTest {
   public void display_size() {
     executeBuild("shared/xoo-sample", "sample", "Sample");
 
-    Navigation nav = Navigation.get(orchestrator);
     ProjectDashboardPage page = nav.openProjectDashboard("sample");
 
     page.getLinesOfCode().should(hasText("13"));
     page.getLanguageDistribution().should(hasText("Xoo"), hasText("13"));
   }
 
+  @Test
+  public void display_tags_without_edit() {
+    executeBuild("shared/xoo-sample", "sample", "Sample");
+
+    // Add some tags to the project
+    wsClient.wsConnector().call(
+      new PostRequest("api/project_tags/set")
+        .setParam("project", "sample")
+        .setParam("tags", "foo,bar,baz")
+    );
+
+    ProjectDashboardPage page = nav.openProjectDashboard("sample");
+    page
+      .shouldHaveTags("foo", "bar", "baz")
+      .shouldNotBeEditable();
+  }
+
+  @Test
+  public void display_tags_with_edit() {
+    executeBuild("shared/xoo-sample", "sample-with-tags", "Sample with tags");
+    // Add some tags to another project to have them in the list
+    wsClient.wsConnector().call(
+            new PostRequest("api/project_tags/set")
+                    .setParam("project", "sample-with-tags")
+                    .setParam("tags", "foo,bar,baz")
+    );
+
+    executeBuild("shared/xoo-sample", "sample", "Sample");
+    ProjectDashboardPage page = nav.logIn().asAdmin().openProjectDashboard("sample");
+    page
+      .shouldHaveTags("No tags")
+      .shouldBeEditable()
+      .openTagEditor()
+      .getTagAtIdx(2).click();
+    page
+      .shouldHaveTags("foo")
+      .sendKeysToTagsInput("test")
+      .getTagAtIdx(0).should(hasText("+ test")).click();
+    page
+      .shouldHaveTags("foo", "test")
+      .getTagAtIdx(1).should(hasText("test"));
+    page
+      .sendKeysToTagsInput(Keys.ENTER)
+      .shouldHaveTags("test");
+  }
+
   @Test
   @Ignore("there is no more place to show the error")
   public void display_a_nice_error_when_requesting_unknown_project() {
-    Navigation nav = Navigation.get(orchestrator);
     nav.open("/dashboard/index?id=unknown");
     nav.getErrorMessage().should(text("The requested project does not exist. Either it has never been analyzed successfully or it has been deleted."));
     // TODO verify that on global homepage
index 296a636921330edf3e22cf3ce2cc88e07d51ad04..0bb44703f640724df8b109fe51afb4e02c664154 100644 (file)
 package pageobjects;
 
 import com.codeborne.selenide.SelenideElement;
+import java.util.Arrays;
+import java.util.List;
 
+import static com.codeborne.selenide.Condition.exist;
+import static com.codeborne.selenide.Condition.hasText;
 import static com.codeborne.selenide.Condition.visible;
 import static com.codeborne.selenide.Selenide.$;
 
@@ -41,4 +45,48 @@ public class ProjectDashboardPage {
     element.shouldBe(visible);
     return element;
   }
+
+  private SelenideElement getTagsMeta() {
+    SelenideElement element = $(".overview-meta-tags");
+    element.shouldBe(visible);
+    return element;
+  }
+
+  public ProjectDashboardPage shouldHaveTags(String... tags) {
+    String tagsList = String.join(", ", Arrays.asList(tags));
+    this.getTagsMeta().$(".tags-list > span").should(hasText(tagsList));
+    return this;
+  }
+
+  public ProjectDashboardPage shouldNotBeEditable() {
+    SelenideElement tagsElem = this.getTagsMeta();
+    tagsElem.$("button").shouldNot(exist);
+    tagsElem.$("div.multi-select").shouldNot(exist);
+    return this;
+  }
+
+  public ProjectDashboardPage shouldBeEditable() {
+    SelenideElement tagsElem = this.getTagsMeta();
+    tagsElem.$("button").shouldBe(visible);
+    return this;
+  }
+
+  public ProjectDashboardPage openTagEditor() {
+    SelenideElement tagsElem = this.getTagsMeta();
+    tagsElem.$("button").shouldBe(visible).click();
+    tagsElem.$("div.multi-select").shouldBe(visible);
+    return this;
+  }
+
+  public SelenideElement getTagAtIdx(Integer idx) {
+    SelenideElement tagsElem = this.getTagsMeta();
+    tagsElem.$("div.multi-select").shouldBe(visible);
+    return tagsElem.$$("ul.menu a").get(idx);
+  }
+
+  public ProjectDashboardPage sendKeysToTagsInput(CharSequence... charSequences) {
+    SelenideElement tagsInput = this.getTagsMeta().find("input");
+    tagsInput.sendKeys(charSequences);
+    return this;
+  }
 }
index 75fa13cb00186b072280a93b8e836d86458d592f..512f3a1f7ef3032dcdaf0a06f3090257cd116f85 100644 (file)
@@ -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(
index eefe20f34384cc5b51d4e73dd4a1ddc87e6ad8b8..c19ef680cc683b60d6c21c980ee361cda86151ab 100644 (file)
@@ -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) {
index c5dfaa64ecb931a7a10652a15d234f9fa3eb30c8..3fdaa3bcdcc1b083847d3e492fe1ebf6e0b3caad 100644 (file)
@@ -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 (file)
index 0000000..04c6650
--- /dev/null
@@ -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 (file)
index 0000000..eaf3dc5
--- /dev/null
@@ -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 (file)
index 0000000..fdf142c
--- /dev/null
@@ -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>
+`;
index ffe231472b782d914d47040a32800198e45f029d..2db27b12117eac0a812b10bc15800d153513b391 100644 (file)
   white-space: nowrap;
 }
 
+.overview-meta-tags {
+  position: relative;
+}
+
 .overview-meta-size-ncloc {
   display: inline-block;
   vertical-align: middle;
index 6f3691c943d43e127e7e788e032fc603c2bc5351..537bdafbc63366a3658fcb9e30c68762b004852a 100644 (file)
@@ -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 (file)
index 0000000..537bc3d
--- /dev/null
@@ -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);
index b53a20d538b057f297a254df0526fa83c76ef090..8eda732fdbbe6fceddca4c09bc97e78c37713f10 100644 (file)
@@ -62,6 +62,7 @@ exports[`test should display tags 1`] = `
     <TagsList
       allowMultiLine={false}
       allowUpdate={false}
+      customClass="spacer-left"
       tags={
         Array [
           "foo",
index 0bac164f8c2e5270c64f79e23c66aa18b58f4fbf..dd1bb987851076fa126a15431699d4aa6b24ffe6 100644 (file)
  */
 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 (file)
index 0000000..b29ee14
--- /dev/null
@@ -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 (file)
index 0000000..ec8adb3
--- /dev/null
@@ -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 (file)
index 0000000..fdd910d
--- /dev/null
@@ -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 (file)
index 0000000..add9308
--- /dev/null
@@ -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 (file)
index 0000000..1a52e06
--- /dev/null
@@ -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 (file)
index 0000000..5e8719e
--- /dev/null
@@ -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 (file)
index 0000000..edb3882
--- /dev/null
@@ -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 (file)
index 0000000..69d1756
--- /dev/null
@@ -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 (file)
index 0000000..600bf81
--- /dev/null
@@ -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/tags/TagsList.css b/server/sonar-web/src/main/js/components/tags/TagsList.css
new file mode 100644 (file)
index 0000000..5963611
--- /dev/null
@@ -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 (file)
index 0000000..6f5e9d2
--- /dev/null
@@ -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<string>,
+  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 (
+      <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" />}
+      </span>
+    );
+  }
+}
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 (file)
index 0000000..f43e091
--- /dev/null
@@ -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/tags/__tests__/TagsList-test.js b/server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js
new file mode 100644 (file)
index 0000000..9eec4be
--- /dev/null
@@ -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(<TagsList tags={tags} />);
+  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(<TagsList />)).toThrow();
+});
+
+it('should correctly handle a lot of tags', () => {
+  const lotOfTags = [];
+  for (let i = 0; i < 20; i++) {
+    lotOfTags.push(tags);
+  }
+  const taglist = shallow(<TagsList tags={lotOfTags} allowMultiLine={true} />);
+  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(<TagsList tags={tags} allowUpdate={true} />);
+  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 (file)
index 0000000..6ef4a86
--- /dev/null
@@ -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 (file)
index 0000000..56264e5
--- /dev/null
@@ -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/components/ui/TagsList.css b/server/sonar-web/src/main/js/components/ui/TagsList.css
deleted file mode 100644 (file)
index bb48ef2..0000000
+++ /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 (file)
index 6f568df..0000000
+++ /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<string>,
-  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 (
-      <span className="tags-list" title={tags.join(', ')}>
-        <i className="icon-tags icon-half-transparent" />
-        <span className={spanClass}>{tags.join(', ')}</span>
-        {allowUpdate && <i className="icon-dropdown icon-half-transparent" />}
-      </span>
-    );
-  }
-}
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 (file)
index 9eec4be..0000000
+++ /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(<TagsList tags={tags} />);
-  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(<TagsList />)).toThrow();
-});
-
-it('should correctly handle a lot of tags', () => {
-  const lotOfTags = [];
-  for (let i = 0; i < 20; i++) {
-    lotOfTags.push(tags);
-  }
-  const taglist = shallow(<TagsList tags={lotOfTags} allowMultiLine={true} />);
-  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(<TagsList tags={tags} allowUpdate={true} />);
-  expect(taglist.find('i').length).toBe(2);
-});
index 6b6da781fb9fb70b39271d28881e31f5e20a751c..358df804d4fd1539b3163f6717f5699550b087d8 100644 (file)
@@ -21,7 +21,8 @@ export const click = element => {
   return element.simulate('click', {
     target: { blur() {} },
     currentTarget: { blur() {} },
-    preventDefault() {}
+    preventDefault() {},
+    stopPropagation() {}
   });
 };
 
index e59b2a8fa070fc85f32e70d446638ad3543e145a..873640f7092f77bde5babcf2c8ab89a4099f85f6 100644 (file)
  * 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
+});
index 473c288feb2ef78151571a470d1643b32dea8b4d..0f90ce9086218658dff92faf973efea7033be766 100644 (file)
@@ -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;
 };
 
index c22410423863798ce41041595b70d94c99057661..17df7a8ec0cae848020c158e10c0c55358599264 100644 (file)
@@ -59,7 +59,6 @@
     &:hover, &:focus {
       text-decoration: none;
       color: @baseFontColor;
-      background-color: @barBackgroundColor;
     }
   }
 
index b58bbe8722acb70bfec6e5e643b370629d4f918b..f76c61c340b8c6ffb239afe0ce671f38d8eff736 100644 (file)
@@ -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;
 }
-