diff --git a/public/app/features/trails/DataTrailBookmarks.test.tsx b/public/app/features/trails/DataTrailBookmarks.test.tsx new file mode 100644 index 00000000000..a74146ddaee --- /dev/null +++ b/public/app/features/trails/DataTrailBookmarks.test.tsx @@ -0,0 +1,68 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { DataTrail } from './DataTrail'; +import { DataTrailsBookmarks } from './DataTrailBookmarks'; +import { getTrailStore, DataTrailBookmark } from './TrailStore/TrailStore'; + +jest.mock('./TrailStore/TrailStore', () => ({ + getTrailStore: jest.fn(), + getBookmarkKey: jest.fn(() => 'bookmark-key'), +})); + +const onSelect = jest.fn(); +const onDelete = jest.fn(); + +describe('DataTrailsBookmarks', () => { + const trail = new DataTrail({}); + const bookmark: DataTrailBookmark = { urlValues: { key: '1', metric: '' }, createdAt: Date.now() }; + + beforeEach(() => { + onSelect.mockClear(); + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [], + getTrailForBookmark: jest.fn(), + })); + }); + + it('does not render if there are no bookmarks', () => { + render(); + expect(screen.queryByText('Or view bookmarks')).not.toBeInTheDocument(); + }); + + it('renders the bookmarks header and toggle button', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [bookmark], + recent: [], + })); + render(); + expect(screen.getByText('Or view bookmarks')).toBeInTheDocument(); + expect(screen.getByLabelText('bookmarkCarrot')).toBeInTheDocument(); + }); + + it('toggles the bookmark list when the toggle button is clicked', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [bookmark], + recent: [], + getTrailForBookmark: jest.fn().mockReturnValue(trail), + })); + render(); + const button = screen.getByLabelText('bookmarkCarrot'); + fireEvent.click(button); + expect(screen.getByText('Select metric')).toBeInTheDocument(); + fireEvent.click(button); + expect(screen.queryByText('Select metric')).not.toBeInTheDocument(); + }); + + it('calls onDelete when the delete button is clicked', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [bookmark], + recent: [], + getTrailForBookmark: jest.fn().mockReturnValue(trail), + })); + render(); + fireEvent.click(screen.getByLabelText('bookmarkCarrot')); + fireEvent.click(screen.getByLabelText('Remove bookmark')); + expect(onDelete).toHaveBeenCalled(); + }); +}); diff --git a/public/app/features/trails/DataTrailBookmarks.tsx b/public/app/features/trails/DataTrailBookmarks.tsx index 8c13f5d6e3f..501bd2e7c9a 100644 --- a/public/app/features/trails/DataTrailBookmarks.tsx +++ b/public/app/features/trails/DataTrailBookmarks.tsx @@ -2,19 +2,18 @@ import { css } from '@emotion/css'; import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps } from '@grafana/scenes'; import { IconButton, useStyles2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; import { DataTrailCard } from './DataTrailCard'; -import { DataTrailsHome } from './DataTrailsHome'; import { getTrailStore, getBookmarkKey } from './TrailStore/TrailStore'; -interface Props extends SceneComponentProps { +type Props = { + onSelect: (index: number) => void; onDelete: (index: number) => void; -} +}; -export function DataTrailsBookmarks({ model, onDelete }: Props) { +export function DataTrailsBookmarks({ onSelect, onDelete }: Props) { const [toggleBookmark, setToggleBookmark] = useState(false); const styles = useStyles2(getStyles); @@ -31,7 +30,7 @@ export function DataTrailsBookmarks({ model, onDelete }: Props) { setToggleBookmark(!toggleBookmark)} @@ -44,7 +43,7 @@ export function DataTrailsBookmarks({ model, onDelete }: Props) { model.onSelectBookmark(index)} + onSelect={() => onSelect(index)} onDelete={() => onDelete(index)} /> ); diff --git a/public/app/features/trails/DataTrailCard.test.tsx b/public/app/features/trails/DataTrailCard.test.tsx new file mode 100644 index 00000000000..c13a9470788 --- /dev/null +++ b/public/app/features/trails/DataTrailCard.test.tsx @@ -0,0 +1,73 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { DataTrail } from './DataTrail'; +import { DataTrailCard } from './DataTrailCard'; +import { DataTrailBookmark } from './TrailStore/TrailStore'; + +jest.mock('./utils', () => ({ + ...jest.requireActual('./utils'), + getDataSource: jest.fn(() => 'Test DataSource'), + getDataSourceName: jest.fn(() => 'Test DataSource Name'), +})); + +describe('DataTrailCard', () => { + // trail is a recent metric exploration + const trail = new DataTrail({ key: '1', metric: 'Test Recent Exploration' }); + // bookmark is a data trail stored in a url + const bookmark: DataTrailBookmark = { urlValues: { key: '1', metric: 'Test Bookmark' }, createdAt: Date.now() }; + const onSelect = jest.fn(); + const onDelete = jest.fn(); + beforeEach(() => { + onSelect.mockClear(); + onDelete.mockClear(); + }); + + it('renders the card with recent metric exploration', () => { + render(); + expect(screen.getByText('Test Recent Exploration')).toBeInTheDocument(); + }); + + it('renders the card with bookmark', () => { + render(); + expect(screen.getByText('Test Bookmark')).toBeInTheDocument(); + }); + + it('calls onSelect when the card is clicked', () => { + render(); + fireEvent.click(screen.getByText('Test Bookmark')); + expect(onSelect).toHaveBeenCalled(); + }); + + it('calls onDelete when the delete button is clicked', () => { + render(); + fireEvent.click(screen.getByTestId('deleteButton')); + expect(onDelete).toHaveBeenCalled(); + }); + + it('truncates singular long label in recent explorations', () => { + const longLabel = + 'aajalsdkfaldkjfalskdjfalsdkjfalsdkjflaskjdflaskjdflaskjdflaskjdflasjkdflaskjdflaskjdflaskjflaskdjfldaskjflasjflaskdjflaskjflasjflaskfjalsdfjlskdjflaskjdflajkfjfalkdfjaverylongalskdjlalsjflajkfklsajdfalskjdflkasjdflkadjf'; + const bookmarkWithLongLabel: DataTrailBookmark = { + urlValues: { key: '1', metric: 'metric', 'var-filters': `zone|=|${longLabel}` }, + createdAt: Date.now(), + }; + render(); + expect(screen.getByText('...', { exact: false })).toBeInTheDocument(); + }); + + it('truncates long list of labels after 3 lines in recent explorations', () => { + const bookmarkWithLongLabel: DataTrailBookmark = { + urlValues: { + key: '1', + metric: 'metric', + // labels are in a comma separated list + 'var-filters': `zone|=|averylonglabeltotakeupspace,zone=averylonglabeltotakeupspace,zone1=averylonglabeltotakeupspace,zone2=averylonglabeltotakeupspace,zone3=averylonglabeltotakeupspace,zone4=averylonglabeltotakeupspace`, + }, + createdAt: Date.now(), + }; + render(); + // to test the non-existence of a truncated label we need queryByText + const truncatedLabel = screen.queryByText('zone4'); + expect(truncatedLabel).not.toBeInTheDocument(); + }); +}); diff --git a/public/app/features/trails/DataTrailCard.tsx b/public/app/features/trails/DataTrailCard.tsx index c671104c6e7..52d130172fd 100644 --- a/public/app/features/trails/DataTrailCard.tsx +++ b/public/app/features/trails/DataTrailCard.tsx @@ -91,6 +91,7 @@ export function DataTrailCard(props: Props) { className={styles.secondary} tooltip="Remove bookmark" onClick={onDelete} + data-testid="deleteButton" /> )} diff --git a/public/app/features/trails/DataTrailsHome.test.tsx b/public/app/features/trails/DataTrailsHome.test.tsx new file mode 100644 index 00000000000..b6e96d2b874 --- /dev/null +++ b/public/app/features/trails/DataTrailsHome.test.tsx @@ -0,0 +1,73 @@ +import { render, screen } from '@testing-library/react'; + +import { AdHocFiltersVariable, sceneGraph, SceneObjectRef, SceneVariableSet } from '@grafana/scenes'; + +import { DataTrail } from './DataTrail'; +import { DataTrailsHome } from './DataTrailsHome'; +import { getTrailStore } from './TrailStore/TrailStore'; +import { VAR_FILTERS } from './shared'; + +jest.mock('./TrailStore/TrailStore', () => ({ + getTrailStore: jest.fn(), +})); + +describe('DataTrailsHome', () => { + let scene: DataTrailsHome; + beforeEach(() => { + const filtersVariable = new AdHocFiltersVariable({ name: VAR_FILTERS }); + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [], + })); + scene = new DataTrailsHome({ + $variables: new SceneVariableSet({ + variables: [filtersVariable], + }), + }); + }); + + it('renders the start button', () => { + render(); + expect(screen.getByText("Let's start!")).toBeInTheDocument(); + }); + + it('renders the learn more button and checks its href', () => { + render(); + const learnMoreButton = screen.getByText('Learn more'); + expect(learnMoreButton).toBeInTheDocument(); + expect(learnMoreButton.closest('a')).toHaveAttribute( + 'href', + 'https://grafana.com/docs/grafana/latest/explore/explore-metrics/' + ); + }); + + it('does not show recent metrics and bookmarks headers for first time user', () => { + render(); + expect(screen.queryByText('Or view a recent exploration')).not.toBeInTheDocument(); + expect(screen.queryByText('Or view bookmarks')).not.toBeInTheDocument(); + expect(screen.queryByRole('separator')).not.toBeInTheDocument(); + }); + + it('truncates singular long label in recent explorations', () => { + const trail = new DataTrail({}); + function getFilterVar() { + const variable = sceneGraph.lookupVariable(VAR_FILTERS, trail); + if (variable instanceof AdHocFiltersVariable) { + return variable; + } + throw new Error('getFilterVar failed'); + } + const filtersVariable = getFilterVar(); + const longLabel = 'averylongalskdjlalsjflajkfklsajdfalskjdflkasjdflkadjf'; + filtersVariable.setState({ + filters: [{ key: 'zone', operator: '=', value: longLabel }], + }); + const trailWithResolveMethod = new SceneObjectRef(trail); + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [trailWithResolveMethod], + })); + render(); + expect(screen.getByText('...', { exact: false })).toBeInTheDocument(); + }); +}); diff --git a/public/app/features/trails/DataTrailsHome.tsx b/public/app/features/trails/DataTrailsHome.tsx index a7c6e9d3859..fd9abab0bf4 100644 --- a/public/app/features/trails/DataTrailsHome.tsx +++ b/public/app/features/trails/DataTrailsHome.tsx @@ -88,8 +88,8 @@ export class DataTrailsHome extends SceneObjectBase { - - + + ); }; diff --git a/public/app/features/trails/DataTrailsRecentMetrics.test.tsx b/public/app/features/trails/DataTrailsRecentMetrics.test.tsx new file mode 100644 index 00000000000..4c3d4560f60 --- /dev/null +++ b/public/app/features/trails/DataTrailsRecentMetrics.test.tsx @@ -0,0 +1,115 @@ +import { render, screen, fireEvent } from '@testing-library/react'; + +import { SceneObjectRef } from '@grafana/scenes'; + +import { DataTrail } from './DataTrail'; +import { DataTrailsRecentMetrics } from './DataTrailsRecentMetrics'; +import { getTrailStore } from './TrailStore/TrailStore'; + +jest.mock('./TrailStore/TrailStore', () => ({ + getTrailStore: jest.fn(), +})); + +const onSelect = jest.fn(); + +describe('DataTrailsRecentMetrics', () => { + beforeEach(() => { + onSelect.mockClear(); + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [], + })); + }); + + it('renders the recent metrics header if there is at least one recent metric', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [ + { + resolve: () => ({ state: { key: '1' } }), + }, + ], + })); + render(); + expect(screen.getByText('Or view a recent exploration')).toBeInTheDocument(); + }); + + it('does not show the "Show more" button if there are 3 or fewer recent metrics', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [ + { + resolve: () => ({ state: { key: '1' } }), + }, + { + resolve: () => ({ state: { key: '2' } }), + }, + { + resolve: () => ({ state: { key: '3' } }), + }, + ], + })); + render(); + expect(screen.queryByText('Show more')).not.toBeInTheDocument(); + }); + + it('shows the "Show more" button if there are more than 3 recent metrics', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [ + { + resolve: () => ({ state: { key: '1' } }), + }, + { + resolve: () => ({ state: { key: '2' } }), + }, + { + resolve: () => ({ state: { key: '3' } }), + }, + { + resolve: () => ({ state: { key: '4' } }), + }, + ], + })); + render(); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + + it('toggles between "Show more" and "Show less" when the button is clicked', () => { + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [ + { + resolve: () => ({ state: { key: '1' } }), + }, + { + resolve: () => ({ state: { key: '2' } }), + }, + { + resolve: () => ({ state: { key: '3' } }), + }, + { + resolve: () => ({ state: { key: '4' } }), + }, + ], + })); + render(); + const button = screen.getByText('Show more'); + fireEvent.click(button); + expect(screen.getByText('Show less')).toBeInTheDocument(); + fireEvent.click(button); + expect(screen.getByText('Show more')).toBeInTheDocument(); + }); + + it('selecting a recent exploration card takes you to the metric', () => { + const trail = new DataTrail({ key: '1', metric: 'select me' }); + const trailWithResolveMethod = new SceneObjectRef(trail); + (getTrailStore as jest.Mock).mockImplementation(() => ({ + bookmarks: [], + recent: [trailWithResolveMethod], + })); + render(); + fireEvent.click(screen.getByText('select me')); + expect(onSelect).toHaveBeenCalledWith(trail); + }); +}); diff --git a/public/app/features/trails/DataTrailsRecentMetrics.tsx b/public/app/features/trails/DataTrailsRecentMetrics.tsx index 4a62124b669..cae68d8060a 100644 --- a/public/app/features/trails/DataTrailsRecentMetrics.tsx +++ b/public/app/features/trails/DataTrailsRecentMetrics.tsx @@ -2,15 +2,16 @@ import { css } from '@emotion/css'; import { useState } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; -import { SceneComponentProps } from '@grafana/scenes'; import { Button, useStyles2, useTheme2 } from '@grafana/ui'; import { Trans } from 'app/core/internationalization'; +import { DataTrail } from './DataTrail'; import { DataTrailCard } from './DataTrailCard'; -import { DataTrailsHome } from './DataTrailsHome'; import { getTrailStore } from './TrailStore/TrailStore'; -export function DataTrailsRecentMetrics({ model }: SceneComponentProps) { +type Props = { onSelect: (trail: DataTrail) => void }; + +export function DataTrailsRecentMetrics({ onSelect }: Props) { const styles = useStyles2(getStyles); const recentMetrics = getTrailStore().recent; const theme = useTheme2(); @@ -40,7 +41,7 @@ export function DataTrailsRecentMetrics({ model }: SceneComponentProps model.onSelectRecentTrail(resolvedTrail)} + onSelect={() => onSelect(resolvedTrail)} /> ); })}