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