Dashboards: Use unified search for dashboard links and improve tests (#112201)

This commit is contained in:
Tom Ratcliffe
2025-10-14 12:02:28 +01:00
committed by GitHub
parent c9f402c764
commit 2d3a62eb21
7 changed files with 226 additions and 121 deletions
@@ -8,11 +8,19 @@ describe('Templating', () => {
it('Tests dashboard links and variables in links', () => {
cy.intercept({
method: 'GET',
url: '/api/search?tag=templating&limit=100',
pathname: /search/,
query: {
tag: 'templating',
limit: '100',
},
}).as('tagsTemplatingSearch');
cy.intercept({
method: 'GET',
url: '/api/search?tag=demo&limit=100',
pathname: /search/,
query: {
tag: 'demo',
limit: '100',
},
}).as('tagsDemoSearch');
e2e.flows.openDashboard({ uid: 'yBCC3aKGk' });
@@ -24,6 +24,7 @@ const getLegacySearchHandler = () =>
// Workaround for the fixture kind being 'dashboard' instead of 'dash-db'
const mappedTypeFilter = typeFilter === 'dash-db' ? 'dashboard' : typeFilter;
const starredFilter = new URL(request.url).searchParams.get('starred') || null;
const tagFilter = new URL(request.url).searchParams.getAll('tag') || null;
const response = mockTree
.filter((filterItem) => {
@@ -32,6 +33,14 @@ const getLegacySearchHandler = () =>
({ item }) => item.kind !== 'ui',
];
if (tagFilter && tagFilter.length > 0) {
filters.push(({ item }) =>
Boolean(
(item.kind === 'folder' || item.kind === 'dashboard') && item.tags?.some((tag) => tagFilter.includes(tag))
)
);
}
if (starredFilter) {
filters.push(({ item }) => mockStarredDashboards.includes(item.uid));
}
@@ -23,6 +23,7 @@ const getSearchHandler = () =>
const typeFilter = new URL(request.url).searchParams.get('type') || null;
const nameFilter = new URL(request.url).searchParams.getAll('name');
const mappedTypeFilter = typeFilter ? typeFilterMap[typeFilter] || typeFilter : null;
const tagFilter = new URL(request.url).searchParams.getAll('tag') || null;
const filtered = mockTree.filter((filterItem) => {
const filters: FilterArray = [
@@ -39,6 +40,14 @@ const getSearchHandler = () =>
filters.push(({ item }) => item.kind === mappedTypeFilter);
}
if (tagFilter && tagFilter.length > 0) {
filters.push(({ item }) =>
Boolean(
(item.kind === 'folder' || item.kind === 'dashboard') && item.tags?.some((tag) => tagFilter.includes(tag))
)
);
}
if (folderFilter && folderFilter !== 'general') {
filters.push(
({ item }) => (item.kind === 'folder' || item.kind === 'dashboard') && item.parentUID === folderFilter
@@ -1,96 +1,158 @@
import { ComponentProps } from 'react';
import { render, screen } from 'test/test-utils';
import { setBackendSrv } from '@grafana/runtime';
import { DashboardLink } from '@grafana/schema';
import { backendSrv } from 'app/core/services/__mocks__/backend_srv';
import { setupMockServer } from '@grafana/test-utils/server';
import { getFolderFixtures } from '@grafana/test-utils/unstable';
import { backendSrv } from 'app/core/services/backend_srv';
import { testWithFeatureToggles } from 'app/features/alerting/unified/test/test-utils';
import { LinkSrv } from 'app/features/panel/panellinks/link_srv';
import { resetGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardSearchItem, DashboardSearchItemType } from '../../../search/types';
import { resolveLinks, searchForTags, DashboardLinksDashboard } from './DashboardLinksDashboard';
import { resolveLinks, searchForTags } from './DashboardLinksDashboard';
const [_, { dashbdD }] = getFolderFixtures();
setBackendSrv(backendSrv);
setupMockServer();
describe('searchForTags', () => {
const setupTestContext = () => {
const tags = ['A', 'B'];
const link: DashboardLink = {
targetBlank: false,
keepTime: false,
includeVars: false,
asDropdown: false,
icon: 'some icon',
tags,
title: 'some title',
tooltip: 'some tooltip',
type: 'dashboards',
url: '/d/6ieouugGk/DashLinks',
};
jest.spyOn(backendSrv, 'search').mockResolvedValue([]);
return { link, backendSrv };
};
describe('when called', () => {
it('then tags from link should be used in search and limit should be 100', async () => {
const { link, backendSrv } = setupTestContext();
const results = await searchForTags(link.tags, { getBackendSrv: () => backendSrv });
expect(results.length).toEqual(0);
expect(backendSrv.search).toHaveBeenCalledWith({ tag: ['A', 'B'], limit: 100 });
expect(backendSrv.search).toHaveBeenCalledTimes(1);
});
});
afterEach(() => {
resetGrafanaSearcher();
});
describe('resolveLinks', () => {
const setupTestContext = (dashboardUID: string, searchHitId: string) => {
const link: DashboardLink = {
targetBlank: false,
keepTime: false,
includeVars: false,
asDropdown: false,
icon: 'some icon',
tags: [],
title: 'some title',
tooltip: 'some tooltip',
type: 'dashboards',
url: '/d/6ieouugGk/DashLinks',
};
const searchHits: DashboardSearchItem[] = [
{
uid: searchHitId,
title: 'DashLinks',
url: '/d/6ieouugGk/DashLinks',
isStarred: false,
type DeepPartial<T> = T extends object
? {
[P in keyof T]?: DeepPartial<T[P]>;
}
: T;
const dashboardUID = '1';
const baseLinkProps: ComponentProps<typeof DashboardLinksDashboard>['link'] = {
asDropdown: true,
icon: 'some icon',
includeVars: false,
keepTime: false,
tags: [],
targetBlank: false,
title: 'some title',
tooltip: '',
type: 'dashboards',
};
const getDashboardLink = () => screen.findByRole('link', { name: new RegExp(dashbdD.item.title) });
const renderComponent = (props: DeepPartial<ComponentProps<typeof DashboardLinksDashboard>> = {}) => {
return render(
<DashboardLinksDashboard
link={{ ...baseLinkProps, ...props.link, tags: (props.link?.tags || []) as string[] }}
dashboardUID={props.dashboardUID || dashboardUID}
linkInfo={{ title: 'some title', ...props.linkInfo }}
/>
);
};
describe.each([
// App platform APIs
true,
// Legacy APIs
false,
])('with unifiedStorageSearchUI: %s', (featureTogglesEnabled) => {
testWithFeatureToggles(featureTogglesEnabled ? ['unifiedStorageSearchUI'] : []);
describe('DashboardLinksDashboard', () => {
it('renders a dropdown', async () => {
const { user } = renderComponent();
const button = screen.getByRole('button', { name: /some title/i });
await user.click(button);
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(await getDashboardLink()).toBeInTheDocument();
});
it('renders dropdown items with target _blank', async () => {
const { user } = renderComponent({ link: { targetBlank: true } });
const button = screen.getByRole('button', { name: /some title/i });
await user.click(button);
expect(await screen.findByRole('menu')).toBeInTheDocument();
expect(await getDashboardLink()).toHaveAttribute('target', '_blank');
});
it('handles an empty list of links', async () => {
const { user } = renderComponent({ link: { tags: ['foo-some-tag-of-which-there-are-none'] } });
const button = screen.getByRole('button', { name: /some title/i });
await user.click(button);
expect(screen.getByRole('menuitem', { name: /no dashboards found/i })).toBeInTheDocument();
});
it('renders a list of links', async () => {
renderComponent({ link: { asDropdown: false } });
const dashboardLink = await getDashboardLink();
expect(dashboardLink).toBeInTheDocument();
expect(dashboardLink).not.toHaveAttribute('target', '_blank');
});
it('renders a list of links with target _blank', async () => {
renderComponent({ link: { asDropdown: false, targetBlank: true } });
const dashboardLink = await getDashboardLink();
expect(dashboardLink).toHaveAttribute('target', '_blank');
});
it('does not render a link to its own dashboard', async () => {
renderComponent({ link: { asDropdown: false }, dashboardUID: dashbdD.item.uid });
await screen.findAllByRole('link');
expect(screen.queryByRole('link', { name: new RegExp(dashbdD.item.title) })).not.toBeInTheDocument();
});
});
describe('resolveLinks', () => {
const setupTestContext = () => {
const link: DashboardLink = {
targetBlank: false,
keepTime: false,
includeVars: false,
asDropdown: false,
icon: 'some icon',
tags: [],
uri: 'db/DashLinks',
type: DashboardSearchItemType.DashDB,
},
];
const linkSrv = {
getLinkUrl: jest.fn((args) => args.url),
} as unknown as LinkSrv;
const sanitize = jest.fn((args) => args);
const sanitizeUrl = jest.fn((args) => args);
title: 'some title',
tooltip: 'some tooltip',
type: 'dashboards',
url: '/d/6ieouugGk/DashLinks',
};
const linkSrv = {
getLinkUrl: jest.fn((args) => args.url),
} as unknown as LinkSrv;
const sanitize = jest.fn((args) => args);
const sanitizeUrl = jest.fn((args) => args);
return { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl };
};
return { link, linkSrv, sanitize, sanitizeUrl };
};
describe('when called', () => {
it('should filter out the calling dashboardUID', () => {
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '1');
it('should filter out the calling dashboardUID', async () => {
const { link, linkSrv, sanitize, sanitizeUrl } = setupTestContext();
const { view: searchHits, totalRows } = await searchForTags([]);
const results = resolveLinks(dashboardUID, link, searchHits, {
const results = resolveLinks(dashbdD.item.uid, link, searchHits, {
getLinkSrv: () => linkSrv,
sanitize,
sanitizeUrl,
});
expect(results.length).toEqual(0);
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0);
expect(sanitize).toHaveBeenCalledTimes(0);
expect(sanitizeUrl).toHaveBeenCalledTimes(0);
expect(results.find((result) => result.uid === dashbdD.item.uid)).toBeUndefined();
const expectedNumberOfResults = totalRows - 1;
expect(results.length).toEqual(expectedNumberOfResults);
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(expectedNumberOfResults);
expect(sanitize).toHaveBeenCalledTimes(expectedNumberOfResults);
expect(sanitizeUrl).toHaveBeenCalledTimes(expectedNumberOfResults);
});
it('should resolve link url', () => {
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
it('should resolve link url', async () => {
const { link, linkSrv, sanitize, sanitizeUrl } = setupTestContext();
const { view: searchHits, totalRows } = await searchForTags([]);
const results = resolveLinks(dashboardUID, link, searchHits, {
getLinkSrv: () => linkSrv,
@@ -98,13 +160,14 @@ describe('resolveLinks', () => {
sanitizeUrl,
});
expect(results.length).toEqual(1);
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1);
expect(linkSrv.getLinkUrl).toHaveBeenCalledWith({ ...link, url: searchHits[0].url });
expect(results.length).toBeGreaterThan(0);
expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(totalRows);
expect(linkSrv.getLinkUrl).toHaveBeenCalledWith({ ...link, url: searchHits.at(0)?.url });
});
it('should sanitize title', () => {
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
it('should sanitize title', async () => {
const { link, linkSrv, sanitize, sanitizeUrl } = setupTestContext();
const { view: searchHits, totalRows } = await searchForTags([]);
const results = resolveLinks(dashboardUID, link, searchHits, {
getLinkSrv: () => linkSrv,
@@ -112,23 +175,24 @@ describe('resolveLinks', () => {
sanitizeUrl,
});
expect(results.length).toEqual(1);
expect(sanitize).toHaveBeenCalledTimes(1);
expect(sanitize).toHaveBeenCalledWith(searchHits[0].title);
expect(results.length).toBeGreaterThan(0);
expect(sanitize).toHaveBeenCalledTimes(totalRows);
expect(sanitize).toHaveBeenCalledWith(searchHits.at(0)?.name);
});
it('should sanitize url', () => {
const { dashboardUID, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext('1', '2');
it('should sanitize url', async () => {
const { link, linkSrv, sanitize, sanitizeUrl } = setupTestContext();
const result = await searchForTags([]);
const { view: searchHits, totalRows } = result;
const results = resolveLinks(dashboardUID, link, searchHits, {
getLinkSrv: () => linkSrv,
sanitize,
sanitizeUrl,
});
expect(results.length).toEqual(1);
expect(sanitizeUrl).toHaveBeenCalledTimes(1);
expect(sanitizeUrl).toHaveBeenCalledWith(searchHits[0].url);
expect(results.length).toBeGreaterThan(0);
expect(sanitizeUrl).toHaveBeenCalledTimes(totalRows);
expect(sanitizeUrl).toHaveBeenCalledWith(searchHits.at(0)?.url);
});
});
});
@@ -9,14 +9,14 @@ import { t } from '@grafana/i18n';
import { DashboardLink } from '@grafana/schema';
import { Dropdown, Icon, LinkButton, Button, Menu, ScrollContainer, useStyles2 } from '@grafana/ui';
import { ButtonLinkProps } from '@grafana/ui/internal';
import { getBackendSrv } from 'app/core/services/backend_srv';
import { DashboardSearchItem } from 'app/features/search/types';
import { getGrafanaSearcher } from 'app/features/search/service/searcher';
import { DashboardQueryResult } from 'app/features/search/service/types';
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
interface Props {
link: DashboardLink;
linkInfo: { title: string; href: string };
linkInfo: { title: string };
dashboardUID: string;
scopedVars?: ScopedVars;
}
@@ -30,8 +30,15 @@ function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
const styles = useStyles2(getStyles);
const resolvedLinks = useResolvedLinks({ dashboardUID, link });
if (!resolvedLinks || resolveLinks.length === 0) {
return null;
if (!resolvedLinks || resolvedLinks.length === 0) {
return (
<Menu>
<Menu.Item
disabled
label={t('dashboard.dashboard-links-menu.label-no-dashboards-found', 'No dashboards found')}
/>
</Menu>
);
}
return (
@@ -60,9 +67,9 @@ function DashboardLinksMenu({ dashboardUID, link }: DashboardLinksMenuProps) {
);
}
export const DashboardLinksDashboard = (props: Props) => {
const { link, linkInfo, dashboardUID } = props;
const resolvedLinks = useResolvedLinks(props);
export const DashboardLinksDashboard = ({ link, linkInfo, dashboardUID }: Props) => {
const { title } = linkInfo;
const resolvedLinks = useResolvedLinks({ link, dashboardUID });
const styles = useStyles2(getStyles);
if (link.asDropdown) {
@@ -78,7 +85,7 @@ export const DashboardLinksDashboard = (props: Props) => {
data-testid={selectors.components.DashboardLinks.dropDown}
>
<Icon aria-hidden name="bars" className={styles.iconMargin} />
<span>{linkInfo.title}</span>
<span>{title}</span>
</DashboardLinkButton>
</Dropdown>
);
@@ -113,7 +120,7 @@ const useResolvedLinks = ({ link, dashboardUID }: Pick<Props, 'link' | 'dashboar
if (!result.value) {
return [];
}
return resolveLinks(dashboardUID, link, result.value);
return resolveLinks(dashboardUID, link, result.value.view);
};
interface ResolvedLinkDTO {
@@ -122,36 +129,32 @@ interface ResolvedLinkDTO {
title: string;
}
export async function searchForTags(
tags: string[],
dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv }
): Promise<DashboardSearchItem[]> {
const limit = 100;
const searchHits: DashboardSearchItem[] = await dependencies.getBackendSrv().search({ tag: tags, limit });
return searchHits;
export async function searchForTags(tags: string[]) {
return getGrafanaSearcher().search({ limit: 100, tags, kind: ['dashboard'] });
}
export function resolveLinks(
dashboardUID: string,
link: DashboardLink,
searchHits: DashboardSearchItem[],
searchHits: DashboardQueryResult[],
dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = {
getLinkSrv,
sanitize,
sanitizeUrl,
}
): ResolvedLinkDTO[] {
return searchHits
.filter((searchHit) => searchHit.uid !== dashboardUID)
.map((searchHit) => {
const uid = searchHit.uid;
const title = dependencies.sanitize(searchHit.title);
const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url });
const url = dependencies.sanitizeUrl(resolvedLink);
return { uid, title, url };
});
const hits: ResolvedLinkDTO[] = [];
for (const searchHit of searchHits) {
if (searchHit.uid === dashboardUID) {
continue;
}
const uid = searchHit.uid;
const title = dependencies.sanitize(searchHit.name);
const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url });
const url = dependencies.sanitizeUrl(resolvedLink);
hits.push({ uid, title, url });
}
return hits;
}
function getStyles(theme: GrafanaTheme2) {
@@ -23,3 +23,14 @@ export function getGrafanaSearcher(): GrafanaSearcher {
}
return searcher!;
}
/**
* Testing only - otherwise tests will use the same searcher instance, making it hard to test unified search vs legacy
* @deprecated Don't use this other than in tests!
*/
export function resetGrafanaSearcher() {
if (process.env.NODE_ENV !== 'test') {
throw new Error('resetGrafanaSearcher can only be used in tests');
}
searcher = undefined;
}
+2 -1
View File
@@ -4607,7 +4607,8 @@
}
},
"dashboard-links-menu": {
"aria-label-dashboard-name": "{{dashboardName}} dashboard"
"aria-label-dashboard-name": "{{dashboardName}} dashboard",
"label-no-dashboards-found": "No dashboards found"
},
"dashboard-loading": {
"cancel-loading-dashboard": "Cancel loading dashboard"