mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
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 <circleci@grafana.com>
This commit is contained in:
parent
77459ae8d8
commit
85dc4e565e
@ -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<HTMLElement>) => any;
|
||||
|
||||
export interface Props extends Omit<HTMLAttributes<HTMLElement>, 'onClick'> {
|
||||
/** Name of the tag to display */
|
||||
name: string;
|
||||
onClick?: (name: string) => any;
|
||||
onClick?: OnTagClick;
|
||||
}
|
||||
|
||||
export const Tag = forwardRef<HTMLElement, Props>(({ name, onClick, className, ...rest }, ref) => {
|
||||
const theme = useTheme();
|
||||
const styles = getTagStyles(theme, name);
|
||||
|
||||
const onTagClick = () => {
|
||||
const onTagClick = (event: React.MouseEvent<HTMLElement>) => {
|
||||
if (onClick) {
|
||||
onClick(name);
|
||||
onClick(name, event);
|
||||
}
|
||||
};
|
||||
|
||||
|
@ -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;
|
||||
}
|
||||
|
@ -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(
|
||||
{
|
||||
|
@ -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)];
|
||||
|
@ -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' }],
|
||||
|
@ -39,7 +39,9 @@ export class TagFilter extends React.Component<Props, any> {
|
||||
};
|
||||
|
||||
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() {
|
||||
|
@ -102,11 +102,12 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="search-results-container">
|
||||
<dashboard-search-results
|
||||
<search-results
|
||||
results="ctrl.sections"
|
||||
editable="true"
|
||||
on-selection-changed="ctrl.selectionChanged()"
|
||||
on-tag-selected="ctrl.filterByTag($tag)"
|
||||
on-selection-changed="ctrl.selectionChanged"
|
||||
on-tag-selected="ctrl.filterByTag"
|
||||
on-toggle-selection="ctrl.toggleSelection"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@ -114,14 +115,14 @@
|
||||
</div>
|
||||
|
||||
<div ng-if="ctrl.canSave && ctrl.folderId && !ctrl.hasFilters && ctrl.sections.length === 0">
|
||||
<empty-list-cta
|
||||
<empty-list-cta
|
||||
title="'This folder doesn\'t have any dashboards yet'"
|
||||
buttonIcon="'gicon gicon-dashboard-new'"
|
||||
buttonIcon="'gicon gicon-dashboard-new'"
|
||||
buttonLink="'dashboard/new?folderId={{ctrl.folderId}}'"
|
||||
buttonTitle="'Create Dashboard'"
|
||||
proTip="'Add/move dashboards to your folder at ->'"
|
||||
proTipLink="'dashboards'"
|
||||
proTipLinkTitle="'Manage dashboards'"
|
||||
buttonTitle="'Create Dashboard'"
|
||||
proTip="'Add/move dashboards to your folder at ->'"
|
||||
proTipLink="'dashboards'"
|
||||
proTipLinkTitle="'Manage dashboards'"
|
||||
proTipTarget=""
|
||||
/>
|
||||
</div>
|
||||
|
@ -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() {
|
||||
|
@ -16,11 +16,12 @@
|
||||
<div class="search-results-scroller">
|
||||
<div class="search-results-container" grafana-scrollbar>
|
||||
<h6 ng-show="!ctrl.isLoading && ctrl.results.length === 0">No dashboards matching your query were found.</h6>
|
||||
<dashboard-search-results
|
||||
<search-results
|
||||
results="ctrl.results"
|
||||
on-tag-selected="ctrl.filterByTag($tag)"
|
||||
on-folder-expanding="ctrl.folderExpanding()"
|
||||
on-folder-expanded="ctrl.folderExpanded($folder)" />
|
||||
on-tag-selected="ctrl.filterByTag"
|
||||
on-folder-expanding="ctrl.folderExpanding"
|
||||
on-selection-changed="ctrl.selectionChanged"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -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;
|
||||
|
@ -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;
|
||||
|
||||
|
@ -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);
|
||||
|
31
public/app/features/search/components/SearchCheckbox.tsx
Normal file
31
public/app/features/search/components/SearchCheckbox.tsx
Normal file
@ -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<Props> = memo(({ checked = false, onClick, editable = false }) => {
|
||||
const styles = getStyles();
|
||||
|
||||
return (
|
||||
editable && (
|
||||
<div onClick={onClick} className={styles.wrapper}>
|
||||
<Forms.Checkbox value={checked} />
|
||||
</div>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
const getStyles = stylesFactory(() => ({
|
||||
// Vertically align absolutely positioned checkbox element
|
||||
wrapper: css`
|
||||
height: 21px;
|
||||
& > label {
|
||||
height: 100%;
|
||||
}
|
||||
`,
|
||||
}));
|
@ -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<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
|
||||
|
51
public/app/features/search/components/SearchItem.test.tsx
Normal file
51
public/app/features/search/components/SearchItem.test.tsx
Normal file
@ -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<Props>, renderMethod = shallow) => {
|
||||
const props: Props = {
|
||||
item: data,
|
||||
onToggleSelection: jest.fn(),
|
||||
onTagSelected: jest.fn(),
|
||||
editable: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = renderMethod(<SearchItem {...props} />);
|
||||
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);
|
||||
});
|
||||
});
|
107
public/app/features/search/components/SearchItem.tsx
Normal file
107
public/app/features/search/components/SearchItem.tsx
Normal file
@ -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<Props> = ({ 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<HTMLElement>) => {
|
||||
event.stopPropagation();
|
||||
onTagSelected(tag);
|
||||
};
|
||||
|
||||
const toggleItem = useCallback(
|
||||
(event: React.MouseEvent<HTMLElement>) => {
|
||||
onToggleSelection(item, event);
|
||||
},
|
||||
[item]
|
||||
);
|
||||
|
||||
return (
|
||||
<li
|
||||
aria-label={selectors.dashboards(item.title)}
|
||||
className={cx(styles.wrapper, { [styles.selected]: item.selected })}
|
||||
onClick={navigate}
|
||||
>
|
||||
<SearchCheckbox editable={editable} checked={item.checked} onClick={toggleItem} />
|
||||
<Icon className={styles.icon} name="th-large" />
|
||||
<div className={styles.body} onClick={onItemClick}>
|
||||
<span>{item.title}</span>
|
||||
<span className={styles.folderTitle}>{item.folderTitle}</span>
|
||||
</div>
|
||||
<TagList tags={item.tags} onClick={tagSelected} className={styles.tags} />
|
||||
</li>
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
`,
|
||||
}));
|
99
public/app/features/search/components/SearchResults.test.tsx
Normal file
99
public/app/features/search/components/SearchResults.test.tsx
Normal file
@ -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<Props>, renderMethod = shallow) => {
|
||||
const props: Props = {
|
||||
//@ts-ignore
|
||||
results: data,
|
||||
onSelectionChanged: () => {},
|
||||
onTagSelected: (name: string) => {},
|
||||
onFolderExpanding: () => {},
|
||||
onToggleSelection: () => {},
|
||||
editable: false,
|
||||
};
|
||||
|
||||
Object.assign(props, propOverrides);
|
||||
|
||||
const wrapper = renderMethod(<SearchResults {...props} />);
|
||||
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);
|
||||
});
|
||||
});
|
159
public/app/features/search/components/SearchResults.tsx
Normal file
159
public/app/features/search/components/SearchResults.tsx
Normal file
@ -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<Props> = ({
|
||||
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 (
|
||||
<ul className={styles.wrapper}>
|
||||
{results.map(section => (
|
||||
<li aria-label="Search section" className={styles.section} key={section.title}>
|
||||
<SectionHeader onSectionClick={toggleFolderExpand} {...{ onToggleSelection, editable, section }} />
|
||||
<ul aria-label="Search items" className={styles.wrapper}>
|
||||
{section.expanded &&
|
||||
section.items.map(item => (
|
||||
<SearchItem key={item.id} {...{ item, editable, onToggleSelection, onTagSelected }} />
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
};
|
||||
|
||||
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<SectionHeaderProps> = ({ section, onSectionClick, onToggleSelection, editable }) => {
|
||||
const theme = useTheme();
|
||||
const styles = getSectionHeaderStyles(theme, section.selected);
|
||||
|
||||
const expandSection = () => {
|
||||
onSectionClick(section);
|
||||
};
|
||||
|
||||
return !section.hideHeader ? (
|
||||
<div className={styles.wrapper} onClick={expandSection}>
|
||||
<SearchCheckbox
|
||||
editable={editable}
|
||||
checked={section.checked}
|
||||
onClick={(e: MouseEvent) => onToggleSelection(section, e)}
|
||||
/>
|
||||
<Icon className={styles.icon} name={section.icon as IconType} />
|
||||
|
||||
<span className={styles.text}>{section.title}</span>
|
||||
{section.url && (
|
||||
<a href={section.url} className={styles.link}>
|
||||
<Icon name="cog" />
|
||||
</a>
|
||||
)}
|
||||
<Icon name={section.expanded ? 'angle-down' : 'angle-right'} className={styles.toggle} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.wrapper} />
|
||||
);
|
||||
};
|
||||
|
||||
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;
|
||||
`,
|
||||
};
|
||||
});
|
5
public/app/features/search/index.ts
Normal file
5
public/app/features/search/index.ts
Normal file
@ -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';
|
54
public/app/features/search/types.ts
Normal file
54
public/app/features/search/types.ts
Normal file
@ -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<DashboardSection>;
|
||||
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;
|
Loading…
Reference in New Issue
Block a user