]> source.dussan.org Git - sonarqube.git/commitdiff
SONAR-9642 Reduce steps for selecting a custom chart (#6)
authorPascal Mugnier <pascal.mugnier@sonarsource.com>
Tue, 20 Mar 2018 10:56:00 +0000 (11:56 +0100)
committerSonarTech <sonartech@sonarsource.com>
Thu, 22 Mar 2018 11:37:49 +0000 (12:37 +0100)
23 files changed:
server/sonar-web/src/main/js/app/styles/components/menu.css
server/sonar-web/src/main/js/app/styles/init/misc.css
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsMeta.tsx
server/sonar-web/src/main/js/apps/coding-rules/components/RuleDetailsTagsPopup.tsx
server/sonar-web/src/main/js/apps/overview/meta/MetaTags.tsx
server/sonar-web/src/main/js/apps/overview/meta/MetaTagsSelector.tsx
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphs.js
server/sonar-web/src/main/js/apps/projectActivity/components/ProjectActivityGraphsHeader.js
server/sonar-web/src/main/js/apps/projectActivity/components/__tests__/__snapshots__/ProjectActivityGraphs-test.js.snap
server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetric.js
server/sonar-web/src/main/js/apps/projectActivity/components/forms/AddGraphMetricPopup.tsx [new file with mode: 0644]
server/sonar-web/src/main/js/apps/projectActivity/utils.js
server/sonar-web/src/main/js/components/common/MultiSelect.tsx
server/sonar-web/src/main/js/components/common/MultiSelectOption.tsx
server/sonar-web/src/main/js/components/common/__tests__/MultiSelect-test.tsx
server/sonar-web/src/main/js/components/common/__tests__/MultiSelectOption-test.tsx
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelect-test.tsx.snap
server/sonar-web/src/main/js/components/common/__tests__/__snapshots__/MultiSelectOption-test.tsx.snap
server/sonar-web/src/main/js/components/issue/components/IssueTags.js
server/sonar-web/src/main/js/components/issue/popups/SetIssueTagsPopup.tsx
server/sonar-web/src/main/js/components/issue/popups/__tests__/__snapshots__/SetIssueTagsPopup-test.tsx.snap
server/sonar-web/src/main/js/components/tags/__tests__/TagsSelector-test.tsx
server/sonar-web/src/main/js/components/tags/__tests__/__snapshots__/TagsSelector-test.tsx.snap

index 5e479d6fc2006397d5a0210c56669913705cb48c..11e247b18a0d4de7cf84a6324b80b119b3620b82 100644 (file)
   background-color: var(--barBorderColor);
 }
 
+.menu > li > a.disabled {
+  color: #bbb !important;
+}
+
 .menu > li > a:hover,
 .menu > li > a:focus {
   text-decoration: none;
   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);
index 72cadc3c21b6e37a833fff32d2f81d831e109f90..f87994be76e3a83f90c72095f09385475a774b10 100644 (file)
   vertical-align: middle;
 }
 
+.vertical-text-top {
+  vertical-align: text-top;
+}
+
 .nowrap {
   white-space: nowrap;
 }
index 97b99759784c5e3c597262b60821a920ee8b2e23..87359ccaf3f088979c8f0ead6f911276127cc277 100644 (file)
@@ -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"
index ddd9a5d54edcf8f0a9750972f7c39825047c4111..38d3520623c2423f5fa84a415367583830d7ecd2 100644 (file)
@@ -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}
       />
     );
   }
index 4dde591feb7de37760ac164761e94c453048ba5b..bdc74e4d941452b073f274ba0c8955b7313fad4b 100644 (file)
@@ -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() {
index 2fdd9fe1f6e4a2813478cc5cf8e543c572cee660..783688ebf7c47e13aa40987b043fc28538ffb59a 100644 (file)
@@ -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}
       />
     );
   }
index 908b2b09241fce0fc8ce51291424238c00767316..430575909198f51700b12f1fdba1c4ca33ba35e9 100644 (file)
@@ -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}
         />
index 7b0a4a2a4bb7134e82db681c32fa635bff1a711d..c9d1c0ec807231b59c5174896d7e9690ecbf37a1 100644 (file)
@@ -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}
           />
         )}
index fbfde630913e7047e1e56612dce88f3c80d177ee..839975cbd4d79e2f5da02e2798d46e8a0013d93c 100644 (file)
@@ -17,6 +17,7 @@ exports[`should render correctly the graph and legends 1`] = `
       ]
     }
     metricsTypeFilter={null}
+    removeCustomMetric={[Function]}
     updateGraph={[Function]}
   />
   <GraphsHistory
index 73d9c3e4af211f3ab61a1a81f48ea717db0004a7..78b47814b1ba0fb4ad39b58969ef814fc6f36760 100644 (file)
  */
 // @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 (file)
index 0000000..81864bf
--- /dev/null
@@ -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>
+  );
+}
index 5a8ae9aa5a1326ceb2022728ba9bae96b9801777..ad2e53d848aee691ffbec4af1947e1b14182f9cd 100644 (file)
@@ -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(
index 91a68f25fe887b698967b46425ff8063a75e6738..892cab66813755908966665dda7010340ca6ca0d 100644 (file)
  * 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>
     );
   }
index 89429426d7eed49fab56b0e52d40e890bd03af33..879bdada43f1781a251c084332c9836072b61f01 100644 (file)
@@ -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>
     );
index 097fd543c364f0adce90c694d841648895cff873..ab5971a2899db8a56abe5b7fc3385f5668e2ed01 100644 (file)
@@ -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} />);
index 5b72d18b21ef23665fc82367d0c959e66840f6aa..88776be404593cf8db01f5c7e5c5a465a6e0632a 100644 (file)
@@ -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();
 });
index 69b2384f3963ea176eb3613b87b2fb8dd55b40c9..87a1bfd052c9ed239b4890c56d590d32f92074b5 100644 (file)
@@ -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>
index b7b9f2a61c01785fb2526c2a2cbb406fb0e017b9..3f9a5cc7ef5709325448714b1b5e5c35c726795a 100644 (file)
@@ -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=""
index e40a85b71afb8c479ffcb21bd3ca07f7aa00e699..fb183a7f8b74813ee1885260215d6e9001492433 100644 (file)
@@ -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}>
index 7aa3bebba62cd999449ead483f3fbf656ad1690e..90b8af5f628fff69d11eb1f8f853200c6b9b2d30 100644 (file)
@@ -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}
       />
     );
   }
index 36d73b1a4cff56432722a61a144eb0d693483884..57990df2131ccef2196386ad1fb70f4fab2ac173 100644 (file)
@@ -14,7 +14,6 @@ exports[`should render tags popup correctly 1`] = `
   }
   tags={
     Array [
-      "mytag",
       "test",
       "second",
     ]
index f2dd87eda022f5a9f5d16918386995149e9799c1..a3e895bef65f2aaceff995e2d017b0af4fbfcbdb 100644 (file)
@@ -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']
index c2d4623b8c871392516a1b7bca42f6fb653ffb3f..b6d33603ede03dfaff770ab7ad2ef95b1b7194c4 100644 (file)
@@ -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]}
   />