Browse Source

SONAR-8844 Add tags editor on the project homepage (#1821)

tags/6.4-RC1
Grégoire Aubert 7 years ago
parent
commit
685a373cc4
33 changed files with 1468 additions and 43 deletions
  1. 58
    4
      it/it-tests/src/test/java/it/measure/ProjectDashboardTest.java
  2. 48
    0
      it/it-tests/src/test/java/pageobjects/ProjectDashboardPage.java
  3. 1
    1
      server/sonar-web/src/main/js/api/components.js
  4. 6
    6
      server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js
  5. 2
    11
      server/sonar-web/src/main/js/apps/overview/meta/Meta.js
  6. 140
    0
      server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js
  7. 61
    0
      server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js
  8. 105
    0
      server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap
  9. 4
    0
      server/sonar-web/src/main/js/apps/overview/styles.css
  10. 2
    2
      server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js
  11. 89
    0
      server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js
  12. 1
    0
      server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap
  13. 16
    3
      server/sonar-web/src/main/js/apps/projects/store/actions.js
  14. 45
    0
      server/sonar-web/src/main/js/components/common/BubblePopup.js
  15. 256
    0
      server/sonar-web/src/main/js/components/common/MultiSelect.js
  16. 72
    0
      server/sonar-web/src/main/js/components/common/MultiSelectOption.js
  17. 32
    0
      server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js
  18. 51
    0
      server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js
  19. 47
    0
      server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js
  20. 16
    0
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap
  21. 164
    0
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap
  22. 64
    0
      server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap
  23. 6
    9
      server/sonar-web/src/main/js/components/tags/TagsList.css
  24. 6
    3
      server/sonar-web/src/main/js/components/tags/TagsList.js
  25. 61
    0
      server/sonar-web/src/main/js/components/tags/TagsSelector.js
  26. 0
    0
      server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js
  27. 50
    0
      server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js
  28. 47
    0
      server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap
  29. 2
    1
      server/sonar-web/src/main/js/helpers/testUtils.js
  30. 7
    0
      server/sonar-web/src/main/js/store/components/actions.js
  31. 8
    1
      server/sonar-web/src/main/js/store/components/reducer.js
  32. 0
    1
      server/sonar-web/src/main/less/components/menu.less
  33. 1
    1
      server/sonar-web/src/main/less/components/search.less

+ 58
- 4
it/it-tests/src/test/java/it/measure/ProjectDashboardTest.java View 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

+ 48
- 0
it/it-tests/src/test/java/pageobjects/ProjectDashboardPage.java View File

@@ -20,7 +20,11 @@
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;
}
}

+ 1
- 1
server/sonar-web/src/main/js/api/components.js View 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(

+ 6
- 6
server/sonar-web/src/main/js/apps/overview/components/OverviewApp.js View 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) {

+ 2
- 11
server/sonar-web/src/main/js/apps/overview/meta/Meta.js View 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} />}


+ 140
- 0
server/sonar-web/src/main/js/apps/overview/meta/MetaTags.js View File

@@ -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>
);
}
}
}

+ 61
- 0
server/sonar-web/src/main/js/apps/overview/meta/__tests__/MetaTags-test.js View File

@@ -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();
});

+ 105
- 0
server/sonar-web/src/main/js/apps/overview/meta/__tests__/__snapshots__/MetaTags-test.js.snap View File

@@ -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>
`;

+ 4
- 0
server/sonar-web/src/main/js/apps/overview/styles.css View File

@@ -313,6 +313,10 @@
white-space: nowrap;
}

.overview-meta-tags {
position: relative;
}

.overview-meta-size-ncloc {
display: inline-block;
vertical-align: middle;

+ 2
- 2
server/sonar-web/src/main/js/apps/projects/components/ProjectCard.js View 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

+ 89
- 0
server/sonar-web/src/main/js/apps/projects/components/ProjectTagsSelectorContainer.js View File

@@ -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);

+ 1
- 0
server/sonar-web/src/main/js/apps/projects/components/__tests__/__snapshots__/ProjectCard-test.js.snap View File

@@ -62,6 +62,7 @@ exports[`test should display tags 1`] = `
<TagsList
allowMultiLine={false}
allowUpdate={false}
customClass="spacer-left"
tags={
Array [
"foo",

+ 16
- 3
server/sonar-web/src/main/js/apps/projects/store/actions.js View File

@@ -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);
}
);
};

+ 45
- 0
server/sonar-web/src/main/js/components/common/BubblePopup.js View File

@@ -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>
);
}
}

+ 256
- 0
server/sonar-web/src/main/js/components/common/MultiSelect.js View File

@@ -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>
);
}
}

+ 72
- 0
server/sonar-web/src/main/js/components/common/MultiSelectOption.js View File

@@ -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>
);
}
}

+ 32
- 0
server/sonar-web/src/main/js/components/common/__tests__/BubblePopup-test.js View File

@@ -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();
});

+ 51
- 0
server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.js View File

@@ -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);
});

+ 47
- 0
server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.js View File

@@ -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();
});

+ 16
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/BubblePopup-test.js.snap View File

@@ -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>
`;

+ 164
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.js.snap View File

@@ -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>
`;

+ 64
- 0
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.js.snap View File

@@ -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>
`;

server/sonar-web/src/main/js/components/ui/TagsList.css → server/sonar-web/src/main/js/components/tags/TagsList.css View File

@@ -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;
}

server/sonar-web/src/main/js/components/ui/TagsList.js → server/sonar-web/src/main/js/components/tags/TagsList.js View File

@@ -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" />}

+ 61
- 0
server/sonar-web/src/main/js/components/tags/TagsSelector.js View File

@@ -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>
);
}
}

server/sonar-web/src/main/js/components/ui/__tests__/TagsList-test.js → server/sonar-web/src/main/js/components/tags/__tests__/TagsList-test.js View File


+ 50
- 0
server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.js View File

@@ -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');
});

+ 47
- 0
server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.js.snap View File

@@ -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>
`;

+ 2
- 1
server/sonar-web/src/main/js/helpers/testUtils.js View File

@@ -21,7 +21,8 @@ export const click = element => {
return element.simulate('click', {
target: { blur() {} },
currentTarget: { blur() {} },
preventDefault() {}
preventDefault() {},
stopPropagation() {}
});
};


+ 7
- 0
server/sonar-web/src/main/js/store/components/actions.js View File

@@ -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
});

+ 8
- 1
server/sonar-web/src/main/js/store/components/reducer.js View 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;
};


+ 0
- 1
server/sonar-web/src/main/less/components/menu.less View File

@@ -59,7 +59,6 @@
&:hover, &:focus {
text-decoration: none;
color: @baseFontColor;
background-color: @barBackgroundColor;
}
}


+ 1
- 1
server/sonar-web/src/main/less/components/search.less View 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;
}


Loading…
Cancel
Save