diff --git a/.betterer.results b/.betterer.results index b2144b44893..83dbdd6ada5 100644 --- a/.betterer.results +++ b/.betterer.results @@ -2416,7 +2416,8 @@ exports[`better eslint`] = { [0, 0, 0, "Unexpected any. Specify a different type.", "5"], [0, 0, 0, "Unexpected any. Specify a different type.", "6"], [0, 0, 0, "Unexpected any. Specify a different type.", "7"], - [0, 0, 0, "Unexpected any. Specify a different type.", "8"] + [0, 0, 0, "Unexpected any. Specify a different type.", "8"], + [0, 0, 0, "Unexpected any. Specify a different type.", "9"] ], "public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"], @@ -2551,10 +2552,6 @@ exports[`better eslint`] = { "public/app/features/dashboard/components/Inspector/PanelInspector.tsx:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], - "public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx:5381": [ - [0, 0, 0, "Styles should be written using objects.", "0"], - [0, 0, 0, "Styles should be written using objects.", "1"] - ], "public/app/features/dashboard/components/PanelEditor/DynamicConfigValueEditor.tsx:5381": [ [0, 0, 0, "Styles should be written using objects.", "0"], [0, 0, 0, "Styles should be written using objects.", "1"] diff --git a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx index fcffa81a170..d557b4efccd 100644 --- a/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardLinksControls.tsx @@ -5,13 +5,13 @@ import { selectors } from '@grafana/e2e-selectors'; import { SceneComponentProps, SceneObjectBase, SceneObjectState } from '@grafana/scenes'; import { DashboardLink } from '@grafana/schema'; import { Tooltip } from '@grafana/ui'; -import { linkIconMap } from 'app/features/dashboard/components/LinksSettings/LinkSettingsEdit'; import { DashboardLinkButton, DashboardLinksDashboard, } from 'app/features/dashboard/components/SubMenu/DashboardLinksDashboard'; import { getLinkSrv } from 'app/features/panel/panellinks/link_srv'; +import { LINK_ICON_MAP } from '../settings/links/utils'; import { getDashboardSceneFor } from '../utils/utils'; interface DashboardLinksControlsState extends SceneObjectState {} @@ -37,7 +37,7 @@ function DashboardLinksControlsRenderer({ model }: SceneComponentProps; } - const icon = linkIconMap[link.icon]; + const icon = LINK_ICON_MAP[link.icon]; const linkElement = ( { ${'description'} | ${'new description'} ${'tags'} | ${['tag3', 'tag4']} ${'editable'} | ${false} + ${'links'} | ${[]} `( 'A change to $prop should set isDirty true', ({ prop, value }: { prop: keyof DashboardSceneState; value: any }) => { diff --git a/public/app/features/dashboard-scene/scene/DashboardScene.tsx b/public/app/features/dashboard-scene/scene/DashboardScene.tsx index 9f56554ac8d..e0ca58c59b9 100644 --- a/public/app/features/dashboard-scene/scene/DashboardScene.tsx +++ b/public/app/features/dashboard-scene/scene/DashboardScene.tsx @@ -38,7 +38,7 @@ import { DashboardSceneUrlSync } from './DashboardSceneUrlSync'; import { ViewPanelScene } from './ViewPanelScene'; import { setupKeyboardShortcuts } from './keyboardShortcuts'; -export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip']; +export const PERSISTED_PROPS = ['title', 'description', 'tags', 'editable', 'graphTooltip', 'links']; export interface DashboardSceneState extends SceneObjectState { /** The title */ @@ -48,7 +48,7 @@ export interface DashboardSceneState extends SceneObjectState { /** Tags */ tags?: string[]; /** Links */ - links?: DashboardLink[]; + links: DashboardLink[]; /** Is editable */ editable?: boolean; /** A uid when saved */ @@ -109,6 +109,7 @@ export class DashboardScene extends SceneObjectBase { meta: {}, editable: true, body: state.body ?? new SceneFlexLayout({ children: [] }), + links: state.links ?? [], ...state, }); diff --git a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap index f3114324f74..d3453ad2b32 100644 --- a/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap +++ b/public/app/features/dashboard-scene/serialization/__snapshots__/transformSceneToSaveModel.test.ts.snap @@ -335,7 +335,20 @@ exports[`transformSceneToSaveModel Given a simple scene with custom settings Sho "fiscalYearStartMonth": 1, "graphTooltip": 0, "id": 1351, - "links": [], + "links": [ + { + "asDropdown": false, + "icon": "external link", + "includeVars": false, + "keepTime": false, + "tags": [], + "targetBlank": false, + "title": "Link 1", + "tooltip": "", + "type": "dashboards", + "url": "", + }, + ], "panels": [ { "datasource": { diff --git a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts index 0e2b7673340..451cab14da2 100644 --- a/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSaveModelToScene.test.ts @@ -45,6 +45,7 @@ import { DashboardControls } from '../scene/DashboardControls'; import { PanelRepeaterGridItem } from '../scene/PanelRepeaterGridItem'; import { PanelTimeRange } from '../scene/PanelTimeRange'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { NEW_LINK } from '../settings/links/utils'; import { getQueryRunnerFor } from '../utils/utils'; import dashboard_to_load1 from './testfiles/dashboard_to_load1.json'; @@ -71,6 +72,7 @@ describe('transformSaveModelToScene', () => { ...defaultTimePickerConfig, hidden: true, }, + links: [{ ...NEW_LINK, title: 'Link 1' }], templating: { list: [ { @@ -110,6 +112,8 @@ describe('transformSaveModelToScene', () => { expect(scene.state.title).toBe('test'); expect(scene.state.uid).toBe('test-uid'); + expect(scene.state.links).toHaveLength(1); + expect(scene.state.links![0].title).toBe('Link 1'); expect(scene.state?.$timeRange?.state.value.raw).toEqual(dash.time); expect(scene.state?.$timeRange?.state.fiscalYearStartMonth).toEqual(2); expect(scene.state?.$timeRange?.state.timeZone).toEqual('America/New_York'); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts index 4f78d9de36b..94134fae230 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.test.ts @@ -31,6 +31,7 @@ import { reduceTransformRegistryItem } from 'app/features/transformers/editors/R import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard'; import { RowRepeaterBehavior } from '../scene/RowRepeaterBehavior'; +import { NEW_LINK } from '../settings/links/utils'; import { activateFullSceneTree, buildPanelRepeaterScene } from '../utils/test-utils'; import { getVizPanelKeyForPanelId } from '../utils/utils'; @@ -185,6 +186,7 @@ describe('transformSceneToSaveModel', () => { time_options: ['5m', '15m', '30m'], hidden: true, }, + links: [{ ...NEW_LINK, title: 'Link 1' }], }; const scene = transformSaveModelToScene({ dashboard: dashboardWithCustomSettings as any, meta: {} }); const saveModel = transformSceneToSaveModel(scene); @@ -817,15 +819,14 @@ describe('transformSceneToSaveModel', () => { expect(result.panels?.[0].gridPos).toEqual({ w: 24, x: 0, y: 0, h: 20 }); }); - // TODO: Uncomment when we support links - // it('should remove links', async () => { - // const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} }); - // activateFullSceneTree(scene); - // const snapshot = transformSceneToSaveModel(scene, true); - // expect(snapshot.links?.length).toBe(1); - // const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot); - // expect(result.links?.length).toBe(0); - // }); + it('should remove links', async () => { + const scene = transformSaveModelToScene({ dashboard: snapshotableDashboardJson as any, meta: {} }); + activateFullSceneTree(scene); + const snapshot = transformSceneToSaveModel(scene, true); + expect(snapshot.links?.length).toBe(1); + const result = trimDashboardForSnapshot('Snap title', getTimeRange({ from: 'now-6h', to: 'now' }), snapshot); + expect(result.links?.length).toBe(0); + }); }); }); }); diff --git a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts index e4b361bccae..1b6bd05c12a 100644 --- a/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts +++ b/public/app/features/dashboard-scene/serialization/transformSceneToSaveModel.ts @@ -138,6 +138,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa fiscalYearStartMonth: timeRange.fiscalYearStartMonth, weekStart: timeRange.weekStart, tags: state.tags, + links: state.links, graphTooltip, }; diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx new file mode 100644 index 00000000000..0c68be0e6ba --- /dev/null +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.test.tsx @@ -0,0 +1,268 @@ +import { render as RTLRender } from '@testing-library/react'; +import React from 'react'; +import { TestProvider } from 'test/helpers/TestProvider'; + +import { + behaviors, + SceneGridLayout, + SceneGridItem, + SceneRefreshPicker, + SceneTimeRange, + SceneTimePicker, +} from '@grafana/scenes'; +import { DashboardCursorSync } from '@grafana/schema'; + +import { DashboardControls } from '../scene/DashboardControls'; +import { DashboardLinksControls } from '../scene/DashboardLinksControls'; +import { DashboardScene } from '../scene/DashboardScene'; +import { activateFullSceneTree } from '../utils/test-utils'; + +import { DashboardLinksEditView } from './DashboardLinksEditView'; +import { NEW_LINK } from './links/utils'; + +jest.mock('react-router-dom', () => ({ + ...jest.requireActual('react-router-dom'), + useLocation: jest.fn().mockReturnValue({ + pathname: '/d/dash-1/settings/links', + search: '', + hash: '', + state: null, + key: '5nvxpbdafa', + }), +})); + +function render(component: React.ReactNode) { + return RTLRender({component}); +} + +describe('DashboardLinksEditView', () => { + describe('Url state', () => { + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + settings = result.settings; + }); + + it('should return the correct urlKey', () => { + expect(settings.getUrlKey()).toBe('links'); + }); + }); + + describe('Dashboard updates', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should have isDirty false', () => { + expect(dashboard.state.isDirty).toBeFalsy(); + }); + + it('should update dashboard state when adding a link', () => { + settings.onNewLink(); + + expect(dashboard.state.links[0]).toEqual(NEW_LINK); + }); + + it('should update dashboard state when deleting a link', () => { + dashboard.setState({ links: [NEW_LINK] }); + settings.onDelete(0); + + expect(dashboard.state.links).toEqual([]); + }); + + it('should update dashboard state when duplicating a link', () => { + dashboard.setState({ links: [NEW_LINK] }); + settings.onDuplicate(NEW_LINK); + + expect(dashboard.state.links).toEqual([NEW_LINK, NEW_LINK]); + }); + + it('should update dashboard state when reordering a link', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.onOrderChange(0, 1); + + expect(dashboard.state.links).toEqual([ + { ...NEW_LINK, title: 'link-2' }, + { ...NEW_LINK, title: 'link-1' }, + ]); + }); + + it('should update dashboard state when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.setState({ editIndex: 0 }); + settings.onUpdateLink({ ...NEW_LINK, title: 'new title' }); + + expect(dashboard.state.links[0].title).toEqual('new title'); + }); + }); + + describe('Edit a link', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should set editIndex when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.onEdit(0); + + expect(settings.state.editIndex).toEqual(0); + }); + + it('should set editIndex when editing a link that does not exist', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.onEdit(1); + + expect(settings.state.editIndex).toBe(1); + }); + + it('should update dashboard state when editing a link', () => { + dashboard.setState({ links: [{ ...NEW_LINK, title: 'old title' }] }); + settings.setState({ editIndex: 0 }); + settings.onUpdateLink({ ...NEW_LINK, title: 'new title' }); + + expect(dashboard.state.links[0].title).toEqual('new title'); + }); + + it('should update dashboard state when going back', () => { + settings.setState({ editIndex: 0 }); + settings.onGoBack(); + + expect(settings.state.editIndex).toBeUndefined(); + }); + }); + + describe('Render the views', () => { + let dashboard: DashboardScene; + let settings: DashboardLinksEditView; + + beforeEach(async () => { + const result = await buildTestScene(); + dashboard = result.dashboard; + settings = result.settings; + }); + + it('should render with no errors', () => { + expect(() => render()).not.toThrow(); + }); + + it('should render the empty state when no links', () => { + dashboard.setState({ links: [] }); + const { getByText } = render(); + + expect(getByText('Add dashboard link')).toBeInTheDocument(); + }); + + it('should render the empty state when no links', () => { + dashboard.setState({ links: [] }); + const { getByText } = render(); + + expect(getByText('Add dashboard link')).toBeInTheDocument(); + }); + + it('should render the list of link when there are links', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + const { getByText } = render(); + + expect(getByText('link-1')).toBeInTheDocument(); + expect(getByText('link-2')).toBeInTheDocument(); + expect(getByText('New link')).toBeInTheDocument(); + }); + + it('should render the list of link when the editing link does not exist', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.setState({ editIndex: 2 }); + const { getByText } = render(); + + expect(getByText('link-1')).toBeInTheDocument(); + expect(getByText('link-2')).toBeInTheDocument(); + expect(getByText('New link')).toBeInTheDocument(); + }); + + it('should render the link form when the editing link does exist', () => { + dashboard.setState({ + links: [ + { ...NEW_LINK, title: 'link-1' }, + { ...NEW_LINK, title: 'link-2' }, + ], + }); + settings.setState({ editIndex: 1 }); + const { getByText } = render(); + + expect(getByText('Edit link')).toBeInTheDocument(); + expect(getByText('Apply')).toBeInTheDocument(); + }); + }); +}); + +async function buildTestScene() { + const settings = new DashboardLinksEditView({}); + const dashboard = new DashboardScene({ + $timeRange: new SceneTimeRange({}), + $behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })], + controls: [ + new DashboardControls({ + variableControls: [], + linkControls: new DashboardLinksControls({}), + timeControls: [ + new SceneTimePicker({}), + new SceneRefreshPicker({ + intervals: ['1s'], + }), + ], + }), + ], + title: 'hello', + uid: 'dash-1', + meta: { + canEdit: true, + }, + body: new SceneGridLayout({ + children: [ + new SceneGridItem({ + key: 'griditem-1', + x: 0, + y: 0, + width: 10, + height: 12, + body: undefined, + }), + ], + }), + editview: settings, + }); + + activateFullSceneTree(dashboard); + + await new Promise((r) => setTimeout(r, 1)); + + dashboard.onEnterEditMode(); + settings.activate(); + + return { dashboard, settings }; +} diff --git a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx index 5be269c6092..c2f570a6e78 100644 --- a/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx +++ b/public/app/features/dashboard-scene/settings/DashboardLinksEditView.tsx @@ -1,14 +1,15 @@ import React from 'react'; -import { NavModel, NavModelItem, PageLayoutType } from '@grafana/data'; -import { locationService } from '@grafana/runtime'; +import { NavModel, NavModelItem, PageLayoutType, arrayUtils } from '@grafana/data'; import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes'; import { DashboardLink } from '@grafana/schema'; -import { Link } from '@grafana/ui'; import { Page } from 'app/core/components/Page/Page'; import { DashboardScene } from '../scene/DashboardScene'; import { NavToolbarActions } from '../scene/NavToolbarActions'; +import { DashboardLinkForm } from '../settings/links/DashboardLinkForm'; +import { DashboardLinkList } from '../settings/links/DashboardLinkList'; +import { NEW_LINK } from '../settings/links/utils'; import { getDashboardSceneFor } from '../utils/utils'; import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync'; @@ -24,49 +25,106 @@ export class DashboardLinksEditView extends SceneObjectBase { + this.links = [...this.links, NEW_LINK]; + this.setState({ editIndex: this.links.length - 1 }); + }; + + public onDelete = (idx: number) => { + this.links = [...this.links.slice(0, idx), ...this.links.slice(idx + 1)]; + + this.setState({ editIndex: undefined }); + }; + + public onDuplicate = (link: DashboardLink) => { + this.links = [...this.links, { ...link }]; + }; + + public onOrderChange = (idx: number, direction: number) => { + this.links = arrayUtils.moveItemImmutably(this.links, idx, idx + direction); + }; + + public onEdit = (editIndex: number) => { + this.setState({ editIndex }); + }; + + public onUpdateLink = (link: DashboardLink) => { + const idx = this.state.editIndex; + + if (idx === undefined) { + return; + } + + this.links = [...this.links.slice(0, idx), link, ...this.links.slice(idx + 1)]; + }; + + public onGoBack = () => { + this.setState({ editIndex: undefined }); + }; } function DashboardLinksEditViewRenderer({ model }: SceneComponentProps) { const { editIndex } = model.useState(); const dashboard = getDashboardSceneFor(model); - const links = dashboard.state.links || []; + const { links, overlay } = dashboard.useState(); const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey()); + const linkToEdit = editIndex !== undefined ? links[editIndex] : undefined; - if (editIndex !== undefined) { - const link = links[editIndex]; - if (link) { - return ; - } + if (linkToEdit) { + return ( + + ); } return ( - {links.map((link, i) => ( - { - e.preventDefault(); - locationService.partial({ editIndex: i }); - }} - > - {link.title} - - ))} + + {overlay && } ); } interface EditLinkViewProps { - link: DashboardLink; + link?: DashboardLink; pageNav: NavModelItem; navModel: NavModel; dashboard: DashboardScene; + onChange: (link: DashboardLink) => void; + onGoBack: () => void; } -function EditLinkView({ pageNav, link, navModel, dashboard }: EditLinkViewProps) { +function EditLinkView({ pageNav, link, navModel, dashboard, onChange, onGoBack }: EditLinkViewProps) { const parentTab = pageNav.children!.find((p) => p.active)!; parentTab.parentItem = pageNav; + const { overlay } = dashboard.useState(); const editLinkPageNav = { text: 'Edit link', @@ -76,7 +134,8 @@ function EditLinkView({ pageNav, link, navModel, dashboard }: EditLinkViewProps) return ( - {JSON.stringify(link)} + + {overlay && } ); } diff --git a/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx b/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx new file mode 100644 index 00000000000..73544270608 --- /dev/null +++ b/public/app/features/dashboard-scene/settings/links/DashboardLinkForm.tsx @@ -0,0 +1,107 @@ +import React from 'react'; + +import { SelectableValue } from '@grafana/data'; +import { DashboardLink } from '@grafana/schema'; +import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button } from '@grafana/ui'; + +import { LINK_ICON_MAP, NEW_LINK } from './utils'; + +const linkTypeOptions = [ + { value: 'dashboards', label: 'Dashboards' }, + { value: 'link', label: 'Link' }, +]; + +const linkIconOptions = Object.keys(LINK_ICON_MAP).map((key) => ({ label: key, value: key })); + +interface DashboardLinkFormProps { + link: DashboardLink; + onUpdate: (link: DashboardLink) => void; + onGoBack: () => void; +} + +export function DashboardLinkForm({ link, onUpdate, onGoBack }: DashboardLinkFormProps) { + const onTagsChange = (tags: string[]) => { + onUpdate({ ...link, tags: tags }); + }; + + const onTypeChange = (selectedItem: SelectableValue) => { + const update = { ...link, type: selectedItem.value }; + + // clear props that are no longe revant for this type + if (update.type === 'dashboards') { + update.url = ''; + update.tooltip = ''; + } else { + update.tags = []; + } + + onUpdate(update); + }; + + const onIconChange = (selectedItem: SelectableValue) => { + onUpdate({ ...link, icon: selectedItem.value }); + }; + + const onChange = (ev: React.FocusEvent) => { + const target = ev.currentTarget; + onUpdate({ + ...link, + [target.name]: target.type === 'checkbox' ? target.checked : target.value, + }); + }; + + const isNew = link.title === NEW_LINK.title; + + return ( +
+ + + + + + + + + + + - - - - - - - - -