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
This commit is contained in:
Ashley Harrison 2022-06-17 10:14:33 +01:00 committed by GitHub
parent fcbe0059c2
commit 1b9dd378f2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 235 additions and 27 deletions

View File

@ -32,7 +32,7 @@ export const Spinner: FC<Props> = (props: Props) => {
const { className, inline = false, iconClassName, style, size = 16 } = props;
const styles = getStyles(size, inline);
return (
<div style={style} className={cx(styles.wrapper, className)}>
<div data-testid="Spinner" style={style} className={cx(styles.wrapper, className)}>
<Icon className={cx('fa-spin', iconClassName)} name="fa fa-spinner" />
</div>
);

View File

@ -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<DashboardQueryResult>(emptySearchData),
};
beforeAll(() => {
grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult);
});
it('shows the folder title as the header', async () => {
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />);
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<DashboardQueryResult>(searchData),
};
beforeAll(() => {
grafanaSearcherSpy = jest.spyOn(getGrafanaSearcher(), 'search').mockResolvedValue(mockSearchResult);
});
it('shows the folder title as the header', async () => {
render(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection renderStandaloneBody section={mockSection} onTagSelected={mockOnTagSelected} />);
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(<FolderSection section={mockSection} onTagSelected={mockOnTagSelected} />);
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(
<FolderSection
section={mockSection}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
onTagSelected={mockOnTagSelected}
/>
);
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(
<FolderSection
section={mockSection}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
onTagSelected={mockOnTagSelected}
/>
);
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(
<FolderSection
section={mockSection}
selection={mockSelection}
selectionToggle={mockSelectionToggle}
onTagSelected={mockOnTagSelected}
/>
);
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');
});
});
});
});

View File

@ -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<SectionHeaderProps> = ({
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<SectionHeaderProps> = ({
}
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<DashboardSectionItem>((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<SectionHeaderProps> = ({
}
const renderResults = () => {
if (!results.value?.length) {
if (results.loading) {
return <Spinner className={styles.spinner} />;
}
if (!results.value) {
return null;
} else if (results.value.length === 0) {
return (
<Card>
<Card.Heading>No results found</Card.Heading>
@ -151,7 +146,11 @@ export const FolderSection: FC<SectionHeaderProps> = ({
// Skip the folder wrapper
if (renderStandaloneBody) {
return <div className={styles.folderViewResults}>{renderResults()}</div>;
return (
<div className={styles.folderViewResults}>
{!results.value?.length && results.loading ? <Spinner className={styles.spinner} /> : renderResults()}
</div>
);
}
return (
@ -171,7 +170,7 @@ export const FolderSection: FC<SectionHeaderProps> = ({
)}
<div className={styles.icon}>
<Icon name={icon as any} />
<Icon name={icon} />
</div>
<div className={styles.text}>

View File

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