diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx new file mode 100644 index 00000000000..46016c66f9c --- /dev/null +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.test.tsx @@ -0,0 +1,114 @@ +import { DashboardLink } from '../../state/DashboardModel'; +import { DashboardSearchHit, DashboardSearchItemType } from '../../../search/types'; +import { resolveLinks, searchForTags } from './DashboardLinksDashboard'; +import { describe, expect } from '../../../../../test/lib/common'; + +describe('searchForTags', () => { + const setupTestContext = () => { + const tags = ['A', 'B']; + const link: DashboardLink = { + targetBlank: false, + asDropdown: false, + icon: 'some icon', + tags, + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: undefined, + }; + const backendSrv: any = { + search: jest.fn(args => []), + }; + + 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, { getBackendSrv: () => backendSrv }); + + expect(results.length).toEqual(0); + expect(backendSrv.search).toHaveBeenCalledWith({ tag: ['A', 'B'], limit: 100 }); + expect(backendSrv.search).toHaveBeenCalledTimes(1); + }); + }); +}); + +describe('resolveLinks', () => { + const setupTestContext = (dashboardId: number, searchHitId: number) => { + const link: DashboardLink = { + targetBlank: false, + asDropdown: false, + icon: 'some icon', + tags: [], + title: 'some title', + tooltip: 'some tooltip', + type: 'dashboards', + url: undefined, + }; + const searchHits: DashboardSearchHit[] = [ + { + id: searchHitId, + title: 'DashLinks', + url: '/d/6ieouugGk/DashLinks', + isStarred: false, + items: [], + tags: [], + uri: 'db/DashLinks', + type: DashboardSearchItemType.DashDB, + }, + ]; + const linkSrv: any = { + getLinkUrl: jest.fn(args => args.url), + }; + const sanitize = jest.fn(args => args); + const sanitizeUrl = jest.fn(args => args); + + return { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl }; + }; + + describe('when called', () => { + it('should filter out the calling dashboardId', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 1); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(0); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(0); + expect(sanitize).toHaveBeenCalledTimes(0); + expect(sanitizeUrl).toHaveBeenCalledTimes(0); + }); + + it('should resolve link url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledTimes(1); + expect(linkSrv.getLinkUrl).toHaveBeenCalledWith({ ...link, url: searchHits[0].url }); + }); + + it('should sanitize title', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitize).toHaveBeenCalledTimes(1); + expect(sanitize).toHaveBeenCalledWith(searchHits[0].title); + }); + + it('should sanitize url', () => { + const { dashboardId, link, searchHits, linkSrv, sanitize, sanitizeUrl } = setupTestContext(1, 2); + + const results = resolveLinks(dashboardId, link, searchHits, { getLinkSrv: () => linkSrv, sanitize, sanitizeUrl }); + + expect(results.length).toEqual(1); + expect(sanitizeUrl).toHaveBeenCalledTimes(1); + expect(sanitizeUrl).toHaveBeenCalledWith(searchHits[0].url); + }); + }); +}); diff --git a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx index 919a2d60188..eb5b45d6cb4 100644 --- a/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx +++ b/public/app/features/dashboard/components/SubMenu/DashboardLinksDashboard.tsx @@ -13,73 +13,53 @@ interface Props { } interface State { - searchHits: DashboardSearchHit[]; + resolvedLinks: ResolvedLinkDTO[]; } export class DashboardLinksDashboard extends PureComponent { - state = { searchHits: [] as DashboardSearchHit[] }; - componentDidMount() { - if (!this.props.link.asDropdown) { - this.onDropDownClick(); + state: State = { resolvedLinks: [] }; + + componentDidUpdate(prevProps: Readonly) { + if (!this.props.link.asDropdown && prevProps.linkInfo !== this.props.linkInfo) { + this.onResolveLinks(); } } - onDropDownClick = () => { + onResolveLinks = async () => { const { dashboardId, link } = this.props; - const limit = 7; - getBackendSrv() - .search({ tag: link.tags, limit }) - .then((dashboards: DashboardSearchHit[]) => { - const processed = dashboards - .filter(dash => dash.id !== dashboardId) - .map(dash => { - return { - ...dash, - url: getLinkSrv().getLinkUrl(dash), - }; - }); + const searchHits = await searchForTags(link); + const resolvedLinks = resolveLinks(dashboardId, link, searchHits); - this.setState({ - searchHits: processed, - }); - }); + this.setState({ resolvedLinks }); }; - renderElement = (linkElement: JSX.Element) => { + renderElement = (linkElement: JSX.Element, key: string) => { const { link } = this.props; - if (link.tooltip) { - return ( -
- {linkElement}; -
- ); - } else { - return
{linkElement}
; - } + return ( +
+ {link.tooltip && {linkElement}} + {!link.tooltip && <>{linkElement}} +
+ ); }; renderList = () => { const { link } = this.props; - const { searchHits } = this.state; + const { resolvedLinks } = this.state; return ( <> - {searchHits.length > 0 && - searchHits.map((dashboard: any, index: number) => { + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { const linkElement = ( - + - {sanitize(dashboard.title)} + {resolvedLink.title} ); - return this.renderElement(linkElement); + return this.renderElement(linkElement, `dashlinks-list-item-${resolvedLink.id}-${index}`); })} ); @@ -87,13 +67,13 @@ export class DashboardLinksDashboard extends PureComponent { renderDropdown = () => { const { link, linkInfo } = this.props; - const { searchHits } = this.state; + const { resolvedLinks } = this.state; const linkElement = ( <> @@ -101,12 +81,12 @@ export class DashboardLinksDashboard extends PureComponent { {linkInfo.title}
    - {searchHits.length > 0 && - searchHits.map((dashboard: any, index: number) => { + {resolvedLinks.length > 0 && + resolvedLinks.map((resolvedLink, index) => { return ( -
  • - - {sanitize(dashboard.title)} +
  • + + {resolvedLink.title}
  • ); @@ -115,14 +95,52 @@ export class DashboardLinksDashboard extends PureComponent { ); - return this.renderElement(linkElement); + return this.renderElement(linkElement, 'dashlinks-dropdown'); }; render() { if (this.props.link.asDropdown) { return this.renderDropdown(); - } else { - return this.renderList(); } + + return this.renderList(); } } + +interface ResolvedLinkDTO { + id: any; + url: string; + title: string; +} + +export async function searchForTags( + link: DashboardLink, + dependencies: { getBackendSrv: typeof getBackendSrv } = { getBackendSrv } +): Promise { + const limit = 100; + const searchHits: DashboardSearchHit[] = await dependencies.getBackendSrv().search({ tag: link.tags, limit }); + + return searchHits; +} + +export function resolveLinks( + dashboardId: any, + link: DashboardLink, + searchHits: DashboardSearchHit[], + dependencies: { getLinkSrv: typeof getLinkSrv; sanitize: typeof sanitize; sanitizeUrl: typeof sanitizeUrl } = { + getLinkSrv, + sanitize, + sanitizeUrl, + } +): ResolvedLinkDTO[] { + return searchHits + .filter(searchHit => searchHit.id !== dashboardId) + .map(searchHit => { + const id = searchHit.id; + const title = dependencies.sanitize(searchHit.title); + const resolvedLink = dependencies.getLinkSrv().getLinkUrl({ ...link, url: searchHit.url }); + const url = dependencies.sanitizeUrl(resolvedLink); + + return { id, title, url }; + }); +} diff --git a/public/app/features/dashboard/state/DashboardModel.ts b/public/app/features/dashboard/state/DashboardModel.ts index 192ac526113..94ca887b989 100644 --- a/public/app/features/dashboard/state/DashboardModel.ts +++ b/public/app/features/dashboard/state/DashboardModel.ts @@ -12,13 +12,13 @@ import { GridPos, panelAdded, PanelModel, panelRemoved } from './PanelModel'; import { DashboardMigrator } from './DashboardMigrator'; import { AppEvent, + dateTimeFormat, + dateTimeFormatTimeAgo, DateTimeInput, PanelEvents, TimeRange, TimeZone, UrlQueryValue, - dateTimeFormat, - dateTimeFormatTimeAgo, } from '@grafana/data'; import { CoreEvents, DashboardMeta, KIOSK_MODE_TV } from 'app/types'; import { getConfig } from '../../../core/config'; @@ -42,8 +42,8 @@ export interface DashboardLink { type: DashboardLinkType; url: string; asDropdown: boolean; - tags: []; - searchHits?: []; + tags: any[]; + searchHits?: any[]; targetBlank: boolean; }