mirror of
https://github.com/grafana/grafana.git
synced 2025-02-25 18:55:37 -06:00
TopNav: Dashboard settings (#52682)
* Scenes: Support new top nav * Page: Make Page component support new and old dashboard page layouts * Pass scrollbar props * Fixing flex layout for dashboard * Progress on dashboard settings working with topnav * Updated * Annotations working * Starting to work fully * Fix merge issue * Fixed tests * Added buttons to annotations editor * Updating tests * Move Page component to each page * fixed general settings page * Fixed versions * Fixed annotation item page * Variables section working * Fixed tests * Minor fixes to versions * Update * Fixing unit tests * Adding add variable button * Restore annotations edit form so it's the same as before * Fixed semicolon in dashboard permissions * Fixing unit tests * Fixing tests * Minor test update * Fixing unit test * Fixing e2e tests * fix for e2e test * fix a11y issues * Changing places Settings -> General * Trying to fix a11y * I hope this fixes the e2e test * Fixing merge issue * tweak
This commit is contained in:
@@ -2,15 +2,35 @@ import { within } from '@testing-library/dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { setAngularLoader, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { locationService, setAngularLoader, setDataSourceSrv } from '@grafana/runtime';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
import { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
import { AnnotationsSettings } from './AnnotationsSettings';
|
||||
|
||||
function setup(dashboard: DashboardModel, editIndex?: number) {
|
||||
const sectionNav = {
|
||||
main: { text: 'Dashboard' },
|
||||
node: {
|
||||
text: 'Annotations',
|
||||
},
|
||||
};
|
||||
|
||||
return render(
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<BrowserRouter>
|
||||
<AnnotationsSettings sectionNav={sectionNav} dashboard={dashboard} editIndex={editIndex} />
|
||||
</BrowserRouter>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('AnnotationsSettings', () => {
|
||||
let dashboard: DashboardModel;
|
||||
|
||||
@@ -20,7 +40,6 @@ describe('AnnotationsSettings', () => {
|
||||
name: 'Grafana',
|
||||
uid: 'uid1',
|
||||
type: 'grafana',
|
||||
isDefault: true,
|
||||
},
|
||||
{ annotations: true }
|
||||
),
|
||||
@@ -79,52 +98,28 @@ describe('AnnotationsSettings', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('it renders a header and cta if no annotations or only builtIn annotation', async () => {
|
||||
render(<AnnotationsSettings dashboard={dashboard} />);
|
||||
test('it renders empty list cta if only builtIn annotation', async () => {
|
||||
setup(dashboard);
|
||||
|
||||
expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('link', { name: /annotations documentation/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await userEvent.click(screen.getByRole('cell', { name: /annotations & alerts \(built\-in\)/i }));
|
||||
test('it renders empty list if annotations', async () => {
|
||||
dashboard.annotations.list = [];
|
||||
setup(dashboard);
|
||||
|
||||
const heading = screen.getByRole('heading', {
|
||||
name: /annotations edit/i,
|
||||
});
|
||||
const nameInput = screen.getByRole('textbox', { name: /name/i });
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'My Annotation');
|
||||
|
||||
expect(screen.queryByText(/grafana/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('checkbox', { name: /hidden/i })).toBeChecked();
|
||||
|
||||
await userEvent.click(within(heading).getByText(/annotations/i));
|
||||
|
||||
expect(screen.getByRole('table')).toBeInTheDocument();
|
||||
expect(screen.getByRole('row', { name: /my annotation \(built\-in\) grafana/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /new query/i })).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByLabelText(/Delete query with title/)[0]);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(screen.queryAllByRole('row').length).toBe(0);
|
||||
expect(
|
||||
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders the anotation names or uid if annotation doesnt exist', async () => {
|
||||
const annotationsList = [
|
||||
test('it renders the annotation names or uid if annotation doesnt exist', async () => {
|
||||
dashboard.annotations.list = [
|
||||
...dashboard.annotations.list,
|
||||
{
|
||||
builtIn: 0,
|
||||
@@ -145,20 +140,14 @@ describe('AnnotationsSettings', () => {
|
||||
type: 'dashboard',
|
||||
},
|
||||
];
|
||||
const dashboardWithAnnotations = new DashboardModel({
|
||||
...dashboard,
|
||||
annotations: {
|
||||
list: [...annotationsList],
|
||||
},
|
||||
});
|
||||
render(<AnnotationsSettings dashboard={dashboardWithAnnotations} />);
|
||||
setup(dashboard);
|
||||
// Check that we have the correct annotations
|
||||
expect(screen.queryByText(/prometheus/i)).toBeInTheDocument();
|
||||
expect(screen.queryByText(/deletedAnnotationId/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders a sortable table of annotations', async () => {
|
||||
const annotationsList = [
|
||||
dashboard.annotations.list = [
|
||||
...dashboard.annotations.list,
|
||||
{
|
||||
builtIn: 0,
|
||||
@@ -179,13 +168,9 @@ describe('AnnotationsSettings', () => {
|
||||
type: 'dashboard',
|
||||
},
|
||||
];
|
||||
const dashboardWithAnnotations = new DashboardModel({
|
||||
...dashboard,
|
||||
annotations: {
|
||||
list: [...annotationsList],
|
||||
},
|
||||
});
|
||||
render(<AnnotationsSettings dashboard={dashboardWithAnnotations} />);
|
||||
|
||||
setup(dashboard);
|
||||
|
||||
// Check that we have sorting buttons
|
||||
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument();
|
||||
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).toBeInTheDocument();
|
||||
@@ -211,18 +196,26 @@ describe('AnnotationsSettings', () => {
|
||||
expect(within(getTableBodyRows()[2]).queryByText(/annotations & alerts/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders a form for adding/editing annotations', async () => {
|
||||
render(<AnnotationsSettings dashboard={dashboard} />);
|
||||
test('Adding a new annotation', async () => {
|
||||
setup(dashboard);
|
||||
|
||||
await userEvent.click(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query')));
|
||||
|
||||
const heading = screen.getByRole('heading', {
|
||||
name: /annotations edit/i,
|
||||
expect(locationService.getSearchObject().editIndex).toBe('1');
|
||||
expect(dashboard.annotations.list.length).toBe(2);
|
||||
});
|
||||
|
||||
test('Editing annotation', async () => {
|
||||
dashboard.annotations.list.push({
|
||||
name: 'New annotation query',
|
||||
datasource: { uid: 'uid2', type: 'testdata' },
|
||||
iconColor: 'red',
|
||||
enable: true,
|
||||
});
|
||||
|
||||
setup(dashboard, 1);
|
||||
|
||||
const nameInput = screen.getByRole('textbox', { name: /name/i });
|
||||
|
||||
expect(heading).toBeInTheDocument();
|
||||
|
||||
await userEvent.clear(nameInput);
|
||||
await userEvent.type(nameInput, 'My Prometheus Annotation');
|
||||
|
||||
@@ -234,25 +227,14 @@ describe('AnnotationsSettings', () => {
|
||||
await userEvent.click(screen.getByText(/prometheus/i));
|
||||
|
||||
expect(screen.getByRole('checkbox', { name: /hidden/i })).not.toBeChecked();
|
||||
});
|
||||
|
||||
await userEvent.click(within(heading).getByText(/annotations/i));
|
||||
test('Deleting annotation', async () => {
|
||||
setup(dashboard, 0);
|
||||
|
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
|
||||
expect(screen.queryByRole('row', { name: /my prometheus annotation prometheus/i })).toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /new query/i })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
|
||||
).not.toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /new query/i }));
|
||||
|
||||
await userEvent.click(within(screen.getByRole('heading', { name: /annotations edit/i })).getByText(/annotations/i));
|
||||
|
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(3);
|
||||
|
||||
await userEvent.click(screen.getAllByLabelText(/Delete query with title/)[0]);
|
||||
await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2);
|
||||
expect(locationService.getSearchObject().editIndex).toBe(undefined);
|
||||
expect(dashboard.annotations.list.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
import { AnnotationQuery, getDataSourceRef } from '@grafana/data';
|
||||
import { getDataSourceSrv } from '@grafana/runtime';
|
||||
import { AnnotationQuery, getDataSourceRef, NavModelItem } from '@grafana/data';
|
||||
import { getDataSourceSrv, locationService } from '@grafana/runtime';
|
||||
import { Page } from 'app/core/components/PageNew/Page';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { DashboardModel } from '../../state';
|
||||
import { AnnotationSettingsEdit, AnnotationSettingsList, newAnnotationName } from '../AnnotationSettings';
|
||||
|
||||
import { DashboardSettingsHeader } from './DashboardSettingsHeader';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
|
||||
export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
const [editIdx, setEditIdx] = useState<number | null>(null);
|
||||
|
||||
const onGoBack = () => {
|
||||
setEditIdx(null);
|
||||
};
|
||||
import { SettingsPageProps } from './types';
|
||||
|
||||
export function AnnotationsSettings({ dashboard, editIndex, sectionNav }: SettingsPageProps) {
|
||||
const onNew = () => {
|
||||
const newAnnotation: AnnotationQuery = {
|
||||
name: newAnnotationName,
|
||||
@@ -28,20 +19,34 @@ export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
};
|
||||
|
||||
dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }];
|
||||
setEditIdx(dashboard.annotations.list.length - 1);
|
||||
locationService.partial({ editIndex: dashboard.annotations.list.length - 1 });
|
||||
};
|
||||
|
||||
const onEdit = (idx: number) => {
|
||||
setEditIdx(idx);
|
||||
locationService.partial({ editIndex: idx });
|
||||
};
|
||||
|
||||
const isEditing = editIdx !== null;
|
||||
const isEditing = editIndex != null && editIndex < dashboard.annotations.list.length;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardSettingsHeader title="Annotations" onGoBack={onGoBack} isEditing={isEditing} />
|
||||
<Page navModel={sectionNav} pageNav={getSubPageNav(dashboard, editIndex)}>
|
||||
{!isEditing && <AnnotationSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
|
||||
{isEditing && <AnnotationSettingsEdit dashboard={dashboard} editIdx={editIdx!} />}
|
||||
</>
|
||||
{isEditing && <AnnotationSettingsEdit dashboard={dashboard} editIdx={editIndex!} />}
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
function getSubPageNav(dashboard: DashboardModel, editIndex: number | undefined): NavModelItem | undefined {
|
||||
if (editIndex == null) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const editItem = dashboard.annotations.list[editIndex];
|
||||
if (editItem) {
|
||||
return {
|
||||
text: editItem.name,
|
||||
};
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { Provider } from 'react-redux';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { locationService, setBackendSrv } from '@grafana/runtime';
|
||||
import { NavModel, NavModelItem } from '@grafana/data';
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
import { configureStore } from 'app/store/configureStore';
|
||||
|
||||
import { DashboardModel } from '../../state';
|
||||
@@ -24,7 +26,6 @@ setBackendSrv({
|
||||
|
||||
describe('DashboardSettings', () => {
|
||||
it('pressing escape navigates away correctly', async () => {
|
||||
jest.spyOn(locationService, 'partial');
|
||||
const dashboard = new DashboardModel(
|
||||
{
|
||||
title: 'Foo',
|
||||
@@ -33,23 +34,22 @@ describe('DashboardSettings', () => {
|
||||
folderId: 1,
|
||||
}
|
||||
);
|
||||
|
||||
const store = configureStore();
|
||||
const context = getGrafanaContextMock();
|
||||
const sectionNav: NavModel = { main: { text: 'Dashboards' }, node: { text: 'Dashboards' } };
|
||||
const pageNav: NavModelItem = { text: 'My cool dashboard' };
|
||||
|
||||
render(
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<DashboardSettings editview="settings" dashboard={dashboard} />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
<GrafanaContext.Provider value={context}>
|
||||
<Provider store={store}>
|
||||
<BrowserRouter>
|
||||
<DashboardSettings editview="settings" dashboard={dashboard} sectionNav={sectionNav} pageNav={pageNav} />
|
||||
</BrowserRouter>
|
||||
</Provider>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
(_, el) => el?.tagName.toLowerCase() === 'h1' && /Foo\s*\/\s*Settings/.test(el?.textContent ?? '')
|
||||
)
|
||||
).toBeInTheDocument();
|
||||
|
||||
await userEvent.keyboard('{Escape}');
|
||||
|
||||
expect(locationService.partial).toHaveBeenCalledWith({ editview: null });
|
||||
expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,13 +1,11 @@
|
||||
import { css, cx } from '@emotion/css';
|
||||
import { useDialog } from '@react-aria/dialog';
|
||||
import { FocusScope } from '@react-aria/focus';
|
||||
import { useOverlay } from '@react-aria/overlays';
|
||||
import React, { useCallback, useMemo, useRef } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import * as H from 'history';
|
||||
import React, { useMemo } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
|
||||
import { GrafanaTheme2, locationUtil } from '@grafana/data';
|
||||
import { locationService, reportInteraction } from '@grafana/runtime';
|
||||
import { Button, CustomScrollbar, Icon, IconName, PageToolbar, stylesFactory, useForceUpdate } from '@grafana/ui';
|
||||
import { locationUtil, NavModel, NavModelItem } from '@grafana/data';
|
||||
import { locationService } from '@grafana/runtime';
|
||||
import { Button, PageToolbar } from '@grafana/ui';
|
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
|
||||
import config from 'app/core/config';
|
||||
import { contextSrv } from 'app/core/services/context_srv';
|
||||
import { AccessControlAction } from 'app/types';
|
||||
@@ -23,132 +21,19 @@ import { GeneralSettings } from './GeneralSettings';
|
||||
import { JsonEditorSettings } from './JsonEditorSettings';
|
||||
import { LinksSettings } from './LinksSettings';
|
||||
import { VersionsSettings } from './VersionsSettings';
|
||||
import { SettingsPage, SettingsPageProps } from './types';
|
||||
|
||||
export interface Props {
|
||||
dashboard: DashboardModel;
|
||||
sectionNav: NavModel;
|
||||
pageNav: NavModelItem;
|
||||
editview: string;
|
||||
}
|
||||
|
||||
export interface SettingsPage {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
component: React.ReactNode;
|
||||
}
|
||||
const onClose = () => locationService.partial({ editview: null, editIndex: null });
|
||||
|
||||
const onClose = () => locationService.partial({ editview: null });
|
||||
|
||||
const MakeEditable = (props: { onMakeEditable: () => any }) => (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">Dashboard not editable</div>
|
||||
<Button type="submit" onClick={props.onMakeEditable}>
|
||||
Make editable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
export function DashboardSettings({ dashboard, editview }: Props) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
const { overlayProps } = useOverlay(
|
||||
{
|
||||
isOpen: true,
|
||||
onClose,
|
||||
},
|
||||
ref
|
||||
);
|
||||
const { dialogProps } = useDialog(
|
||||
{
|
||||
'aria-label': 'Dashboard settings',
|
||||
},
|
||||
ref
|
||||
);
|
||||
const forceUpdate = useForceUpdate();
|
||||
const onMakeEditable = useCallback(() => {
|
||||
dashboard.editable = true;
|
||||
dashboard.meta.canMakeEditable = false;
|
||||
dashboard.meta.canEdit = true;
|
||||
dashboard.meta.canSave = true;
|
||||
forceUpdate();
|
||||
}, [dashboard, forceUpdate]);
|
||||
|
||||
const pages = useMemo((): SettingsPage[] => {
|
||||
const pages: SettingsPage[] = [];
|
||||
|
||||
if (dashboard.meta.canEdit) {
|
||||
pages.push({
|
||||
title: 'General',
|
||||
id: 'settings',
|
||||
icon: 'sliders-v-alt',
|
||||
component: <GeneralSettings dashboard={dashboard} />,
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Annotations',
|
||||
id: 'annotations',
|
||||
icon: 'comment-alt',
|
||||
component: <AnnotationsSettings dashboard={dashboard} />,
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Variables',
|
||||
id: 'templating',
|
||||
icon: 'calculator-alt',
|
||||
component: <VariableEditorContainer dashboard={dashboard} />,
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Links',
|
||||
id: 'links',
|
||||
icon: 'link',
|
||||
component: <LinksSettings dashboard={dashboard} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.meta.canMakeEditable) {
|
||||
pages.push({
|
||||
title: 'General',
|
||||
icon: 'sliders-v-alt',
|
||||
id: 'settings',
|
||||
component: <MakeEditable onMakeEditable={onMakeEditable} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.id && dashboard.meta.canSave) {
|
||||
pages.push({
|
||||
title: 'Versions',
|
||||
id: 'versions',
|
||||
icon: 'history',
|
||||
component: <VersionsSettings dashboard={dashboard} />,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.id && dashboard.meta.canAdmin) {
|
||||
if (!config.rbacEnabled) {
|
||||
pages.push({
|
||||
title: 'Permissions',
|
||||
id: 'permissions',
|
||||
icon: 'lock',
|
||||
component: <DashboardPermissions dashboard={dashboard} />,
|
||||
});
|
||||
} else if (contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsRead)) {
|
||||
pages.push({
|
||||
title: 'Permissions',
|
||||
id: 'permissions',
|
||||
icon: 'lock',
|
||||
component: <AccessControlDashboardPermissions dashboard={dashboard} />,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pages.push({
|
||||
title: 'JSON Model',
|
||||
id: 'dashboard_json',
|
||||
icon: 'arrow',
|
||||
component: <JsonEditorSettings dashboard={dashboard} />,
|
||||
});
|
||||
|
||||
return pages;
|
||||
}, [dashboard, onMakeEditable]);
|
||||
export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }: Props) {
|
||||
const pages = useMemo(() => getSettingsPages(dashboard), [dashboard]);
|
||||
|
||||
const onPostSave = () => {
|
||||
dashboard.meta.hasUnsavedFolderChange = false;
|
||||
@@ -158,69 +43,183 @@ export function DashboardSettings({ dashboard, editview }: Props) {
|
||||
const currentPage = pages.find((page) => page.id === editview) ?? pages[0];
|
||||
const canSaveAs = contextSrv.hasEditPermissionInFolders;
|
||||
const canSave = dashboard.meta.canSave;
|
||||
const styles = getStyles(config.theme2);
|
||||
const location = useLocation();
|
||||
const editIndex = getEditIndex(location);
|
||||
const subSectionNav = getSectionNav(pageNav, sectionNav, pages, currentPage, location);
|
||||
|
||||
const actions = [
|
||||
canSaveAs && (
|
||||
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onPostSave} variant="secondary" key="save as" />
|
||||
),
|
||||
canSave && <SaveDashboardButton dashboard={dashboard} onSaveSuccess={onPostSave} key="Save" />,
|
||||
];
|
||||
|
||||
return (
|
||||
<FocusScope contain autoFocus>
|
||||
<div className="dashboard-settings" ref={ref} {...overlayProps} {...dialogProps}>
|
||||
<PageToolbar
|
||||
className={styles.toolbar}
|
||||
title={dashboard.title}
|
||||
section="Settings"
|
||||
parent={folderTitle}
|
||||
onGoBack={onClose}
|
||||
/>
|
||||
<CustomScrollbar>
|
||||
<div className={styles.scrollInner}>
|
||||
<div className={styles.settingsWrapper}>
|
||||
<aside className="dashboard-settings__aside">
|
||||
{pages.map((page) => (
|
||||
<Link
|
||||
onClick={() => reportInteraction(`Dashboard settings navigation to ${page.id}`)}
|
||||
to={(loc) => locationUtil.getUrlForPartial(loc, { editview: page.id })}
|
||||
className={cx('dashboard-settings__nav-item', { active: page.id === editview })}
|
||||
key={page.id}
|
||||
>
|
||||
<Icon name={page.icon} style={{ marginRight: '4px' }} />
|
||||
{page.title}
|
||||
</Link>
|
||||
))}
|
||||
<div className="dashboard-settings__aside-actions">
|
||||
{canSave && <SaveDashboardButton dashboard={dashboard} onSaveSuccess={onPostSave} />}
|
||||
{canSaveAs && (
|
||||
<SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onPostSave} variant="secondary" />
|
||||
)}
|
||||
</div>
|
||||
</aside>
|
||||
<div className={styles.settingsContent}>{currentPage.component}</div>
|
||||
</div>
|
||||
</div>
|
||||
</CustomScrollbar>
|
||||
</div>
|
||||
</FocusScope>
|
||||
<>
|
||||
{!config.featureToggles.topnav ? (
|
||||
<PageToolbar title={`${dashboard.title} / Settings`} parent={folderTitle} onGoBack={onClose}>
|
||||
{actions}
|
||||
</PageToolbar>
|
||||
) : (
|
||||
<AppChromeUpdate actions={actions} />
|
||||
)}
|
||||
<currentPage.component sectionNav={subSectionNav} dashboard={dashboard} editIndex={editIndex} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({
|
||||
scrollInner: css`
|
||||
min-width: 100%;
|
||||
display: flex;
|
||||
`,
|
||||
toolbar: css`
|
||||
width: 60vw;
|
||||
min-width: min-content;
|
||||
`,
|
||||
settingsWrapper: css`
|
||||
margin: ${theme.spacing(0, 2, 2)};
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
`,
|
||||
settingsContent: css`
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
padding: 32px;
|
||||
border: 1px solid ${theme.colors.border.weak};
|
||||
background: ${theme.colors.background.primary};
|
||||
border-radius: ${theme.shape.borderRadius()};
|
||||
`,
|
||||
}));
|
||||
function getSettingsPages(dashboard: DashboardModel) {
|
||||
const pages: SettingsPage[] = [];
|
||||
|
||||
if (dashboard.meta.canEdit) {
|
||||
pages.push({
|
||||
title: 'General',
|
||||
id: 'settings',
|
||||
icon: 'sliders-v-alt',
|
||||
component: GeneralSettings,
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Annotations',
|
||||
id: 'annotations',
|
||||
icon: 'comment-alt',
|
||||
component: AnnotationsSettings,
|
||||
subTitle:
|
||||
'Annotation queries return events that can be visualized as event markers in graphs across the dashboard.',
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Variables',
|
||||
id: 'templating',
|
||||
icon: 'calculator-alt',
|
||||
component: VariableEditorContainer,
|
||||
subTitle: 'Variables can make your dashboard more dynamic and act as global filters.',
|
||||
});
|
||||
|
||||
pages.push({
|
||||
title: 'Links',
|
||||
id: 'links',
|
||||
icon: 'link',
|
||||
component: LinksSettings,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.meta.canMakeEditable) {
|
||||
pages.push({
|
||||
title: 'General',
|
||||
icon: 'sliders-v-alt',
|
||||
id: 'settings',
|
||||
component: MakeEditable,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.id && dashboard.meta.canSave) {
|
||||
pages.push({
|
||||
title: 'Versions',
|
||||
id: 'versions',
|
||||
icon: 'history',
|
||||
component: VersionsSettings,
|
||||
});
|
||||
}
|
||||
|
||||
if (dashboard.id && dashboard.meta.canAdmin) {
|
||||
if (!config.rbacEnabled) {
|
||||
pages.push({
|
||||
title: 'Permissions',
|
||||
id: 'permissions',
|
||||
icon: 'lock',
|
||||
component: DashboardPermissions,
|
||||
});
|
||||
} else if (contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsRead)) {
|
||||
pages.push({
|
||||
title: 'Permissions',
|
||||
id: 'permissions',
|
||||
icon: 'lock',
|
||||
component: AccessControlDashboardPermissions,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pages.push({
|
||||
title: 'JSON Model',
|
||||
id: 'dashboard_json',
|
||||
icon: 'arrow',
|
||||
component: JsonEditorSettings,
|
||||
});
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function getSectionNav(
|
||||
pageNav: NavModelItem,
|
||||
sectionNav: NavModel,
|
||||
pages: SettingsPage[],
|
||||
currentPage: SettingsPage,
|
||||
location: H.Location
|
||||
): NavModel {
|
||||
const main: NavModelItem = {
|
||||
text: 'Settings',
|
||||
children: [],
|
||||
icon: 'apps',
|
||||
hideFromBreadcrumbs: true,
|
||||
};
|
||||
|
||||
main.children = pages.map((page) => ({
|
||||
text: page.title,
|
||||
icon: page.icon,
|
||||
id: page.id,
|
||||
url: locationUtil.getUrlForPartial(location, { editview: page.id, editIndex: null }),
|
||||
active: page === currentPage,
|
||||
parentItem: main,
|
||||
subTitle: page.subTitle,
|
||||
}));
|
||||
|
||||
if (pageNav.parentItem) {
|
||||
pageNav = {
|
||||
...pageNav,
|
||||
parentItem: {
|
||||
...pageNav.parentItem,
|
||||
parentItem: sectionNav.node,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
pageNav = {
|
||||
...pageNav,
|
||||
parentItem: sectionNav.node,
|
||||
};
|
||||
}
|
||||
|
||||
main.parentItem = pageNav;
|
||||
|
||||
return {
|
||||
main,
|
||||
node: main.children.find((x) => x.active)!,
|
||||
};
|
||||
}
|
||||
|
||||
function MakeEditable({ dashboard }: SettingsPageProps) {
|
||||
const onMakeEditable = () => {
|
||||
dashboard.editable = true;
|
||||
dashboard.meta.canMakeEditable = false;
|
||||
dashboard.meta.canEdit = true;
|
||||
dashboard.meta.canSave = true;
|
||||
// TODO add some kind of reload
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="dashboard-settings__header">Dashboard not editable</div>
|
||||
<Button type="submit" onClick={onMakeEditable}>
|
||||
Make editable
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function getEditIndex(location: H.Location): number | undefined {
|
||||
const editIndex = new URLSearchParams(location.search).get('editIndex');
|
||||
if (editIndex != null) {
|
||||
return parseInt(editIndex, 10);
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
|
||||
import { config } from '@grafana/runtime';
|
||||
import { Icon, HorizontalGroup } from '@grafana/ui';
|
||||
|
||||
type Props = {
|
||||
@@ -9,6 +10,10 @@ type Props = {
|
||||
};
|
||||
|
||||
export const DashboardSettingsHeader: React.FC<Props> = ({ onGoBack, isEditing, title }) => {
|
||||
if (config.featureToggles.topnav) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="dashboard-settings__header">
|
||||
<HorizontalGroup align="center" justify="space-between">
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
import { byRole } from 'testing-library-selector';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { setBackendSrv } from '@grafana/runtime';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
|
||||
import { DashboardModel } from '../../state';
|
||||
|
||||
@@ -34,10 +37,23 @@ const setupTestContext = (options: Partial<Props>) => {
|
||||
),
|
||||
updateTimeZone: jest.fn(),
|
||||
updateWeekStart: jest.fn(),
|
||||
sectionNav: {
|
||||
main: { text: 'Dashboard' },
|
||||
node: {
|
||||
text: 'Settings',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const props = { ...defaults, ...options };
|
||||
const { rerender } = render(<GeneralSettings {...props} />);
|
||||
|
||||
const { rerender } = render(
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<BrowserRouter>
|
||||
<GeneralSettings {...props} />
|
||||
</BrowserRouter>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
|
||||
return { rerender, props };
|
||||
};
|
||||
|
||||
@@ -2,23 +2,19 @@ import React, { useState } from 'react';
|
||||
import { connect, ConnectedProps } from 'react-redux';
|
||||
|
||||
import { TimeZone } from '@grafana/data';
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { config } from '@grafana/runtime';
|
||||
import { CollapsableSection, Field, Input, RadioButtonGroup, TagsInput } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/PageNew/Page';
|
||||
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
|
||||
import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton';
|
||||
|
||||
import { PreviewSettings } from './PreviewSettings';
|
||||
import { TimePickerSettings } from './TimePickerSettings';
|
||||
import { SettingsPageProps } from './types';
|
||||
|
||||
interface OwnProps {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
|
||||
export type Props = OwnProps & ConnectedProps<typeof connector>;
|
||||
export type Props = SettingsPageProps & ConnectedProps<typeof connector>;
|
||||
|
||||
const GRAPH_TOOLTIP_OPTIONS = [
|
||||
{ value: 0, label: 'Default' },
|
||||
@@ -26,7 +22,12 @@ const GRAPH_TOOLTIP_OPTIONS = [
|
||||
{ value: 2, label: 'Shared Tooltip' },
|
||||
];
|
||||
|
||||
export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWeekStart }: Props): JSX.Element {
|
||||
export function GeneralSettingsUnconnected({
|
||||
dashboard,
|
||||
updateTimeZone,
|
||||
updateWeekStart,
|
||||
sectionNav,
|
||||
}: Props): JSX.Element {
|
||||
const [renderCounter, setRenderCounter] = useState(0);
|
||||
|
||||
const onFolderChange = (folder: { id: number; title: string }) => {
|
||||
@@ -90,73 +91,76 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
|
||||
];
|
||||
|
||||
return (
|
||||
<div style={{ maxWidth: '600px' }}>
|
||||
<h3 className="dashboard-settings__header" aria-label={selectors.pages.Dashboard.Settings.General.title}>
|
||||
General
|
||||
</h3>
|
||||
<div className="gf-form-group">
|
||||
<Field label="Name">
|
||||
<Input id="title-input" name="title" onBlur={onBlur} defaultValue={dashboard.title} />
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<Input id="description-input" name="description" onBlur={onBlur} defaultValue={dashboard.description} />
|
||||
</Field>
|
||||
<Field label="Tags">
|
||||
<TagsInput id="tags-input" tags={dashboard.tags} onChange={onTagsChange} />
|
||||
</Field>
|
||||
<Field label="Folder">
|
||||
<FolderPicker
|
||||
inputId="dashboard-folder-input"
|
||||
initialTitle={dashboard.meta.folderTitle}
|
||||
initialFolderId={dashboard.meta.folderId}
|
||||
onChange={onFolderChange}
|
||||
enableCreateNew={true}
|
||||
dashboardId={dashboard.id}
|
||||
skipInitialLoad={true}
|
||||
/>
|
||||
</Field>
|
||||
<Page navModel={sectionNav}>
|
||||
<div style={{ maxWidth: '600px' }}>
|
||||
<div className="gf-form-group">
|
||||
<Field label="Name">
|
||||
<Input id="title-input" name="title" onBlur={onBlur} defaultValue={dashboard.title} />
|
||||
</Field>
|
||||
<Field label="Description">
|
||||
<Input id="description-input" name="description" onBlur={onBlur} defaultValue={dashboard.description} />
|
||||
</Field>
|
||||
<Field label="Tags">
|
||||
<TagsInput id="tags-input" tags={dashboard.tags} onChange={onTagsChange} />
|
||||
</Field>
|
||||
<Field label="Folder">
|
||||
<FolderPicker
|
||||
inputId="dashboard-folder-input"
|
||||
initialTitle={dashboard.meta.folderTitle}
|
||||
initialFolderId={dashboard.meta.folderId}
|
||||
onChange={onFolderChange}
|
||||
enableCreateNew={true}
|
||||
dashboardId={dashboard.id}
|
||||
skipInitialLoad={true}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field
|
||||
label="Editable"
|
||||
description="Set to read-only to disable all editing. Reload the dashboard for changes to take effect"
|
||||
>
|
||||
<RadioButtonGroup value={dashboard.editable} options={editableOptions} onChange={onEditableChange} />
|
||||
</Field>
|
||||
<Field
|
||||
label="Editable"
|
||||
description="Set to read-only to disable all editing. Reload the dashboard for changes to take effect"
|
||||
>
|
||||
<RadioButtonGroup value={dashboard.editable} options={editableOptions} onChange={onEditableChange} />
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && (
|
||||
<PreviewSettings uid={dashboard.uid} />
|
||||
)}
|
||||
|
||||
<TimePickerSettings
|
||||
onTimeZoneChange={onTimeZoneChange}
|
||||
onWeekStartChange={onWeekStartChange}
|
||||
onRefreshIntervalChange={onRefreshIntervalChange}
|
||||
onNowDelayChange={onNowDelayChange}
|
||||
onHideTimePickerChange={onHideTimePickerChange}
|
||||
onLiveNowChange={onLiveNowChange}
|
||||
refreshIntervals={dashboard.timepicker.refresh_intervals}
|
||||
timePickerHidden={dashboard.timepicker.hidden}
|
||||
nowDelay={dashboard.timepicker.nowDelay}
|
||||
timezone={dashboard.timezone}
|
||||
weekStart={dashboard.weekStart}
|
||||
liveNow={dashboard.liveNow}
|
||||
/>
|
||||
|
||||
{/* @todo: Update "Graph tooltip" description to remove prompt about reloading when resolving #46581 */}
|
||||
<CollapsableSection label="Panel options" isOpen={true}>
|
||||
<Field
|
||||
label="Graph tooltip"
|
||||
description="Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect"
|
||||
>
|
||||
<RadioButtonGroup
|
||||
onChange={onTooltipChange}
|
||||
options={GRAPH_TOOLTIP_OPTIONS}
|
||||
value={dashboard.graphTooltip}
|
||||
/>
|
||||
</Field>
|
||||
</CollapsableSection>
|
||||
|
||||
<div className="gf-form-button-row">
|
||||
{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && (
|
||||
<PreviewSettings uid={dashboard.uid} />
|
||||
)}
|
||||
|
||||
<TimePickerSettings
|
||||
onTimeZoneChange={onTimeZoneChange}
|
||||
onWeekStartChange={onWeekStartChange}
|
||||
onRefreshIntervalChange={onRefreshIntervalChange}
|
||||
onNowDelayChange={onNowDelayChange}
|
||||
onHideTimePickerChange={onHideTimePickerChange}
|
||||
onLiveNowChange={onLiveNowChange}
|
||||
refreshIntervals={dashboard.timepicker.refresh_intervals}
|
||||
timePickerHidden={dashboard.timepicker.hidden}
|
||||
nowDelay={dashboard.timepicker.nowDelay}
|
||||
timezone={dashboard.timezone}
|
||||
weekStart={dashboard.weekStart}
|
||||
liveNow={dashboard.liveNow}
|
||||
/>
|
||||
|
||||
{/* @todo: Update "Graph tooltip" description to remove prompt about reloading when resolving #46581 */}
|
||||
<CollapsableSection label="Panel options" isOpen={true}>
|
||||
<Field
|
||||
label="Graph tooltip"
|
||||
description="Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect"
|
||||
>
|
||||
<RadioButtonGroup onChange={onTooltipChange} options={GRAPH_TOOLTIP_OPTIONS} value={dashboard.graphTooltip} />
|
||||
</Field>
|
||||
</CollapsableSection>
|
||||
|
||||
<div className="gf-form-button-row">
|
||||
{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}
|
||||
</div>
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -3,17 +3,15 @@ import React, { useState } from 'react';
|
||||
import AutoSizer from 'react-virtualized-auto-sizer';
|
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data';
|
||||
import { Button, CodeEditor, HorizontalGroup, useStyles2 } from '@grafana/ui';
|
||||
import { Button, CodeEditor, Stack, useStyles2 } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/PageNew/Page';
|
||||
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
|
||||
|
||||
import { getDashboardSrv } from '../../services/DashboardSrv';
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
import { SettingsPageProps } from './types';
|
||||
|
||||
export const JsonEditorSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
export function JsonEditorSettings({ dashboard, sectionNav }: SettingsPageProps) {
|
||||
const [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboard.getSaveModelClone(), null, 2));
|
||||
const onBlur = (value: string) => {
|
||||
setDashboardJson(value);
|
||||
@@ -28,44 +26,41 @@ export const JsonEditorSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
};
|
||||
|
||||
const styles = useStyles2(getStyles);
|
||||
const subTitle =
|
||||
'The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, panel settings, layout, queries, and so on';
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h3 className="dashboard-settings__header">JSON Model</h3>
|
||||
<div className="dashboard-settings__subheader">
|
||||
The JSON model below is the data structure that defines the dashboard. This includes dashboard settings, panel
|
||||
settings, layout, queries, and so on.
|
||||
</div>
|
||||
<Page navModel={sectionNav} subTitle={subTitle}>
|
||||
<div className="dashboard-settings__subheader"></div>
|
||||
|
||||
<div className={styles.editWrapper}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<CodeEditor
|
||||
value={dashboardJson}
|
||||
language="json"
|
||||
width={width}
|
||||
height={height}
|
||||
showMiniMap={true}
|
||||
showLineNumbers={true}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
<Stack direction="column" gap={4} flexGrow={1}>
|
||||
<div className={styles.editWrapper}>
|
||||
<AutoSizer>
|
||||
{({ width, height }) => (
|
||||
<CodeEditor
|
||||
value={dashboardJson}
|
||||
language="json"
|
||||
width={width}
|
||||
height={height}
|
||||
showMiniMap={true}
|
||||
showLineNumbers={true}
|
||||
onBlur={onBlur}
|
||||
/>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
<div>
|
||||
{dashboard.meta.canSave && (
|
||||
<Button type="submit" onClick={onClick}>
|
||||
Save changes
|
||||
</Button>
|
||||
)}
|
||||
</AutoSizer>
|
||||
</div>
|
||||
{dashboard.meta.canSave && (
|
||||
<HorizontalGroup>
|
||||
<Button type="submit" onClick={onClick}>
|
||||
Save changes
|
||||
</Button>
|
||||
</HorizontalGroup>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Stack>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
const getStyles = (theme: GrafanaTheme2) => ({
|
||||
editWrapper: css`
|
||||
height: calc(100vh - 250px);
|
||||
margin-bottom: 10px;
|
||||
`,
|
||||
const getStyles = (_: GrafanaTheme2) => ({
|
||||
editWrapper: css({ flexGrow: 1 }),
|
||||
});
|
||||
|
||||
@@ -2,72 +2,88 @@ import { within } from '@testing-library/dom';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { selectors } from '@grafana/e2e-selectors';
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
|
||||
import { DashboardModel } from '../../state';
|
||||
|
||||
import { LinksSettings } from './LinksSettings';
|
||||
|
||||
describe('LinksSettings', () => {
|
||||
let dashboard = {};
|
||||
const links = [
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: [],
|
||||
targetBlank: false,
|
||||
title: 'link 1',
|
||||
tooltip: '',
|
||||
type: 'link',
|
||||
url: 'https://www.google.com',
|
||||
function setup(dashboard: DashboardModel) {
|
||||
const sectionNav = {
|
||||
main: { text: 'Dashboard' },
|
||||
node: {
|
||||
text: 'Links',
|
||||
},
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: ['gdev'],
|
||||
targetBlank: false,
|
||||
title: 'link 2',
|
||||
tooltip: '',
|
||||
type: 'dashboards',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: [],
|
||||
targetBlank: false,
|
||||
title: '',
|
||||
tooltip: '',
|
||||
type: 'link',
|
||||
url: 'https://www.bing.com',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
return render(
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<BrowserRouter>
|
||||
<LinksSettings dashboard={dashboard} sectionNav={sectionNav} />
|
||||
</BrowserRouter>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
function buildTestDashboard() {
|
||||
return new DashboardModel({
|
||||
links: [
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: [],
|
||||
targetBlank: false,
|
||||
title: 'link 1',
|
||||
tooltip: '',
|
||||
type: 'link',
|
||||
url: 'https://www.google.com',
|
||||
},
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: ['gdev'],
|
||||
targetBlank: false,
|
||||
title: 'link 2',
|
||||
tooltip: '',
|
||||
type: 'dashboards',
|
||||
url: '',
|
||||
},
|
||||
{
|
||||
asDropdown: false,
|
||||
icon: 'external link',
|
||||
includeVars: false,
|
||||
keepTime: false,
|
||||
tags: [],
|
||||
targetBlank: false,
|
||||
title: '',
|
||||
tooltip: '',
|
||||
type: 'link',
|
||||
url: 'https://www.bing.com',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
describe('LinksSettings', () => {
|
||||
const getTableBody = () => screen.getAllByRole('rowgroup')[1];
|
||||
const getTableBodyRows = () => within(getTableBody()).getAllByRole('row');
|
||||
const assertRowHasText = (index: number, text: string) => {
|
||||
expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument();
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
dashboard = {
|
||||
id: 74,
|
||||
version: 7,
|
||||
links: [...links],
|
||||
};
|
||||
});
|
||||
|
||||
test('it renders a header and cta if no links', () => {
|
||||
const linklessDashboard = { ...dashboard, links: [] };
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={linklessDashboard} />);
|
||||
const linklessDashboard = new DashboardModel({ links: [] });
|
||||
setup(linklessDashboard);
|
||||
|
||||
expect(screen.getByRole('heading', { name: 'Dashboard links' })).toBeInTheDocument();
|
||||
expect(screen.getByRole('heading', { name: 'Links' })).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
|
||||
).toBeInTheDocument();
|
||||
@@ -75,18 +91,19 @@ describe('LinksSettings', () => {
|
||||
});
|
||||
|
||||
test('it renders a table of links', () => {
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={dashboard} />);
|
||||
const dashboard = buildTestDashboard();
|
||||
setup(dashboard);
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length);
|
||||
expect(getTableBodyRows().length).toBe(dashboard.links.length);
|
||||
expect(
|
||||
screen.queryByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it rearranges the order of dashboard links', async () => {
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={dashboard} />);
|
||||
const dashboard = buildTestDashboard();
|
||||
const links = dashboard.links;
|
||||
setup(dashboard);
|
||||
|
||||
// Check that we have sorting buttons
|
||||
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-up' })).not.toBeInTheDocument();
|
||||
@@ -114,33 +131,36 @@ describe('LinksSettings', () => {
|
||||
});
|
||||
|
||||
test('it duplicates dashboard links', async () => {
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={dashboard} />);
|
||||
const dashboard = buildTestDashboard();
|
||||
setup(dashboard);
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length);
|
||||
expect(getTableBodyRows().length).toBe(dashboard.links.length);
|
||||
|
||||
await userEvent.click(within(getTableBody()).getAllByRole('button', { name: /copy/i })[0]);
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length + 1);
|
||||
expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2);
|
||||
expect(getTableBodyRows().length).toBe(4);
|
||||
expect(within(getTableBody()).getAllByText(dashboard.links[0].title).length).toBe(2);
|
||||
});
|
||||
|
||||
test('it deletes dashboard links', async () => {
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={dashboard} />);
|
||||
const dashboard = buildTestDashboard();
|
||||
const originalLinks = dashboard.links;
|
||||
setup(dashboard);
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length);
|
||||
expect(getTableBodyRows().length).toBe(dashboard.links.length);
|
||||
|
||||
await userEvent.click(within(getTableBody()).getAllByLabelText(/Delete link with title/)[0]);
|
||||
await userEvent.click(within(getTableBody()).getByRole('button', { name: 'Delete' }));
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length - 1);
|
||||
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
|
||||
expect(getTableBodyRows().length).toBe(2);
|
||||
expect(within(getTableBody()).queryByText(originalLinks[0].title)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('it renders a form which modifies dashboard links', async () => {
|
||||
// @ts-ignore
|
||||
render(<LinksSettings dashboard={dashboard} />);
|
||||
const dashboard = buildTestDashboard();
|
||||
const originalLinks = dashboard.links;
|
||||
setup(dashboard);
|
||||
|
||||
await userEvent.click(screen.getByRole('button', { name: /new/i }));
|
||||
|
||||
expect(screen.queryByText('Type')).toBeInTheDocument();
|
||||
@@ -164,21 +184,19 @@ describe('LinksSettings', () => {
|
||||
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link');
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i)
|
||||
);
|
||||
|
||||
expect(getTableBodyRows().length).toBe(links.length + 1);
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
|
||||
expect(getTableBodyRows().length).toBe(4);
|
||||
expect(within(getTableBody()).queryByText('New Dashboard Link')).toBeInTheDocument();
|
||||
|
||||
await userEvent.click(screen.getAllByText(links[0].type)[0]);
|
||||
await userEvent.click(screen.getAllByText(dashboard.links[0].type)[0]);
|
||||
await userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
|
||||
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link');
|
||||
await userEvent.click(
|
||||
within(screen.getByRole('heading', { name: /dashboard links edit/i })).getByText(/dashboard links/i)
|
||||
);
|
||||
|
||||
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument();
|
||||
await userEvent.click(screen.getByRole('button', { name: /Apply/i }));
|
||||
|
||||
expect(within(getTableBody()).queryByText(originalLinks[0].title)).not.toBeInTheDocument();
|
||||
expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,17 +1,15 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { Page } from 'app/core/components/PageNew/Page';
|
||||
|
||||
import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings';
|
||||
import { newLink } from '../LinksSettings/LinkSettingsEdit';
|
||||
|
||||
import { DashboardSettingsHeader } from './DashboardSettingsHeader';
|
||||
interface Props {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
import { SettingsPageProps } from './types';
|
||||
|
||||
export type LinkSettingsMode = 'list' | 'new' | 'edit';
|
||||
|
||||
export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
export function LinksSettings({ dashboard, sectionNav }: SettingsPageProps) {
|
||||
const [editIdx, setEditIdx] = useState<number | null>(null);
|
||||
|
||||
const onGoBack = () => {
|
||||
@@ -30,10 +28,9 @@ export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
|
||||
const isEditing = editIdx !== null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<DashboardSettingsHeader onGoBack={onGoBack} title="Dashboard links" isEditing={isEditing} />
|
||||
<Page navModel={sectionNav}>
|
||||
{!isEditing && <LinkSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
|
||||
{isEditing && <LinkSettingsEdit dashboard={dashboard} editLinkIdx={editIdx!} onGoBack={onGoBack} />}
|
||||
</>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,6 +3,10 @@ import { render, screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { UserEvent } from '@testing-library/user-event/dist/types/setup';
|
||||
import React from 'react';
|
||||
import { BrowserRouter } from 'react-router-dom';
|
||||
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
|
||||
|
||||
import { GrafanaContext } from 'app/core/context/GrafanaContext';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import { historySrv } from '../VersionHistory/HistorySrv';
|
||||
@@ -23,7 +27,7 @@ const queryByFullText = (text: string) =>
|
||||
return false;
|
||||
});
|
||||
|
||||
describe('VersionSettings', () => {
|
||||
function setup() {
|
||||
const dashboard = new DashboardModel({
|
||||
id: 74,
|
||||
version: 11,
|
||||
@@ -31,6 +35,23 @@ describe('VersionSettings', () => {
|
||||
getRelativeTime: jest.fn(() => 'time ago'),
|
||||
});
|
||||
|
||||
const sectionNav = {
|
||||
main: { text: 'Dashboard' },
|
||||
node: {
|
||||
text: 'Versions',
|
||||
},
|
||||
};
|
||||
|
||||
return render(
|
||||
<GrafanaContext.Provider value={getGrafanaContextMock()}>
|
||||
<BrowserRouter>
|
||||
<VersionsSettings sectionNav={sectionNav} dashboard={dashboard} />
|
||||
</BrowserRouter>
|
||||
</GrafanaContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
describe('VersionSettings', () => {
|
||||
let user: UserEvent;
|
||||
|
||||
beforeEach(() => {
|
||||
@@ -48,7 +69,7 @@ describe('VersionSettings', () => {
|
||||
test('renders a header and a loading indicator followed by results in a table', async () => {
|
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions);
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument();
|
||||
expect(screen.queryByText(/fetching history list/i)).toBeInTheDocument();
|
||||
@@ -67,7 +88,7 @@ describe('VersionSettings', () => {
|
||||
test('does not render buttons if versions === 1', async () => {
|
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1));
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument();
|
||||
@@ -81,7 +102,7 @@ describe('VersionSettings', () => {
|
||||
test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => {
|
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5));
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions|/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument();
|
||||
@@ -95,7 +116,7 @@ describe('VersionSettings', () => {
|
||||
test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => {
|
||||
// @ts-ignore
|
||||
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT));
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(screen.queryByRole('button', { name: /show more versions/i })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole('button', { name: /compare versions/i })).not.toBeInTheDocument();
|
||||
@@ -119,7 +140,7 @@ describe('VersionSettings', () => {
|
||||
() => new Promise((resolve) => setTimeout(() => resolve(versions.slice(VERSIONS_FETCH_LIMIT)), 1000))
|
||||
);
|
||||
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(1);
|
||||
|
||||
@@ -148,7 +169,7 @@ describe('VersionSettings', () => {
|
||||
.mockImplementationOnce(() => Promise.resolve(diffs.lhs))
|
||||
.mockImplementationOnce(() => Promise.resolve(diffs.rhs));
|
||||
|
||||
render(<VersionsSettings dashboard={dashboard} />);
|
||||
setup();
|
||||
|
||||
expect(historySrv.getHistoryList).toBeCalledTimes(1);
|
||||
|
||||
@@ -163,7 +184,7 @@ describe('VersionSettings', () => {
|
||||
|
||||
await user.click(compareButton);
|
||||
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /versions comparing 2 11/i })).toBeInTheDocument());
|
||||
await waitFor(() => expect(screen.getByRole('heading', { name: /comparing 2 11/i })).toBeInTheDocument());
|
||||
|
||||
expect(queryByFullText('Version 11 updated by admin')).toBeInTheDocument();
|
||||
expect(queryByFullText('Version 2 updated by admin')).toBeInTheDocument();
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import React, { PureComponent } from 'react';
|
||||
|
||||
import { Spinner, HorizontalGroup } from '@grafana/ui';
|
||||
import { Page } from 'app/core/components/PageNew/Page';
|
||||
|
||||
import { DashboardModel } from '../../state/DashboardModel';
|
||||
import {
|
||||
historySrv,
|
||||
RevisionsModel,
|
||||
@@ -12,9 +12,9 @@ import {
|
||||
VersionHistoryComparison,
|
||||
} from '../VersionHistory';
|
||||
|
||||
interface Props {
|
||||
dashboard: DashboardModel;
|
||||
}
|
||||
import { SettingsPageProps } from './types';
|
||||
|
||||
interface Props extends SettingsPageProps {}
|
||||
|
||||
type State = {
|
||||
isLoading: boolean;
|
||||
@@ -141,9 +141,8 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
||||
|
||||
if (viewMode === 'compare') {
|
||||
return (
|
||||
<div>
|
||||
<Page navModel={this.props.sectionNav}>
|
||||
<VersionHistoryHeader
|
||||
isComparing
|
||||
onClick={this.reset}
|
||||
baseVersion={baseInfo?.version}
|
||||
newVersion={newInfo?.version}
|
||||
@@ -159,13 +158,12 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
||||
diffData={diffData}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<VersionHistoryHeader />
|
||||
<Page navModel={this.props.sectionNav}>
|
||||
{isLoading ? (
|
||||
<VersionsHistorySpinner msg="Fetching history list…" />
|
||||
) : (
|
||||
@@ -181,7 +179,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
|
||||
isLastPage={!!this.isLastPage()}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</Page>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { ComponentType } from 'react';
|
||||
|
||||
import { NavModel } from '@grafana/data';
|
||||
import { IconName } from '@grafana/ui';
|
||||
|
||||
import { DashboardModel } from '../../state';
|
||||
|
||||
export interface SettingsPage {
|
||||
id: string;
|
||||
title: string;
|
||||
icon: IconName;
|
||||
component: ComponentType<SettingsPageProps>;
|
||||
subTitle?: string;
|
||||
}
|
||||
|
||||
export interface SettingsPageProps {
|
||||
dashboard: DashboardModel;
|
||||
sectionNav: NavModel;
|
||||
editIndex?: number;
|
||||
}
|
||||
Reference in New Issue
Block a user