SearchV2: Use the same components for both SQL and bluge backends (#50125)

This commit is contained in:
Ryan McKinley 2022-06-30 09:45:45 -07:00 committed by GitHub
parent 8b3b667a47
commit c0f10af545
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
35 changed files with 40 additions and 3199 deletions

View File

@ -3617,7 +3617,7 @@ exports[`better eslint`] = {
[0, 34, 3, "Unexpected any. Specify a different type.", "193409811"],
[0, 39, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/core/services/backend_srv.ts:360059123": [
"public/app/core/services/backend_srv.ts:2084076322": [
[83, 20, 3, "Unexpected any. Specify a different type.", "193409811"],
[153, 63, 3, "Unexpected any. Specify a different type.", "193409811"],
[231, 40, 3, "Unexpected any. Specify a different type.", "193409811"],
@ -3632,7 +3632,7 @@ exports[`better eslint`] = {
[408, 33, 3, "Unexpected any. Specify a different type.", "193409811"],
[412, 31, 3, "Unexpected any. Specify a different type.", "193409811"],
[416, 31, 3, "Unexpected any. Specify a different type.", "193409811"],
[427, 16, 3, "Unexpected any. Specify a different type.", "193409811"]
[428, 16, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/core/services/context_srv.ts:698805616": [
[59, 10, 3, "Unexpected any. Specify a different type.", "193409811"],
@ -3712,16 +3712,16 @@ exports[`better eslint`] = {
[146, 12, 3, "Unexpected any. Specify a different type.", "193409811"],
[159, 12, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/core/services/search_srv.ts:1940811612": [
[16, 61, 3, "Unexpected any. Specify a different type.", "193409811"],
[17, 57, 3, "Unexpected any. Specify a different type.", "193409811"],
[38, 13, 153, "Do not use any type assertions.", "3055602986"],
[44, 58, 3, "Unexpected any. Specify a different type.", "193409811"],
[51, 9, 15, "Do not use any type assertions.", "4195655835"],
[51, 21, 3, "Unexpected any. Specify a different type.", "193409811"],
[63, 18, 3, "Unexpected any. Specify a different type.", "193409811"],
[64, 20, 3, "Unexpected any. Specify a different type.", "193409811"],
[100, 81, 3, "Unexpected any. Specify a different type.", "193409811"]
"public/app/core/services/search_srv.ts:3529274317": [
[17, 61, 3, "Unexpected any. Specify a different type.", "193409811"],
[18, 57, 3, "Unexpected any. Specify a different type.", "193409811"],
[39, 13, 153, "Do not use any type assertions.", "3055602986"],
[45, 58, 3, "Unexpected any. Specify a different type.", "193409811"],
[52, 9, 15, "Do not use any type assertions.", "4195655835"],
[52, 21, 3, "Unexpected any. Specify a different type.", "193409811"],
[64, 18, 3, "Unexpected any. Specify a different type.", "193409811"],
[65, 20, 3, "Unexpected any. Specify a different type.", "193409811"],
[101, 81, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/core/services/withFocusedPanelId.ts:1347414526": [
[5, 22, 37, "Do not use any type assertions.", "3344232204"],
@ -4247,9 +4247,9 @@ exports[`better eslint`] = {
[157, 27, 44, "Do not use any type assertions.", "720973826"],
[158, 40, 44, "Do not use any type assertions.", "720973826"]
],
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:1101887836": [
[19, 33, 27, "Do not use any type assertions.", "1321813536"],
[21, 30, 27, "Do not use any type assertions.", "1321813536"]
"public/app/features/alerting/unified/components/AnnotationDetailsField.tsx:2388344112": [
[20, 33, 27, "Do not use any type assertions.", "1321813536"],
[22, 30, 27, "Do not use any type assertions.", "1321813536"]
],
"public/app/features/alerting/unified/components/Expression.tsx:2035202716": [
[25, 25, 3, "Unexpected any. Specify a different type.", "193409811"],
@ -6727,17 +6727,6 @@ exports[`better eslint`] = {
"public/app/features/sandbox/TestStuffPage.tsx:3698683147": [
[134, 30, 29, "Do not use any type assertions.", "3195381622"]
],
"public/app/features/search/components/DashboardSearch.test.tsx:3245889886": [
[18, 23, 33, "Do not use any type assertions.", "2540133228"],
[30, 15, 3, "Unexpected any. Specify a different type.", "193409811"],
[72, 14, 130, "Do not use any type assertions.", "3429995880"],
[75, 10, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/components/MoveToFolderModal.tsx:3435881653": [
[31, 21, 48, "Do not use any type assertions.", "646093117"],
[31, 93, 3, "Unexpected any. Specify a different type.", "193409811"],
[64, 51, 15, "Do not use any type assertions.", "3135191849"]
],
"public/app/features/search/components/SearchCard.tsx:417509806": [
[20, 36, 3, "Unexpected any. Specify a different type.", "193409811"],
[116, 37, 32, "Do not use any type assertions.", "1966145380"],
@ -6746,18 +6735,6 @@ exports[`better eslint`] = {
"public/app/features/search/components/SearchItem.tsx:1278234167": [
[15, 35, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/components/SearchResults.tsx:4264592788": [
[19, 35, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/hooks/useDashboardSearch.ts:2034499138": [
[61, 28, 32, "Do not use any type assertions.", "327142118"]
],
"public/app/features/search/hooks/useManageDashboards.test.ts:3293235715": [
[31, 45, 41, "Do not use any type assertions.", "3856094708"],
[31, 45, 15, "Do not use any type assertions.", "363922340"],
[33, 43, 31, "Do not use any type assertions.", "2087413285"],
[33, 43, 13, "Do not use any type assertions.", "2146830713"]
],
"public/app/features/search/hooks/useSearchKeyboardSelection.ts:877924082": [
[87, 24, 42, "Do not use any type assertions.", "3335657801"]
],
@ -6797,25 +6774,6 @@ exports[`better eslint`] = {
[49, 50, 3, "Unexpected any. Specify a different type.", "193409811"],
[145, 15, 56, "Do not use any type assertions.", "1375039711"]
],
"public/app/features/search/reducers/dashboardSearch.test.ts:1813679195": [
[5, 66, 17, "Do not use any type assertions.", "3518965757"],
[5, 78, 3, "Unexpected any. Specify a different type.", "193409811"],
[8, 42, 9, "Do not use any type assertions.", "3692209159"],
[8, 48, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/reducers/manageDashboards.test.ts:2083873033": [
[11, 26, 9, "Do not use any type assertions.", "3692209159"],
[11, 32, 3, "Unexpected any. Specify a different type.", "193409811"],
[16, 43, 3, "Unexpected any. Specify a different type.", "193409811"],
[17, 43, 3, "Unexpected any. Specify a different type.", "193409811"],
[17, 77, 3, "Unexpected any. Specify a different type.", "193409811"],
[23, 44, 3, "Unexpected any. Specify a different type.", "193409811"],
[24, 44, 3, "Unexpected any. Specify a different type.", "193409811"],
[24, 78, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/reducers/manageDashboards.ts:4114284001": [
[81, 11, 24, "Do not use any type assertions.", "1870559034"]
],
"public/app/features/search/service/bluge.ts:59496993": [
[21, 15, 68, "Do not use any type assertions.", "2766474629"],
[34, 17, 128, "Do not use any type assertions.", "437379020"],
@ -6837,50 +6795,12 @@ exports[`better eslint`] = {
[99, 18, 61, "Do not use any type assertions.", "2014388220"],
[104, 16, 68, "Do not use any type assertions.", "257067967"]
],
"public/app/features/search/types.ts:479421789": [
[64, 12, 3, "Unexpected any. Specify a different type.", "193409811"],
[89, 54, 3, "Unexpected any. Specify a different type.", "193409811"]
"public/app/features/search/types.ts:762810555": [
[56, 12, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/utils.test.ts:3441767255": [
[15, 48, 17, "Do not use any type assertions.", "3518965757"],
[15, 60, 3, "Unexpected any. Specify a different type.", "193409811"],
[33, 37, 15, "Do not use any type assertions.", "4195655835"],
[33, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[39, 37, 15, "Do not use any type assertions.", "4195655835"],
[39, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[45, 37, 15, "Do not use any type assertions.", "4195655835"],
[45, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[46, 40, 14, "Do not use any type assertions.", "1705067515"],
[46, 51, 3, "Unexpected any. Specify a different type.", "193409811"],
[53, 37, 15, "Do not use any type assertions.", "4195655835"],
[53, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[58, 37, 15, "Do not use any type assertions.", "4195655835"],
[58, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[63, 37, 15, "Do not use any type assertions.", "4195655835"],
[63, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[64, 40, 14, "Do not use any type assertions.", "1705067515"],
[64, 51, 3, "Unexpected any. Specify a different type.", "193409811"],
[72, 37, 15, "Do not use any type assertions.", "4195655835"],
[72, 49, 3, "Unexpected any. Specify a different type.", "193409811"],
[78, 40, 15, "Do not use any type assertions.", "4195655835"],
[78, 52, 3, "Unexpected any. Specify a different type.", "193409811"],
[103, 29, 3, "Unexpected any. Specify a different type.", "193409811"],
[103, 64, 3, "Unexpected any. Specify a different type.", "193409811"],
[110, 29, 3, "Unexpected any. Specify a different type.", "193409811"],
[110, 64, 3, "Unexpected any. Specify a different type.", "193409811"]
],
"public/app/features/search/utils.ts:3454404257": [
[86, 39, 3, "Unexpected any. Specify a different type.", "193409811"],
[109, 11, 54, "Do not use any type assertions.", "1072049889"],
[128, 40, 3, "Unexpected any. Specify a different type.", "193409811"],
[128, 62, 3, "Unexpected any. Specify a different type.", "193409811"],
[143, 5, 28, "Do not use any type assertions.", "2423908815"],
[171, 13, 72, "Do not use any type assertions.", "1798742037"],
[173, 13, 77, "Do not use any type assertions.", "414989384"],
[202, 34, 38, "Do not use any type assertions.", "1471143425"],
[222, 11, 24, "Do not use any type assertions.", "2044269175"],
[246, 28, 17, "Do not use any type assertions.", "1811834489"],
[251, 5, 29, "Do not use any type assertions.", "4135357902"]
"public/app/features/search/utils.ts:3780754211": [
[34, 28, 17, "Do not use any type assertions.", "1811834489"],
[39, 5, 29, "Do not use any type assertions.", "4135357902"]
],
"public/app/features/serviceaccounts/ServiceAccountPage.test.tsx:3979425477": [
[33, 20, 23, "Do not use any type assertions.", "499357842"],

View File

@ -385,7 +385,6 @@
"rst2html": "github:thoward/rst2html#990cb89f2a300cdd9151790be377c4c0840df809",
"rxjs": "7.5.5",
"sass": "link:./public/sass",
"search-query-parser": "1.6.0",
"selecto": "1.16.2",
"semver": "7.3.7",
"slate": "0.47.8",

View File

@ -425,6 +425,7 @@ export class BackendSrv implements BackendService {
return this.request({ url: '/api/login/ping', method: 'GET', retry: 1 });
}
/** @deprecated */
search(query: any): Promise<DashboardSearchHit[]> {
return this.get('/api/search', query);
}

View File

@ -13,6 +13,7 @@ interface Sections {
[key: string]: Partial<DashboardSection>;
}
/** @deprecated */
export class SearchSrv {
private getRecentDashboards(sections: DashboardSection[] | any) {
return this.queryForRecentDashboards().then((result: any[]) => {

View File

@ -1,102 +0,0 @@
import { css } from '@emotion/css';
import React, { FC, ChangeEvent, FormEvent } from 'react';
import { GrafanaTheme2, SelectableValue } from '@grafana/data';
import { config } from '@grafana/runtime';
import { HorizontalGroup, RadioButtonGroup, Checkbox, InlineSwitch, useStyles2 } from '@grafana/ui';
import { SortPicker } from 'app/core/components/Select/SortPicker';
import { TagFilter } from 'app/core/components/TagFilter/TagFilter';
import { SearchSrv } from 'app/core/services/search_srv';
import { DashboardQuery, SearchLayout } from '../types';
export const layoutOptions = [
{ value: SearchLayout.Folders, icon: 'folder', ariaLabel: 'View by folders' },
{ value: SearchLayout.List, icon: 'list-ul', ariaLabel: 'View as list' },
];
const searchSrv = new SearchSrv();
interface Props {
onLayoutChange: (layout: SearchLayout) => void;
setShowPreviews: (newValue: boolean) => void;
onSortChange: (value: SelectableValue) => void;
onStarredFilterChange?: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: (tags: string[]) => void;
query: DashboardQuery;
showStarredFilter?: boolean;
hideLayout?: boolean;
showPreviews?: boolean;
}
export const ActionRow: FC<Props> = ({
onLayoutChange,
setShowPreviews,
onSortChange,
onStarredFilterChange = () => {},
onTagFilterChange,
query,
showStarredFilter,
hideLayout,
showPreviews,
}) => {
const styles = useStyles2(getStyles);
const previewsEnabled = config.featureToggles.dashboardPreviews;
return (
<div className={styles.actionRow}>
<div className={styles.rowContainer}>
<HorizontalGroup spacing="md" width="auto">
{!hideLayout ? (
<RadioButtonGroup options={layoutOptions} onChange={onLayoutChange} value={query.layout} />
) : null}
<SortPicker onChange={onSortChange} value={query.sort?.value} />
{previewsEnabled && (
<InlineSwitch
id="search-show-previews"
label="Show previews"
showLabel
value={showPreviews}
onChange={(ev: ChangeEvent<HTMLInputElement>) => setShowPreviews(ev.target.checked)}
transparent
/>
)}
</HorizontalGroup>
</div>
<HorizontalGroup spacing="md" width="auto">
{showStarredFilter && (
<div className={styles.checkboxWrapper}>
<Checkbox label="Filter by starred" onChange={onStarredFilterChange} value={query.starred} />
</div>
)}
<TagFilter isClearable tags={query.tag} tagOptions={searchSrv.getDashboardTags} onChange={onTagFilterChange} />
</HorizontalGroup>
</div>
);
};
ActionRow.displayName = 'ActionRow';
export const getStyles = (theme: GrafanaTheme2) => {
return {
actionRow: css`
display: none;
${theme.breakpoints.up('md')} {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: ${theme.spacing(2)};
width: 100%;
}
`,
rowContainer: css`
margin-right: ${theme.spacing(1)};
`,
checkboxWrapper: css`
label {
line-height: 1.2;
}
`,
};
};

View File

@ -1,71 +0,0 @@
import { css } from '@emotion/css';
import React, { FC } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { ConfirmModal, stylesFactory, useTheme } from '@grafana/ui';
import { deleteFoldersAndDashboards } from 'app/features/manage-dashboards/state/actions';
import { DashboardSection, OnDeleteItems } from '../types';
import { getCheckedUids } from '../utils';
interface Props {
onDeleteItems: OnDeleteItems;
results: DashboardSection[];
isOpen: boolean;
onDismiss: () => void;
}
export const ConfirmDeleteModal: FC<Props> = ({ results, onDeleteItems, isOpen, onDismiss }) => {
const theme = useTheme();
const styles = getStyles(theme);
const uids = getCheckedUids(results);
const { folders, dashboards } = uids;
const folderCount = folders.length;
const dashCount = dashboards.length;
let text = 'Do you want to delete the ';
let subtitle;
const dashEnding = dashCount === 1 ? '' : 's';
const folderEnding = folderCount === 1 ? '' : 's';
if (folderCount > 0 && dashCount > 0) {
text += `selected folder${folderEnding} and dashboard${dashEnding}?\n`;
subtitle = `All dashboards and alerts of the selected folder${folderEnding} will also be deleted`;
} else if (folderCount > 0) {
text += `selected folder${folderEnding} and all ${folderCount === 1 ? 'its' : 'their'} dashboards and alerts?`;
} else {
text += `selected dashboard${dashEnding}?`;
}
const deleteItems = () => {
deleteFoldersAndDashboards(folders, dashboards).then(() => {
onDismiss();
onDeleteItems(folders, dashboards);
});
};
return isOpen ? (
<ConfirmModal
isOpen={isOpen}
title="Delete"
body={
<>
{text} {subtitle && <div className={styles.subtitle}>{subtitle}</div>}
</>
}
confirmText="Delete"
onConfirm={deleteItems}
onDismiss={onDismiss}
/>
) : null;
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
subtitle: css`
font-size: ${theme.typography.size.base};
padding-top: ${theme.spacing.md};
`,
};
});

View File

@ -4,7 +4,7 @@ import { connect, MapStateToProps } from 'react-redux';
import { useAsync } from 'react-use';
import { NavModel, locationUtil } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { locationService } from '@grafana/runtime';
import Page from 'app/core/components/Page/Page';
import { getNavModel } from 'app/core/selectors/navModel';
import { FolderDTO, StoreState } from 'app/types';
@ -12,7 +12,6 @@ import { FolderDTO, StoreState } from 'app/types';
import { GrafanaRouteComponentProps } from '../../../core/navigation/types';
import { loadFolderPage } from '../loaders';
import ManageDashboards from './ManageDashboards';
import ManageDashboardsNew from './ManageDashboardsNew';
export interface DashboardListPageRouteParams {
@ -46,22 +45,16 @@ export const DashboardListPage: FC<Props> = memo(({ navModel, match, location })
return (
<Page navModel={value?.pageNavModel ?? navModel}>
{Boolean(config.featureToggles.panelTitleSearch) ? (
<Page.Contents
isLoading={loading}
className={css`
display: flex;
flex-direction: column;
overflow: hidden;
`}
>
<ManageDashboardsNew folder={value?.folder} />
</Page.Contents>
) : (
<Page.Contents isLoading={loading}>
<ManageDashboards folder={value?.folder} />
</Page.Contents>
)}
<Page.Contents
isLoading={loading}
className={css`
display: flex;
flex-direction: column;
overflow: hidden;
`}
>
<ManageDashboardsNew folder={value?.folder} />
</Page.Contents>
</Page>
);
});

View File

@ -1,152 +0,0 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { selectors } from '@grafana/e2e-selectors';
import { locationService, setEchoSrv } from '@grafana/runtime';
import * as MockSearchSrv from 'app/core/services/__mocks__/search_srv';
import { Echo } from 'app/core/services/echo/Echo';
import * as SearchSrv from 'app/core/services/search_srv';
import { searchResults } from '../testData';
import { SearchLayout } from '../types';
import { DashboardSearchOLD as DashboardSearch, Props } from './DashboardSearch';
jest.mock('app/core/services/search_srv');
// Typecast the mock search so the mock import is correctly recognised by TS
// https://stackoverflow.com/a/53222290
const { mockSearch } = SearchSrv as typeof MockSearchSrv;
beforeEach(() => {
jest.useFakeTimers();
mockSearch.mockClear();
});
afterEach(() => {
jest.useRealTimers();
});
const setup = (testProps?: Partial<Props>) => {
const props: any = {
onCloseSearch: () => {},
...testProps,
};
render(<DashboardSearch {...props} />);
jest.runOnlyPendingTimers();
};
/**
* Need to wrap component render in async act and use jest.runAllTimers to test
* calls inside useDebounce hook
*/
describe('DashboardSearch', () => {
beforeAll(() => {
setEchoSrv(new Echo());
});
it('should call search api with default query when initialised', async () => {
locationService.push('/');
setup();
await waitFor(() => screen.getByPlaceholderText('Search dashboards by name'));
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith({
query: '',
tag: [],
skipRecent: false,
skipStarred: false,
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
prevSort: null,
});
});
it('should call api with updated query on query change', async () => {
locationService.push('/');
setup();
const input = await screen.findByPlaceholderText('Search dashboards by name');
await act((async () => {
await fireEvent.input(input, { target: { value: 'Test' } });
jest.runOnlyPendingTimers();
}) as any);
expect(mockSearch).toHaveBeenCalledWith({
query: 'Test',
skipRecent: false,
skipStarred: false,
tag: [],
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
prevSort: null,
});
});
it("should render 'No results' message when there are no dashboards", async () => {
locationService.push('/');
setup();
const message = await screen.findByText('No dashboards matching your query were found.');
expect(message).toBeInTheDocument();
});
it('should render search results', async () => {
mockSearch.mockResolvedValueOnce(searchResults);
locationService.push('/');
setup();
const section = await screen.findAllByTestId(selectors.components.Search.sectionV2);
expect(section).toHaveLength(2);
expect(screen.getAllByTestId(selectors.components.Search.itemsV2)).toHaveLength(1);
});
it('should call search with selected tags', async () => {
locationService.push('/');
setup();
await waitFor(() => screen.getByLabelText('Tag filter'));
const tagComponent = screen.getByLabelText('Tag filter');
expect(tagComponent).toBeInTheDocument();
tagComponent.focus();
await waitFor(() => selectOptionInTest(tagComponent, 'tag1'));
await waitFor(() =>
expect(mockSearch).toHaveBeenCalledWith({
query: '',
skipRecent: false,
skipStarred: false,
tag: ['tag1'],
starred: false,
folderIds: [],
layout: SearchLayout.Folders,
sort: undefined,
prevSort: null,
})
);
});
it('should call search api with provided search params', async () => {
locationService.partial({ query: 'test query', tag: ['tag1'], sort: 'asc' });
setup({});
await waitFor(() => {
expect(mockSearch).toHaveBeenCalledTimes(1);
expect(mockSearch).toHaveBeenCalledWith(
expect.objectContaining({
query: 'test query',
tag: ['tag1'],
sort: 'asc',
})
);
});
});
});

View File

@ -1,35 +1,21 @@
import { css } from '@emotion/css';
import React, { FC, memo, useState } from 'react';
import React, { useState } from 'react';
import { useDebounce, useLocalStorage } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { CustomScrollbar, IconButton, stylesFactory, useStyles2, useTheme2 } from '@grafana/ui';
import { IconButton, stylesFactory, useStyles2 } from '@grafana/ui';
import { SEARCH_PANELS_LOCAL_STORAGE_KEY } from '../constants';
import { useDashboardSearch } from '../hooks/useDashboardSearch';
import { useKeyNavigationListener } from '../hooks/useSearchKeyboardSelection';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchView } from '../page/components/SearchView';
import { ActionRow } from './ActionRow';
import { PreviewsSystemRequirements } from './PreviewsSystemRequirements';
import { SearchField } from './SearchField';
import { SearchResults } from './SearchResults';
export interface Props {
onCloseSearch: () => void;
}
export default function DashboardSearch({ onCloseSearch }: Props) {
if (config.featureToggles.panelTitleSearch) {
// TODO: "folder:current" ????
return <DashboardSearchNew onCloseSearch={onCloseSearch} />;
}
return <DashboardSearchOLD onCloseSearch={onCloseSearch} />;
}
function DashboardSearchNew({ onCloseSearch }: Props) {
export function DashboardSearch({ onCloseSearch }: Props) {
const styles = useStyles2(getStyles);
const { query, onQueryChange } = useSearchQuery({});
@ -87,59 +73,6 @@ function DashboardSearchNew({ onCloseSearch }: Props) {
);
}
export const DashboardSearchOLD: FC<Props> = memo(({ onCloseSearch }) => {
const { query, onQueryChange, onTagFilterChange, onTagAdd, onSortChange, onLayoutChange } = useSearchQuery({});
const { results, loading, onToggleSection, onKeyDown, showPreviews, setShowPreviews } = useDashboardSearch(
query,
onCloseSearch
);
const theme = useTheme2();
const styles = getStyles(theme);
return (
<div tabIndex={0} className={styles.overlay}>
<div className={styles.container}>
<div className={styles.searchField}>
<SearchField query={query} onChange={onQueryChange} onKeyDown={onKeyDown} autoFocus clearable />
<div className={styles.closeBtn}>
<IconButton name="times" onClick={onCloseSearch} size="xxl" tooltip="Close search" />
</div>
</div>
<div className={styles.search}>
<ActionRow
{...{
onLayoutChange,
setShowPreviews,
onSortChange,
onTagFilterChange,
query,
showPreviews,
}}
/>
<PreviewsSystemRequirements
bottomSpacing={3}
showPreviews={showPreviews}
onRemove={() => setShowPreviews(false)}
/>
<CustomScrollbar>
<SearchResults
results={results}
loading={loading}
onTagSelected={onTagAdd}
editable={false}
onToggleSection={onToggleSection}
layout={query.layout}
showPreviews={showPreviews}
/>
</CustomScrollbar>
</div>
</div>
</div>
);
});
DashboardSearchOLD.displayName = 'DashboardSearchOLD';
const getStyles = stylesFactory((theme: GrafanaTheme2) => {
return {
overlay: css`

View File

@ -1,184 +0,0 @@
import { css } from '@emotion/css';
import React, { FC, memo, useState } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { FilterInput, Spinner, stylesFactory, useTheme } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { contextSrv } from 'app/core/services/context_srv';
import { FolderDTO, AccessControlAction } from 'app/types';
import { useManageDashboards } from '../hooks/useManageDashboards';
import { useSearchQuery } from '../hooks/useSearchQuery';
import { SearchLayout } from '../types';
import { ConfirmDeleteModal } from './ConfirmDeleteModal';
import { DashboardActions } from './DashboardActions';
import { MoveToFolderModal } from './MoveToFolderModal';
import { SearchResults } from './SearchResults';
import { SearchResultsFilter } from './SearchResultsFilter';
export interface Props {
folder?: FolderDTO;
}
const { isEditor } = contextSrv;
export const ManageDashboards: FC<Props> = memo(({ folder }) => {
const folderId = folder?.id;
const folderUid = folder?.uid;
const theme = useTheme();
const styles = getStyles(theme);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [isMoveModalOpen, setIsMoveModalOpen] = useState(false);
const defaultLayout = folderId ? SearchLayout.List : SearchLayout.Folders;
const queryParamsDefaults = {
skipRecent: true,
skipStarred: true,
folderIds: folderId ? [folderId] : [],
layout: defaultLayout,
};
const {
query,
hasFilters,
onQueryChange,
onTagFilterChange,
onStarredFilterChange,
onTagAdd,
onSortChange,
onLayoutChange,
} = useSearchQuery(queryParamsDefaults);
const {
results,
loading,
initialLoading,
allChecked,
hasEditPermissionInFolders,
canMove,
canSave,
canDelete,
onToggleSection,
onToggleChecked,
onToggleAllChecked,
onDeleteItems,
onMoveItems,
noFolders,
showPreviews,
setShowPreviews,
} = useManageDashboards(query, {}, folder);
const onMoveTo = () => {
setIsMoveModalOpen(true);
};
const onItemDelete = () => {
setIsDeleteModalOpen(true);
};
if (initialLoading) {
return <Spinner className={styles.spinner} />;
}
if (noFolders && !hasFilters) {
return (
<EmptyListCTA
title="This folder doesn't have any dashboards yet"
buttonIcon="plus"
buttonTitle="Create Dashboard"
buttonLink={`dashboard/new?folderId=${folderId}`}
proTip="Add/move dashboards to your folder at ->"
proTipLink="dashboards"
proTipLinkTitle="Manage dashboards"
proTipTarget=""
/>
);
}
return (
<div className={styles.container}>
<div className="page-action-bar">
<div className="gf-form gf-form--grow m-r-2">
<FilterInput value={query.query} onChange={onQueryChange} placeholder={'Search dashboards by name'} />
</div>
<DashboardActions
folderId={folderId}
canCreateFolders={contextSrv.hasAccess(AccessControlAction.FoldersCreate, isEditor)}
canCreateDashboards={contextSrv.hasAccess(
AccessControlAction.DashboardsCreate,
hasEditPermissionInFolders || !!canSave
)}
/>
</div>
<div className={styles.results}>
<SearchResultsFilter
allChecked={allChecked}
canDelete={hasEditPermissionInFolders && canDelete}
canMove={hasEditPermissionInFolders && canMove}
deleteItem={onItemDelete}
moveTo={onMoveTo}
setShowPreviews={setShowPreviews}
onToggleAllChecked={onToggleAllChecked}
onStarredFilterChange={onStarredFilterChange}
onSortChange={onSortChange}
onTagFilterChange={onTagFilterChange}
query={query}
showPreviews={showPreviews}
hideLayout={!!folderUid}
onLayoutChange={onLayoutChange}
editable={hasEditPermissionInFolders}
/>
<SearchResults
loading={loading}
results={results}
editable={hasEditPermissionInFolders}
onTagSelected={onTagAdd}
onToggleSection={onToggleSection}
onToggleChecked={onToggleChecked}
layout={query.layout}
showPreviews={showPreviews}
/>
</div>
<ConfirmDeleteModal
onDeleteItems={onDeleteItems}
results={results}
isOpen={isDeleteModalOpen}
onDismiss={() => setIsDeleteModalOpen(false)}
/>
<MoveToFolderModal
onMoveItems={onMoveItems}
results={results}
isOpen={isMoveModalOpen}
onDismiss={() => setIsMoveModalOpen(false)}
/>
</div>
);
});
ManageDashboards.displayName = 'ManageDashboards';
export default ManageDashboards;
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
container: css`
height: 100%;
display: flex;
flex-direction: column;
`,
results: css`
display: flex;
flex-direction: column;
flex: 1 1 0;
height: 100%;
padding-top: ${theme.spacing.lg};
`,
spinner: css`
display: flex;
justify-content: center;
align-items: center;
min-height: 200px;
`,
};
});

View File

@ -1,90 +0,0 @@
import { css } from '@emotion/css';
import React, { FC, useState } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { Button, HorizontalGroup, Modal, stylesFactory, useTheme } from '@grafana/ui';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { useAppNotification } from 'app/core/copy/appNotification';
import { moveDashboards } from 'app/features/manage-dashboards/state/actions';
import { FolderInfo } from 'app/types';
import { DashboardSection, OnMoveItems } from '../types';
import { getCheckedDashboards } from '../utils';
interface Props {
onMoveItems: OnMoveItems;
results: DashboardSection[];
isOpen: boolean;
onDismiss: () => void;
}
export const MoveToFolderModal: FC<Props> = ({ results, onMoveItems, isOpen, onDismiss }) => {
const [folder, setFolder] = useState<FolderInfo | null>(null);
const theme = useTheme();
const styles = getStyles(theme);
const selectedDashboards = getCheckedDashboards(results);
const notifyApp = useAppNotification();
const moveTo = () => {
if (folder && selectedDashboards.length) {
const folderTitle = folder.title ?? 'General';
moveDashboards(selectedDashboards.map((d) => d.uid) as string[], folder).then((result: any) => {
if (result.successCount > 0) {
const ending = result.successCount === 1 ? '' : 's';
const header = `Dashboard${ending} Moved`;
const msg = `${result.successCount} dashboard${ending} moved to ${folderTitle}`;
notifyApp.success(header, msg);
}
if (result.totalCount === result.alreadyInFolderCount) {
notifyApp.error('Error', `Dashboard already belongs to folder ${folderTitle}`);
} else {
onMoveItems(selectedDashboards, folder);
}
onDismiss();
});
}
};
return isOpen ? (
<Modal
className={styles.modal}
title="Choose Dashboard Folder"
icon="folder-plus"
isOpen={isOpen}
onDismiss={onDismiss}
>
<>
<div className={styles.content}>
<p>
Move the {selectedDashboards.length} selected dashboard{selectedDashboards.length === 1 ? '' : 's'} to the
following folder:
</p>
<FolderPicker onChange={(f) => setFolder(f as FolderInfo)} />
</div>
<HorizontalGroup justify="center">
<Button variant="primary" onClick={moveTo}>
Move
</Button>
<Button variant="secondary" onClick={onDismiss}>
Cancel
</Button>
</HorizontalGroup>
</>
</Modal>
) : null;
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
modal: css`
width: 500px;
`,
content: css`
margin-bottom: ${theme.spacing.lg};
`,
};
});

View File

@ -1,82 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC } from 'react';
import { GrafanaTheme } from '@grafana/data';
import { useStyles } from '@grafana/ui';
import { DashboardQuery } from '../types';
type Omit<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
interface SearchFieldProps extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'onChange'> {
query: DashboardQuery;
onChange: (query: string) => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
clearable?: boolean;
width?: number;
}
const getSearchFieldStyles = (theme: GrafanaTheme) => ({
wrapper: css`
width: 100%;
display: flex;
position: relative;
align-items: center;
`,
input: css`
box-sizing: border-box;
outline: none;
background-color: transparent;
background: transparent;
border-bottom: 2px solid ${theme.colors.border1};
font-size: 20px;
line-height: 38px;
width: 100%;
&::placeholder {
color: ${theme.colors.textWeak};
}
`,
spacer: css`
flex-grow: 1;
`,
icon: cx(
css`
color: ${theme.colors.textWeak};
padding: 0 ${theme.spacing.md};
`
),
clearButton: css`
font-size: ${theme.typography.size.sm};
color: ${theme.colors.textWeak};
text-decoration: underline;
&:hover {
cursor: pointer;
color: ${theme.colors.textStrong};
}
`,
});
export const SearchField: FC<SearchFieldProps> = ({ query, onChange, size, clearable, className, ...inputProps }) => {
const styles = useStyles(getSearchFieldStyles);
return (
<div className={cx(styles.wrapper, className)}>
<input
type="text"
placeholder="Search dashboards by name"
value={query.query}
onChange={(event: React.ChangeEvent<HTMLInputElement>) => {
onChange(event.currentTarget.value);
}}
tabIndex={0}
spellCheck={false}
className={styles.input}
{...inputProps}
/>
<div className={styles.spacer} />
</div>
);
};

View File

@ -1,87 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { generalFolder, searchResults } from '../testData';
import { SearchLayout } from '../types';
import { Props, SearchResults } from './SearchResults';
beforeEach(() => {
jest.clearAllMocks();
});
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
results: searchResults,
onTagSelected: (name: string) => {},
onToggleSection: () => {},
editable: false,
layout: SearchLayout.Folders,
};
Object.assign(props, propOverrides);
render(<SearchResults {...props} />);
};
describe('SearchResults', () => {
it('should render result items', () => {
setup();
expect(screen.getAllByTestId(selectors.components.Search.sectionV2)).toHaveLength(2);
});
it('should render section items for expanded section', () => {
setup();
expect(screen.getAllByText('General', { exact: false })[0]).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.itemsV2)).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 1'))).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardItem('Test 2'))).toBeInTheDocument();
// Check search cards aren't in the DOM
expect(screen.queryByTestId(selectors.components.Search.cards)).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 1'))).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.Search.dashboardCard('Test 2'))).not.toBeInTheDocument();
});
it('should render search card items for expanded section when showPreviews is enabled', () => {
setup({ showPreviews: true });
expect(screen.getAllByText('General', { exact: false })[0]).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.cards)).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 1'))).toBeInTheDocument();
expect(screen.getByTestId(selectors.components.Search.dashboardCard('Test 2'))).toBeInTheDocument();
// Check search items aren't in the DOM
expect(screen.queryByTestId(selectors.components.Search.itemsV2)).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 1'))).not.toBeInTheDocument();
expect(screen.queryByTestId(selectors.components.Search.dashboardItem('Test 2'))).not.toBeInTheDocument();
});
it('should not render checkboxes for non-editable results', () => {
setup({ editable: false });
expect(screen.queryAllByRole('checkbox')).toHaveLength(0);
});
it('should render checkboxes for editable results', () => {
setup({ editable: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(4);
});
it('should collapse folder item list on header click', () => {
const mockOnToggleSection = jest.fn();
setup({ onToggleSection: mockOnToggleSection });
fireEvent.click(screen.getAllByText('General', { exact: false })[0]);
expect(mockOnToggleSection).toHaveBeenCalledWith(generalFolder);
});
it('should not throw an error if the search results have an empty title', () => {
const mockOnToggleSection = jest.fn();
const searchResultsEmptyTitle = searchResults.slice();
searchResultsEmptyTitle[0].title = '';
expect(() => {
setup({ results: searchResultsEmptyTitle, onToggleSection: mockOnToggleSection });
}).not.toThrowError();
});
});

View File

@ -1,193 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, memo } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer';
import { FixedSizeList, FixedSizeGrid } from 'react-window';
import { GrafanaTheme } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Spinner, stylesFactory, useTheme } from '@grafana/ui';
import { SEARCH_ITEM_HEIGHT, SEARCH_ITEM_MARGIN } from '../constants';
import { DashboardSection, OnToggleChecked, SearchLayout } from '../types';
import { SearchCard } from './SearchCard';
import { SearchItem } from './SearchItem';
import { SectionHeader } from './SectionHeader';
export interface Props {
editable?: boolean;
loading?: boolean;
onTagSelected: (name: string) => any;
onToggleChecked?: OnToggleChecked;
onToggleSection: (section: DashboardSection) => void;
results: DashboardSection[];
showPreviews?: boolean;
layout?: string;
}
const { sectionV2: sectionLabel, itemsV2: itemsLabel, cards: cardsLabel } = selectors.components.Search;
export const SearchResults: FC<Props> = memo(
({ editable, loading, onTagSelected, onToggleChecked, onToggleSection, results, showPreviews, layout }) => {
const theme = useTheme();
const styles = getSectionStyles(theme);
const itemProps = { editable, onToggleChecked, onTagSelected };
const renderFolders = () => {
const Wrapper = showPreviews ? SearchCard : SearchItem;
return (
<div className={styles.wrapper}>
{results.map((section) => {
return (
<div data-testid={sectionLabel} className={styles.section} key={section.id || section.title}>
{section.title && (
<SectionHeader onSectionClick={onToggleSection} {...{ onToggleChecked, editable, section }}>
<div
data-testid={showPreviews ? cardsLabel : itemsLabel}
className={cx(styles.sectionItems, { [styles.gridContainer]: showPreviews })}
>
{section.items.map((item) => (
<Wrapper {...itemProps} key={item.uid} item={item} />
))}
</div>
</SectionHeader>
)}
</div>
);
})}
</div>
);
};
const renderDashboards = () => {
const items = results[0]?.items;
return (
<div className={styles.listModeWrapper}>
<AutoSizer>
{({ height, width }) => {
const numColumns = Math.ceil(width / 320);
const cellWidth = width / numColumns;
const cellHeight = (cellWidth - 64) * 0.75 + 56 + 8;
const numRows = Math.ceil(items.length / numColumns);
return showPreviews ? (
<FixedSizeGrid
columnCount={numColumns}
columnWidth={cellWidth}
rowCount={numRows}
rowHeight={cellHeight}
className={styles.wrapper}
innerElementType="ul"
height={height}
width={width}
>
{({ columnIndex, rowIndex, style }) => {
const index = rowIndex * numColumns + columnIndex;
const item = items[index];
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing
// And without this wrapper there is no room for that margin
return item ? (
<li style={style} className={styles.virtualizedGridItemWrapper}>
<SearchCard key={item.id} {...itemProps} item={item} />
</li>
) : null;
}}
</FixedSizeGrid>
) : (
<FixedSizeList
className={styles.wrapper}
innerElementType="ul"
itemSize={SEARCH_ITEM_HEIGHT + SEARCH_ITEM_MARGIN}
height={height}
itemCount={items.length}
width={width}
>
{({ index, style }) => {
const item = items[index];
// The wrapper div is needed as the inner SearchItem has margin-bottom spacing
// And without this wrapper there is no room for that margin
return (
<li style={style}>
<SearchItem key={item.id} {...itemProps} item={item} />
</li>
);
}}
</FixedSizeList>
);
}}
</AutoSizer>
</div>
);
};
if (loading) {
return <Spinner className={styles.spinner} />;
} else if (!results || !results.length) {
return <div className={styles.noResults}>No dashboards matching your query were found.</div>;
}
return (
<div className={styles.resultsContainer}>
{layout === SearchLayout.Folders ? renderFolders() : renderDashboards()}
</div>
);
}
);
SearchResults.displayName = 'SearchResults';
const getSectionStyles = stylesFactory((theme: GrafanaTheme) => {
const { md, sm } = theme.spacing;
return {
virtualizedGridItemWrapper: css`
padding: 4px;
`,
wrapper: css`
display: flex;
flex-direction: column;
> ul {
list-style: none;
}
`,
section: css`
display: flex;
flex-direction: column;
background: ${theme.colors.panelBg};
border-bottom: solid 1px ${theme.colors.border2};
`,
sectionItems: css`
margin: 0 24px 0 32px;
`,
spinner: css`
display: flex;
justify-content: center;
align-items: center;
min-height: 100px;
`,
gridContainer: css`
display: grid;
gap: ${sm};
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
margin-bottom: ${md};
`,
resultsContainer: css`
position: relative;
flex-grow: 10;
margin-bottom: ${md};
background: ${theme.colors.bg1};
border: 1px solid ${theme.colors.border1};
border-radius: 3px;
height: 100%;
`,
noResults: css`
padding: ${md};
background: ${theme.colors.bg2};
font-style: italic;
margin-top: ${theme.spacing.md};
`,
listModeWrapper: css`
position: relative;
height: 100%;
padding: ${md};
`,
};
});

View File

@ -1,94 +0,0 @@
import { fireEvent, render, screen } from '@testing-library/react';
import React from 'react';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { SearchLayout } from '../types';
import { Props, SearchResultsFilter } from './SearchResultsFilter';
jest.mock('app/core/services/search_srv');
const noop = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
const searchQuery = {
starred: false,
sort: null,
prevSort: null,
tag: ['tag'],
query: '',
skipRecent: true,
skipStarred: true,
folderIds: [],
layout: SearchLayout.Folders,
};
const setup = (propOverrides?: Partial<Props>) => {
const props: Props = {
allChecked: false,
canDelete: false,
canMove: false,
deleteItem: noop,
moveTo: noop,
onStarredFilterChange: noop,
onTagFilterChange: noop,
onToggleAllChecked: noop,
onLayoutChange: noop,
query: searchQuery,
onSortChange: noop,
setShowPreviews: noop,
editable: true,
};
Object.assign(props, propOverrides);
render(<SearchResultsFilter {...props} />);
};
describe('SearchResultsFilter', () => {
it('should render "filter by starred" and "filter by tag" filters by default', async () => {
setup();
expect(await screen.findAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).not.toBeInTheDocument();
expect(screen.queryByText('Delete')).not.toBeInTheDocument();
});
it('should render Move and Delete buttons when canDelete is true', () => {
setup({ canDelete: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).toBeInTheDocument();
expect(screen.queryByText('Delete')).toBeInTheDocument();
});
it('should render Move and Delete buttons when canMove is true', () => {
setup({ canMove: true });
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
expect(screen.queryByText('Move')).toBeInTheDocument();
expect(screen.queryByText('Delete')).toBeInTheDocument();
});
it('should call onStarredFilterChange when "filter by starred" is changed', async () => {
const mockFilterStarred = jest.fn();
setup({ onStarredFilterChange: mockFilterStarred });
const checkbox = await screen.findByLabelText(/filter by starred/i);
fireEvent.click(checkbox);
expect(mockFilterStarred).toHaveBeenCalledTimes(1);
});
it('should be called with proper filter option when "filter by tags" is changed', async () => {
const mockFilterByTags = jest.fn();
setup({
onTagFilterChange: mockFilterByTags,
query: { ...searchQuery, tag: [] },
});
const tagComponent = await screen.findByLabelText('Tag filter');
await tagComponent.focus();
await selectOptionInTest(tagComponent, 'tag1');
expect(mockFilterByTags).toHaveBeenCalledTimes(1);
expect(mockFilterByTags).toHaveBeenCalledWith(['tag1']);
});
});

View File

@ -1,121 +0,0 @@
import { css } from '@emotion/css';
import React, { FC, FormEvent } from 'react';
import { GrafanaTheme, SelectableValue } from '@grafana/data';
import { Button, Checkbox, stylesFactory, useTheme, HorizontalGroup } from '@grafana/ui';
import { DashboardQuery, SearchLayout } from '../types';
import { ActionRow } from './ActionRow';
import { PreviewsSystemRequirements } from './PreviewsSystemRequirements';
export interface Props {
allChecked?: boolean;
canDelete?: boolean;
canMove?: boolean;
deleteItem: () => void;
hideLayout?: boolean;
moveTo: () => void;
onLayoutChange: (layout: SearchLayout) => void;
setShowPreviews: (newValue: boolean) => void;
onSortChange: (value: SelectableValue) => void;
onStarredFilterChange: (event: FormEvent<HTMLInputElement>) => void;
onTagFilterChange: (tags: string[]) => void;
onToggleAllChecked: () => void;
query: DashboardQuery;
showPreviews?: boolean;
editable?: boolean;
}
export const SearchResultsFilter: FC<Props> = ({
allChecked,
canDelete,
canMove,
deleteItem,
hideLayout,
moveTo,
onLayoutChange,
setShowPreviews,
onSortChange,
onStarredFilterChange,
onTagFilterChange,
onToggleAllChecked,
query,
showPreviews,
editable,
}) => {
const showActions = canDelete || canMove;
const theme = useTheme();
const styles = getStyles(theme);
return (
<div className={styles.wrapper}>
<div className={styles.rowWrapper}>
{editable && (
<div className={styles.checkboxWrapper}>
<Checkbox aria-label="Select all" value={allChecked} onChange={onToggleAllChecked} />
</div>
)}
{showActions ? (
<HorizontalGroup spacing="md">
<Button disabled={!canMove} onClick={moveTo} icon="exchange-alt" variant="secondary">
Move
</Button>
<Button disabled={!canDelete} onClick={deleteItem} icon="trash-alt" variant="destructive">
Delete
</Button>
</HorizontalGroup>
) : (
<ActionRow
{...{
hideLayout,
onLayoutChange,
setShowPreviews,
onSortChange,
onStarredFilterChange,
onTagFilterChange,
query,
showPreviews,
}}
showStarredFilter
/>
)}
</div>
<PreviewsSystemRequirements
topSpacing={2}
bottomSpacing={3}
showPreviews={showPreviews}
onRemove={() => setShowPreviews(false)}
/>
</div>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
const { sm, md } = theme.spacing;
return {
wrapper: css`
display: flex;
flex-direction: column;
`,
rowWrapper: css`
height: ${theme.height.md}px;
display: flex;
justify-content: flex-start;
gap: ${theme.spacing.md};
align-items: center;
margin-bottom: ${sm};
> label {
height: 20px;
margin: 0 ${md} 0 ${sm};
}
`,
checkboxWrapper: css`
label {
line-height: 1.2;
width: max-content;
}
`,
};
});

View File

@ -4,7 +4,7 @@ import { useUrlParams } from 'app/core/navigation/hooks';
import { defaultQueryParams } from '../reducers/searchQueryReducer';
import DashboardSearch from './DashboardSearch';
import { DashboardSearch } from './DashboardSearch';
export const SearchWrapper: FC = memo(() => {
const [params, updateUrlParams] = useUrlParams();

View File

@ -1,141 +0,0 @@
import { css, cx } from '@emotion/css';
import React, { FC, useCallback } from 'react';
import { useLocalStorage } from 'react-use';
import { GrafanaTheme } from '@grafana/data';
import { CollapsableSection, Icon, stylesFactory, useTheme } from '@grafana/ui';
import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId';
import { DashboardSection, OnToggleChecked } from '../types';
import { getSectionIcon, getSectionStorageKey } from '../utils';
import { SearchCheckbox } from './SearchCheckbox';
interface SectionHeaderProps {
editable?: boolean;
onSectionClick: (section: DashboardSection) => void;
onToggleChecked?: OnToggleChecked;
section: DashboardSection;
children: React.ReactNode;
}
export const SectionHeader: FC<SectionHeaderProps> = ({
section,
onSectionClick,
children,
onToggleChecked,
editable = false,
}) => {
const theme = useTheme();
const styles = getSectionHeaderStyles(theme, section.selected, editable);
const setSectionExpanded = useLocalStorage(getSectionStorageKey(section.title), true)[1];
const onSectionExpand = () => {
setSectionExpanded(!section.expanded);
onSectionClick(section);
};
const handleCheckboxClick = useCallback(
(ev: React.MouseEvent) => {
ev.stopPropagation();
ev.preventDefault();
onToggleChecked?.(section);
},
[onToggleChecked, section]
);
const id = useUniqueId();
const labelId = `section-header-label-${id}`;
return (
<CollapsableSection
isOpen={section.expanded ?? false}
onToggle={onSectionExpand}
className={styles.wrapper}
contentClassName={styles.content}
loading={section.itemsFetching}
labelId={labelId}
label={
<>
<SearchCheckbox
className={styles.checkbox}
editable={editable}
checked={section.checked}
onClick={handleCheckboxClick}
aria-label="Select folder"
/>
<div className={styles.icon}>
<Icon name={getSectionIcon(section)} />
</div>
<div className={styles.text}>
<span id={labelId}>{section.title}</span>
{section.url && (
<a href={section.url} className={styles.link}>
<span className={styles.separator}>|</span> <Icon name="folder-upload" /> Go to folder
</a>
)}
</div>
</>
}
>
{children}
</CollapsableSection>
);
};
const getSectionHeaderStyles = stylesFactory((theme: GrafanaTheme, selected = false, editable: boolean) => {
const { sm } = theme.spacing;
return {
wrapper: cx(
css`
align-items: center;
font-size: ${theme.typography.size.base};
padding: 12px;
border-bottom: none;
color: ${theme.colors.textWeak};
z-index: 1;
&:hover,
&.selected {
color: ${theme.colors.text};
}
&:hover,
&:focus-visible,
&:focus-within {
a {
opacity: 1;
}
}
`,
'pointer',
{ selected }
),
checkbox: css`
padding: 0 ${sm} 0 0;
`,
icon: css`
padding: 0 ${sm} 0 ${editable ? 0 : sm};
`,
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;
`,
separator: css`
margin-right: 6px;
`,
content: css`
padding-top: 0px;
padding-bottom: 0px;
`,
};
});

View File

@ -7,6 +7,5 @@ export const SECTION_STORAGE_KEY = 'search.sections';
export const GENERAL_FOLDER_ID = 0;
export const GENERAL_FOLDER_UID = 'general';
export const GENERAL_FOLDER_TITLE = 'General';
export const PREVIEWS_LOCAL_STORAGE_KEY = 'grafana.dashboard.previews';
export const SEARCH_PANELS_LOCAL_STORAGE_KEY = 'grafana.search.include.panels';
export const SEARCH_SELECTED_LAYOUT = 'grafana.search.layout';

View File

@ -1,80 +0,0 @@
import { KeyboardEvent, useReducer } from 'react';
import { useDebounce } from 'react-use';
import { locationUtil } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from '../reducers/actionTypes';
import { dashboardsSearchState, DashboardsSearchState, searchReducer } from '../reducers/dashboardSearch';
import { DashboardQuery, DashboardSearchItemType, DashboardSection } from '../types';
import { findSelected } from '../utils';
import { reportDashboardListViewed } from './useManageDashboards';
import { useSearch } from './useSearch';
import { useShowDashboardPreviews } from './useShowDashboardPreviews';
export const useDashboardSearch = (query: DashboardQuery, onCloseSearch: () => void) => {
const reducer = useReducer(searchReducer, dashboardsSearchState);
const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews();
const {
state: { results, loading },
onToggleSection,
dispatch,
} = useSearch<DashboardsSearchState>(query, reducer, { queryParsing: true });
useDebounce(
() => {
reportDashboardListViewed('dashboard_search', showPreviews, previewFeatureEnabled, {
layout: query.layout,
starred: query.starred,
sortValue: query.sort?.value,
query: query.query,
tagCount: query.tag?.length,
});
},
1000,
[
showPreviews,
previewFeatureEnabled,
query.layout,
query.starred,
query.sort?.value,
query.query?.length,
query.tag?.length,
]
);
const onKeyDown = (event: KeyboardEvent<HTMLInputElement>) => {
switch (event.key) {
case 'Escape':
onCloseSearch();
break;
case 'ArrowUp':
dispatch({ type: MOVE_SELECTION_UP });
break;
case 'ArrowDown':
dispatch({ type: MOVE_SELECTION_DOWN });
break;
case 'Enter':
const selectedItem = findSelected(results);
if (selectedItem) {
if (selectedItem.type === DashboardSearchItemType.DashFolder) {
onToggleSection(selectedItem as DashboardSection);
} else {
locationService.push(locationUtil.stripBaseFromUrl(selectedItem.url));
// Delay closing to prevent current page flicker
setTimeout(onCloseSearch, 0);
}
}
}
};
return {
results,
loading,
onToggleSection,
onKeyDown,
showPreviews,
setShowPreviews,
};
};

View File

@ -1,230 +0,0 @@
import { renderHook } from '@testing-library/react-hooks';
import { Dispatch } from 'react';
import { setEchoSrv } from '@grafana/runtime/src';
import { Echo } from 'app/core/services/echo/Echo';
import { GENERAL_FOLDER_ID } from '../constants';
import { ManageDashboardsState } from '../reducers/manageDashboards';
import { DashboardQuery, DashboardSearchItemType, DashboardSection, SearchAction } from '../types';
import { useManageDashboards } from './useManageDashboards';
import * as useSearch from './useSearch';
describe('useManageDashboards', () => {
const useSearchMock = jest.spyOn(useSearch, 'useSearch');
const toggle = async (section: DashboardSection) => section;
beforeAll(() => {
setEchoSrv(new Echo());
});
function setupTestContext({ results = [] }: { results?: DashboardSection[] } = {}) {
jest.clearAllMocks();
const state: ManageDashboardsState = {
results,
loading: false,
selectedIndex: 0,
initialLoading: false,
allChecked: false,
};
const dispatch: Dispatch<SearchAction> = null as unknown as Dispatch<SearchAction>;
useSearchMock.mockReturnValue({ state, dispatch, onToggleSection: toggle });
const dashboardQuery: DashboardQuery = {} as unknown as DashboardQuery;
const { result } = renderHook(() => useManageDashboards(dashboardQuery, {}));
return { result };
}
describe('when called and only General folder is selected', () => {
it('then canDelete should be false', () => {
const results: DashboardSection[] = [
{ id: 1, checked: false, items: [], title: 'One', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
{
id: GENERAL_FOLDER_ID,
checked: true,
items: [],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{ id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(false);
});
});
describe('when called and General folder and another folder are selected', () => {
it('then canDelete should be false', () => {
const results: DashboardSection[] = [
{
id: 1,
checked: true,
items: [
{
id: 11,
checked: true,
title: 'Eleven',
type: DashboardSearchItemType.DashDB,
url: '/',
isStarred: false,
tags: [],
uri: '',
},
],
title: 'One',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{
id: GENERAL_FOLDER_ID,
checked: true,
items: [
{
id: 10,
checked: true,
title: 'Ten',
type: DashboardSearchItemType.DashDB,
url: '/',
isStarred: false,
tags: [],
uri: '',
},
],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{ id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(false);
});
});
describe('when called on an empty General folder that is not selected but another folder is selected', () => {
it('then canDelete should be true', () => {
const results: DashboardSection[] = [
{
id: 1,
checked: true,
items: [
{
id: 11,
checked: true,
title: 'Eleven',
type: DashboardSearchItemType.DashDB,
url: '/',
isStarred: false,
tags: [],
uri: '',
},
],
title: 'One',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{
id: GENERAL_FOLDER_ID,
checked: false,
items: [],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{ id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(true);
});
});
describe('when called on a non empty General folder that is not selected dashboard in General folder is selected', () => {
it('then canDelete should be true', () => {
const results: DashboardSection[] = [
{
id: GENERAL_FOLDER_ID,
checked: false,
items: [
{
id: 10,
checked: true,
title: 'Ten',
type: DashboardSearchItemType.DashDB,
url: '/',
isStarred: false,
tags: [],
uri: '',
},
],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(true);
});
});
describe('when called and no folder is selected', () => {
it('then canDelete should be false', () => {
const results: DashboardSection[] = [
{ id: 1, checked: false, items: [], title: 'One', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
{
id: GENERAL_FOLDER_ID,
checked: false,
items: [],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{ id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(false);
});
});
describe('when called on an empty folder', () => {
it('then canDelete should be true', () => {
const results: DashboardSection[] = [
{ id: 1, checked: true, items: [], title: 'One', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
{
id: GENERAL_FOLDER_ID,
checked: false,
items: [],
title: 'General',
type: DashboardSearchItemType.DashFolder,
toggle,
url: '/',
},
{ id: 2, checked: false, items: [], title: 'Two', type: DashboardSearchItemType.DashFolder, toggle, url: '/' },
];
const { result } = setupTestContext({ results });
expect(result.current.canDelete).toBe(true);
});
});
});

View File

@ -1,131 +0,0 @@
import { useCallback, useMemo, useReducer } from 'react';
import { useDebounce } from 'react-use';
import { reportInteraction } from '@grafana/runtime/src';
import { contextSrv } from 'app/core/services/context_srv';
import { FolderDTO } from 'app/types';
import { GENERAL_FOLDER_ID } from '../constants';
import { DELETE_ITEMS, MOVE_ITEMS, TOGGLE_ALL_CHECKED, TOGGLE_CHECKED } from '../reducers/actionTypes';
import { manageDashboardsReducer, manageDashboardsState, ManageDashboardsState } from '../reducers/manageDashboards';
import { DashboardQuery, DashboardSection, OnDeleteItems, OnMoveItems, OnToggleChecked, SearchLayout } from '../types';
import { useSearch } from './useSearch';
import { useShowDashboardPreviews } from './useShowDashboardPreviews';
const hasChecked = (section: DashboardSection) => {
return section.checked || section.items.some((item) => item.checked);
};
export const reportDashboardListViewed = (
dashboardListType: 'manage_dashboards' | 'dashboard_search',
showPreviews: boolean,
previewsEnabled: boolean,
query: {
layout?: SearchLayout;
starred?: boolean;
sortValue?: string;
query?: string;
tagCount?: number;
}
) => {
const previews = previewsEnabled ? (showPreviews ? 'on' : 'off') : 'feature_disabled';
reportInteraction(`${dashboardListType}_viewed`, {
previews,
layout: query.layout,
starredFilter: query.starred ?? false,
sort: query.sortValue ?? '',
tagCount: query.tagCount ?? 0,
queryLength: query.query?.length ?? 0,
});
};
export const useManageDashboards = (
query: DashboardQuery,
state: Partial<ManageDashboardsState> = {},
folder?: FolderDTO
) => {
const reducer = useReducer(manageDashboardsReducer, {
...manageDashboardsState,
...state,
});
const { showPreviews, setShowPreviews, previewFeatureEnabled } = useShowDashboardPreviews();
useDebounce(
() => {
reportDashboardListViewed('manage_dashboards', showPreviews, previewFeatureEnabled, {
layout: query.layout,
starred: query.starred,
sortValue: query.sort?.value,
query: query.query,
tagCount: query.tag?.length,
});
},
1000,
[
showPreviews,
previewFeatureEnabled,
query.layout,
query.starred,
query.sort?.value,
query.query?.length,
query.tag?.length,
]
);
const {
state: { results, loading, initialLoading, allChecked },
onToggleSection,
dispatch,
} = useSearch<ManageDashboardsState>(query, reducer, {});
const onToggleChecked: OnToggleChecked = useCallback(
(item) => {
dispatch({ type: TOGGLE_CHECKED, payload: item });
},
[dispatch]
);
const onToggleAllChecked = () => {
dispatch({ type: TOGGLE_ALL_CHECKED });
};
const onDeleteItems: OnDeleteItems = (folders, dashboards) => {
dispatch({ type: DELETE_ITEMS, payload: { folders, dashboards } });
};
const onMoveItems: OnMoveItems = (selectedDashboards, folder) => {
dispatch({ type: MOVE_ITEMS, payload: { dashboards: selectedDashboards, folder } });
};
const canMove = useMemo(() => results.some((result) => result.items.some((item) => item.checked)), [results]);
const canDelete = useMemo(() => {
const somethingChecked = results.some(hasChecked);
const includesGeneralFolder = results.find((result) => result.checked && result.id === GENERAL_FOLDER_ID);
return somethingChecked && !includesGeneralFolder;
}, [results]);
const canSave = folder?.canSave;
const hasEditPermissionInFolders = folder ? canSave : contextSrv.hasEditPermissionInFolders;
const noFolders = canSave && folder?.id && results.length === 0 && !loading && !initialLoading;
return {
results,
loading,
initialLoading,
canSave,
allChecked,
hasEditPermissionInFolders,
canMove,
canDelete,
onToggleSection,
onToggleChecked,
onToggleAllChecked,
onDeleteItems,
onMoveItems,
noFolders,
showPreviews,
setShowPreviews,
};
};

View File

@ -1,56 +0,0 @@
import { useCallback, useEffect } from 'react';
import { useDebounce } from 'react-use';
import { backendSrv } from 'app/core/services/backend_srv';
import { SearchSrv } from 'app/core/services/search_srv';
import { FETCH_RESULTS, FETCH_ITEMS, TOGGLE_SECTION, SEARCH_START, FETCH_ITEMS_START } from '../reducers/actionTypes';
import { DashboardSection, UseSearch } from '../types';
import { hasId, getParsedQuery } from '../utils';
const searchSrv = new SearchSrv();
/**
* Base hook for search functionality.
* Returns state and dispatch, among others, from 'reducer' param, so it can be
* further extended.
* @param query
* @param reducer - return result of useReducer
* @param params - custom params
*/
export const useSearch: UseSearch = (query, reducer, params = {}) => {
const { queryParsing } = params;
const [state, dispatch] = reducer;
const search = () => {
dispatch({ type: SEARCH_START });
const parsedQuery = getParsedQuery(query, queryParsing);
searchSrv.search(parsedQuery).then((results) => {
dispatch({ type: FETCH_RESULTS, payload: results });
});
};
// Set loading state before debounced search
useEffect(() => {
dispatch({ type: SEARCH_START });
}, [query.tag, query.sort, query.starred, query.layout, dispatch]);
useDebounce(search, 300, [query, queryParsing]);
const onToggleSection = useCallback(
(section: DashboardSection) => {
if (hasId(section.title) && !section.items.length) {
dispatch({ type: FETCH_ITEMS_START, payload: section.id });
backendSrv.search({ folderIds: [section.id] }).then((items) => {
dispatch({ type: FETCH_ITEMS, payload: { section, items } });
dispatch({ type: TOGGLE_SECTION, payload: section });
});
} else {
dispatch({ type: TOGGLE_SECTION, payload: section });
}
},
[dispatch]
);
return { state, dispatch, onToggleSection };
};

View File

@ -1,12 +0,0 @@
import { useLocalStorage } from 'react-use';
import { config } from '@grafana/runtime/src';
import { PREVIEWS_LOCAL_STORAGE_KEY } from '../constants';
export const useShowDashboardPreviews = () => {
const previewFeatureEnabled = Boolean(config.featureToggles.dashboardPreviews);
const [showPreviews, setShowPreviews] = useLocalStorage<boolean>(PREVIEWS_LOCAL_STORAGE_KEY, false);
return { showPreviews: Boolean(showPreviews && previewFeatureEnabled), previewFeatureEnabled, setShowPreviews };
};

View File

@ -1,9 +1,2 @@
export { SearchResults } from './components/SearchResults';
export { SearchField } from './components/SearchField';
export { SearchItem } from './components/SearchItem';
export { SearchCheckbox } from './components/SearchCheckbox';
export { SearchWrapper } from './components/SearchWrapper';
export { SearchResultsFilter } from './components/SearchResultsFilter';
export { ManageDashboards } from './components/ManageDashboards';
export { ConfirmDeleteModal } from './components/ConfirmDeleteModal';
export { MoveToFolderModal } from './components/MoveToFolderModal';

View File

@ -1,17 +1,3 @@
export const FETCH_RESULTS = 'FETCH_RESULTS';
export const TOGGLE_SECTION = 'TOGGLE_SECTION';
export const FETCH_ITEMS = 'FETCH_ITEMS';
export const FETCH_ITEMS_START = 'FETCH_ITEMS_START';
export const MOVE_SELECTION_UP = 'MOVE_SELECTION_UP';
export const MOVE_SELECTION_DOWN = 'MOVE_SELECTION_DOWN';
export const SEARCH_START = 'SEARCH_START';
// Manage dashboards
export const TOGGLE_ALL_CHECKED = 'TOGGLE_ALL_CHECKED';
export const TOGGLE_CHECKED = 'TOGGLE_SECTION_CHECKED';
export const MOVE_ITEMS = 'MOVE_ITEMS';
export const DELETE_ITEMS = 'DELETE_ITEMS';
// Search Query
export const TOGGLE_STARRED = 'TOGGLE_STARRED';
export const REMOVE_STARRED = 'REMOVE_STARRED';

View File

@ -1,94 +0,0 @@
import { searchResults, sections } from '../testData';
import { FETCH_ITEMS, FETCH_RESULTS, TOGGLE_SECTION, MOVE_SELECTION_DOWN, MOVE_SELECTION_UP } from './actionTypes';
import { searchReducer as reducer, dashboardsSearchState } from './dashboardSearch';
const defaultState = { selectedIndex: 0, loading: false, results: sections as any[], initialLoading: false };
describe('Dashboard Search reducer', () => {
it('should return the initial state', () => {
expect(reducer(dashboardsSearchState, {} as any)).toEqual(dashboardsSearchState);
});
it('should set the results and mark first item as selected', () => {
const newState = reducer(dashboardsSearchState, { type: FETCH_RESULTS, payload: searchResults });
expect(newState).toEqual({ loading: false, selectedIndex: 0, results: searchResults, initialLoading: false });
expect(newState.results[0].selected).toBeTruthy();
});
it('should toggle selected section', () => {
const newState = reducer(defaultState, { type: TOGGLE_SECTION, payload: sections[5] });
expect(newState.results[5].expanded).toBeFalsy();
const newState2 = reducer(defaultState, { type: TOGGLE_SECTION, payload: sections[1] });
expect(newState2.results[1].expanded).toBeTruthy();
});
it('should handle FETCH_ITEMS', () => {
const items = [
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
isStarred: false,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: 'dash-db',
isStarred: false,
},
];
const newState = reducer(defaultState, {
type: FETCH_ITEMS,
payload: {
section: sections[2],
items,
},
});
expect(newState.results[2].items).toEqual(items);
});
it('should handle MOVE_SELECTION_DOWN', () => {
const newState = reducer(defaultState, {
type: MOVE_SELECTION_DOWN,
});
expect(newState.selectedIndex).toEqual(1);
expect(newState.results[0].items[0].selected).toBeTruthy();
const newState2 = reducer(newState, {
type: MOVE_SELECTION_DOWN,
});
expect(newState2.selectedIndex).toEqual(2);
expect(newState2.results[1].selected).toBeTruthy();
// Shouldn't go over the visible results length - 1 (9)
const newState3 = reducer(
{ ...defaultState, selectedIndex: 9 },
{
type: MOVE_SELECTION_DOWN,
}
);
expect(newState3.selectedIndex).toEqual(9);
});
it('should handle MOVE_SELECTION_UP', () => {
// shouldn't move beyond 0
const newState = reducer(defaultState, {
type: MOVE_SELECTION_UP,
});
expect(newState.selectedIndex).toEqual(0);
const newState2 = reducer(
{ ...defaultState, selectedIndex: 3 },
{
type: MOVE_SELECTION_UP,
}
);
expect(newState2.selectedIndex).toEqual(2);
expect(newState2.results[1].selected).toBeTruthy();
});
});

View File

@ -1,108 +0,0 @@
import { DashboardSection, SearchAction } from '../types';
import { getFlattenedSections, getLookupField, markSelected } from '../utils';
import {
FETCH_ITEMS,
FETCH_RESULTS,
TOGGLE_SECTION,
MOVE_SELECTION_DOWN,
MOVE_SELECTION_UP,
SEARCH_START,
FETCH_ITEMS_START,
} from './actionTypes';
export interface DashboardsSearchState {
results: DashboardSection[];
loading: boolean;
selectedIndex: number;
/** Used for first time page load */
initialLoading: boolean;
}
export const dashboardsSearchState: DashboardsSearchState = {
results: [],
loading: true,
initialLoading: true,
selectedIndex: 0,
};
export const searchReducer = (state: DashboardsSearchState, action: SearchAction) => {
switch (action.type) {
case SEARCH_START:
if (!state.loading) {
return { ...state, loading: true };
}
return state;
case FETCH_RESULTS: {
const results = action.payload;
// Highlight the first item ('Starred' folder)
if (results.length > 0) {
results[0].selected = true;
}
return { ...state, results, loading: false, initialLoading: false };
}
case TOGGLE_SECTION: {
const section = action.payload;
const lookupField = getLookupField(section.title);
return {
...state,
results: state.results.map((result: DashboardSection) => {
if (section[lookupField] === result[lookupField]) {
return { ...result, expanded: !result.expanded };
}
return result;
}),
};
}
case FETCH_ITEMS: {
const { section, items } = action.payload;
return {
...state,
itemsFetching: false,
results: state.results.map((result: DashboardSection) => {
if (section.id === result.id) {
return { ...result, items, itemsFetching: false };
}
return result;
}),
};
}
case FETCH_ITEMS_START: {
const id = action.payload;
if (id) {
return {
...state,
results: state.results.map((result) => (result.id === id ? { ...result, itemsFetching: true } : result)),
};
}
return state;
}
case MOVE_SELECTION_DOWN: {
const flatIds = getFlattenedSections(state.results);
if (state.selectedIndex < flatIds.length - 1) {
const newIndex = state.selectedIndex + 1;
const selectedId = flatIds[newIndex];
return {
...state,
selectedIndex: newIndex,
results: markSelected(state.results, selectedId),
};
}
return state;
}
case MOVE_SELECTION_UP:
if (state.selectedIndex > 0) {
const flatIds = getFlattenedSections(state.results);
const newIndex = state.selectedIndex - 1;
const selectedId = flatIds[newIndex];
return {
...state,
selectedIndex: newIndex,
results: markSelected(state.results, selectedId),
};
}
return state;
default:
return state;
}
};

View File

@ -1,96 +0,0 @@
import { sections } from '../testData';
import { DashboardSection, UidsToDelete } from '../types';
import { TOGGLE_ALL_CHECKED, TOGGLE_CHECKED, DELETE_ITEMS, MOVE_ITEMS } from './actionTypes';
import { manageDashboardsReducer as reducer, manageDashboardsState as state } from './manageDashboards';
// Remove Recent and Starred sections as they're not used in manage dashboards
const results = sections.slice(2);
describe('Manage dashboards reducer', () => {
it('should return the initial state', () => {
expect(reducer(state, {} as any)).toEqual(state);
});
it('should handle TOGGLE_ALL_CHECKED', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_ALL_CHECKED });
expect(newState.results.every((result: any) => result.checked === true)).toBe(true);
expect(newState.results.every((result: any) => result.items.every((item: any) => item.checked === true))).toBe(
true
);
expect(newState.allChecked).toBe(true);
const newState2 = reducer({ ...newState, results }, { type: TOGGLE_ALL_CHECKED });
expect(newState2.results.every((result: any) => result.checked === false)).toBe(true);
expect(newState2.results.every((result: any) => result.items.every((item: any) => item.checked === false))).toBe(
true
);
expect(newState2.allChecked).toBe(false);
});
it('should handle TOGGLE_CHECKED sections', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_CHECKED, payload: results[0] });
expect(newState.results[0].checked).toBe(true);
expect(newState.results[1].checked).toBeFalsy();
const newState2 = reducer(newState, { type: TOGGLE_CHECKED, payload: results[1] });
expect(newState2.results[0].checked).toBe(true);
expect(newState2.results[1].checked).toBe(true);
});
it('should handle TOGGLE_CHECKED items', () => {
const newState = reducer({ ...state, results }, { type: TOGGLE_CHECKED, payload: { id: 4069 } });
expect(newState.results[3].items[0].checked).toBe(true);
const newState2 = reducer(newState, { type: TOGGLE_CHECKED, payload: { id: 1 } });
expect(newState2.results[3].items[0].checked).toBe(true);
expect(newState2.results[3].items[1].checked).toBeFalsy();
expect(newState2.results[3].items[2].checked).toBe(true);
});
it('should handle DELETE_ITEMS', () => {
const toDelete: UidsToDelete = { dashboards: ['OzAIf_rWz', 'lBdLINUWk'], folders: ['search-test-data'] };
const newState = reducer({ ...state, results }, { type: DELETE_ITEMS, payload: toDelete });
expect(newState.results).toHaveLength(3);
expect(newState.results[1].id).toEqual(4074);
expect(newState.results[2].items).toHaveLength(1);
expect(newState.results[2].items[0].id).toEqual(4069);
});
it('should handle MOVE_ITEMS', () => {
// Move 2 dashboards to a folder with id 2
const toMove = {
dashboards: [
{
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: 'dash-db',
isStarred: false,
},
{
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: 'dash-db',
isStarred: true,
},
],
folder: { id: 2 },
};
const newState = reducer({ ...state, results }, { type: MOVE_ITEMS, payload: toMove });
expect(newState.results[0].items).toHaveLength(2);
expect(newState.results[0].items[0].uid).toEqual('OzAIf_rWz');
expect(newState.results[0].items[1].uid).toEqual('lBdLINUWk');
expect(newState.results[3].items).toHaveLength(1);
expect(newState.results[3].items[0].uid).toEqual('LCFWfl9Zz');
});
it('should not display dashboards in a non-expanded folder', () => {
const general = results.find((res) => res.id === 0);
const toMove = { dashboards: general?.items, folder: { id: 4074 } };
const newState = reducer({ ...state, results }, { type: MOVE_ITEMS, payload: toMove });
expect(newState.results.find((res: DashboardSection) => res.id === 4074).items).toHaveLength(0);
expect(newState.results.find((res: DashboardSection) => res.id === 0).items).toHaveLength(0);
});
});

View File

@ -1,90 +0,0 @@
import { DashboardSection, DashboardSectionItem, SearchAction } from '../types';
import { mergeReducers } from '../utils';
import { TOGGLE_ALL_CHECKED, TOGGLE_CHECKED, MOVE_ITEMS, DELETE_ITEMS } from './actionTypes';
import { dashboardsSearchState, DashboardsSearchState, searchReducer } from './dashboardSearch';
export interface ManageDashboardsState extends DashboardsSearchState {
allChecked: boolean;
}
export const manageDashboardsState: ManageDashboardsState = {
...dashboardsSearchState,
allChecked: false,
};
const reducer = (state: ManageDashboardsState, action: SearchAction) => {
switch (action.type) {
case TOGGLE_ALL_CHECKED:
const newAllChecked = !state.allChecked;
return {
...state,
results: state.results.map((result) => {
return {
...result,
checked: newAllChecked,
items: result.items.map((item) => ({ ...item, checked: newAllChecked })),
};
}),
allChecked: newAllChecked,
};
case TOGGLE_CHECKED:
const { id } = action.payload;
return {
...state,
results: state.results.map((result) => {
if (result.id === id) {
return {
...result,
checked: !result.checked,
items: result.items.map((item) => ({ ...item, checked: !result.checked })),
};
}
return {
...result,
items: result.items.map((item) => (item.id === id ? { ...item, checked: !item.checked } : item)),
};
}),
};
case MOVE_ITEMS: {
const dashboards: DashboardSectionItem[] = action.payload.dashboards;
const folder: DashboardSection = action.payload.folder;
const uids = dashboards.map((db) => db.uid);
return {
...state,
results: state.results.map((result) => {
if (folder.id === result.id) {
return result.expanded
? {
...result,
items: [...result.items, ...dashboards.map((db) => ({ ...db, checked: false }))],
checked: false,
}
: result;
} else {
return { ...result, items: result.items.filter((item) => !uids.includes(item.uid)) };
}
}),
};
}
case DELETE_ITEMS: {
const { folders, dashboards } = action.payload;
if (!folders.length && !dashboards.length) {
return state;
}
return {
...state,
results: state.results.reduce((filtered, result) => {
if (!folders.includes(result.uid)) {
return [...filtered, { ...result, items: result.items.filter((item) => !dashboards.includes(item.uid)) }];
}
return filtered;
}, [] as DashboardSection[]),
};
}
default:
return state;
}
};
export const manageDashboardsReducer = mergeReducers([searchReducer, reducer]);

View File

@ -1,361 +0,0 @@
import { DashboardSearchItemType, DashboardSection, DashboardSectionItem } from './types';
function makeSection(sectionPartial: Partial<DashboardSection>): DashboardSection {
return {
title: 'Default title',
id: Number.MAX_SAFE_INTEGER - 1,
score: -99,
expanded: true,
type: DashboardSearchItemType.DashFolder,
items: [],
url: '/default-url',
...sectionPartial,
};
}
const makeSectionItem = (itemPartial: Partial<DashboardSectionItem>): DashboardSectionItem => {
return {
id: Number.MAX_SAFE_INTEGER - 2,
uid: 'default-uid',
title: 'Default dashboard title',
type: DashboardSearchItemType.DashDB,
isStarred: false,
tags: [],
uri: 'db/default-slug',
url: '/d/default-uid/default-slug',
...itemPartial,
};
};
export const generalFolder: DashboardSection = {
id: 0,
title: 'General',
items: [
{
id: 1,
uid: 'lBdLINUWk',
title: 'Test 1',
uri: 'db/test1',
url: '/d/lBdLINUWk/test1',
type: DashboardSearchItemType.DashDB,
tags: [],
isStarred: false,
checked: true,
},
{
id: 46,
uid: '8DY63kQZk',
title: 'Test 2',
uri: 'db/test2',
url: '/d/8DY63kQZk/test2',
type: DashboardSearchItemType.DashDB,
tags: [],
isStarred: false,
checked: true,
},
],
icon: 'folder-open',
score: 1,
expanded: true,
checked: false,
url: '',
type: DashboardSearchItemType.DashFolder,
};
export const searchResults: DashboardSection[] = [
{
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: false,
items: [],
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 0,
checked: true,
type: DashboardSearchItemType.DashFolder,
},
generalFolder,
];
// Search results with more info
export const sections: DashboardSection[] = [
makeSection({
title: 'Starred',
score: -2,
expanded: true,
items: [
makeSectionItem({
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
}),
],
}),
makeSection({
title: 'Recent',
icon: 'clock-o',
score: -1,
expanded: false,
items: [
makeSectionItem({
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 46,
uid: '8DY63kQZk',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 20,
uid: '7MeksYbmk',
title: 'Alerting with TestData',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
}),
makeSectionItem({
id: 4073,
uid: 'j9SHflrWk',
title: 'New dashboard Copy 4',
type: DashboardSearchItemType.DashDB,
isStarred: false,
folderId: 2,
}),
],
}),
makeSection({
id: 2,
uid: 'JB_zdOUWk',
title: 'gdev dashboards',
expanded: true,
url: '/dashboards/f/JB_zdOUWk/gdev-dashboards',
icon: 'folder',
score: 2,
items: [],
}),
makeSection({
id: 2568,
uid: 'search-test-data',
title: 'Search test data folder',
expanded: false,
items: [],
url: '/dashboards/f/search-test-data/search-test-data-folder',
icon: 'folder',
score: 3,
}),
makeSection({
id: 4074,
uid: 'iN5TFj9Zk',
title: 'Test',
expanded: false,
items: [],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
}),
makeSection({
id: 0,
title: 'General',
icon: 'folder-open',
score: 5,
expanded: true,
items: [
makeSectionItem({
id: 4069,
uid: 'LCFWfl9Zz',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 4072,
uid: 'OzAIf_rWz',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 1,
uid: 'lBdLINUWk',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
}),
],
}),
];
export const checkedGeneralFolder: DashboardSection[] = [
makeSection({
id: 4074,
uid: 'other-folder-dash',
title: 'Test',
expanded: false,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
}),
makeSection({
id: 0,
title: 'General',
uid: 'other-folder-abc',
score: 5,
expanded: true,
checked: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4069,
uid: 'general-abc',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 4072,
uid: 'general-def',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 1,
uid: 'general-ghi',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
checked: true,
}),
],
}),
];
export const checkedOtherFolder: DashboardSection[] = [
makeSection({
id: 4074,
uid: 'other-folder-abc',
title: 'Test',
expanded: false,
checked: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
],
url: '/dashboards/f/iN5TFj9Zk/test',
icon: 'folder',
score: 4,
}),
makeSection({
id: 0,
title: 'General',
icon: 'folder-open',
score: 5,
expanded: true,
type: DashboardSearchItemType.DashFolder,
items: [
makeSectionItem({
id: 4069,
uid: 'general-abc',
title: 'New dashboard Copy',
uri: 'db/new-dashboard-copy',
url: '/d/LCFWfl9Zz/new-dashboard-copy',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 4072,
uid: 'general-def',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
}),
makeSectionItem({
id: 1,
uid: 'general-ghi',
title: 'Prom dash',
type: DashboardSearchItemType.DashDB,
isStarred: true,
}),
],
}),
];
export const folderViewAllChecked: DashboardSection[] = [
makeSection({
checked: true,
selected: true,
title: '',
items: [
makeSectionItem({
id: 4072,
uid: 'other-folder-dash-abc',
title: 'New dashboard Copy 3',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
makeSectionItem({
id: 46,
uid: 'other-folder-dash-def',
title: 'Stocks',
type: DashboardSearchItemType.DashDB,
isStarred: false,
checked: true,
}),
],
}),
];

View File

@ -1,10 +1,7 @@
import { Dispatch } from 'react';
import { Action } from 'redux';
import { SelectableValue, WithAccessControlMetadata } from '@grafana/data';
import { FolderInfo } from '../../types';
export enum DashboardSearchItemType {
DashDB = 'dash-db',
DashHome = 'dash-home',
@ -56,20 +53,10 @@ export interface DashboardSectionItem {
export interface DashboardSearchHit extends DashboardSectionItem, DashboardSection, WithAccessControlMetadata {}
export interface DashboardTag {
term: string;
count: number;
}
export interface SearchAction extends Action {
payload?: any;
}
export interface UidsToDelete {
folders: string[];
dashboards: string[];
}
export interface DashboardQuery {
query: string;
tag: string[];
@ -84,22 +71,7 @@ export interface DashboardQuery {
layout: SearchLayout;
}
export type SearchReducer<S> = [S, Dispatch<SearchAction>];
interface UseSearchParams {
queryParsing?: boolean;
searchCallback?: (folderUid: string | undefined) => any;
folderUid?: string;
}
export type UseSearch = <S>(
query: DashboardQuery,
reducer: SearchReducer<S>,
params: UseSearchParams
) => { state: S; dispatch: Dispatch<SearchAction>; onToggleSection: (section: DashboardSection) => void };
export type OnToggleChecked = (item: DashboardSectionItem | DashboardSection) => void;
export type OnDeleteItems = (folders: string[], dashboards: string[]) => void;
export type OnMoveItems = (selectedDashboards: DashboardSectionItem[], folder: FolderInfo | null) => void;
export enum SearchLayout {
List = 'list',

View File

@ -1,168 +1,7 @@
import { sections, searchResults, checkedGeneralFolder, checkedOtherFolder, folderViewAllChecked } from './testData';
import { SearchQueryParams } from './types';
import {
findSelected,
getCheckedDashboardsUids,
getCheckedUids,
getFlattenedSections,
markSelected,
mergeReducers,
parseRouteParams,
} from './utils';
import { parseRouteParams } from './utils';
describe('Search utils', () => {
describe('getFlattenedSections', () => {
it('should return an array of items plus children for expanded items', () => {
const flatSections = getFlattenedSections(sections as any[]);
expect(flatSections).toHaveLength(10);
expect(flatSections).toEqual([
'Starred',
'Starred-1',
'Recent',
'2',
'2568',
'4074',
'0',
'0-4069',
'0-4072',
'0-1',
]);
});
describe('markSelected', () => {
it('should correctly mark the section item without id as selected', () => {
const results = markSelected(sections as any, 'Recent');
//@ts-ignore
expect(results[1].selected).toBe(true);
});
it('should correctly mark the section item with id as selected', () => {
const results = markSelected(sections as any, '4074');
//@ts-ignore
expect(results[4].selected).toBe(true);
});
it('should mark all other sections as not selected', () => {
const results = markSelected(sections as any, 'Starred');
const newResults = markSelected(results as any, '0');
//@ts-ignore
expect(newResults[0].selected).toBeFalsy();
expect(newResults[5].selected).toBeTruthy();
});
it('should correctly mark an item of a section as selected', () => {
const results = markSelected(sections as any, '0-4072');
expect(results[5].items[1].selected).toBeTruthy();
});
it('should not mark an item as selected for non-expanded section', () => {
const results = markSelected(sections as any, 'Recent-4072');
expect(results[1].items[0].selected).toBeFalsy();
});
it('should mark all other items as not selected', () => {
const results = markSelected(sections as any, '0-4069');
const newResults = markSelected(results as any, '0-1');
//@ts-ignore
expect(newResults[5].items[0].selected).toBeFalsy();
expect(newResults[5].items[1].selected).toBeFalsy();
expect(newResults[5].items[2].selected).toBeTruthy();
});
it('should correctly select one of the same items in different sections', () => {
const results = markSelected(sections as any, 'Starred-1');
expect(results[0].items[0].selected).toBeTruthy();
// Same item in diff section
expect(results[5].items[2].selected).toBeFalsy();
// Switch order
const newResults = markSelected(sections as any, '0-1');
expect(newResults[0].items[0].selected).toBeFalsy();
// Same item in diff section
expect(newResults[5].items[2].selected).toBeTruthy();
});
});
describe('findSelected', () => {
it('should find selected section', () => {
const results = [...sections, { id: 'Test', selected: true }];
const found = findSelected(results);
expect(found?.id).toEqual('Test');
});
it('should find selected item', () => {
const results = [{ expanded: true, id: 'Test', items: [{ id: 1 }, { id: 2, selected: true }, { id: 3 }] }];
const found = findSelected(results);
expect(found?.id).toEqual(2);
});
});
});
describe('mergeReducers', () => {
const reducer1 = (state: any = { reducer1: false }, action: any) => {
if (action.type === 'reducer1') {
return { ...state, reducer1: !state.reducer1 };
}
return state;
};
const reducer2 = (state: any = { reducer2: false }, action: any) => {
if (action.type === 'reducer2') {
return { ...state, reducer2: !state.reducer2 };
}
return state;
};
const mergedReducers = mergeReducers([reducer1, reducer2]);
it('should merge state from all reducers into one without nesting', () => {
expect(mergedReducers({ reducer1: false }, { type: '' })).toEqual({ reducer1: false });
});
it('should correctly set state from multiple reducers', () => {
const state = { reducer1: false, reducer2: true };
const newState = mergedReducers(state, { type: 'reducer2' });
expect(newState).toEqual({ reducer1: false, reducer2: false });
const newState2 = mergedReducers(newState, { type: 'reducer1' });
expect(newState2).toEqual({ reducer1: true, reducer2: false });
});
});
describe('getCheckedUids', () => {
it('should not return any UIDs if no items are checked', () => {
expect(getCheckedUids(sections)).toEqual({ folders: [], dashboards: [] });
});
it('should return only dashboard UIDs if the General folder is checked', () => {
expect(getCheckedUids(checkedGeneralFolder)).toEqual({
folders: [],
dashboards: ['general-abc', 'general-def', 'general-ghi'],
});
});
it('should return only dashboard UIDs if all items are checked when viewing a folder', () => {
expect(getCheckedUids(folderViewAllChecked)).toEqual({
folders: [],
dashboards: ['other-folder-dash-abc', 'other-folder-dash-def'],
});
});
it('should return folder + dashboard UIDs when folder is checked in the root view', () => {
expect(getCheckedUids(checkedOtherFolder)).toEqual({
folders: ['other-folder-abc'],
dashboards: ['other-folder-dash-abc', 'other-folder-dash-def'],
});
});
});
describe('getCheckedDashboardsUids', () => {
it('should get uids of all checked dashboards', () => {
expect(getCheckedDashboardsUids(searchResults)).toEqual(['lBdLINUWk', '8DY63kQZk']);
});
});
describe('parseRouteParams', () => {
it('should remove all undefined keys', () => {
const params: Partial<SearchQueryParams> = { sort: undefined, tag: undefined, query: 'test' };

View File

@ -1,207 +1,7 @@
import { parse, SearchParserResult } from 'search-query-parser';
import { UrlQueryMap } from '@grafana/data';
import { IconName } from '@grafana/ui';
import { getDashboardSrv } from '../dashboard/services/DashboardSrv';
import { NO_ID_SECTIONS, SECTION_STORAGE_KEY } from './constants';
import { DashboardQuery, DashboardSection, DashboardSectionItem, SearchAction, UidsToDelete } from './types';
/**
* Check if folder has id. Only Recent and Starred folders are the ones without
* ids so far, as they are created manually after results are fetched from API.
* @param str
*/
export const hasId = (str: string) => {
return !NO_ID_SECTIONS.includes(str);
};
/**
* Return ids for folders concatenated with their items ids, if section is expanded.
* For items the id format is '{folderId}-{itemId}' to allow mapping them to their folders
* @param sections
*/
export const getFlattenedSections = (sections: DashboardSection[]): string[] => {
return sections.flatMap((section) => {
const id = hasId(section.title) ? String(section.id) : section.title;
if (section.expanded && section.items.length) {
return [id, ...section.items.map((item) => `${id}-${item.id}`)];
}
return id;
});
};
/**
* Get all items for currently expanded sections
* @param sections
*/
export const getVisibleItems = (sections: DashboardSection[]) => {
return sections.flatMap((section) => {
if (section.expanded) {
return section.items;
}
return [];
});
};
/**
* Since Recent and Starred folders don't have id, title field is used as id
* @param title - title field of the section
*/
export const getLookupField = (title: string) => {
return hasId(title) ? 'id' : 'title';
};
/**
* Go through all the folders and items in expanded folders and toggle their selected
* prop according to currently selected index. Used for item highlighting when navigating
* the search results list using keyboard arrows
* @param sections
* @param selectedId
*/
export const markSelected = (sections: DashboardSection[], selectedId: string) => {
return sections.map((result: DashboardSection) => {
const lookupField = getLookupField(selectedId);
result = { ...result, selected: String(result[lookupField]) === selectedId };
if (result.expanded && result.items.length) {
return {
...result,
items: result.items.map((item) => {
const [sectionId, itemId] = selectedId.split('-');
const lookup = getLookupField(sectionId);
return { ...item, selected: String(item.id) === itemId && String(result[lookup]) === sectionId };
}),
};
}
return result;
});
};
/**
* Find items with property 'selected' set true in a list of folders and their items.
* Does recursive search in the items list.
* @param sections
*/
export const findSelected = (sections: any): DashboardSection | DashboardSectionItem | null => {
let found = null;
for (const section of sections) {
if (section.expanded && section.items.length) {
found = findSelected(section.items);
}
if (section.selected) {
found = section;
}
if (found) {
return found;
}
}
return null;
};
export const parseQuery = (query: string) => {
const parsedQuery = parse(query, {
keywords: ['folder'],
});
if (typeof parsedQuery === 'string') {
return {
text: parsedQuery,
} as SearchParserResult;
}
return parsedQuery;
};
/**
* Merge multiple reducers into one, keeping the state structure flat (no nested
* separate state for each reducer). If there are multiple state slices with the same
* key, the latest reducer's state is applied.
* Compared to Redux's combineReducers this allows multiple reducers to operate
* on the same state or different slices of the same state. Useful when multiple
* components have the same structure but different or extra logic when modifying it.
* If reducers have the same action types, the action types from the rightmost reducer
* take precedence
* @param reducers
*/
export const mergeReducers = (reducers: any[]) => (prevState: any, action: SearchAction) => {
return reducers.reduce((nextState, reducer) => ({ ...nextState, ...reducer(nextState, action) }), prevState);
};
/**
* Collect all the checked dashboards
* @param sections
*/
export const getCheckedDashboards = (sections: DashboardSection[]): DashboardSectionItem[] => {
if (!sections.length) {
return [];
}
return sections.reduce((uids, section) => {
return section.items ? [...uids, ...section.items.filter((item) => item.checked)] : uids;
}, [] as DashboardSectionItem[]);
};
/**
* Collect uids of all the checked dashboards
* @param sections
*/
export const getCheckedDashboardsUids = (sections: DashboardSection[]) => {
if (!sections.length) {
return [];
}
return getCheckedDashboards(sections).map((item) => item.uid);
};
/**
* Collect uids of all checked folders and dashboards. Used for delete operation, among others
* @param sections
*/
export const getCheckedUids = (sections: DashboardSection[]): UidsToDelete => {
const emptyResults: UidsToDelete = { folders: [], dashboards: [] };
if (!sections.length) {
return emptyResults;
}
return sections.reduce((result, section) => {
if (section?.id !== 0 && section.checked && section.uid) {
return { ...result, folders: [...result.folders, section.uid] } as UidsToDelete;
} else {
return { ...result, dashboards: getCheckedDashboardsUids(sections) } as UidsToDelete;
}
}, emptyResults);
};
/**
* When search is done within a dashboard folder, add folder id to the search query
* to narrow down the results to the folder
* @param query
* @param queryParsing
*/
export const getParsedQuery = (query: DashboardQuery, queryParsing = false) => {
const parsedQuery = { ...query, sort: query.sort?.value };
if (!queryParsing) {
return parsedQuery;
}
let folderIds: number[] = [];
if (parseQuery(query.query).folder === 'current') {
try {
const dash = getDashboardSrv().getCurrent();
if (dash?.meta.folderId) {
folderIds = [dash?.meta.folderId];
}
} catch (e) {
console.error(e);
}
}
return { ...parsedQuery, query: parseQuery(query.query).text as string, folderIds };
};
import { SECTION_STORAGE_KEY } from './constants';
import { DashboardQuery } from './types';
/**
* Check if search query has filters enabled. Excludes folderId
@ -214,18 +14,6 @@ export const hasFilters = (query: DashboardQuery) => {
return Boolean(query.query || query.tag?.length > 0 || query.starred || query.sort);
};
/**
* Get section icon depending on expanded state. Currently works for folder icons only
* @param section
*/
export const getSectionIcon = (section: DashboardSection): IconName => {
if (!hasId(section.title)) {
return section.icon as IconName;
}
return section.expanded ? 'folder-open' : 'folder';
};
/**
* Get storage key for a dashboard folder by its title
* @param title

View File

@ -21329,7 +21329,6 @@ __metadata:
rxjs: 7.5.5
sass: 1.52.3
sass-loader: 13.0.0
search-query-parser: 1.6.0
selecto: 1.16.2
semver: 7.3.7
sinon: 14.0.0
@ -33161,13 +33160,6 @@ __metadata:
languageName: node
linkType: hard
"search-query-parser@npm:1.6.0":
version: 1.6.0
resolution: "search-query-parser@npm:1.6.0"
checksum: 6ce290e1d1602401ab5e4561f2a9744d1ffd75da5759cc8ead162153258d8470dd86757a3be577772e883d48673dec42b26843fc4aa5155f1ec902112ce92225
languageName: node
linkType: hard
"secure-compare@npm:3.0.1":
version: 3.0.1
resolution: "secure-compare@npm:3.0.1"