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:
Alex Khomenko 2020-03-26 11:09:08 +02:00 committed by GitHub
parent 77459ae8d8
commit 85dc4e565e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
20 changed files with 608 additions and 58 deletions

View File

@ -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);
}
};

View File

@ -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;
}

View File

@ -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(
{

View File

@ -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)];

View File

@ -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' }],

View File

@ -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() {

View File

@ -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>

View File

@ -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() {

View File

@ -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>

View File

@ -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;

View File

@ -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;

View File

@ -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);

View 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%;
}
`,
}));

View File

@ -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>>;

View 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);
});
});

View 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;
}
`,
}));

View 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);
});
});

View 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;
`,
};
});

View 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';

View 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;