Quellcode durchsuchen

Tag filters in search (#10521)

* tag filter: initial react component

* dashboard: move tag filter to filterbox

* tag filter: customize value rendering

* tag filter: get color from name

* tag filter:  custom option renderer

* tag filter: mode with tags in different container

* tag filter: refactor

* refactoring PR #10519

* tag filter: refactor of PR #10521
Torkel Ödegaard vor 8 Jahren
Ursprung
Commit
11ba8070b8

+ 6 - 0
public/app/core/angular_wrappers.ts

@@ -5,6 +5,7 @@ import EmptyListCTA from './components/EmptyListCTA/EmptyListCTA';
 import LoginBackground from './components/Login/LoginBackground';
 import { SearchResult } from './components/search/SearchResult';
 import UserPicker from './components/UserPicker/UserPicker';
+import { TagFilter } from './components/TagFilter/TagFilter';
 
 export function registerAngularDirectives() {
   react2AngularDirective('passwordStrength', PasswordStrength, ['password']);
@@ -13,4 +14,9 @@ export function registerAngularDirectives() {
   react2AngularDirective('loginBackground', LoginBackground, []);
   react2AngularDirective('searchResult', SearchResult, []);
   react2AngularDirective('selectUserPicker', UserPicker, ['backendSrv', 'teamId', 'refreshList']);
+  react2AngularDirective('tagFilter', TagFilter, [
+    'tags',
+    ['onSelect', { watchDepth: 'reference' }],
+    ['tagOptions', { watchDepth: 'reference' }],
+  ]);
 }

+ 37 - 0
public/app/core/components/TagFilter/TagBadge.tsx

@@ -0,0 +1,37 @@
+import React from 'react';
+import tags from 'app/core/utils/tags';
+
+export interface IProps {
+  label: string;
+  removeIcon: boolean;
+  count: number;
+  onClick: any;
+}
+
+export class TagBadge extends React.Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick(event) {
+    this.props.onClick(event);
+  }
+
+  render() {
+    const { label, removeIcon, count } = this.props;
+    const { color, borderColor } = tags.getTagColorsFromName(label);
+    const tagStyle = {
+      backgroundColor: color,
+      borderColor: borderColor,
+    };
+    const countLabel = count !== 0 && <span className="tag-count-label">{`(${count})`}</span>;
+
+    return (
+      <span className={`label label-tag`} onClick={this.onClick} style={tagStyle}>
+        {removeIcon && <i className="fa fa-remove" />}
+        {label} {countLabel}
+      </span>
+    );
+  }
+}

+ 69 - 0
public/app/core/components/TagFilter/TagFilter.tsx

@@ -0,0 +1,69 @@
+import _ from 'lodash';
+import React from 'react';
+import { Async } from 'react-select';
+import { TagValue } from './TagValue';
+import { TagOption } from './TagOption';
+
+export interface IProps {
+  tags: string[];
+  tagOptions: () => any;
+  onSelect: (tag: string) => void;
+}
+
+export class TagFilter extends React.Component<IProps, any> {
+  inlineTags: boolean;
+
+  constructor(props) {
+    super(props);
+
+    this.searchTags = this.searchTags.bind(this);
+    this.onChange = this.onChange.bind(this);
+    this.onTagRemove = this.onTagRemove.bind(this);
+  }
+
+  searchTags(query) {
+    return this.props.tagOptions().then(options => {
+      const tags = _.map(options, tagOption => {
+        return { value: tagOption.term, label: tagOption.term, count: tagOption.count };
+      });
+      return { options: tags };
+    });
+  }
+
+  onChange(newTags) {
+    this.props.onSelect(newTags);
+  }
+
+  onTagRemove(tag) {
+    let newTags = _.without(this.props.tags, tag.label);
+    newTags = _.map(newTags, tag => {
+      return { value: tag };
+    });
+    this.props.onSelect(newTags);
+  }
+
+  render() {
+    let selectOptions = {
+      loadOptions: this.searchTags,
+      onChange: this.onChange,
+      value: this.props.tags,
+      multi: true,
+      className: 'gf-form-input gf-form-input--form-dropdown',
+      placeholder: 'Tags',
+      loadingPlaceholder: 'Loading...',
+      noResultsText: 'No tags found',
+      optionComponent: TagOption,
+    };
+
+    selectOptions['valueComponent'] = TagValue;
+
+    return (
+      <div className="gf-form gf-form--has-input-icon gf-form--grow">
+        <div className="tag-filter">
+          <Async {...selectOptions} />
+        </div>
+        <i className="gf-form-input-icon fa fa-tag" />
+      </div>
+    );
+  }
+}

+ 52 - 0
public/app/core/components/TagFilter/TagOption.tsx

@@ -0,0 +1,52 @@
+import React from 'react';
+import { TagBadge } from './TagBadge';
+
+export interface IProps {
+  onSelect: any;
+  onFocus: any;
+  option: any;
+  isFocused: any;
+  className: any;
+}
+
+export class TagOption extends React.Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.handleMouseDown = this.handleMouseDown.bind(this);
+    this.handleMouseEnter = this.handleMouseEnter.bind(this);
+    this.handleMouseMove = this.handleMouseMove.bind(this);
+  }
+
+  handleMouseDown(event) {
+    event.preventDefault();
+    event.stopPropagation();
+    this.props.onSelect(this.props.option, event);
+  }
+
+  handleMouseEnter(event) {
+    this.props.onFocus(this.props.option, event);
+  }
+
+  handleMouseMove(event) {
+    if (this.props.isFocused) {
+      return;
+    }
+    this.props.onFocus(this.props.option, event);
+  }
+
+  render() {
+    const { option, className } = this.props;
+
+    return (
+      <button
+        onMouseDown={this.handleMouseDown}
+        onMouseEnter={this.handleMouseEnter}
+        onMouseMove={this.handleMouseMove}
+        title={option.title}
+        className={`tag-filter-option btn btn-link ${className || ''}`}
+      >
+        <TagBadge label={option.label} removeIcon={false} count={option.count} onClick={this.handleMouseDown} />
+      </button>
+    );
+  }
+}

+ 26 - 0
public/app/core/components/TagFilter/TagValue.tsx

@@ -0,0 +1,26 @@
+import React from 'react';
+import { TagBadge } from './TagBadge';
+
+export interface IProps {
+  value: any;
+  className: any;
+  onClick: any;
+  onRemove: any;
+}
+
+export class TagValue extends React.Component<IProps, any> {
+  constructor(props) {
+    super(props);
+    this.onClick = this.onClick.bind(this);
+  }
+
+  onClick(event) {
+    this.props.onRemove(this.props.value, event);
+  }
+
+  render() {
+    const { value } = this.props;
+
+    return <TagBadge label={value.label} removeIcon={true} count={0} onClick={this.onClick} />;
+  }
+}

+ 13 - 23
public/app/core/components/search/search.html

@@ -13,7 +13,7 @@
 						spellcheck='false'
 						ng-change="ctrl.search()"
 						ng-blur="ctrl.searchInputBlur()"
-						/>
+            />
 
 		<div class="search-field-spacer"></div>
 	</div>
@@ -31,28 +31,18 @@
     </div>
 
     <div class="search-dropdown__col_2">
-      <!-- <div class="search&#45;filter&#45;box"> -->
-      <!--   <div class="search&#45;filter&#45;box__header"> -->
-      <!--     <i class="fa fa&#45;filter"></i> -->
-      <!--     Filter by: -->
-      <!--     <a class="pointer pull&#45;right small"> -->
-      <!--       <i class="fa fa&#45;remove"></i> Clear -->
-      <!--     </a> -->
-      <!--   </div> -->
-      <!--  -->
-      <!--   <div class="gf&#45;form"> -->
-      <!--     <folder&#45;picker initial&#45;title="ctrl.initialFolderFilterTitle" -->
-      <!--                    on&#45;change="ctrl.onFolderChange($folder)" -->
-      <!--                    label&#45;class="width&#45;4"> -->
-      <!--     </folder&#45;picker> -->
-      <!--   </div> -->
-      <!--  -->
-      <!--   <div class="gf&#45;form"> -->
-      <!--     <label class="gf&#45;form&#45;label width&#45;4">Tags</label> -->
-      <!--     <bootstrap&#45;tagsinput ng&#45;model="ctrl.dashboard.tags" tagclass="label label&#45;tag" placeholder="add tags"> -->
-      <!--     </bootstrap&#45;tagsinput> -->
-      <!--   </div> -->
-      <!-- </div> -->
+      <div class="search-filter-box" ng-click="ctrl.onFilterboxClick()">
+        <div class="search-filter-box__header">
+          <i class="fa fa-filter"></i>
+          Filter by:
+          <a class="pointer pull-right small" ng-click="ctrl.clearSearchFilter()">
+            <i class="fa fa-remove"></i> Clear
+          </a>
+        </div>
+
+        <tag-filter tags="ctrl.query.tag" tagOptions="ctrl.getTags" onSelect="ctrl.onTagSelect">
+        </tag-filter>
+      </div>
 
       <div class="search-filter-box">
         <a href="dashboard/new" class="search-filter-box-link">

+ 30 - 5
public/app/core/components/search/search.ts

@@ -22,6 +22,8 @@ export class SearchCtrl {
     appEvents.on('hide-dash-search', this.closeSearch.bind(this), $scope);
 
     this.initialFolderFilterTitle = 'All';
+    this.getTags = this.getTags.bind(this);
+    this.onTagSelect = this.onTagSelect.bind(this);
   }
 
   closeSearch() {
@@ -88,6 +90,23 @@ export class SearchCtrl {
     }
   }
 
+  searchInputBlur() {
+    this.search();
+  }
+
+  onFilterboxClick() {
+    this.giveSearchFocus = 0;
+    this.preventClose();
+  }
+
+  preventClose() {
+    this.ignoreClose = true;
+
+    this.$timeout(() => {
+      this.ignoreClose = false;
+    }, 100);
+  }
+
   moveSelection(direction) {
     if (this.results.length === 0) {
       return;
@@ -160,7 +179,6 @@ export class SearchCtrl {
     if (_.indexOf(this.query.tag, tag) === -1) {
       this.query.tag.push(tag);
       this.search();
-      this.giveSearchFocus = this.giveSearchFocus + 1;
     }
   }
 
@@ -173,10 +191,17 @@ export class SearchCtrl {
   }
 
   getTags() {
-    return this.searchSrv.getDashboardTags().then(results => {
-      this.results = results;
-      this.giveSearchFocus = this.giveSearchFocus + 1;
-    });
+    return this.searchSrv.getDashboardTags();
+  }
+
+  onTagSelect(newTags) {
+    this.query.tag = _.map(newTags, tag => tag.value);
+    this.search();
+  }
+
+  clearSearchFilter() {
+    this.query.tag = [];
+    this.search();
   }
 
   showStarred() {

+ 2 - 73
public/app/core/directives/tags.ts

@@ -1,82 +1,11 @@
 import angular from 'angular';
 import $ from 'jquery';
 import coreModule from '../core_module';
+import tags from 'app/core/utils/tags';
 import 'vendor/tagsinput/bootstrap-tagsinput.js';
 
-function djb2(str) {
-  var hash = 5381;
-  for (var i = 0; i < str.length; i++) {
-    hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
-  }
-  return hash;
-}
-
 function setColor(name, element) {
-  var hash = djb2(name.toLowerCase());
-  var colors = [
-    '#E24D42',
-    '#1F78C1',
-    '#BA43A9',
-    '#705DA0',
-    '#466803',
-    '#508642',
-    '#447EBC',
-    '#C15C17',
-    '#890F02',
-    '#757575',
-    '#0A437C',
-    '#6D1F62',
-    '#584477',
-    '#629E51',
-    '#2F4F4F',
-    '#BF1B00',
-    '#806EB7',
-    '#8a2eb8',
-    '#699e00',
-    '#000000',
-    '#3F6833',
-    '#2F575E',
-    '#99440A',
-    '#E0752D',
-    '#0E4AB4',
-    '#58140C',
-    '#052B51',
-    '#511749',
-    '#3F2B5B',
-  ];
-  var borderColors = [
-    '#FF7368',
-    '#459EE7',
-    '#E069CF',
-    '#9683C6',
-    '#6C8E29',
-    '#76AC68',
-    '#6AA4E2',
-    '#E7823D',
-    '#AF3528',
-    '#9B9B9B',
-    '#3069A2',
-    '#934588',
-    '#7E6A9D',
-    '#88C477',
-    '#557575',
-    '#E54126',
-    '#A694DD',
-    '#B054DE',
-    '#8FC426',
-    '#262626',
-    '#658E59',
-    '#557D84',
-    '#BF6A30',
-    '#FF9B53',
-    '#3470DA',
-    '#7E3A32',
-    '#2B5177',
-    '#773D6F',
-    '#655181',
-  ];
-  var color = colors[Math.abs(hash % colors.length)];
-  var borderColor = borderColors[Math.abs(hash % borderColors.length)];
+  const { color, borderColor } = tags.getTagColorsFromName(name);
   element.css('background-color', color);
   element.css('border-color', borderColor);
 }

+ 86 - 0
public/app/core/utils/tags.ts

@@ -0,0 +1,86 @@
+const TAG_COLORS = [
+  '#E24D42',
+  '#1F78C1',
+  '#BA43A9',
+  '#705DA0',
+  '#466803',
+  '#508642',
+  '#447EBC',
+  '#C15C17',
+  '#890F02',
+  '#757575',
+  '#0A437C',
+  '#6D1F62',
+  '#584477',
+  '#629E51',
+  '#2F4F4F',
+  '#BF1B00',
+  '#806EB7',
+  '#8a2eb8',
+  '#699e00',
+  '#000000',
+  '#3F6833',
+  '#2F575E',
+  '#99440A',
+  '#E0752D',
+  '#0E4AB4',
+  '#58140C',
+  '#052B51',
+  '#511749',
+  '#3F2B5B',
+];
+
+const TAG_BORDER_COLORS = [
+  '#FF7368',
+  '#459EE7',
+  '#E069CF',
+  '#9683C6',
+  '#6C8E29',
+  '#76AC68',
+  '#6AA4E2',
+  '#E7823D',
+  '#AF3528',
+  '#9B9B9B',
+  '#3069A2',
+  '#934588',
+  '#7E6A9D',
+  '#88C477',
+  '#557575',
+  '#E54126',
+  '#A694DD',
+  '#B054DE',
+  '#8FC426',
+  '#262626',
+  '#658E59',
+  '#557D84',
+  '#BF6A30',
+  '#FF9B53',
+  '#3470DA',
+  '#7E3A32',
+  '#2B5177',
+  '#773D6F',
+  '#655181',
+];
+
+/**
+ * Returns tag badge background and border colors based on hashed tag name.
+ * @param name tag name
+ */
+export function getTagColorsFromName(name: string): { color: string; borderColor: string } {
+  let hash = djb2(name.toLowerCase());
+  let color = TAG_COLORS[Math.abs(hash % TAG_COLORS.length)];
+  let borderColor = TAG_BORDER_COLORS[Math.abs(hash % TAG_BORDER_COLORS.length)];
+  return { color, borderColor };
+}
+
+function djb2(str) {
+  let hash = 5381;
+  for (var i = 0; i < str.length; i++) {
+    hash = (hash << 5) + hash + str.charCodeAt(i); /* hash * 33 + c */
+  }
+  return hash;
+}
+
+export default {
+  getTagColorsFromName,
+};

+ 1 - 0
public/sass/base/_type.scss

@@ -413,4 +413,5 @@ a.external-link {
 .highlight-search-match {
   background: transparent;
   color: $yellow;
+  padding: 0;
 }

+ 29 - 1
public/sass/components/_form_select_box.scss

@@ -26,18 +26,41 @@ $select-menu-box-shadow: $menu-dropdown-shadow;
   @include box-shadow($shadow);
 }
 
+// react-select tweaks
 .gf-form-input--form-dropdown {
   padding: 0;
   border: 0;
   overflow: visible;
 
   .Select-placeholder {
-    color: $gray-4;
+    color: $input-color-placeholder;
   }
 
   > .Select-control {
     @include select-control();
     border-color: $dark-3;
+
+    input {
+      min-width: 1rem;
+    }
+
+    .Select-clear,
+    .Select-arrow {
+      margin-right: 8px;
+    }
+
+    .Select-value {
+      display: inline-block;
+      padding: 2px 4px;
+      font-size: $font-size-base * 0.846;
+      font-weight: bold;
+      line-height: 14px; // ensure proper line-height if floated
+      color: $white;
+      vertical-align: baseline;
+      white-space: nowrap;
+      text-shadow: 0 -1px 0 rgba(0, 0, 0, 0.25);
+      background-color: $gray-1;
+    }
   }
 
   &.is-open > .Select-control {
@@ -50,6 +73,11 @@ $select-menu-box-shadow: $menu-dropdown-shadow;
     @include select-control-focus();
   }
 
+  &.is-focused:not(.is-open) > .Select-control {
+    background-color: $input-bg;
+    @include select-control-focus();
+  }
+
   .Select-menu-outer {
     border: 0;
     width: auto;

+ 5 - 0
public/sass/components/_gf-form.scss

@@ -51,6 +51,11 @@ $input-border: 1px solid $input-border-color;
       color: $text-muted;
     }
   }
+
+  .Select--multi .Select-multi-value-wrapper,
+  .Select-placeholder {
+    padding-left: 30px;
+  }
 }
 
 .gf-form-disabled {

+ 12 - 2
public/sass/components/_search.scss

@@ -26,7 +26,7 @@
   box-shadow: $navbarShadow;
   position: relative;
 
-  input {
+  & > input {
     max-width: 653px;
     //padding: 0.5rem 1.5rem 0.5rem 0;
     padding: 1rem 1rem 0.75rem 1rem;
@@ -38,6 +38,13 @@
     background-color: $navbarButtonBackground;
     flex-grow: 10;
   }
+
+  // .tag-filter {
+  //   .Select-control {
+  //     width: 300px;
+  //     background-color: $navbarBackground;
+  //   }
+  // }
 }
 
 .search-field-spacer {
@@ -67,13 +74,16 @@
   flex-grow: 1;
   height: 100%;
   padding-top: 16px;
+  display: flex;
+  flex-direction: column;
+  align-items: flex-start;
 }
 
 .search-filter-box {
   background: $search-filter-box-bg;
   border-radius: 2px;
   padding: $spacer*1.5;
-  max-width: 340px;
+  min-width: 340px;
   margin-bottom: $spacer * 1.5;
   margin-left: $spacer * 1.5;
 }

+ 1 - 1
public/sass/components/_tags.scss

@@ -21,7 +21,7 @@
   border-radius: 3px;
   text-shadow: none;
   font-size: 13px;
-  padding: 2px 6px;
+  padding: 3px 6px 1px 6px;
   border-width: 1px;
   border-style: solid;
   box-shadow: 0 0 1px rgba($white, 0.2);

+ 51 - 4
public/sass/components/_tagsinput.scss

@@ -21,16 +21,15 @@
     margin-right: 2px;
     color: white;
 
-    [data-role="remove"] {
+    [data-role='remove'] {
       margin-left: 8px;
       cursor: pointer;
       &::after {
-        content: "x";
+        content: 'x';
         padding: 0px 2px;
       }
       &:hover {
-        box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2),
-          0 1px 2px rgba(0, 0, 0, 0.05);
+        box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.2), 0 1px 2px rgba(0, 0, 0, 0.05);
         &:active {
           box-shadow: inset 0 3px 5px rgba(0, 0, 0, 0.125);
         }
@@ -38,3 +37,51 @@
     }
   }
 }
+
+.tag-filter {
+  line-height: 22px;
+  flex-grow: 1;
+
+  .label-tag {
+    margin-left: 6px;
+    font-size: 11px;
+    cursor: pointer;
+
+    .fa.fa-remove {
+      margin-right: 3px;
+    }
+  }
+
+  .tag-filter-option {
+    position: relative;
+    text-align: left;
+    width: 100%;
+    display: block;
+    border-radius: 0;
+  }
+
+  .tag-count-label {
+    margin-left: 3px;
+  }
+
+  .gf-form-input--form-dropdown {
+    .Select-menu-outer {
+      border: 0;
+      width: 100%;
+    }
+  }
+}
+
+.tag-filter-values {
+  display: inline;
+
+  .label-tag {
+    margin: 6px 6px 0px 0px;
+    font-size: 11px;
+    cursor: pointer;
+
+    .fa.fa-remove {
+      margin-right: 3px;
+    }
+  }
+}