diff options
author | Pascal Mugnier <pascal.mugnier@sonarsource.com> | 2018-03-20 11:56:00 +0100 |
---|---|---|
committer | SonarTech <sonartech@sonarsource.com> | 2018-03-22 12:37:49 +0100 |
commit | 82a1cb0b4a6ea692624b290b15080fdf7d1b475c (patch) | |
tree | f0014002f03c233da8b4628440a28784fcd136f3 /server/sonar-web/src | |
parent | 169ffd14e1e2510b5fe6845577678b00f56ff9eb (diff) | |
download | sonarqube-82a1cb0b4a6ea692624b290b15080fdf7d1b475c.tar.gz sonarqube-82a1cb0b4a6ea692624b290b15080fdf7d1b475c.zip |
SONAR-9642 Reduce steps for selecting a custom chart (#6)
Diffstat (limited to 'server/sonar-web/src')
23 files changed, 455 insertions, 182 deletions
diff --git a/server/sonar-web/src/main/js/app/styles/components/menu.css b/server/sonar-web/src/main/js/app/styles/components/menu.css index 5e479d6fc20..11e247b18a0 100644 --- a/server/sonar-web/src/main/js/app/styles/components/menu.css +++ b/server/sonar-web/src/main/js/app/styles/components/menu.css @@ -56,6 +56,10 @@ background-color: var(--barBorderColor); } +.menu > li > a.disabled { + color: #bbb !important; +} + .menu > li > a:hover, .menu > li > a:focus { text-decoration: none; @@ -75,11 +79,20 @@ background-color: var(--barBackgroundColor); } -.menu .menu-vertically-limited { +.menu .menu-vertically-limited, +.menu.menu-vertically-limited { max-height: 300px; overflow-y: auto; } +.menu-vertically-limited.with-top-separator { + border-top: 1px solid #e6e6e6; +} + +.menu-vertically-limited.with-bottom-separator { + border-bottom: 1px solid #e6e6e6; +} + .menu .menu-footer > a > span { border-bottom: 1px solid var(--gray80); color: var(--secondFontColor); diff --git a/server/sonar-web/src/main/js/app/styles/init/misc.css b/server/sonar-web/src/main/js/app/styles/init/misc.css index 72cadc3c21b..f87994be76e 100644 --- a/server/sonar-web/src/main/js/app/styles/init/misc.css +++ b/server/sonar-web/src/main/js/app/styles/init/misc.css @@ -29,6 +29,10 @@ vertical-align: middle; } +.vertical-text-top { + vertical-align: text-top; +} + .nowrap { white-space: nowrap; } diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx index 97b99759784..87359ccaf3f 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx @@ -101,6 +101,7 @@ export default class RuleDetailsMeta extends React.PureComponent<Props, State> { const { canWrite, ruleDetails } = this.props; const { sysTags = [], tags = [] } = ruleDetails; const allTags = [...sysTags, ...tags]; + return ( <li className="coding-rules-detail-property" data-meta="tags"> {this.props.canWrite ? ( @@ -111,7 +112,7 @@ export default class RuleDetailsMeta extends React.PureComponent<Props, State> { organization={this.props.organization} setTags={this.props.onTagsChange} sysTags={sysTags} - tags={tags} + tags={allTags} /> } position="bottomleft" diff --git a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx index ddd9a5d54ed..38d3520623c 100644 --- a/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx +++ b/server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { without, uniq } from 'lodash'; +import { without, uniq, difference } from 'lodash'; import TagsSelector from '../../../components/tags/TagsSelector'; import { getRuleTags } from '../../../api/rules'; import { BubblePopupPosition } from '../../../components/common/BubblePopup'; @@ -74,6 +74,7 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta }; render() { + const availableTags = difference(this.state.searchResult, this.props.tags); return ( <TagsSelector listSize={LIST_SIZE} @@ -82,7 +83,7 @@ export default class RuleDetailsTagsPopup extends React.PureComponent<Props, Sta onUnselect={this.onUnselect} position={this.props.popupPosition || {}} selectedTags={this.props.tags} - tags={this.state.searchResult} + tags={availableTags} /> ); } diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.tsx b/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.tsx index 4dde591feb7..bdc74e4d941 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.tsx +++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaTags.tsx @@ -85,11 +85,11 @@ export default class MetaTags extends React.PureComponent<Props, State> { right: containerPos.width - eltPos.width }); - handleSetProjectTags = (tags: string[]) => { - setProjectTags({ project: this.props.component.key, tags: tags.join(',') }).then( - () => this.props.onComponentChange({ tags }), - () => {} - ); + handleSetProjectTags = (values: string[]) => { + setProjectTags({ + project: this.props.component.key, + tags: values.join(',') + }).then(() => this.props.onComponentChange({ tags: values }), () => {}); }; render() { diff --git a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx index 2fdd9fe1f6e..783688ebf7c 100644 --- a/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx +++ b/server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { without } from 'lodash'; +import { without, difference } from 'lodash'; import TagsSelector from '../../../components/tags/TagsSelector'; import { BubblePopupPosition } from '../../../components/common/BubblePopup'; import { searchProjectTags } from '../../../api/components'; @@ -71,6 +71,7 @@ export default class MetaTagsSelector extends React.PureComponent<Props, State> }; render() { + const availableTags = difference(this.state.searchResult, this.props.selectedTags); return ( <TagsSelector listSize={LIST_SIZE} @@ -79,7 +80,7 @@ export default class MetaTagsSelector extends React.PureComponent<Props, State> onUnselect={this.onUnselect} position={this.props.position} selectedTags={this.props.selectedTags} - tags={this.state.searchResult} + tags={availableTags} /> ); } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js index 908b2b09241..43057590919 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js @@ -202,6 +202,7 @@ export default class ProjectActivityGraphs extends React.PureComponent { graph={query.graph} metrics={metrics} metricsTypeFilter={this.getMetricsTypeFilter()} + removeCustomMetric={this.removeCustomMetric} selectedMetrics={this.props.query.customMetrics} updateGraph={this.updateGraph} /> diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js index 7b0a4a2a4bb..c9d1c0ec807 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js @@ -28,6 +28,7 @@ import { translate } from '../../../helpers/l10n'; /*:: type Props = { addCustomMetric: string => void, + removeCustomMetric: string => void, graph: string, metrics: Array<Metric>, metricsTypeFilter: ?Array<string>, @@ -56,10 +57,10 @@ export default class ProjectActivityGraphsHeader extends React.PureComponent { <Select className="pull-left input-medium" clearable={false} + onChange={this.handleGraphChange} + options={selectOptions} searchable={false} value={this.props.graph} - options={selectOptions} - onChange={this.handleGraphChange} /> {isCustomGraph(this.props.graph) && ( <AddGraphMetric @@ -67,6 +68,7 @@ export default class ProjectActivityGraphsHeader extends React.PureComponent { className="pull-left spacer-left" metrics={this.props.metrics} metricsTypeFilter={this.props.metricsTypeFilter} + removeMetric={this.props.removeCustomMetric} selectedMetrics={this.props.selectedMetrics} /> )} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap index fbfde630913..839975cbd4d 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap @@ -17,6 +17,7 @@ exports[`should render correctly the graph and legends 1`] = ` ] } metricsTypeFilter={null} + removeCustomMetric={[Function]} updateGraph={[Function]} /> <GraphsHistory diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetric.js b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetric.js index 73d9c3e4af2..78b47814b1b 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetric.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetric.js @@ -19,16 +19,12 @@ */ // @flow import React from 'react'; -import classNames from 'classnames'; -import Modal from '../../../../components/controls/Modal'; -import Select from '../../../../components/controls/Select'; -import Tooltip from '../../../../components/controls/Tooltip'; +import { find, sortBy } from 'lodash'; +import AddGraphMetricPopup from './AddGraphMetricPopup'; +import DropdownIcon from '../../../../components/icons-components/DropdownIcon'; +import BubblePopupHelper from '../../../../components/common/BubblePopupHelper'; import { isDiffMetric } from '../../../../helpers/measures'; -import { - getLocalizedMetricName, - translate, - translateWithParameters -} from '../../../../helpers/l10n'; +import { getLocalizedMetricName, translate } from '../../../../helpers/l10n'; /*:: import type { Metric } from '../../types'; */ /*:: @@ -37,6 +33,7 @@ type Props = { className?: string, metrics: Array<Metric>, metricsTypeFilter: ?Array<string>, + removeMetric: (metric: string) => void, selectedMetrics: Array<string> }; */ @@ -44,24 +41,46 @@ type Props = { /*:: type State = { open: boolean, - selectedMetric?: string + query: string, }; */ export default class AddGraphMetric extends React.PureComponent { /*:: props: Props; */ state /*: State */ = { - open: false + open: false, + metrics: [], + query: '', + selectedMetrics: [] }; - getMetricsOptions = (metricsTypeFilter /*: ?Array<string> */) => { - return this.props.metrics + filterSelected = (query /*: string*/, selectedElements /*: string[]*/) => { + return selectedElements.filter(element => + this.getLocalizedMetricNameFromKey(element) + .toLowerCase() + .includes(query.toLowerCase()) + ); + }; + + getPopupPos = (containerPos /*: ClientRect*/) => ({ + top: containerPos.height, + right: containerPos.width - 240 + }); + + filterMetricsElements = ( + { metricsTypeFilter, metrics, selectedMetrics } /*: Props */, + query /*: string*/ + ) => { + return metrics .filter(metric => { if ( metric.hidden || isDiffMetric(metric.key) || ['DATA', 'DISTRIB'].includes(metric.type) || - this.props.selectedMetrics.includes(metric.key) + selectedMetrics.includes(metric.key) || + !getLocalizedMetricName(metric) + .toLowerCase() + .includes(query.toLowerCase()) ) { return false; } @@ -70,112 +89,96 @@ export default class AddGraphMetric extends React.PureComponent { } return true; }) - .map((metric /*: Metric */) => ({ - value: metric.key, - label: getLocalizedMetricName(metric) - })); + .map(metric => metric.key); + }; + + getSelectedMetricsElements = ( + metrics /*: Array<Metric> */, + selectedMetrics /*: Array<string> | null */, + query /*: string */ + ) => { + const selected /*: Array<string> */ = + selectedMetrics === null ? this.props.selectedMetrics : selectedMetrics; + return metrics.filter(metric => selected.includes(metric.key)).map(metric => metric.key); }; - openForm = () => { - this.setState({ - open: true + getLocalizedMetricNameFromKey = (key /*: string*/) => { + const metric = find(this.props.metrics, { key }); + return metric === undefined ? key : getLocalizedMetricName(metric); + }; + + toggleForm = () => { + this.setState(state => { + return { open: !state.open }; }); }; - closeForm = () => { - this.setState({ - open: false, - selectedMetric: undefined + onSearch = (query /*: string */) => { + this.setState({ query }); + return Promise.resolve(); + }; + + onSelect = (metric /*: string */) => { + this.props.addMetric(metric); + this.setState(state => { + return { + selectedMetrics: sortBy([...state.selectedMetrics, metric]), + metrics: this.filterMetricsElements(this.props, state.query) + }; }); }; - handleChange = (option /*: { value: string, label: string } */) => - this.setState({ selectedMetric: option.value }); + onUnselect = (metric /*: string */) => { + this.props.removeMetric(metric); + this.setState(state => { + return { + metrics: sortBy([...state.metrics, metric]), + selectedMetrics: state.selectedMetrics.filter(selected => selected !== metric) + }; + }); + }; - handleSubmit = (e /*: Object */) => { - e.preventDefault(); - if (this.state.selectedMetric) { - this.props.addMetric(this.state.selectedMetric); - this.closeForm(); - } + togglePopup = (open /*: boolean*/) => { + this.setState({ open }); }; - renderModal() { - const { metricsTypeFilter } = this.props; - const header = translate('project_activity.graphs.custom.add_metric'); + render() { + const { query } = this.state; + const filteredMetrics = this.filterMetricsElements(this.props, query); + const selectedMetrics = this.getSelectedMetricsElements( + this.props.metrics, + this.props.selectedMetrics, + query + ); return ( - <Modal key="add-metric-modal" contentLabel={header} onRequestClose={this.closeForm}> - <header className="modal-head"> - <h2>{header}</h2> - </header> - <form onSubmit={this.handleSubmit}> - <div className="modal-body"> - <div className="modal-large-field"> - <label>{translate('project_activity.graphs.custom.search')}</label> - <Select - autofocus={true} - className="Select-big" - clearable={false} - noResultsText={translate('no_results')} - onChange={this.handleChange} - options={this.getMetricsOptions(metricsTypeFilter)} - placeholder="" - searchable={true} - value={this.state.selectedMetric} - /> - <span className="alert alert-info"> - {metricsTypeFilter != null && metricsTypeFilter.length > 0 - ? translateWithParameters( - 'project_activity.graphs.custom.type_x_message', - metricsTypeFilter - .map(type => translate('metric.type', type)) - .sort() - .join(', ') - ) - : translate('project_activity.graphs.custom.add_metric_info')} - </span> - </div> - </div> - <footer className="modal-foot"> - <div> - <button type="submit" disabled={!this.state.selectedMetric}> + <div className="display-inline-block"> + <BubblePopupHelper + isOpen={this.state.open} + offset={{ horizontal: 16, vertical: 0 }} + popup={ + <AddGraphMetricPopup + elements={filteredMetrics} + filterSelected={this.filterSelected} + metricsTypeFilter={this.props.metricsTypeFilter} + onSearch={this.onSearch} + onSelect={this.onSelect} + onUnselect={this.onUnselect} + renderLabel={element => this.getLocalizedMetricNameFromKey(element)} + selectedElements={selectedMetrics} + /> + } + position="bottomright" + togglePopup={this.togglePopup}> + <button className="spacer-left" onClick={this.toggleForm} type="button"> + <span> + <span className="text-ellipsis spacer-right"> {translate('project_activity.graphs.custom.add')} - </button> - <button type="reset" className="button-link" onClick={this.closeForm}> - {translate('cancel')} - </button> - </div> - </footer> - </form> - </Modal> - ); - } - - render() { - if (this.props.selectedMetrics.length >= 6) { - // Use the class .disabled instead of the property to prevent a bug from - // rc-tooltip : https://github.com/react-component/tooltip/issues/18 - return ( - <Tooltip - placement="right" - overlay={translate('project_activity.graphs.custom.add_metric_info')}> - <button className={classNames('disabled', this.props.className)}> - {translate('project_activity.graphs.custom.add')} + </span> + <DropdownIcon className="vertical-text-top" /> + </span> </button> - </Tooltip> - ); - } - - const buttonComponent = ( - <button key="add-metric-button" className={this.props.className} onClick={this.openForm}> - {translate('project_activity.graphs.custom.add')} - </button> + </BubblePopupHelper> + </div> ); - - if (this.state.open) { - return [buttonComponent, this.renderModal()]; - } - - return buttonComponent; } } diff --git a/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetricPopup.tsx b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetricPopup.tsx new file mode 100644 index 00000000000..81864bf8920 --- /dev/null +++ b/server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetricPopup.tsx @@ -0,0 +1,79 @@ +/* + * SonarQube + * Copyright (C) 2009-2018 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 * as React from 'react'; +import BubblePopup from '../../../../components/common/BubblePopup'; +import MultiSelect from '../../../../components/common/MultiSelect'; +import { translate, translateWithParameters } from '../../../../helpers/l10n'; + +interface Props { + elements: string[]; + filterSelected: (query: string, selectedElements: string[]) => string[]; + metricsTypeFilter: string[]; + onSearch: (query: string) => Promise<void>; + onSelect: (item: string) => void; + onUnselect: (item: string) => void; + popupPosition?: any; + renderLabel: (element: string) => React.ReactNode; + selectedElements: string[]; +} + +export default function AddGraphMetricPopup({ elements, metricsTypeFilter, ...props }: Props) { + let footerNode: React.ReactNode = ''; + + if (props.selectedElements.length >= 6) { + footerNode = ( + <span className="alert alert-info spacer-left spacer-right spacer-top"> + {translate('project_activity.graphs.custom.add_metric_info')} + </span> + ); + } else if (metricsTypeFilter != null && metricsTypeFilter.length > 0) { + footerNode = ( + <span className="alert alert-info spacer-left spacer-right spacer-top"> + {translateWithParameters( + 'project_activity.graphs.custom.type_x_message', + metricsTypeFilter + .map((type: string) => translate('metric.type', type)) + .sort() + .join(', ') + )} + </span> + ); + } + + return ( + <BubblePopup + customClass="bubble-popup-bottom-right bubble-popup-menu abs-width-300" + position={props.popupPosition}> + <MultiSelect + allowNewElements={false} + allowSelection={props.selectedElements.length < 6} + elements={elements} + filterSelected={props.filterSelected} + footerNode={footerNode} + onSearch={props.onSearch} + onSelect={props.onSelect} + onUnselect={props.onUnselect} + placeholder={translate('search.search_for_tags')} + renderLabel={props.renderLabel} + selectedElements={props.selectedElements} + /> + </BubblePopup> + ); +} diff --git a/server/sonar-web/src/main/js/apps/projectActivity/utils.js b/server/sonar-web/src/main/js/apps/projectActivity/utils.js index 5a8ae9aa5a1..ad2e53d848a 100644 --- a/server/sonar-web/src/main/js/apps/projectActivity/utils.js +++ b/server/sonar-web/src/main/js/apps/projectActivity/utils.js @@ -111,7 +111,7 @@ export function generateSeries( metrics /*: Array<Metric> | { [string]: Metric } */, displayedMetrics /*: Array<string> */ ) /*: Array<Serie> */ { - if (displayedMetrics.length <= 0) { + if (displayedMetrics.length <= 0 || typeof measuresHistory === 'undefined') { return []; } return sortBy( diff --git a/server/sonar-web/src/main/js/components/common/MultiSelect.tsx b/server/sonar-web/src/main/js/components/common/MultiSelect.tsx index 91a68f25fe8..892cab66813 100644 --- a/server/sonar-web/src/main/js/components/common/MultiSelect.tsx +++ b/server/sonar-web/src/main/js/components/common/MultiSelect.tsx @@ -18,17 +18,24 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; +import * as classNames from 'classnames'; import { difference } from 'lodash'; import MultiSelectOption from './MultiSelectOption'; import SearchBox from '../controls/SearchBox'; +import { translateWithParameters } from '../../helpers/l10n'; interface Props { + allowNewElements?: boolean; + allowSelection?: boolean; elements: string[]; + filterSelected?: (query: string, selectedElements: string[]) => string[]; + footerNode?: React.ReactNode; listSize?: number; onSearch: (query: string) => Promise<void>; onSelect: (item: string) => void; onUnselect: (item: string) => void; placeholder: string; + renderLabel?: (element: string) => React.ReactNode; selectedElements: string[]; validateSearchInput?: (value: string) => string; } @@ -42,7 +49,9 @@ interface State { } interface DefaultProps { + filterSelected: (query: string, selectedElements: string[]) => string[]; listSize: number; + renderLabel: (element: string) => React.ReactNode; validateSearchInput: (value: string) => string; } @@ -54,7 +63,10 @@ export default class MultiSelect extends React.PureComponent<Props, State> { mounted = false; static defaultProps: DefaultProps = { - listSize: 10, + filterSelected: (query: string, selectedElements: string[]) => + selectedElements.filter(elem => elem.includes(query)), + listSize: 0, + renderLabel: (element: string) => element, validateSearchInput: (value: string) => value }; @@ -72,7 +84,7 @@ export default class MultiSelect extends React.PureComponent<Props, State> { componentDidMount() { this.mounted = true; this.onSearchQuery(''); - this.updateSelectedElements(this.props); + this.updateSelectedElements(this.props as PropsWithDefault); this.updateUnselectedElements(this.props as PropsWithDefault); if (this.container) { this.container.addEventListener('keydown', this.handleKeyboard, true); @@ -164,13 +176,13 @@ export default class MultiSelect extends React.PureComponent<Props, State> { onUnselectItem = (item: string) => this.props.onUnselect(item); isNewElement = (elem: string, { selectedElements, elements }: Props) => - elem && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1; + elem.length > 0 && selectedElements.indexOf(elem) === -1 && elements.indexOf(elem) === -1; - updateSelectedElements = (props: Props) => { + updateSelectedElements = (props: PropsWithDefault) => { this.setState((state: State) => { if (state.query) { return { - selectedElements: [...props.selectedElements.filter(elem => elem.includes(state.query))] + selectedElements: props.filterSelected(state.query, props.selectedElements) }; } else { return { selectedElements: [...props.selectedElements] }; @@ -180,7 +192,9 @@ export default class MultiSelect extends React.PureComponent<Props, State> { updateUnselectedElements = (props: PropsWithDefault) => { this.setState((state: State) => { - if (props.listSize < state.selectedElements.length) { + if (props.listSize === 0) { + return { unselectedElements: difference(props.elements, props.selectedElements) }; + } else if (props.listSize < state.selectedElements.length) { return { unselectedElements: [] }; } else { return { @@ -239,8 +253,17 @@ export default class MultiSelect extends React.PureComponent<Props, State> { }; render() { + const { allowSelection = true, allowNewElements = true, footerNode = '' } = this.props; + const { renderLabel } = this.props as PropsWithDefault; const { query, activeIdx, selectedElements, unselectedElements } = this.state; const activeElement = this.getAllElements(this.props, this.state)[activeIdx]; + const infiniteList = this.props.listSize === 0; + const listClasses = classNames('menu', { + 'menu-vertically-limited': infiniteList, + 'spacer-top': infiniteList, + 'with-top-separator': infiniteList, + 'with-bottom-separator': Boolean(footerNode) + }); return ( <div className="multi-select" ref={div => (this.container = div)}> @@ -254,7 +277,11 @@ export default class MultiSelect extends React.PureComponent<Props, State> { value={query} /> </div> - <ul className="menu"> + <ul className={listClasses}> + {selectedElements.length < 1 && + unselectedElements.length < 1 && ( + <li className="spacer-left">{translateWithParameters('no_results_for_x', query)}</li> + )} {selectedElements.length > 0 && selectedElements.map(element => ( <MultiSelectOption @@ -263,6 +290,7 @@ export default class MultiSelect extends React.PureComponent<Props, State> { key={element} onHover={this.handleElementHover} onSelectChange={this.handleSelectChange} + renderLabel={renderLabel} selected={true} /> ))} @@ -270,23 +298,28 @@ export default class MultiSelect extends React.PureComponent<Props, State> { unselectedElements.map(element => ( <MultiSelectOption active={activeElement === element} + disabled={!allowSelection} element={element} key={element} onHover={this.handleElementHover} onSelectChange={this.handleSelectChange} + renderLabel={renderLabel} /> ))} - {this.isNewElement(query, this.props) && ( - <MultiSelectOption - active={activeElement === query} - custom={true} - element={query} - key={query} - onHover={this.handleElementHover} - onSelectChange={this.handleSelectChange} - /> - )} + {allowNewElements && + this.isNewElement(query, this.props) && ( + <MultiSelectOption + active={activeElement === query} + custom={true} + element={query} + key={query} + onHover={this.handleElementHover} + onSelectChange={this.handleSelectChange} + renderLabel={renderLabel} + /> + )} </ul> + {footerNode} </div> ); } diff --git a/server/sonar-web/src/main/js/components/common/MultiSelectOption.tsx b/server/sonar-web/src/main/js/components/common/MultiSelectOption.tsx index 89429426d7e..879bdada43f 100644 --- a/server/sonar-web/src/main/js/components/common/MultiSelectOption.tsx +++ b/server/sonar-web/src/main/js/components/common/MultiSelectOption.tsx @@ -21,12 +21,14 @@ import * as React from 'react'; import * as classNames from 'classnames'; interface Props { - element: string; - selected?: boolean; - custom?: boolean; active?: boolean; - onSelectChange: (elem: string, selected: boolean) => void; + custom?: boolean; + disabled?: boolean; + element: string; onHover: (elem: string) => void; + onSelectChange: (elem: string, selected: boolean) => void; + renderLabel: (element: string) => React.ReactNode; + selected?: boolean; } export default class MultiSelectOption extends React.PureComponent<Props> { @@ -34,27 +36,36 @@ export default class MultiSelectOption extends React.PureComponent<Props> { evt.stopPropagation(); evt.preventDefault(); evt.currentTarget.blur(); - this.props.onSelectChange(this.props.element, !this.props.selected); + + if (!this.props.disabled) { + this.props.onSelectChange(this.props.element, !this.props.selected); + } }; handleHover = () => this.props.onHover(this.props.element); render() { + const { selected, disabled } = this.props; const className = classNames('icon-checkbox', { - 'icon-checkbox-checked': this.props.selected + 'icon-checkbox-checked': selected, + 'icon-checkbox-invisible': disabled + }); + const activeClass = classNames({ + active: this.props.active, + disabled, + 'cursor-not-allowed': disabled }); - const activeClass = classNames({ active: this.props.active }); return ( <li> <a - href="#" className={activeClass} + href="#" onClick={this.handleSelect} - onMouseOver={this.handleHover} - onFocus={this.handleHover}> + onFocus={this.handleHover} + onMouseOver={this.handleHover}> <i className={className} /> {this.props.custom && '+ '} - {this.props.element} + {this.props.renderLabel(this.props.element)} </a> </li> ); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx index 097fd543c36..ab5971a2899 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx @@ -27,10 +27,15 @@ const props = { onSearch: () => Promise.resolve(), onSelect: () => {}, onUnselect: () => {}, + renderLabel: (element: string) => element, placeholder: '' }; -const elements = ['foo', 'bar', 'baz']; +const elements = [ + { key: 'foo', label: 'foo' }, + { key: 'bar', label: 'bar' }, + { key: 'baz', label: 'baz' } +]; it('should render multiselect with selected elements', () => { const multiselect = shallow(<MultiSelect {...props} />); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.tsx b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.tsx index 5b72d18b21e..88776be4045 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.tsx +++ b/server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.tsx @@ -24,21 +24,26 @@ import MultiSelectOption from '../MultiSelectOption'; const props = { element: 'mytag', onSelectChange: () => {}, - onHover: () => {} + onHover: () => {}, + renderLabel: (element: string) => element }; -it('should render standard tag', () => { +it('should render standard element', () => { expect(shallow(<MultiSelectOption {...props} />)).toMatchSnapshot(); }); -it('should render selected tag', () => { +it('should render selected element', () => { expect(shallow(<MultiSelectOption {...props} selected={true} />)).toMatchSnapshot(); }); -it('should render custom tag', () => { +it('should render custom element', () => { expect(shallow(<MultiSelectOption {...props} custom={true} />)).toMatchSnapshot(); }); -it('should render active tag', () => { - expect(shallow(<MultiSelectOption {...props} selected={true} active={true} />)).toMatchSnapshot(); +it('should render active element', () => { + expect(shallow(<MultiSelectOption {...props} active={true} selected={true} />)).toMatchSnapshot(); +}); + +it('should render disabled element', () => { + expect(shallow(<MultiSelectOption {...props} disabled={true} />)).toMatchSnapshot(); }); diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap index 69b2384f396..87a1bfd052c 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap @@ -17,7 +17,7 @@ exports[`should render multiselect with selected elements 1`] = ` /> </div> <ul - className="menu" + className="menu menu-vertically-limited spacer-top with-top-separator" > <MultiSelectOption active={true} @@ -25,6 +25,7 @@ exports[`should render multiselect with selected elements 1`] = ` key="bar" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} selected={true} /> </ul> @@ -48,7 +49,7 @@ exports[`should render multiselect with selected elements 2`] = ` /> </div> <ul - className="menu" + className="menu menu-vertically-limited spacer-top with-top-separator" > <MultiSelectOption active={true} @@ -56,21 +57,50 @@ exports[`should render multiselect with selected elements 2`] = ` key="bar" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} selected={true} /> <MultiSelectOption active={false} - element="foo" - key="foo" + disabled={false} + element={ + Object { + "key": "foo", + "label": "foo", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} /> <MultiSelectOption active={false} - element="baz" - key="baz" + disabled={false} + element={ + Object { + "key": "bar", + "label": "bar", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} + /> + <MultiSelectOption + active={false} + disabled={false} + element={ + Object { + "key": "baz", + "label": "baz", + } + } + key="[object Object]" + onHover={[Function]} + onSelectChange={[Function]} + renderLabel={[Function]} /> </ul> </div> @@ -93,7 +123,7 @@ exports[`should render multiselect with selected elements 3`] = ` /> </div> <ul - className="menu" + className="menu menu-vertically-limited spacer-top with-top-separator" > <MultiSelectOption active={false} @@ -101,21 +131,50 @@ exports[`should render multiselect with selected elements 3`] = ` key="bar" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} selected={true} /> <MultiSelectOption active={false} - element="foo" - key="foo" + disabled={false} + element={ + Object { + "key": "foo", + "label": "foo", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} /> <MultiSelectOption active={true} - element="baz" - key="baz" + disabled={false} + element={ + Object { + "key": "bar", + "label": "bar", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} + /> + <MultiSelectOption + active={false} + disabled={false} + element={ + Object { + "key": "baz", + "label": "baz", + } + } + key="[object Object]" + onHover={[Function]} + onSelectChange={[Function]} + renderLabel={[Function]} /> </ul> </div> @@ -138,7 +197,7 @@ exports[`should render multiselect with selected elements 4`] = ` /> </div> <ul - className="menu" + className="menu menu-vertically-limited spacer-top with-top-separator" > <MultiSelectOption active={false} @@ -146,21 +205,50 @@ exports[`should render multiselect with selected elements 4`] = ` key="bar" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} selected={true} /> <MultiSelectOption active={false} - element="foo" - key="foo" + disabled={false} + element={ + Object { + "key": "foo", + "label": "foo", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} /> <MultiSelectOption active={true} - element="baz" - key="baz" + disabled={false} + element={ + Object { + "key": "bar", + "label": "bar", + } + } + key="[object Object]" + onHover={[Function]} + onSelectChange={[Function]} + renderLabel={[Function]} + /> + <MultiSelectOption + active={false} + disabled={false} + element={ + Object { + "key": "baz", + "label": "baz", + } + } + key="[object Object]" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} /> <MultiSelectOption active={false} @@ -169,6 +257,7 @@ exports[`should render multiselect with selected elements 4`] = ` key="test" onHover={[Function]} onSelectChange={[Function]} + renderLabel={[Function]} /> </ul> </div> diff --git a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.tsx.snap b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.tsx.snap index b7b9f2a61c0..3f9a5cc7ef5 100644 --- a/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`should render active tag 1`] = ` +exports[`should render active element 1`] = ` <li> <a className="active" @@ -18,7 +18,7 @@ exports[`should render active tag 1`] = ` </li> `; -exports[`should render custom tag 1`] = ` +exports[`should render custom element 1`] = ` <li> <a className="" @@ -37,7 +37,25 @@ exports[`should render custom tag 1`] = ` </li> `; -exports[`should render selected tag 1`] = ` +exports[`should render disabled element 1`] = ` +<li> + <a + className="disabled cursor-not-allowed" + href="#" + onClick={[Function]} + onFocus={[Function]} + onMouseOver={[Function]} + > + <i + className="icon-checkbox icon-checkbox-invisible" + /> + + mytag + </a> +</li> +`; + +exports[`should render selected element 1`] = ` <li> <a className="" @@ -55,7 +73,7 @@ exports[`should render selected tag 1`] = ` </li> `; -exports[`should render standard tag 1`] = ` +exports[`should render standard element 1`] = ` <li> <a className="" diff --git a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js index e40a85b71af..fb183a7f8b7 100644 --- a/server/sonar-web/src/main/js/components/issue/components/IssueTags.js +++ b/server/sonar-web/src/main/js/components/issue/components/IssueTags.js @@ -59,20 +59,21 @@ export default class IssueTags extends React.PureComponent { render() { const { issue } = this.props; + const { tags = [] } = issue; if (this.props.canSetTags) { return ( <BubblePopupHelper isOpen={this.props.isOpen} - position="bottomright" - togglePopup={this.toggleSetTags} popup={ <SetIssueTagsPopup organization={issue.projectOrganization} - selectedTags={issue.tags} + selectedTags={tags} setTags={this.setTags} /> - }> + } + position="bottomright" + togglePopup={this.toggleSetTags}> <button className={'js-issue-edit-tags button-link issue-action issue-action-with-options'} onClick={this.toggleSetTags}> diff --git a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx index 7aa3bebba62..90b8af5f628 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx +++ b/server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx @@ -18,7 +18,7 @@ * Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. */ import * as React from 'react'; -import { without } from 'lodash'; +import { difference, without } from 'lodash'; import { BubblePopupPosition } from '../../../components/common/BubblePopup'; import TagsSelector from '../../../components/tags/TagsSelector'; import { searchIssueTags } from '../../../api/issues'; @@ -72,6 +72,7 @@ export default class SetIssueTagsPopup extends React.PureComponent<Props, State> }; render() { + const availableTags = difference(this.state.searchResult, this.props.selectedTags); return ( <TagsSelector listSize={LIST_SIZE} @@ -80,7 +81,7 @@ export default class SetIssueTagsPopup extends React.PureComponent<Props, State> onUnselect={this.onUnselect} position={this.props.popupPosition} selectedTags={this.props.selectedTags} - tags={this.state.searchResult} + tags={availableTags} /> ); } diff --git a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap index 36d73b1a4cf..57990df2131 100644 --- a/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap @@ -14,7 +14,6 @@ exports[`should render tags popup correctly 1`] = ` } tags={ Array [ - "mytag", "test", "second", ] diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx index f2dd87eda02..a3e895bef65 100644 --- a/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx +++ b/server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx @@ -26,6 +26,7 @@ const props = { onSearch: () => Promise.resolve(), onSelect: () => {}, onUnselect: () => {}, + renderLabel: (element: string) => element, position: { right: 0, top: 0 }, selectedTags: ['bar'], tags: ['foo', 'bar', 'baz'] diff --git a/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.tsx.snap b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.tsx.snap index c2d4623b8c8..b6d33603ede 100644 --- a/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.tsx.snap +++ b/server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.tsx.snap @@ -18,11 +18,13 @@ exports[`should render with selected tags 1`] = ` "baz", ] } + filterSelected={[Function]} listSize={10} onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} placeholder="search.search_for_tags" + renderLabel={[Function]} selectedElements={ Array [ "bar", @@ -45,11 +47,13 @@ exports[`should render without tags at all 1`] = ` > <MultiSelect elements={Array []} + filterSelected={[Function]} listSize={10} onSearch={[Function]} onSelect={[Function]} onUnselect={[Function]} placeholder="search.search_for_tags" + renderLabel={[Function]} selectedElements={Array []} validateSearchInput={[Function]} /> |