DashboardLinksSettings: Move them to Scenes (#79998)

This commit is contained in:
Ivan Ortega Alba 2024-01-16 12:24:54 +01:00 committed by GitHub
parent 313c43749c
commit 07778cb221
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 659 additions and 259 deletions

View File

@ -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"]

View File

@ -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<Dashboard
return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={uid} />;
}
const icon = linkIconMap[link.icon];
const icon = LINK_ICON_MAP[link.icon];
const linkElement = (
<DashboardLinkButton

View File

@ -61,6 +61,7 @@ describe('DashboardScene', () => {
${'description'} | ${'new description'}
${'tags'} | ${['tag3', 'tag4']}
${'editable'} | ${false}
${'links'} | ${[]}
`(
'A change to $prop should set isDirty true',
({ prop, value }: { prop: keyof DashboardSceneState; value: any }) => {

View File

@ -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<DashboardSceneState> {
meta: {},
editable: true,
body: state.body ?? new SceneFlexLayout({ children: [] }),
links: state.links ?? [],
...state,
});

View File

@ -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": {

View File

@ -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');

View File

@ -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);
});
});
});
});

View File

@ -138,6 +138,7 @@ export function transformSceneToSaveModel(scene: DashboardScene, isSnapshot = fa
fiscalYearStartMonth: timeRange.fiscalYearStartMonth,
weekStart: timeRange.weekStart,
tags: state.tags,
links: state.links,
graphTooltip,
};

View File

@ -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(<TestProvider>{component}</TestProvider>);
}
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(<settings.Component model={settings} />)).not.toThrow();
});
it('should render the empty state when no links', () => {
dashboard.setState({ links: [] });
const { getByText } = render(<settings.Component model={settings} />);
expect(getByText('Add dashboard link')).toBeInTheDocument();
});
it('should render the empty state when no links', () => {
dashboard.setState({ links: [] });
const { getByText } = render(<settings.Component model={settings} />);
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(<settings.Component model={settings} />);
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(<settings.Component model={settings} />);
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(<settings.Component model={settings} />);
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 };
}

View File

@ -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<DashboardLinksEditVi
public getUrlKey(): string {
return 'links';
}
private get dashboard(): DashboardScene {
return getDashboardSceneFor(this);
}
private get links(): DashboardLink[] {
return this.dashboard.state.links;
}
private set links(links: DashboardLink[]) {
this.dashboard.setState({ links });
}
public onNewLink = () => {
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<DashboardLinksEditView>) {
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 <EditLinkView pageNav={pageNav} navModel={navModel} link={link} dashboard={dashboard} />;
}
if (linkToEdit) {
return (
<EditLinkView
pageNav={pageNav}
navModel={navModel}
link={linkToEdit}
dashboard={dashboard}
onChange={model.onUpdateLink}
onGoBack={model.onGoBack}
/>
);
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
{links.map((link, i) => (
<Link
key={`${link.title}-${i}`}
onClick={(e) => {
e.preventDefault();
locationService.partial({ editIndex: i });
}}
>
{link.title}
</Link>
))}
<DashboardLinkList
links={links}
onNew={model.onNewLink}
onEdit={model.onEdit}
onDelete={model.onDelete}
onDuplicate={model.onDuplicate}
onOrderChange={model.onOrderChange}
/>
{overlay && <overlay.Component model={overlay} />}
</Page>
);
}
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 (
<Page navModel={navModel} pageNav={editLinkPageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
{JSON.stringify(link)}
<DashboardLinkForm link={link!} onUpdate={onChange} onGoBack={onGoBack} />
{overlay && <overlay.Component model={overlay} />}
</Page>
);
}

View File

@ -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<HTMLInputElement>) => {
const target = ev.currentTarget;
onUpdate({
...link,
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
});
};
const isNew = link.title === NEW_LINK.title;
return (
<div style={{ maxWidth: '600px' }}>
<Field label="Title">
<Input name="title" id="title" value={link.title} onChange={onChange} autoFocus={isNew} />
</Field>
<Field label="Type">
<Select inputId="link-type-input" value={link.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
{link.type === 'dashboards' && (
<>
<Field label="With tags">
<TagsInput tags={link.tags} onChange={onTagsChange} />
</Field>
</>
)}
{link.type === 'link' && (
<>
<Field label="URL">
<Input name="url" value={link.url} onChange={onChange} />
</Field>
<Field label="Tooltip">
<Input name="tooltip" value={link.tooltip} onChange={onChange} placeholder="Open dashboard" />
</Field>
<Field label="Icon">
<Select value={link.icon} options={linkIconOptions} onChange={onIconChange} />
</Field>
</>
)}
<CollapsableSection label="Options" isOpen={true}>
{link.type === 'dashboards' && (
<Field>
<Checkbox label="Show as dropdown" name="asDropdown" value={link.asDropdown} onChange={onChange} />
</Field>
)}
<Field>
<Checkbox label="Include current time range" name="keepTime" value={link.keepTime} onChange={onChange} />
</Field>
<Field>
<Checkbox
label="Include current template variable values"
name="includeVars"
value={link.includeVars}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox label="Open link in new tab" name="targetBlank" value={link.targetBlank} onChange={onChange} />
</Field>
</CollapsableSection>
<Button onClick={onGoBack}>Apply</Button>
</div>
);
}

View File

@ -0,0 +1,111 @@
import { css } from '@emotion/css';
import React from 'react';
import { DashboardLink } from '@grafana/schema';
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { ListNewButton } from '../../../dashboard/components/DashboardSettings/ListNewButton';
interface DashboardLinkListProps {
links: DashboardLink[];
onNew: () => void;
onEdit: (idx: number) => void;
onDuplicate: (link: DashboardLink) => void;
onDelete: (idx: number) => void;
onOrderChange: (idx: number, direction: number) => void;
}
export function DashboardLinkList({
links,
onNew,
onOrderChange,
onEdit,
onDuplicate,
onDelete,
}: DashboardLinkListProps) {
const styles = useStyles2(getStyles);
const isEmptyList = links.length === 0;
if (isEmptyList) {
return (
<div>
<EmptyListCTA
onClick={onNew}
title="There are no dashboard links added yet"
buttonIcon="link"
buttonTitle="Add dashboard link"
infoBoxTitle="What are dashboard links?"
infoBox={{
__html:
'<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
}}
/>
</div>
);
}
return (
<>
<table role="grid" className="filter-table filter-table--hover">
<thead>
<tr>
<th>Type</th>
<th>Info</th>
<th colSpan={3} />
</tr>
</thead>
<tbody>
{links.map((link, idx) => (
<tr key={`${link.title}-${idx}`}>
<td role="gridcell" className="pointer" onClick={() => onEdit(idx)}>
<Icon name="external-link-alt" /> &nbsp; {link.type}
</td>
<td role="gridcell">
<HorizontalGroup>
{link.title && <span className={styles.titleWrapper}>{link.title}</span>}
{link.type === 'link' && <span className={styles.urlWrapper}>{link.url}</span>}
{link.type === 'dashboards' && <TagList tags={link.tags ?? []} />}
</HorizontalGroup>
</td>
<td style={{ width: '1%' }} role="gridcell">
{idx !== 0 && (
<IconButton name="arrow-up" onClick={() => onOrderChange(idx, -1)} tooltip="Move link up" />
)}
</td>
<td style={{ width: '1%' }} role="gridcell">
{links.length > 1 && idx !== links.length - 1 ? (
<IconButton name="arrow-down" onClick={() => onOrderChange(idx, 1)} tooltip="Move link down" />
) : null}
</td>
<td style={{ width: '1%' }} role="gridcell">
<IconButton name="copy" onClick={() => onDuplicate(link)} tooltip="Copy link" />
</td>
<td style={{ width: '1%' }} role="gridcell">
<DeleteButton
aria-label={`Delete link with title "${link.title}"`}
size="sm"
onConfirm={() => onDelete(idx)}
/>
</td>
</tr>
))}
</tbody>
</table>
<ListNewButton onClick={onNew}>New link</ListNewButton>
</>
);
}
const getStyles = () => ({
titleWrapper: css({
width: '20vw',
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
urlWrapper: css({
width: '40vw',
textOverflow: 'ellipsis',
overflow: 'hidden',
}),
});

View File

@ -0,0 +1,25 @@
import { IconName } from '@grafana/data';
import { DashboardLink } from '@grafana/schema';
export const NEW_LINK: DashboardLink = {
icon: 'external link',
title: 'New link',
tooltip: '',
type: 'dashboards',
url: '',
asDropdown: false,
tags: [],
targetBlank: false,
keepTime: false,
includeVars: false,
};
export const LINK_ICON_MAP: Record<string, IconName | undefined> = {
'external link': 'external-link-alt',
dashboard: 'apps',
question: 'question-circle',
info: 'info-circle',
bolt: 'bolt',
doc: 'file-alt',
cloud: 'cloud',
};

View File

@ -16,6 +16,7 @@ import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard';
import { DashboardControls } from '../scene/DashboardControls';
import { DashboardLinksControls } from '../scene/DashboardLinksControls';
import { DashboardScene } from '../scene/DashboardScene';
import { NEW_LINK } from '../settings/links/utils';
import { DashboardModelCompatibilityWrapper } from './DashboardModelCompatibilityWrapper';
@ -29,6 +30,7 @@ describe('DashboardModelCompatibilityWrapper', () => {
expect(wrapper.editable).toBe(false);
expect(wrapper.graphTooltip).toBe(DashboardCursorSync.Off);
expect(wrapper.tags).toEqual(['hello-tag']);
expect(wrapper.links).toEqual([NEW_LINK]);
expect(wrapper.time.from).toBe('now-6h');
expect(wrapper.timezone).toBe('America/New_York');
expect(wrapper.weekStart).toBe('friday');
@ -109,6 +111,7 @@ function setup() {
title: 'hello',
description: 'hello description',
tags: ['hello-tag'],
links: [NEW_LINK],
uid: 'dash-1',
editable: false,
$timeRange: new SceneTimeRange({

View File

@ -82,6 +82,10 @@ export class DashboardModelCompatibilityWrapper {
return this._scene.state.tags;
}
public get links() {
return this._scene.state.links;
}
public get meta() {
return this._scene.state.meta;
}

View File

@ -3,9 +3,9 @@ import React, { useState } from 'react';
import { NavModelItem } from '@grafana/data';
import { config, locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/Page/Page';
import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils';
import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings';
import { newLink } from '../LinksSettings/LinkSettingsEdit';
import { SettingsPageProps } from './types';
@ -20,7 +20,7 @@ export function LinksSettings({ dashboard, sectionNav, editIndex }: SettingsPage
};
const onNew = () => {
dashboard.links = [...dashboard.links, { ...newLink }];
dashboard.links = [...dashboard.links, { ...NEW_LINK }];
setIsNew(true);
locationService.partial({ editIndex: dashboard.links.length - 1 });
};

View File

@ -1,41 +1,11 @@
import React, { useState } from 'react';
import { SelectableValue } from '@grafana/data';
import { DashboardLink } from '@grafana/schema';
import { CollapsableSection, TagsInput, Select, Field, Input, Checkbox, Button, IconName } from '@grafana/ui';
import { DashboardLinkForm } from 'app/features/dashboard-scene/settings/links/DashboardLinkForm';
import { NEW_LINK } from 'app/features/dashboard-scene/settings/links/utils';
import { DashboardModel } from '../../state/DashboardModel';
export const newLink: DashboardLink = {
icon: 'external link',
title: 'New link',
tooltip: '',
type: 'dashboards',
url: '',
asDropdown: false,
tags: [],
targetBlank: false,
keepTime: false,
includeVars: false,
};
const linkTypeOptions = [
{ value: 'dashboards', label: 'Dashboards' },
{ value: 'link', label: 'Link' },
];
export const linkIconMap: Record<string, IconName | undefined> = {
'external link': 'external-link-alt',
dashboard: 'apps',
question: 'question-circle',
info: 'info-circle',
bolt: 'bolt',
doc: 'file-alt',
cloud: 'cloud',
};
const linkIconOptions = Object.keys(linkIconMap).map((key) => ({ label: key, value: key }));
type LinkSettingsEditProps = {
editLinkIdx: number;
dashboard: DashboardModel;
@ -43,7 +13,7 @@ type LinkSettingsEditProps = {
};
export const LinkSettingsEdit = ({ editLinkIdx, dashboard, onGoBack }: LinkSettingsEditProps) => {
const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : newLink);
const [linkSettings, setLinkSettings] = useState(editLinkIdx !== null ? dashboard.links[editLinkIdx] : NEW_LINK);
const onUpdate = (link: DashboardLink) => {
const links = [...dashboard.links];
@ -52,98 +22,5 @@ export const LinkSettingsEdit = ({ editLinkIdx, dashboard, onGoBack }: LinkSetti
setLinkSettings(link);
};
const onTagsChange = (tags: string[]) => {
onUpdate({ ...linkSettings, tags: tags });
};
const onTypeChange = (selectedItem: SelectableValue) => {
const update = { ...linkSettings, 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({ ...linkSettings, icon: selectedItem.value });
};
const onChange = (ev: React.FocusEvent<HTMLInputElement>) => {
const target = ev.currentTarget;
onUpdate({
...linkSettings,
[target.name]: target.type === 'checkbox' ? target.checked : target.value,
});
};
const isNew = linkSettings.title === newLink.title;
return (
<div style={{ maxWidth: '600px' }}>
<Field label="Title">
<Input name="title" id="title" value={linkSettings.title} onChange={onChange} autoFocus={isNew} />
</Field>
<Field label="Type">
<Select inputId="link-type-input" value={linkSettings.type} options={linkTypeOptions} onChange={onTypeChange} />
</Field>
{linkSettings.type === 'dashboards' && (
<>
<Field label="With tags">
<TagsInput tags={linkSettings.tags} onChange={onTagsChange} />
</Field>
</>
)}
{linkSettings.type === 'link' && (
<>
<Field label="URL">
<Input name="url" value={linkSettings.url} onChange={onChange} />
</Field>
<Field label="Tooltip">
<Input name="tooltip" value={linkSettings.tooltip} onChange={onChange} placeholder="Open dashboard" />
</Field>
<Field label="Icon">
<Select value={linkSettings.icon} options={linkIconOptions} onChange={onIconChange} />
</Field>
</>
)}
<CollapsableSection label="Options" isOpen={true}>
{linkSettings.type === 'dashboards' && (
<Field>
<Checkbox label="Show as dropdown" name="asDropdown" value={linkSettings.asDropdown} onChange={onChange} />
</Field>
)}
<Field>
<Checkbox
label="Include current time range"
name="keepTime"
value={linkSettings.keepTime}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label="Include current template variable values"
name="includeVars"
value={linkSettings.includeVars}
onChange={onChange}
/>
</Field>
<Field>
<Checkbox
label="Open link in new tab"
name="targetBlank"
value={linkSettings.targetBlank}
onChange={onChange}
/>
</Field>
</CollapsableSection>
<Button onClick={onGoBack}>Apply</Button>
</div>
);
return <DashboardLinkForm link={linkSettings} onUpdate={onUpdate} onGoBack={onGoBack} />;
};

View File

@ -1,13 +1,10 @@
import { css } from '@emotion/css';
import React, { useState } from 'react';
import { arrayUtils } from '@grafana/data';
import { DashboardLink } from '@grafana/schema';
import { DeleteButton, HorizontalGroup, Icon, IconButton, TagList, useStyles2 } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { DashboardLinkList } from 'app/features/dashboard-scene/settings/links/DashboardLinkList';
import { DashboardModel } from '../../state/DashboardModel';
import { ListNewButton } from '../DashboardSettings/ListNewButton';
type LinkSettingsListProps = {
dashboard: DashboardModel;
@ -15,9 +12,11 @@ type LinkSettingsListProps = {
onEdit: (idx: number) => void;
};
/**
* Used in DashboardSettings to display the list of links.
* It updates the DashboardModel instance when links are added, edited, duplicated or deleted.
*/
export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListProps) => {
const styles = useStyles2(getStyles);
const [links, setLinks] = useState(dashboard.links);
const moveLink = (idx: number, direction: number) => {
@ -25,7 +24,7 @@ export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListP
setLinks(dashboard.links);
};
const duplicateLink = (link: DashboardLink, idx: number) => {
const duplicateLink = (link: DashboardLink) => {
dashboard.links = [...links, { ...link }];
setLinks(dashboard.links);
};
@ -35,85 +34,14 @@ export const LinkSettingsList = ({ dashboard, onNew, onEdit }: LinkSettingsListP
setLinks(dashboard.links);
};
const isEmptyList = dashboard.links.length === 0;
if (isEmptyList) {
return (
<div>
<EmptyListCTA
onClick={onNew}
title="There are no dashboard links added yet"
buttonIcon="link"
buttonTitle="Add dashboard link"
infoBoxTitle="What are dashboard links?"
infoBox={{
__html:
'<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
}}
/>
</div>
);
}
return (
<>
<table role="grid" className="filter-table filter-table--hover">
<thead>
<tr>
<th>Type</th>
<th>Info</th>
<th colSpan={3} />
</tr>
</thead>
<tbody>
{links.map((link, idx) => (
<tr key={`${link.title}-${idx}`}>
<td role="gridcell" className="pointer" onClick={() => onEdit(idx)}>
<Icon name="external-link-alt" /> &nbsp; {link.type}
</td>
<td role="gridcell">
<HorizontalGroup>
{link.title && <span className={styles.titleWrapper}>{link.title}</span>}
{link.type === 'link' && <span className={styles.urlWrapper}>{link.url}</span>}
{link.type === 'dashboards' && <TagList tags={link.tags ?? []} />}
</HorizontalGroup>
</td>
<td style={{ width: '1%' }} role="gridcell">
{idx !== 0 && <IconButton name="arrow-up" onClick={() => moveLink(idx, -1)} tooltip="Move link up" />}
</td>
<td style={{ width: '1%' }} role="gridcell">
{links.length > 1 && idx !== links.length - 1 ? (
<IconButton name="arrow-down" onClick={() => moveLink(idx, 1)} tooltip="Move link down" />
) : null}
</td>
<td style={{ width: '1%' }} role="gridcell">
<IconButton name="copy" onClick={() => duplicateLink(link, idx)} tooltip="Copy link" />
</td>
<td style={{ width: '1%' }} role="gridcell">
<DeleteButton
aria-label={`Delete link with title "${link.title}"`}
size="sm"
onConfirm={() => deleteLink(idx)}
/>
</td>
</tr>
))}
</tbody>
</table>
<ListNewButton onClick={onNew}>New link</ListNewButton>
</>
<DashboardLinkList
links={links}
onNew={onNew}
onEdit={onEdit}
onDuplicate={duplicateLink}
onDelete={deleteLink}
onOrderChange={moveLink}
/>
);
};
const getStyles = () => ({
titleWrapper: css`
width: 20vw;
text-overflow: ellipsis;
overflow: hidden;
`,
urlWrapper: css`
width: 40vw;
text-overflow: ellipsis;
overflow: hidden;
`,
});

View File

@ -6,10 +6,10 @@ import { selectors } from '@grafana/e2e-selectors';
import { TimeRangeUpdatedEvent } from '@grafana/runtime';
import { DashboardLink } from '@grafana/schema';
import { Tooltip, useForceUpdate } from '@grafana/ui';
import { LINK_ICON_MAP } from 'app/features/dashboard-scene/settings/links/utils';
import { getLinkSrv } from '../../../panel/panellinks/link_srv';
import { DashboardModel } from '../../state';
import { linkIconMap } from '../LinksSettings/LinkSettingsEdit';
import { DashboardLinkButton, DashboardLinksDashboard } from './DashboardLinksDashboard';
@ -40,7 +40,7 @@ export const DashboardLinks = ({ dashboard, links }: Props) => {
return <DashboardLinksDashboard key={key} link={link} linkInfo={linkInfo} dashboardUID={dashboard.uid} />;
}
const icon = linkIconMap[link.icon];
const icon = LINK_ICON_MAP[link.icon];
const linkElement = (
<DashboardLinkButton