Dashboards: Use unified search for dashboard links and improve tests (#112201)
This commit is contained in:
@@ -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));
|
||||
}
|
||||
|
||||
+9
@@ -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
|
||||
|
||||
+153
-89
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user