diff --git a/e2e/old-arch/dashboards-suite/templating-dashboard-links-and-variables.spec.ts b/e2e/old-arch/dashboards-suite/templating-dashboard-links-and-variables.spec.ts index 06e13002db4..a612d23b50d 100644 --- a/e2e/old-arch/dashboards-suite/templating-dashboard-links-and-variables.spec.ts +++ b/e2e/old-arch/dashboards-suite/templating-dashboard-links-and-variables.spec.ts @@ -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' }); diff --git a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts index 84cda34bb0c..ed42d326d46 100644 --- a/packages/grafana-test-utils/src/handlers/api/search/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/api/search/handlers.ts @@ -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)); } diff --git a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts index f6d079755c5..6981201f81f 100644 --- a/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts +++ b/packages/grafana-test-utils/src/handlers/apis/dashboard.grafana.app/v0alpha1/handlers.ts @@ -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 diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx index 8759dcef8f9..76485079afe 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx @@ -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 extends object + ? { + [P in keyof T]?: DeepPartial; + } + : T; + +const dashboardUID = '1'; + +const baseLinkProps: ComponentProps['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> = {}) => { + return render( + + ); +}; + +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); }); }); }); diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 7b7286ea618..af9f6702832 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -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 ( + + + + ); } 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} > - {linkInfo.title} + {title} ); @@ -113,7 +120,7 @@ const useResolvedLinks = ({ link, dashboardUID }: Pick { - 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) { diff --git a/public/app/features/search/service/searcher.ts b/public/app/features/search/service/searcher.ts index 2710a789050..9ea970c7156 100644 --- a/public/app/features/search/service/searcher.ts +++ b/public/app/features/search/service/searcher.ts @@ -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; +} diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 1556f5b49eb..f3afe2eb828 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -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"