From 1b9dd378f2978d4061a1985ae1863edbfb15431e Mon Sep 17 00:00:00 2001 From: Ashley Harrison Date: Fri, 17 Jun 2022 10:14:33 +0100 Subject: [PATCH] Chore: Unit tests for `FolderSection` (#50961) * unit test scaffolding for FolderSection * remove redundant spinner + more test scaffolding * more test experimentation * clear localStorage between test runs * more tests + add back spinner when renderStandaloneBody * bit of tidy up * update Spinner data-testid * fix import ordering --- .../src/components/Spinner/Spinner.tsx | 2 +- .../page/components/FolderSection.test.tsx | 209 ++++++++++++++++++ .../search/page/components/FolderSection.tsx | 49 ++-- .../search/page/components/FolderView.tsx | 2 +- 4 files changed, 235 insertions(+), 27 deletions(-) create mode 100644 public/app/features/search/page/components/FolderSection.test.tsx diff --git a/packages/grafana-ui/src/components/Spinner/Spinner.tsx b/packages/grafana-ui/src/components/Spinner/Spinner.tsx index ad25d9fbc65..b5419622dd8 100644 --- a/packages/grafana-ui/src/components/Spinner/Spinner.tsx +++ b/packages/grafana-ui/src/components/Spinner/Spinner.tsx @@ -32,7 +32,7 @@ export const Spinner: FC = (props: Props) => { const { className, inline = false, iconClassName, style, size = 16 } = props; const styles = getStyles(size, inline); return ( -
+
); diff --git a/public/app/features/search/page/components/FolderSection.test.tsx b/public/app/features/search/page/components/FolderSection.test.tsx new file mode 100644 index 00000000000..b77e5d1f216 --- /dev/null +++ b/public/app/features/search/page/components/FolderSection.test.tsx @@ -0,0 +1,209 @@ +import { render, screen, act } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { ArrayVector, DataFrame, DataFrameView, FieldType } from '@grafana/data'; + +import { DashboardQueryResult, getGrafanaSearcher, QueryResponse } from '../../service'; +import { DashboardSearchItemType } from '../../types'; + +import { FolderSection } from './FolderSection'; + +describe('FolderSection', () => { + let grafanaSearcherSpy: jest.SpyInstance; + const mockOnTagSelected = jest.fn(); + const mockSelectionToggle = jest.fn(); + const mockSelection = jest.fn(); + const mockSection = { + kind: 'folder', + uid: 'my-folder', + title: 'My folder', + }; + + // need to make sure we clear localStorage + // otherwise tests can interfere with each other and the starting expanded state of the component + afterEach(() => { + window.localStorage.clear(); + }); + + describe('when where are no results', () => { + const emptySearchData: DataFrame = { + fields: [ + { name: 'kind', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'name', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'uid', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'url', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + { name: 'tags', type: FieldType.other, config: {}, values: new ArrayVector([]) }, + { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector([]) }, + ], + length: 0, + }; + + const mockSearchResult: QueryResponse = { + isItemLoaded: jest.fn(), + loadMoreItems: jest.fn(), + totalRows: emptySearchData.length, + view: new DataFrameView(emptySearchData), + }; + + beforeAll(() => { + grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); + }); + + it('shows the folder title as the header', async () => { + render(); + expect(await screen.findByRole('button', { name: mockSection.title })).toBeInTheDocument(); + }); + + describe('when renderStandaloneBody is set', () => { + it('shows a "No results found" message and does not show the folder title header', async () => { + render(); + expect(await screen.findByText('No results found')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: mockSection.title })).not.toBeInTheDocument(); + }); + + it('renders a loading spinner whilst waiting for the results', async () => { + // mock the query promise so we can resolve manually + let promiseResolver: (arg0: QueryResponse) => void; + const promise = new Promise((resolve) => { + promiseResolver = resolve; + }); + grafanaSearcherSpy.mockImplementationOnce(() => promise); + + render(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + + // resolve the promise + await act(async () => { + promiseResolver(mockSearchResult); + }); + + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByText('No results found')).toBeInTheDocument(); + }); + }); + + it('shows a "No results found" message when expanding the folder', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); + expect(getGrafanaSearcher().search).toHaveBeenCalled(); + expect(await screen.findByText('No results found')).toBeInTheDocument(); + }); + }); + + describe('when there are results', () => { + const searchData: DataFrame = { + fields: [ + { name: 'kind', type: FieldType.string, config: {}, values: new ArrayVector([DashboardSearchItemType.DashDB]) }, + { name: 'name', type: FieldType.string, config: {}, values: new ArrayVector(['My dashboard 1']) }, + { name: 'uid', type: FieldType.string, config: {}, values: new ArrayVector(['my-dashboard-1']) }, + { name: 'url', type: FieldType.string, config: {}, values: new ArrayVector(['/my-dashboard-1']) }, + { name: 'tags', type: FieldType.other, config: {}, values: new ArrayVector([['foo', 'bar']]) }, + { name: 'location', type: FieldType.string, config: {}, values: new ArrayVector(['/my-dashboard-1']) }, + ], + length: 1, + }; + + const mockSearchResult: QueryResponse = { + isItemLoaded: jest.fn(), + loadMoreItems: jest.fn(), + totalRows: searchData.length, + view: new DataFrameView(searchData), + }; + + beforeAll(() => { + grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult); + }); + + it('shows the folder title as the header', async () => { + render(); + expect(await screen.findByRole('button', { name: mockSection.title })).toBeInTheDocument(); + }); + + describe('when renderStandaloneBody is set', () => { + it('shows the folder children and does not render the folder title', async () => { + render(); + expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); + expect(screen.queryByRole('button', { name: mockSection.title })).not.toBeInTheDocument(); + }); + + it('renders a loading spinner whilst waiting for the results', async () => { + // mock the query promise so we can resolve manually + let promiseResolver: (arg0: QueryResponse) => void; + const promise = new Promise((resolve) => { + promiseResolver = resolve; + }); + grafanaSearcherSpy.mockImplementationOnce(() => promise); + + render(); + expect(await screen.findByTestId('Spinner')).toBeInTheDocument(); + + // resolve the promise + await act(async () => { + promiseResolver(mockSearchResult); + }); + + expect(screen.queryByTestId('Spinner')).not.toBeInTheDocument(); + expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); + }); + }); + + it('shows the folder contents when expanding the folder', async () => { + render(); + + await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); + expect(getGrafanaSearcher().search).toHaveBeenCalled(); + expect(await screen.findByText('My dashboard 1')).toBeInTheDocument(); + }); + + describe('when clicking the checkbox', () => { + it('does not expand the section', async () => { + render( + + ); + + await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); + expect(screen.queryByText('My dashboard 1')).not.toBeInTheDocument(); + }); + + it('selects only the folder if the folder is not expanded', async () => { + render( + + ); + + await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); + expect(mockSelectionToggle).toHaveBeenCalledWith('folder', 'my-folder'); + expect(mockSelectionToggle).not.toHaveBeenCalledWith('dashboard', 'my-dashboard-1'); + }); + + it('selects the folder and all children when the folder is expanded', async () => { + render( + + ); + + await userEvent.click(await screen.findByRole('button', { name: mockSection.title })); + expect(getGrafanaSearcher().search).toHaveBeenCalled(); + + await userEvent.click(await screen.findByRole('checkbox', { name: 'Select folder' })); + expect(mockSelectionToggle).toHaveBeenCalledWith('folder', 'my-folder'); + expect(mockSelectionToggle).toHaveBeenCalledWith('dashboard', 'my-dashboard-1'); + }); + }); + }); +}); diff --git a/public/app/features/search/page/components/FolderSection.tsx b/public/app/features/search/page/components/FolderSection.tsx index 98453597016..e769ba82704 100644 --- a/public/app/features/search/page/components/FolderSection.tsx +++ b/public/app/features/search/page/components/FolderSection.tsx @@ -3,7 +3,7 @@ import React, { FC } from 'react'; import { useAsync, useLocalStorage } from 'react-use'; import { GrafanaTheme } from '@grafana/data'; -import { Card, Checkbox, CollapsableSection, Icon, Spinner, stylesFactory, useTheme } from '@grafana/ui'; +import { Card, Checkbox, CollapsableSection, Icon, IconName, Spinner, stylesFactory, useTheme } from '@grafana/ui'; import { getSectionStorageKey } from 'app/features/search/utils'; import { useUniqueId } from 'app/plugins/datasource/influxdb/components/useUniqueId'; @@ -18,7 +18,7 @@ export interface DashboardSection { title: string; selected?: boolean; // not used ? keyboard url?: string; - icon?: string; + icon?: IconName; itemsUIDs?: string[]; // for pseudo folders } @@ -46,7 +46,7 @@ export const FolderSection: FC = ({ const results = useAsync(async () => { if (!sectionExpanded && !renderStandaloneBody) { - return Promise.resolve([] as DashboardSectionItem[]); + return Promise.resolve([]); } let folderUid: string | undefined = section.uid; let folderTitle: string | undefined = section.title; @@ -65,21 +65,18 @@ export const FolderSection: FC = ({ } const raw = await getGrafanaSearcher().search({ ...query, tags }); - const v = raw.view.map( - (item) => - ({ - uid: item.uid, - title: item.name, - url: item.url, - uri: item.url, - type: item.kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, - id: 666, // do not use me! - isStarred: false, - tags: item.tags ?? [], - folderUid, - folderTitle, - } as DashboardSectionItem) - ); + const v = raw.view.map((item) => ({ + uid: item.uid, + title: item.name, + url: item.url, + uri: item.url, + type: item.kind === 'folder' ? DashboardSearchItemType.DashFolder : DashboardSearchItemType.DashDB, + id: 666, // do not use me! + isStarred: false, + tags: item.tags ?? [], + folderUid, + folderTitle, + })); return v; }, [sectionExpanded, section, tags]); @@ -117,11 +114,9 @@ export const FolderSection: FC = ({ } const renderResults = () => { - if (!results.value?.length) { - if (results.loading) { - return ; - } - + if (!results.value) { + return null; + } else if (results.value.length === 0) { return ( No results found @@ -151,7 +146,11 @@ export const FolderSection: FC = ({ // Skip the folder wrapper if (renderStandaloneBody) { - return
{renderResults()}
; + return ( +
+ {!results.value?.length && results.loading ? : renderResults()} +
+ ); } return ( @@ -171,7 +170,7 @@ export const FolderSection: FC = ({ )}
- +
diff --git a/public/app/features/search/page/components/FolderView.tsx b/public/app/features/search/page/components/FolderView.tsx index ee5c4a53a4b..3430581e555 100644 --- a/public/app/features/search/page/components/FolderView.tsx +++ b/public/app/features/search/page/components/FolderView.tsx @@ -36,7 +36,7 @@ export const FolderView = ({ selection, selectionToggle, onTagSelected, tags, hi if (ids.length) { const itemsUIDs = await getBackendSrv().get(`/api/dashboards/ids/${ids.slice(0, 30).join(',')}`); if (itemsUIDs.length) { - folders.push({ title: 'Recent', icon: 'clock', kind: 'query-recent', uid: '__recent', itemsUIDs }); + folders.push({ title: 'Recent', icon: 'clock-nine', kind: 'query-recent', uid: '__recent', itemsUIDs }); } } }