From 11ba8070b86d0d829716fdc62f6127b1fd6200e0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torkel=20=C3=96degaard?= Date: Tue, 16 Jan 2018 12:52:13 +0100 Subject: [PATCH] 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 --- public/app/core/angular_wrappers.ts | 6 ++ .../core/components/TagFilter/TagBadge.tsx | 37 ++++++++ .../core/components/TagFilter/TagFilter.tsx | 69 +++++++++++++++ .../core/components/TagFilter/TagOption.tsx | 52 +++++++++++ .../core/components/TagFilter/TagValue.tsx | 26 ++++++ public/app/core/components/search/search.html | 36 +++----- public/app/core/components/search/search.ts | 35 ++++++-- public/app/core/directives/tags.ts | 75 +--------------- public/app/core/utils/tags.ts | 86 +++++++++++++++++++ public/sass/base/_type.scss | 1 + public/sass/components/_form_select_box.scss | 30 ++++++- public/sass/components/_gf-form.scss | 5 ++ public/sass/components/_search.scss | 14 ++- public/sass/components/_tags.scss | 2 +- public/sass/components/_tagsinput.scss | 55 +++++++++++- 15 files changed, 420 insertions(+), 109 deletions(-) create mode 100644 public/app/core/components/TagFilter/TagBadge.tsx create mode 100644 public/app/core/components/TagFilter/TagFilter.tsx create mode 100644 public/app/core/components/TagFilter/TagOption.tsx create mode 100644 public/app/core/components/TagFilter/TagValue.tsx create mode 100644 public/app/core/utils/tags.ts diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 6e68e7c8d2f..c3311e39659 100644 --- a/public/app/core/angular_wrappers.ts +++ b/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' }], + ]); } diff --git a/public/app/core/components/TagFilter/TagBadge.tsx b/public/app/core/components/TagFilter/TagBadge.tsx new file mode 100644 index 00000000000..e5c2e357a58 --- /dev/null +++ b/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 { + 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 && {`(${count})`}; + + return ( + + {removeIcon && } + {label} {countLabel} + + ); + } +} diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx new file mode 100644 index 00000000000..0b6058f3dd2 --- /dev/null +++ b/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 { + 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 ( +
+
+ +
+ +
+ ); + } +} diff --git a/public/app/core/components/TagFilter/TagOption.tsx b/public/app/core/components/TagFilter/TagOption.tsx new file mode 100644 index 00000000000..402544dd5f3 --- /dev/null +++ b/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 { + 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 ( + + ); + } +} diff --git a/public/app/core/components/TagFilter/TagValue.tsx b/public/app/core/components/TagFilter/TagValue.tsx new file mode 100644 index 00000000000..2e7819951f2 --- /dev/null +++ b/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 { + 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 ; + } +} diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index 3b83284757e..6fafad19df2 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -13,7 +13,7 @@ spellcheck='false' ng-change="ctrl.search()" ng-blur="ctrl.searchInputBlur()" - /> + />
@@ -31,28 +31,18 @@
- - - - - - - - - - - - - - - - - - - - - - +
+
+ + Filter by: + + Clear + +
+ + + +
diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts index dcb88d7d32d..a22d1dcb88b 100644 --- a/public/app/core/components/search/search.ts +++ b/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() { diff --git a/public/app/core/directives/tags.ts b/public/app/core/directives/tags.ts index b5020b71fc7..6b36ee7d503 100644 --- a/public/app/core/directives/tags.ts +++ b/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); } diff --git a/public/app/core/utils/tags.ts b/public/app/core/utils/tags.ts new file mode 100644 index 00000000000..678fd8c94be --- /dev/null +++ b/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, +}; diff --git a/public/sass/base/_type.scss b/public/sass/base/_type.scss index b69f7a0b439..1c3516c2828 100644 --- a/public/sass/base/_type.scss +++ b/public/sass/base/_type.scss @@ -413,4 +413,5 @@ a.external-link { .highlight-search-match { background: transparent; color: $yellow; + padding: 0; } diff --git a/public/sass/components/_form_select_box.scss b/public/sass/components/_form_select_box.scss index 880f6d4c647..8cb8025a7b2 100644 --- a/public/sass/components/_form_select_box.scss +++ b/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; diff --git a/public/sass/components/_gf-form.scss b/public/sass/components/_gf-form.scss index 599a3e32a21..2113bbae43c 100644 --- a/public/sass/components/_gf-form.scss +++ b/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 { diff --git a/public/sass/components/_search.scss b/public/sass/components/_search.scss index f2db2140f71..d90943f4b60 100644 --- a/public/sass/components/_search.scss +++ b/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; } diff --git a/public/sass/components/_tags.scss b/public/sass/components/_tags.scss index ef9a82bc630..9d015f952fe 100644 --- a/public/sass/components/_tags.scss +++ b/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); diff --git a/public/sass/components/_tagsinput.scss b/public/sass/components/_tagsinput.scss index d25a8fc33ba..915a0f6d480 100644 --- a/public/sass/components/_tagsinput.scss +++ b/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; + } + } +}