From 85dc4e565e5a4b01ea40518da8bc3fd1d781ce3b Mon Sep 17 00:00:00 2001 From: Alex Khomenko Date: Thu, 26 Mar 2020 11:09:08 +0200 Subject: [PATCH] Search/migrate search results (#22930) * Search: Setup SearchResults.tsx * Search: add watchDepth * Search: Use SearchResults.tsx in Angular template * Search: Render search result header * Search: Move new search components to features/search * Search: Render nested dashboards * Search: Expand dashboard folder * Search: Remove fa prefix from icon names * Search: Enable search results toggling * Search: Add onItemClick handler * Search: Add missing aria-label * Search: Add no results message * Search: Fix e2e selectors * Search: Update SearchField imports * Search: Add conditional classes * Search: Abstract DashboardCheckbox * Search: Separate ResultItem * Search: Style ResultItem * Search: Separate search components * Search: Tweak checkbox styling * Search: Simplify component names * Search: Separate tag component * Search: Checkbox docs * Search: Remove inline on click * Add Tag component * Add Tag story * Add TagList * Group Tab and TabList * Fix typechecks * Remove Meta * Use forwardRef for the Tag * Search: Use TagList from grafana/ui * Search: Add media query for TagList * Search: Add types * Search: Remove selectThemeVariant from SearchItem.tsx * Search: Style section + header * Search: Use semantic html * Search: Adjust section padding * Search: Setup tests * Search: Fix tests * Search: tweak result styles * Search: Expand SearchResults tests * Search: Add SearchItem tests * Search: Use SearchResults in search.html * Search: Toggle search result sections * Search: Make selected prop optional * Search: Fix tag selection * Search: Fix tag filter onChange * Search: Fix uncontrolled state change warning * Search: Update icon names * Search: memoize SearchCheckbox.tsx * Search: Update types * Search: Cleanup events * Search: Semantic html * Use styleMixins * Search: Tweak styling * Search: useCallback for checkbox toggle * Search: Add stylesFactory Co-authored-by: CirceCI --- .../grafana-ui/src/components/Tags/Tag.tsx | 8 +- .../src/components/Tags/TagList.tsx | 4 +- packages/grafana-ui/src/themes/mixins.ts | 7 + packages/grafana-ui/src/utils/tags.ts | 2 +- public/app/core/angular_wrappers.ts | 12 +- .../core/components/TagFilter/TagFilter.tsx | 4 +- .../manage_dashboards/manage_dashboards.html | 19 ++- .../manage_dashboards/manage_dashboards.ts | 49 ++++-- public/app/core/components/search/search.html | 9 +- public/app/core/components/search/search.ts | 21 ++- .../core/components/search/search_results.ts | 9 - public/app/core/services/search_srv.ts | 12 +- .../search/components/SearchCheckbox.tsx | 31 ++++ .../search/components}/SearchField.tsx | 4 +- .../search/components/SearchItem.test.tsx | 51 ++++++ .../features/search/components/SearchItem.tsx | 107 ++++++++++++ .../search/components/SearchResults.test.tsx | 99 +++++++++++ .../search/components/SearchResults.tsx | 159 ++++++++++++++++++ public/app/features/search/index.ts | 5 + public/app/features/search/types.ts | 54 ++++++ 20 files changed, 608 insertions(+), 58 deletions(-) create mode 100644 public/app/features/search/components/SearchCheckbox.tsx rename public/app/{core/components/search => features/search/components}/SearchField.tsx (97%) create mode 100644 public/app/features/search/components/SearchItem.test.tsx create mode 100644 public/app/features/search/components/SearchItem.tsx create mode 100644 public/app/features/search/components/SearchResults.test.tsx create mode 100644 public/app/features/search/components/SearchResults.tsx create mode 100644 public/app/features/search/index.ts create mode 100644 public/app/features/search/types.ts diff --git a/packages/grafana-ui/src/components/Tags/Tag.tsx b/packages/grafana-ui/src/components/Tags/Tag.tsx index 611646ba665..dbb69b6fe03 100644 --- a/packages/grafana-ui/src/components/Tags/Tag.tsx +++ b/packages/grafana-ui/src/components/Tags/Tag.tsx @@ -4,19 +4,21 @@ import { GrafanaTheme } from '@grafana/data'; import { useTheme } from '../../themes'; import { getTagColorsFromName } from '../../utils'; +export type OnTagClick = (name: string, event: React.MouseEvent) => any; + export interface Props extends Omit, 'onClick'> { /** Name of the tag to display */ name: string; - onClick?: (name: string) => any; + onClick?: OnTagClick; } export const Tag = forwardRef(({ name, onClick, className, ...rest }, ref) => { const theme = useTheme(); const styles = getTagStyles(theme, name); - const onTagClick = () => { + const onTagClick = (event: React.MouseEvent) => { if (onClick) { - onClick(name); + onClick(name, event); } }; diff --git a/packages/grafana-ui/src/components/Tags/TagList.tsx b/packages/grafana-ui/src/components/Tags/TagList.tsx index da62227b53f..bd4b1fa11a5 100644 --- a/packages/grafana-ui/src/components/Tags/TagList.tsx +++ b/packages/grafana-ui/src/components/Tags/TagList.tsx @@ -1,10 +1,10 @@ import React, { FC } from 'react'; import { cx, css } from 'emotion'; -import { Tag } from './Tag'; +import { OnTagClick, Tag } from './Tag'; export interface Props { tags: string[]; - onClick?: (name: string) => any; + onClick?: OnTagClick; /** Custom styles for the wrapper component */ className?: string; } diff --git a/packages/grafana-ui/src/themes/mixins.ts b/packages/grafana-ui/src/themes/mixins.ts index 21283b3a207..82e4fa6445b 100644 --- a/packages/grafana-ui/src/themes/mixins.ts +++ b/packages/grafana-ui/src/themes/mixins.ts @@ -47,6 +47,13 @@ export function listItem(theme: GrafanaTheme): string { `; } +export function listItemSelected(theme: GrafanaTheme): string { + return ` + background: ${theme.isLight ? theme.colors.gray6 : theme.colors.dark9}; + color: ${theme.colors.textStrong}; + `; +} + export const panelEditorNestedListStyles = stylesFactory((theme: GrafanaTheme) => { const borderColor = selectThemeVariant( { diff --git a/packages/grafana-ui/src/utils/tags.ts b/packages/grafana-ui/src/utils/tags.ts index 431cd562193..a75ac5f7f09 100644 --- a/packages/grafana-ui/src/utils/tags.ts +++ b/packages/grafana-ui/src/utils/tags.ts @@ -66,7 +66,7 @@ const TAG_BORDER_COLORS = [ * 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 } { +export function getTagColorsFromName(name = ''): { color: string; borderColor: string } { const hash = djb2(name.toLowerCase()); const color = TAG_COLORS[Math.abs(hash % TAG_COLORS.length)]; const borderColor = TAG_BORDER_COLORS[Math.abs(hash % TAG_BORDER_COLORS.length)]; diff --git a/public/app/core/angular_wrappers.ts b/public/app/core/angular_wrappers.ts index 01a1b238e1c..af2921302bc 100644 --- a/public/app/core/angular_wrappers.ts +++ b/public/app/core/angular_wrappers.ts @@ -18,7 +18,6 @@ import { UnitPicker, } from '@grafana/ui'; import { FunctionEditor } from 'app/plugins/datasource/graphite/FunctionEditor'; -import { SearchField } from './components/search/SearchField'; import ReactProfileWrapper from 'app/features/profile/ReactProfileWrapper'; import { LokiAnnotationsQueryEditor } from '../plugins/datasource/loki/components/AnnotationsQueryEditor'; import { HelpModal } from './components/help/HelpModal'; @@ -29,6 +28,7 @@ import { SaveDashboardButtonConnected, } from '../features/dashboard/components/SaveDashboard/SaveDashboardButton'; import { VariableEditorContainer } from '../features/variables/editor/VariableEditorContainer'; +import { SearchField, SearchResults } from '../features/search'; export function registerAngularDirectives() { react2AngularDirective('footer', Footer, []); @@ -50,12 +50,22 @@ export function registerAngularDirectives() { 'infoBox', 'infoBoxTitle', ]); + //Search react2AngularDirective('searchField', SearchField, [ 'query', 'autoFocus', ['onChange', { watchDepth: 'reference' }], ['onKeyDown', { watchDepth: 'reference' }], ]); + react2AngularDirective('searchResults', SearchResults, [ + 'results', + 'editable', + 'selectors', + ['onSelectionChanged', { watchDepth: 'reference' }], + ['onTagSelected', { watchDepth: 'reference' }], + ['onFolderExpanding', { watchDepth: 'reference' }], + ['onToggleSelection', { watchDepth: 'reference' }], + ]); react2AngularDirective('tagFilter', TagFilter, [ 'tags', ['onChange', { watchDepth: 'reference' }], diff --git a/public/app/core/components/TagFilter/TagFilter.tsx b/public/app/core/components/TagFilter/TagFilter.tsx index ccdde95fb6a..353fe871cc1 100644 --- a/public/app/core/components/TagFilter/TagFilter.tsx +++ b/public/app/core/components/TagFilter/TagFilter.tsx @@ -39,7 +39,9 @@ export class TagFilter extends React.Component { }; onChange = (newTags: any[]) => { - this.props.onChange(newTags.map(tag => tag.value)); + // On remove with 1 item returns null, so we need to make sure it's an empty array in that case + // https://github.com/JedWatson/react-select/issues/3632 + this.props.onChange((newTags || []).map(tag => tag.value)); }; render() { diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.html b/public/app/core/components/manage_dashboards/manage_dashboards.html index 5ec38555503..ba5f28ed203 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.html +++ b/public/app/core/components/manage_dashboards/manage_dashboards.html @@ -102,11 +102,12 @@
-
@@ -114,14 +115,14 @@
-
diff --git a/public/app/core/components/manage_dashboards/manage_dashboards.ts b/public/app/core/components/manage_dashboards/manage_dashboards.ts index 81e56d6f425..df440ed0356 100644 --- a/public/app/core/components/manage_dashboards/manage_dashboards.ts +++ b/public/app/core/components/manage_dashboards/manage_dashboards.ts @@ -143,17 +143,19 @@ export class ManageDashboardsCtrl { } } - selectionChanged() { + selectionChanged = () => { let selectedDashboards = 0; - for (const section of this.sections) { - selectedDashboards += _.filter(section.items, { checked: true } as any).length; - } + if (this.sections) { + for (const section of this.sections) { + selectedDashboards += _.filter(section.items, { checked: true } as any).length; + } - const selectedFolders = _.filter(this.sections, { checked: true }).length; - this.canMove = selectedDashboards > 0; - this.canDelete = selectedDashboards > 0 || selectedFolders > 0; - } + const selectedFolders = _.filter(this.sections, { checked: true }).length; + this.canMove = selectedDashboards > 0; + this.canDelete = selectedDashboards > 0 || selectedFolders > 0; + } + }; getFoldersAndDashboardsToDelete(): FoldersAndDashboardUids { const selectedDashboards: FoldersAndDashboardUids = { @@ -254,13 +256,14 @@ export class ManageDashboardsCtrl { }); } - filterByTag(tag: any) { - if (_.indexOf(this.query.tag, tag) === -1) { - this.query.tag.push(tag); + filterByTag = (tag: any) => { + if (tag) { + if (_.indexOf(this.query.tag, tag) === -1) { + this.query.tag.push(tag); + } } - return this.refreshList(); - } + }; onQueryChange() { return this.refreshList(); @@ -333,6 +336,26 @@ export class ManageDashboardsCtrl { return url; } + + // TODO handle this inside SearchResults component + toggleSelection = (item: any, evt: any) => { + if (evt) { + evt.stopPropagation(); + evt.preventDefault(); + } + + item.checked = !item.checked; + + if (item.items) { + _.each(item.items, i => { + i.checked = item.checked; + }); + } + + if (this.selectionChanged) { + this.selectionChanged(); + } + }; } export function manageDashboardsDirective() { diff --git a/public/app/core/components/search/search.html b/public/app/core/components/search/search.html index bf5d4953228..3b4ed5fc29c 100644 --- a/public/app/core/components/search/search.html +++ b/public/app/core/components/search/search.html @@ -16,11 +16,12 @@
No dashboards matching your query were found.
- + on-tag-selected="ctrl.filterByTag" + on-folder-expanding="ctrl.folderExpanding" + on-selection-changed="ctrl.selectionChanged" + />
diff --git a/public/app/core/components/search/search.ts b/public/app/core/components/search/search.ts index 7a0a2f0fd80..6a45f5dc076 100644 --- a/public/app/core/components/search/search.ts +++ b/public/app/core/components/search/search.ts @@ -261,12 +261,14 @@ export class SearchCtrl { return query.query === '' && query.starred === false && query.tags.length === 0; } - filterByTag(tag: string) { - if (_.indexOf(this.query.tags, tag) === -1) { - this.query.tags.push(tag); - this.search(); + filterByTag = (tag: string) => { + if (tag) { + if (_.indexOf(this.query.tags, tag) === -1) { + this.query.tags.push(tag); + this.search(); + } } - } + }; removeTag(tag: string, evt: any) { this.query.tags = _.without(this.query.tags, tag); @@ -297,15 +299,20 @@ export class SearchCtrl { this.search(); } + selectionChanged = () => { + // TODO remove after React-side state management is implemented + // This method is only used as a callback after toggling section, to trigger results rerender + }; + search() { this.showImport = false; this.selectedIndex = -1; this.searchDashboards(this.query.parsedQuery['folder']); } - folderExpanding() { + folderExpanding = () => { this.moveSelection(0); - } + }; private getFlattenedResultForNavigation(): SelectedIndicies[] { let folderIndex = 0; diff --git a/public/app/core/components/search/search_results.ts b/public/app/core/components/search/search_results.ts index 48a4a4ef0f6..cf897af36a7 100644 --- a/public/app/core/components/search/search_results.ts +++ b/public/app/core/components/search/search_results.ts @@ -44,15 +44,6 @@ export class SearchResultsCtrl { } } - navigateToFolder(section: any, evt: any) { - this.$location.path(section.url); - - if (evt) { - evt.stopPropagation(); - evt.preventDefault(); - } - } - toggleSelection(item: any, evt: any) { item.checked = !item.checked; diff --git a/public/app/core/services/search_srv.ts b/public/app/core/services/search_srv.ts index 10d64cccd9c..79da932ad63 100644 --- a/public/app/core/services/search_srv.ts +++ b/public/app/core/services/search_srv.ts @@ -26,7 +26,7 @@ export class SearchSrv { if (result.length > 0) { sections['recent'] = { title: 'Recent', - icon: 'fa fa-clock-o', + icon: 'clock-o', score: -1, removable: true, expanded: this.recentIsOpen, @@ -81,7 +81,7 @@ export class SearchSrv { if (result.length > 0) { sections['starred'] = { title: 'Starred', - icon: 'fa fa-star-o', + icon: 'star-o', score: -2, expanded: this.starredIsOpen, toggle: this.toggleStarred.bind(this), @@ -141,7 +141,7 @@ export class SearchSrv { items: [], toggle: this.toggleFolder.bind(this), url: hit.url, - icon: 'fa fa-folder', + icon: 'folder', score: _.keys(sections).length, }; } @@ -161,7 +161,7 @@ export class SearchSrv { title: hit.folderTitle, url: hit.folderUrl, items: [], - icon: 'fa fa-folder-open', + icon: 'folder-open', toggle: this.toggleFolder.bind(this), score: _.keys(sections).length, }; @@ -170,7 +170,7 @@ export class SearchSrv { id: 0, title: 'General', items: [], - icon: 'fa fa-folder-open', + icon: 'folder-open', toggle: this.toggleFolder.bind(this), score: _.keys(sections).length, }; @@ -186,7 +186,7 @@ export class SearchSrv { private toggleFolder(section: Section) { section.expanded = !section.expanded; - section.icon = section.expanded ? 'fa fa-folder-open' : 'fa fa-folder'; + section.icon = section.expanded ? 'folder-open' : 'folder'; if (section.items.length) { return Promise.resolve(section); diff --git a/public/app/features/search/components/SearchCheckbox.tsx b/public/app/features/search/components/SearchCheckbox.tsx new file mode 100644 index 00000000000..ddbb6b2c2a6 --- /dev/null +++ b/public/app/features/search/components/SearchCheckbox.tsx @@ -0,0 +1,31 @@ +import React, { FC, memo } from 'react'; +import { css } from 'emotion'; +import { Forms, stylesFactory } from '@grafana/ui'; + +interface Props { + checked: boolean; + onClick: any; + editable?: boolean; +} + +export const SearchCheckbox: FC = memo(({ checked = false, onClick, editable = false }) => { + const styles = getStyles(); + + return ( + editable && ( +
+ +
+ ) + ); +}); + +const getStyles = stylesFactory(() => ({ + // Vertically align absolutely positioned checkbox element + wrapper: css` + height: 21px; + & > label { + height: 100%; + } + `, +})); diff --git a/public/app/core/components/search/SearchField.tsx b/public/app/features/search/components/SearchField.tsx similarity index 97% rename from public/app/core/components/search/SearchField.tsx rename to public/app/features/search/components/SearchField.tsx index 10ddaac30b3..5e513ffa97d 100644 --- a/public/app/core/components/search/SearchField.tsx +++ b/public/app/features/search/components/SearchField.tsx @@ -1,10 +1,10 @@ import React, { useContext } from 'react'; +import { css, cx } from 'emotion'; // @ts-ignore import tinycolor from 'tinycolor2'; -import { SearchQuery } from './search'; -import { css, cx } from 'emotion'; import { ThemeContext } from '@grafana/ui'; import { GrafanaTheme } from '@grafana/data'; +import { SearchQuery } from 'app/core/components/search/search'; type Omit = Pick>; diff --git a/public/app/features/search/components/SearchItem.test.tsx b/public/app/features/search/components/SearchItem.test.tsx new file mode 100644 index 00000000000..6ef73e50f69 --- /dev/null +++ b/public/app/features/search/components/SearchItem.test.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { SearchItem, Props } from './SearchItem'; +import { Tag } from '@grafana/ui'; + +const data = { + id: 1, + uid: 'lBdLINUWk', + title: 'Test 1', + uri: 'db/test1', + url: '/d/lBdLINUWk/test1', + slug: '', + type: 'dash-db', + //@ts-ignore + tags: ['Tag1', 'Tag2'], + isStarred: false, + checked: false, +}; + +const setup = (propOverrides?: Partial, renderMethod = shallow) => { + const props: Props = { + item: data, + onToggleSelection: jest.fn(), + onTagSelected: jest.fn(), + editable: false, + }; + + Object.assign(props, propOverrides); + + const wrapper = renderMethod(); + const instance = wrapper.instance(); + + return { + wrapper, + instance, + }; +}; + +describe('SearchItem', () => { + it('should render the item', () => { + const { wrapper } = setup(); + expect(wrapper.find({ 'aria-label': 'Dashboard search item Test 1' })).toHaveLength(1); + expect(wrapper.findWhere(comp => comp.type() === 'div' && comp.text() === 'Test 1')).toHaveLength(1); + }); + + it("should render item's tags", () => { + // @ts-ignore + const { wrapper } = setup({}, mount); + expect(wrapper.find(Tag)).toHaveLength(2); + }); +}); diff --git a/public/app/features/search/components/SearchItem.tsx b/public/app/features/search/components/SearchItem.tsx new file mode 100644 index 00000000000..3fb040ba089 --- /dev/null +++ b/public/app/features/search/components/SearchItem.tsx @@ -0,0 +1,107 @@ +import React, { FC, useCallback } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { e2e } from '@grafana/e2e'; +import { Icon, useTheme, TagList, styleMixins, stylesFactory } from '@grafana/ui'; +import appEvents from 'app/core/app_events'; +import { CoreEvents } from 'app/types'; +import { DashboardSectionItem, ItemClickWithEvent } from '../types'; +import { SearchCheckbox } from './SearchCheckbox'; + +export interface Props { + item: DashboardSectionItem; + editable?: boolean; + onToggleSelection: ItemClickWithEvent; + onTagSelected: (name: string) => any; +} + +const { selectors } = e2e.pages.Dashboards; + +export const SearchItem: FC = ({ item, editable, onToggleSelection, onTagSelected }) => { + const theme = useTheme(); + const styles = getResultsItemStyles(theme); + + const onItemClick = () => { + //Check if one string can be found in the other + if (window.location.pathname.includes(item.url) || item.url.includes(window.location.pathname)) { + appEvents.emit(CoreEvents.hideDashSearch); + } + }; + + const navigate = () => { + window.location.pathname = item.url; + }; + + const tagSelected = (tag: string, event: React.MouseEvent) => { + event.stopPropagation(); + onTagSelected(tag); + }; + + const toggleItem = useCallback( + (event: React.MouseEvent) => { + onToggleSelection(item, event); + }, + [item] + ); + + return ( +
  • + + +
    + {item.title} + {item.folderTitle} +
    + +
  • + ); +}; + +const getResultsItemStyles = stylesFactory((theme: GrafanaTheme) => ({ + wrapper: css` + ${styleMixins.listItem(theme)}; + display: flex; + align-items: center; + margin: ${theme.spacing.xxs}; + padding: 0 ${theme.spacing.sm}; + min-height: 37px; + + :hover { + cursor: pointer; + } + `, + selected: css` + ${styleMixins.listItemSelected(theme)}; + `, + body: css` + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 1 auto; + overflow: hidden; + padding: 0 10px; + `, + folderTitle: css` + color: ${theme.colors.textWeak}; + font-size: ${theme.typography.size.xs}; + line-height: ${theme.typography.lineHeight.xs}; + position: relative; + top: -1px; + `, + icon: css` + font-size: ${theme.typography.size.lg}; + width: auto; + height: auto; + padding: 1px 2px 0 10px; + `, + tags: css` + justify-content: flex-end; + @media only screen and (max-width: ${theme.breakpoints.md}) { + display: none; + } + `, +})); diff --git a/public/app/features/search/components/SearchResults.test.tsx b/public/app/features/search/components/SearchResults.test.tsx new file mode 100644 index 00000000000..bc53da4fd6b --- /dev/null +++ b/public/app/features/search/components/SearchResults.test.tsx @@ -0,0 +1,99 @@ +import React from 'react'; +import { shallow, mount } from 'enzyme'; +import { SearchResults, Props } from './SearchResults'; + +const data = [ + { + id: 2, + uid: 'JB_zdOUWk', + title: 'gdev dashboards', + expanded: false, + //@ts-ignore + items: [], + url: '/dashboards/f/JB_zdOUWk/gdev-dashboards', + icon: 'folder', + score: 0, + checked: false, + }, + { + id: 0, + title: 'General', + items: [ + { + id: 1, + uid: 'lBdLINUWk', + title: 'Test 1', + uri: 'db/test1', + url: '/d/lBdLINUWk/test1', + slug: '', + type: 'dash-db', + //@ts-ignore + tags: [], + isStarred: false, + checked: false, + }, + { + id: 46, + uid: '8DY63kQZk', + title: 'Test 2', + uri: 'db/test2', + url: '/d/8DY63kQZk/test2', + slug: '', + type: 'dash-db', + tags: [], + isStarred: false, + checked: false, + }, + ], + icon: 'folder-open', + score: 1, + expanded: true, + checked: false, + }, +]; + +const setup = (propOverrides?: Partial, renderMethod = shallow) => { + const props: Props = { + //@ts-ignore + results: data, + onSelectionChanged: () => {}, + onTagSelected: (name: string) => {}, + onFolderExpanding: () => {}, + onToggleSelection: () => {}, + editable: false, + }; + + Object.assign(props, propOverrides); + + const wrapper = renderMethod(); + const instance = wrapper.instance(); + + return { + wrapper, + instance, + }; +}; + +describe('SearchResults', () => { + it('should render result items', () => { + const { wrapper } = setup(); + expect(wrapper.find({ 'aria-label': 'Search section' })).toHaveLength(2); + }); + + it('should render section items for expanded section', () => { + const { wrapper } = setup(); + expect(wrapper.find({ 'aria-label': 'Search items' }).children()).toHaveLength(2); + }); + + it('should not render checkboxes for non-editable results', () => { + //@ts-ignore + const { wrapper } = setup({ editable: false }, mount); + expect(wrapper.find({ type: 'checkbox' })).toHaveLength(0); + }); + + it('should render checkboxes for non-editable results', () => { + //@ts-ignore + const { wrapper } = setup({ editable: true }, mount); + expect(wrapper.find({ type: 'checkbox' })).toHaveLength(4); + }); +}); diff --git a/public/app/features/search/components/SearchResults.tsx b/public/app/features/search/components/SearchResults.tsx new file mode 100644 index 00000000000..7e77c8d93e4 --- /dev/null +++ b/public/app/features/search/components/SearchResults.tsx @@ -0,0 +1,159 @@ +import React, { FC } from 'react'; +import { css, cx } from 'emotion'; +import { GrafanaTheme } from '@grafana/data'; +import { Icon, stylesFactory, useTheme } from '@grafana/ui'; +import { IconType } from '@grafana/ui/src/components/Icon/types'; +import { DashboardSection, ItemClickWithEvent } from '../types'; +import { SearchItem } from './SearchItem'; +import { SearchCheckbox } from './SearchCheckbox'; + +export interface Props { + results: DashboardSection[] | undefined; + onSelectionChanged: () => void; + onTagSelected: (name: string) => any; + onFolderExpanding: () => void; + onToggleSelection: ItemClickWithEvent; + editable: boolean; +} + +export const SearchResults: FC = ({ + results, + onSelectionChanged, + onTagSelected, + onFolderExpanding, + onToggleSelection, + editable, +}) => { + const theme = useTheme(); + const styles = getSectionStyles(theme); + + const toggleFolderExpand = (section: DashboardSection) => { + if (section.toggle) { + if (!section.expanded && onFolderExpanding) { + onFolderExpanding(); + } + + section.toggle(section).then(() => { + if (onSelectionChanged) { + onSelectionChanged(); + } + }); + } + }; + + // TODO display 'No results' messages after manage dashboards is refactored + if (!results) { + return null; + } + + return ( +
      + {results.map(section => ( +
    • + +
        + {section.expanded && + section.items.map(item => ( + + ))} +
      +
    • + ))} +
    + ); +}; + +const getSectionStyles = stylesFactory((theme: GrafanaTheme) => { + return { + wrapper: css` + list-style: none; + `, + section: css` + background: ${theme.colors.panelBg}; + border-bottom: solid 1px ${theme.isLight ? theme.colors.gray95 : theme.colors.gray25}; + padding: 0px 4px 4px 4px; + margin-bottom: 3px; + `, + }; +}); + +interface SectionHeaderProps { + section: DashboardSection; + onSectionClick: (section: DashboardSection) => void; + onToggleSelection: ItemClickWithEvent; + editable: boolean; +} + +const SectionHeader: FC = ({ section, onSectionClick, onToggleSelection, editable }) => { + const theme = useTheme(); + const styles = getSectionHeaderStyles(theme, section.selected); + + const expandSection = () => { + onSectionClick(section); + }; + + return !section.hideHeader ? ( +
    + onToggleSelection(section, e)} + /> + + + {section.title} + {section.url && ( + + + + )} + +
    + ) : ( +
    + ); +}; + +const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false) => { + const { sm, xs } = theme.spacing; + return { + wrapper: cx( + css` + display: flex; + font-size: ${theme.typography.size.base}; + padding: ${sm} ${xs} ${xs}; + color: ${theme.colors.textWeak}; + + &:hover, + &.selected { + color: ${theme.colors.text}; + } + + &:hover { + a { + opacity: 1; + } + } + `, + 'pointer', + { selected } + ), + icon: css` + padding: 5px 0; + width: 43px; + `, + text: css` + flex-grow: 1; + line-height: 24px; + `, + link: css` + padding: 2px 10px 0; + color: ${theme.colors.textWeak}; + opacity: 0; + transition: opacity 150ms ease-in-out; + `, + toggle: css` + padding: 5px; + `, + }; +}); diff --git a/public/app/features/search/index.ts b/public/app/features/search/index.ts new file mode 100644 index 00000000000..578d36df252 --- /dev/null +++ b/public/app/features/search/index.ts @@ -0,0 +1,5 @@ +export { SearchResults } from './components/SearchResults'; +export { SearchField } from './components/SearchField'; +export { SearchItem } from './components/SearchItem'; +export { SearchCheckbox } from './components/SearchCheckbox'; +export * from './types'; diff --git a/public/app/features/search/types.ts b/public/app/features/search/types.ts new file mode 100644 index 00000000000..0572caaef23 --- /dev/null +++ b/public/app/features/search/types.ts @@ -0,0 +1,54 @@ +export interface DashboardSection { + id: number; + uid?: string; + title: string; + expanded: boolean; + url: string; + icon: string; + score: number; + hideHeader?: boolean; + checked: boolean; + items: DashboardSectionItem[]; + toggle?: (section: DashboardSection) => Promise; + selected?: boolean; +} + +export interface DashboardSectionItem { + id: number; + uid: string; + title: string; + uri: string; + url: string; + type: string; + tags: string[]; + isStarred: boolean; + folderId?: number; + folderUid?: string; + folderTitle?: string; + folderUrl?: string; + checked: boolean; + selected?: boolean; +} + +export interface DashboardTag { + term: string; + count: number; +} + +export interface DashboardQuery { + query: string; + mode: string; + tag: string[]; + starred: boolean; + skipRecent: boolean; + skipStarred: boolean; + folderIds: number[]; +} + +export interface SectionsState { + sections: DashboardSection[]; + allChecked: boolean; + dashboardTags: DashboardTag[]; +} + +export type ItemClickWithEvent = (item: DashboardSectionItem | DashboardSection, event: any) => void;