@@ -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 |
@@ -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; | |||
} | |||
} |
@@ -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( |
@@ -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) { |
@@ -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} />} | |||
@@ -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> | |||
); | |||
} | |||
} | |||
} |
@@ -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(); | |||
}); |
@@ -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> | |||
`; |
@@ -313,6 +313,10 @@ | |||
white-space: nowrap; | |||
} | |||
.overview-meta-tags { | |||
position: relative; | |||
} | |||
.overview-meta-size-ncloc { | |||
display: inline-block; | |||
vertical-align: middle; |
@@ -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 |
@@ -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); |
@@ -62,6 +62,7 @@ exports[`test should display tags 1`] = ` | |||
<TagsList | |||
allowMultiLine={false} | |||
allowUpdate={false} | |||
customClass="spacer-left" | |||
tags={ | |||
Array [ | |||
"foo", |
@@ -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); | |||
} | |||
); | |||
}; |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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(); | |||
}); |
@@ -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); | |||
}); |
@@ -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(); | |||
}); |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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> | |||
`; |
@@ -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; | |||
} |
@@ -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" />} |
@@ -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> | |||
); | |||
} | |||
} |
@@ -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'); | |||
}); |
@@ -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> | |||
`; |
@@ -21,7 +21,8 @@ export const click = element => { | |||
return element.simulate('click', { | |||
target: { blur() {} }, | |||
currentTarget: { blur() {} }, | |||
preventDefault() {} | |||
preventDefault() {}, | |||
stopPropagation() {} | |||
}); | |||
}; | |||
@@ -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 | |||
}); |
@@ -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; | |||
}; | |||
@@ -59,7 +59,6 @@ | |||
&:hover, &:focus { | |||
text-decoration: none; | |||
color: @baseFontColor; | |||
background-color: @barBackgroundColor; | |||
} | |||
} | |||
@@ -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; | |||
} | |||