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:
Torkel Ödegaard 2022-08-24 18:05:12 +02:00 committed by GitHub
parent fe61a97c9d
commit 264645eecd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
43 changed files with 929 additions and 873 deletions

View File

@ -3877,9 +3877,6 @@ exports[`better eslint`] = {
"public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],
"public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx:5381": [
[0, 0, 0, "Unexpected any. Specify a different type.", "0"]
],

View File

@ -61,7 +61,8 @@ e2e.scenario({
e2e.components.Select.option().should('be.visible').contains(toTimeZone).click();
// click to go back to the dashboard.
e2e.components.BackButton.backArrow().click({ force: true }).wait(2000);
e2e.components.BackButton.backArrow().click({ force: true }).wait(5000);
e2e.components.RefreshPicker.runButtonV2().click();
for (const title of panelsToCheck) {
e2e.components.Panels.Panel.containerByTitle(title)

View File

@ -31,6 +31,7 @@ export interface NavModelItem extends NavLinkDTO {
highlightId?: string;
tabSuffix?: ComponentType<{ className?: string }>;
showIconInNavbar?: boolean;
hideFromBreadcrumbs?: boolean;
}
export enum NavSection {

View File

@ -72,7 +72,7 @@ export const Pages = {
* @deprecated use components.TimeZonePicker.containerV2 from Grafana 8.3 instead
*/
timezone: 'Time zone picker select container',
title: 'Dashboard settings page title',
title: 'Tab General',
},
Annotations: {
List: {

View File

@ -10,6 +10,7 @@ interface StackProps {
alignItems?: CSSProperties['alignItems'];
wrap?: boolean;
gap?: number;
flexGrow?: CSSProperties['flexGrow'];
}
export const Stack: React.FC<StackProps> = ({ children, ...props }) => {
@ -25,5 +26,6 @@ const getStyles = (theme: GrafanaTheme2, props: StackProps) => ({
flexWrap: props.wrap ?? true ? 'wrap' : undefined,
alignItems: props.alignItems,
gap: theme.spacing(props.gap ?? 2),
flexGrow: props.flexGrow,
}),
});

View File

@ -25,7 +25,7 @@ export const VerticalTab = React.forwardRef<HTMLAnchorElement, TabProps>(
const linkClass = cx(tabsStyles.link, active && tabsStyles.activeStyle);
return (
<li className={tabsStyles.item}>
<div className={tabsStyles.item}>
<a
href={href}
className={linkClass}
@ -38,7 +38,7 @@ export const VerticalTab = React.forwardRef<HTMLAnchorElement, TabProps>(
>
{content()}
</a>
</li>
</div>
);
}
);

View File

@ -112,5 +112,16 @@ export function getPageStyles(theme: GrafanaTheme2) {
margin-left: ${theme.spacing(1)};
margin-top: ${theme.spacing(0.5)};
}
.dashboard-content {
display: 'flex';
flex-grow: 1;
min-height: 0;
flex-direction: 'column';
}
.dashboard-content--hidden {
display: none;
}
`;
}

View File

@ -10,7 +10,9 @@ export function buildBreadcrumbs(sectionNav: NavModelItem, pageNav?: NavModelIte
addCrumbs(node.parentItem);
}
crumbs.push({ text: node.text, href: node.url ?? '' });
if (!node.hideFromBreadcrumbs) {
crumbs.push({ text: node.text, href: node.url ?? '' });
}
}
addCrumbs(sectionNav);

View File

@ -22,7 +22,7 @@ export function SectionNav(props: Props) {
{main.img && <img className={styles.sectionImg} src={main.img} alt={`logo of ${main.text}`} />}
{props.model.main.text}
</h2>
<div className={styles.items}>
<div className={styles.items} role="tablist">
{directChildren.map((child, index) => {
return (
!child.hideFromTabs &&
@ -81,9 +81,7 @@ const getStyles = (theme: GrafanaTheme2) => {
fontSize: theme.typography.h4.fontSize,
margin: 0,
}),
items: css({
// paddingLeft: '9px',
}),
items: css({}),
sectionImg: css({
height: 48,
}),

View File

@ -40,6 +40,7 @@ function getSectionRoot(node: NavModelItem): NavModelItem {
function enrichNodeWithActiveState(node: NavModelItem): NavModelItem {
const nodeCopy = { ...node };
if (nodeCopy.parentItem) {
nodeCopy.parentItem = { ...nodeCopy.parentItem };
const root = nodeCopy.parentItem;
@ -56,6 +57,7 @@ function enrichNodeWithActiveState(node: NavModelItem): NavModelItem {
nodeCopy.parentItem = enrichNodeWithActiveState(root);
}
return nodeCopy;
}

View File

@ -117,7 +117,7 @@ export class KeybindingSrv {
const search = locationService.getSearchObject();
if (search.editview) {
locationService.partial({ editview: null });
locationService.partial({ editview: null, editIndex: null });
return;
}

View File

@ -3,8 +3,8 @@ import { useAsync } from 'react-use';
import { AnnotationQuery, DataSourceInstanceSettings, getDataSourceRef } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourcePicker, getDataSourceSrv } from '@grafana/runtime';
import { Checkbox, CollapsableSection, Field, HorizontalGroup, Input } from '@grafana/ui';
import { DataSourcePicker, getDataSourceSrv, locationService } from '@grafana/runtime';
import { Button, Checkbox, Field, FieldSet, HorizontalGroup, Input, Stack } from '@grafana/ui';
import { ColorValueEditor } from 'app/core/components/OptionsUI/color';
import StandardAnnotationQueryEditor from 'app/features/annotations/components/StandardAnnotationQueryEditor';
@ -62,52 +62,80 @@ export const AnnotationSettingsEdit: React.FC<Props> = ({ editIdx, dashboard })
});
};
const onApply = goBackToList;
const onPreview = () => {
locationService.partial({ editview: null, editIndex: null });
};
const onDelete = () => {
const annotations = dashboard.annotations.list;
dashboard.annotations.list = [...annotations.slice(0, editIdx), ...annotations.slice(editIdx + 1)];
goBackToList();
};
const isNewAnnotation = annotation.name === newAnnotationName;
return (
<div>
<Field label="Name">
<Input
aria-label={selectors.pages.Dashboard.Settings.Annotations.Settings.name}
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
width={50}
/>
</Field>
<Field label="Data source" htmlFor="data-source-picker">
<DataSourcePicker
width={50}
annotations
variables
current={annotation.datasource}
onChange={onDataSourceChange}
/>
</Field>
<Field label="Enabled" description="When enabled the annotation query is issued every dashboard refresh">
<Checkbox name="enable" id="enable" value={annotation.enable} onChange={onChange} />
</Field>
<Field
label="Hidden"
description="Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden."
>
<Checkbox name="hide" id="hide" value={annotation.hide} onChange={onChange} />
</Field>
<Field label="Color" description="Color to use for the annotation event markers">
<HorizontalGroup>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</HorizontalGroup>
</Field>
<CollapsableSection isOpen={true} label="Query">
<FieldSet>
<Field label="Name">
<Input
aria-label={selectors.pages.Dashboard.Settings.Annotations.Settings.name}
name="name"
id="name"
autoFocus={isNewAnnotation}
value={annotation.name}
onChange={onNameChange}
width={50}
/>
</Field>
<Field label="Data source" htmlFor="data-source-picker">
<DataSourcePicker
width={50}
annotations
variables
current={annotation.datasource}
onChange={onDataSourceChange}
/>
</Field>
<Field label="Enabled" description="When enabled the annotation query is issued every dashboard refresh">
<Checkbox name="enable" id="enable" value={annotation.enable} onChange={onChange} />
</Field>
<Field
label="Hidden"
description="Annotation queries can be toggled on or off at the top of the dashboard. With this option checked this toggle will be hidden."
>
<Checkbox name="hide" id="hide" value={annotation.hide} onChange={onChange} />
</Field>
<Field label="Color" description="Color to use for the annotation event markers">
<HorizontalGroup>
<ColorValueEditor value={annotation?.iconColor} onChange={onColorChange} />
</HorizontalGroup>
</Field>
<h3 className="page-heading">Query</h3>
{ds?.annotations && (
<StandardAnnotationQueryEditor datasource={ds} annotation={annotation} onChange={onUpdate} />
)}
{ds && !ds.annotations && <AngularEditorLoader datasource={ds} annotation={annotation} onChange={onUpdate} />}
</CollapsableSection>
</FieldSet>
<Stack>
<Button variant="destructive" onClick={onDelete}>
Delete
</Button>
<Button variant="secondary" onClick={onPreview}>
Preview in dashboard
</Button>
<Button variant="primary" onClick={onApply}>
Apply
</Button>
</Stack>
</div>
);
};
AnnotationSettingsEdit.displayName = 'AnnotationSettingsEdit';
function goBackToList() {
locationService.partial({ editIndex: null });
}

View File

@ -1,17 +1,18 @@
import React from 'react';
import { Permissions } from 'app/core/components/AccessControl';
import { Page } from 'app/core/components/PageNew/Page';
import { contextSrv } from 'app/core/core';
import { AccessControlAction } from 'app/types';
import { DashboardModel } from '../../state';
import { SettingsPageProps } from '../DashboardSettings/types';
interface Props {
dashboard: DashboardModel;
}
export const AccessControlDashboardPermissions = ({ dashboard }: Props) => {
export const AccessControlDashboardPermissions = ({ dashboard, sectionNav }: SettingsPageProps) => {
const canSetPermissions = contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsWrite);
return <Permissions resource={'dashboards'} resourceId={dashboard.uid} canSetPermissions={canSetPermissions} />;
return (
<Page navModel={sectionNav}>
<Permissions resource={'dashboards'} resourceId={dashboard.uid} canSetPermissions={canSetPermissions} />
</Page>
);
};

View File

@ -3,6 +3,7 @@ import { connect, ConnectedProps } from 'react-redux';
import { Tooltip, Icon, Button } from '@grafana/ui';
import { SlideDown } from 'app/core/components/Animations/SlideDown';
import { Page } from 'app/core/components/PageNew/Page';
import AddPermission from 'app/core/components/PermissionList/AddPermission';
import PermissionList from 'app/core/components/PermissionList/PermissionList';
import PermissionsInfo from 'app/core/components/PermissionList/PermissionsInfo';
@ -10,13 +11,13 @@ import { StoreState } from 'app/types';
import { DashboardAcl, PermissionLevel, NewDashboardAclItem } from 'app/types/acl';
import { checkFolderPermissions } from '../../../folders/state/actions';
import { DashboardModel } from '../../state/DashboardModel';
import {
getDashboardPermissions,
addDashboardPermission,
removeDashboardPermission,
updateDashboardPermission,
} from '../../state/actions';
import { SettingsPageProps } from '../DashboardSettings/types';
const mapStateToProps = (state: StoreState) => ({
permissions: state.dashboard.permissions,
@ -33,11 +34,7 @@ const mapDispatchToProps = {
const connector = connect(mapStateToProps, mapDispatchToProps);
export interface OwnProps {
dashboard: DashboardModel;
}
export type Props = OwnProps & ConnectedProps<typeof connector>;
export type Props = SettingsPageProps & ConnectedProps<typeof connector>;
export interface State {
isAdding: boolean;
@ -91,20 +88,20 @@ export class DashboardPermissionsUnconnected extends PureComponent<Props, State>
}
render() {
const {
permissions,
dashboard: {
meta: { hasUnsavedFolderChange },
},
} = this.props;
const { permissions, dashboard, sectionNav } = this.props;
const { isAdding } = this.state;
return hasUnsavedFolderChange ? (
<h5>You have changed a folder, please save to view permissions.</h5>
) : (
<div>
if (dashboard.meta.hasUnsavedFolderChange) {
return (
<Page navModel={sectionNav}>
<h5>You have changed a folder, please save to view permissions.</h5>
</Page>
);
}
return (
<Page navModel={sectionNav}>
<div className="page-action-bar">
<h3 className="page-sub-heading">Permissions</h3>
<Tooltip placement="auto" content={<PermissionsInfo />}>
<Icon className="icon--has-hover page-sub-heading-icon" name="question-circle" />
</Tooltip>
@ -123,7 +120,7 @@ export class DashboardPermissionsUnconnected extends PureComponent<Props, State>
isFetching={false}
folderInfo={this.getFolder()}
/>
</div>
</Page>
);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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&hellip;" />
) : (
@ -181,7 +179,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
isLastPage={!!this.isLastPage()}
/>
)}
</div>
</Page>
);
}
}

View File

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

View File

@ -35,17 +35,19 @@ export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, o
if (isEmptyList) {
return (
<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>
<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>
);
}

View File

@ -1,7 +1,7 @@
import React from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
import { Button, ButtonVariant, ModalsController } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardDrawer } from './SaveDashboardDrawer';
@ -43,22 +43,20 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
<ModalsController>
{({ showModal, hideModal }) => {
return (
<FullWidthButtonContainer>
<Button
onClick={() => {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
isCopy: true,
});
}}
variant={variant}
aria-label={selectors.pages.Dashboard.Settings.General.saveAsDashBoard}
>
Save As...
</Button>
</FullWidthButtonContainer>
<Button
onClick={() => {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
isCopy: true,
});
}}
variant={variant}
aria-label={selectors.pages.Dashboard.Settings.General.saveAsDashBoard}
>
Save As...
</Button>
);
}}
</ModalsController>

View File

@ -1,6 +1,6 @@
import React from 'react';
import { HorizontalGroup, Tooltip, Button } from '@grafana/ui';
import { Tooltip, Button, Stack } from '@grafana/ui';
type VersionsButtonsType = {
hasMore: boolean;
@ -16,7 +16,7 @@ export const VersionsHistoryButtons: React.FC<VersionsButtonsType> = ({
getDiff,
isLastPage,
}) => (
<HorizontalGroup>
<Stack>
{hasMore && (
<Button type="button" onClick={() => getVersions(true)} variant="secondary" disabled={isLastPage}>
Show more versions
@ -27,5 +27,5 @@ export const VersionsHistoryButtons: React.FC<VersionsButtonsType> = ({
Compare versions
</Button>
</Tooltip>
</HorizontalGroup>
</Stack>
);

View File

@ -3,10 +3,9 @@ import { noop } from 'lodash';
import React from 'react';
import { GrafanaTheme } from '@grafana/data';
import { Icon, useStyles } from '@grafana/ui';
import { Icon, IconButton, useStyles } from '@grafana/ui';
type VersionHistoryHeaderProps = {
isComparing?: boolean;
onClick?: () => void;
baseVersion?: number;
newVersion?: number;
@ -14,7 +13,6 @@ type VersionHistoryHeaderProps = {
};
export const VersionHistoryHeader: React.FC<VersionHistoryHeaderProps> = ({
isComparing = false,
onClick = noop,
baseVersion = 0,
newVersion = 0,
@ -24,15 +22,11 @@ export const VersionHistoryHeader: React.FC<VersionHistoryHeaderProps> = ({
return (
<h3 className={styles.header}>
<span onClick={onClick} className={isComparing ? 'pointer' : ''}>
Versions
<IconButton name="arrow-left" size="xl" onClick={onClick} />
<span>
Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
{isNewLatest && <cite className="muted">(Latest)</cite>}
</span>
{isComparing && (
<span>
<Icon name="angle-right" /> Comparing {baseVersion} <Icon name="arrows-h" /> {newVersion}{' '}
{isNewLatest && <cite className="muted">(Latest)</cite>}
</span>
)}
</h3>
);
};
@ -40,6 +34,8 @@ export const VersionHistoryHeader: React.FC<VersionHistoryHeaderProps> = ({
const getStyles = (theme: GrafanaTheme) => ({
header: css`
font-size: ${theme.typography.heading.h3};
display: flex;
gap: ${theme.spacing.md};
margin-bottom: ${theme.spacing.lg};
`,
});

View File

@ -111,6 +111,9 @@ function dashboardPageScenario(description: string, scenarioFn: (ctx: ScenarioCo
match: { params: { slug: 'my-dash', uid: '11' } } as any,
route: { routeName: DashboardRoutes.Normal } as any,
}),
navIndex: {
dashboards: { text: 'Dashboards' },
},
initPhase: DashboardInitPhase.NotStarted,
initError: null,
initDashboard: jest.fn(),

View File

@ -1,8 +1,8 @@
import classnames from 'classnames';
import { cx } from '@emotion/css';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { locationUtil, NavModelItem, TimeRange } from '@grafana/data';
import { locationUtil, NavModel, NavModelItem, TimeRange } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { locationService } from '@grafana/runtime';
import { Themeable2, withTheme2 } from '@grafana/ui';
@ -12,7 +12,8 @@ import { PageLayoutType } from 'app/core/components/Page/types';
import { createErrorNotification } from 'app/core/copy/appNotification';
import { getKioskMode } from 'app/core/navigation/kiosk';
import { GrafanaRouteComponentProps } from 'app/core/navigation/types';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { getNavModel } from 'app/core/selectors/navModel';
import { PanelModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { getPageNavFromSlug, getRootContentNavModel } from 'app/features/storage/StorageFolderPage';
import { DashboardRoutes, KioskMode, StoreState } from 'app/types';
@ -58,6 +59,7 @@ export const mapStateToProps = (state: StoreState) => ({
initPhase: state.dashboard.initPhase,
initError: state.dashboard.initError,
dashboard: state.dashboard.getModel(),
navIndex: state.navIndex,
});
const mapDispatchToProps = {
@ -88,12 +90,13 @@ export interface State {
panelNotFound: boolean;
editPanelAccessDenied: boolean;
scrollElement?: HTMLDivElement;
pageNav?: NavModelItem;
sectionNav?: NavModel;
}
export class UnthemedDashboardPage extends PureComponent<Props, State> {
private forceRouteReloadCounter = 0;
state: State = this.getCleanState();
pageNav?: NavModelItem;
getCleanState(): State {
return {
@ -150,8 +153,6 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return;
}
this.updatePageNav(dashboard);
if (
prevProps.match.params.uid !== match.params.uid ||
(routeReloadCounter !== undefined && this.forceRouteReloadCounter !== routeReloadCounter)
@ -226,6 +227,8 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return state;
}
state = updateStatePageNavFromProps(props, state);
// Entering edit mode
if (!state.editPanel && urlEditPanelId) {
const panel = dashboard.getPanelByUrlId(urlEditPanelId);
@ -319,59 +322,19 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
return inspectPanel;
}
updatePageNav(dashboard: DashboardModel) {
if (!this.pageNav || dashboard.title !== this.pageNav.text) {
this.pageNav = {
text: dashboard.title,
url: locationUtil.getUrlForPartial(this.props.history.location, {
editview: null,
editPanel: null,
viewPanel: null,
}),
};
}
// Check if folder changed
if (
dashboard.meta.folderTitle &&
(!this.pageNav.parentItem || this.pageNav.parentItem.text !== dashboard.meta.folderTitle)
) {
this.pageNav.parentItem = {
text: dashboard.meta.folderTitle,
url: `/dashboards/f/${dashboard.meta.folderUid}`,
};
}
if (this.props.route.routeName === DashboardRoutes.Path) {
const pageNav = getPageNavFromSlug(this.props.match.params.slug!);
if (pageNav?.parentItem) {
this.pageNav.parentItem = pageNav.parentItem;
}
}
}
getPageProps() {
if (this.props.route.routeName === DashboardRoutes.Path) {
return { navModel: getRootContentNavModel(), pageNav: this.pageNav };
} else {
return { navId: 'dashboards', pageNav: this.pageNav };
}
}
render() {
const { dashboard, initError, queryParams, isPublic } = this.props;
const { editPanel, viewPanel, updateScrollTop } = this.state;
const { editPanel, viewPanel, updateScrollTop, pageNav, sectionNav } = this.state;
const kioskMode = !isPublic ? getKioskMode() : KioskMode.Full;
if (!dashboard) {
if (!dashboard || !pageNav || !sectionNav) {
return <DashboardLoading initPhase={this.props.initPhase} />;
}
const inspectPanel = this.getInspectPanel();
const containerClassNames = classnames({ 'panel-in-fullscreen': viewPanel });
const showSubMenu = !editPanel && kioskMode === KioskMode.Off && !this.props.queryParams.editview;
const toolbar = kioskMode !== KioskMode.Full && (
const toolbar = kioskMode !== KioskMode.Full && !queryParams.editview && (
<header data-testid={selectors.pages.Dashboard.DashNav.navV2}>
<DashNav
dashboard={dashboard}
@ -386,33 +349,97 @@ export class UnthemedDashboardPage extends PureComponent<Props, State> {
);
return (
<Page
{...this.getPageProps()}
layout={PageLayoutType.Dashboard}
toolbar={toolbar}
className={containerClassNames}
scrollRef={this.setScrollRef}
scrollTop={updateScrollTop}
>
<DashboardPrompt dashboard={dashboard} />
<>
<Page
navModel={sectionNav}
pageNav={pageNav}
layout={PageLayoutType.Dashboard}
toolbar={toolbar}
className={cx(viewPanel && 'panel-in-fullscreen', queryParams.editview && 'dashboard-content--hidden')}
scrollRef={this.setScrollRef}
scrollTop={updateScrollTop}
>
<DashboardPrompt dashboard={dashboard} />
{initError && <DashboardFailed />}
{showSubMenu && (
<section aria-label={selectors.pages.Dashboard.SubMenu.submenu}>
<SubMenu dashboard={dashboard} annotations={dashboard.annotations.list} links={dashboard.links} />
</section>
{initError && <DashboardFailed />}
{showSubMenu && (
<section aria-label={selectors.pages.Dashboard.SubMenu.submenu}>
<SubMenu dashboard={dashboard} annotations={dashboard.annotations.list} links={dashboard.links} />
</section>
)}
<DashboardGrid dashboard={dashboard} viewPanel={viewPanel} editPanel={editPanel} />
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} />}
{editPanel && <PanelEditor dashboard={dashboard} sourcePanel={editPanel} tab={this.props.queryParams.tab} />}
</Page>
{queryParams.editview && (
<DashboardSettings
dashboard={dashboard}
editview={queryParams.editview}
pageNav={pageNav}
sectionNav={sectionNav}
/>
)}
<DashboardGrid dashboard={dashboard} viewPanel={viewPanel} editPanel={editPanel} />
{inspectPanel && <PanelInspector dashboard={dashboard} panel={inspectPanel} />}
{editPanel && <PanelEditor dashboard={dashboard} sourcePanel={editPanel} tab={this.props.queryParams.tab} />}
{queryParams.editview && <DashboardSettings dashboard={dashboard} editview={queryParams.editview} />}
</Page>
</>
);
}
}
function updateStatePageNavFromProps(props: Props, state: State): State {
const { dashboard } = props;
if (!dashboard) {
return state;
}
let pageNav = state.pageNav;
let sectionNav = state.sectionNav;
if (!pageNav || dashboard.title !== pageNav.text) {
pageNav = {
text: dashboard.title,
url: locationUtil.getUrlForPartial(props.history.location, {
editview: null,
editPanel: null,
viewPanel: null,
}),
};
}
// Check if folder changed
const { folderTitle } = dashboard.meta;
if (folderTitle && pageNav && pageNav.parentItem?.text !== folderTitle) {
pageNav = {
...pageNav,
parentItem: {
text: folderTitle,
url: `/dashboards/f/${dashboard.meta.folderUid}`,
},
};
}
if (props.route.routeName === DashboardRoutes.Path) {
sectionNav = getRootContentNavModel();
const pageNav = getPageNavFromSlug(props.match.params.slug!);
if (pageNav?.parentItem) {
pageNav.parentItem = pageNav.parentItem;
}
} else {
sectionNav = getNavModel(props.navIndex, 'dashboards');
}
if (state.pageNav === pageNav && state.sectionNav === sectionNav) {
return state;
}
return {
...state,
pageNav,
sectionNav,
};
}
export const DashboardPage = withTheme2(UnthemedDashboardPage);
DashboardPage.displayName = 'DashboardPage';
export default connector(DashboardPage);

View File

@ -3,7 +3,7 @@ import { locationService } from '@grafana/runtime';
import { reduxTester } from '../../../../test/core/redux/reduxTester';
import { variableAdapters } from '../adapters';
import { changeVariableEditorExtended, setIdInEditor } from '../editor/reducer';
import { changeVariableEditorExtended } from '../editor/reducer';
import { adHocBuilder } from '../shared/testing/builders';
import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers';
import { toKeyedAction } from '../state/keyedVariablesReducer';
@ -441,7 +441,6 @@ describe('adhoc actions', () => {
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(createAddVariableAction(variable))
.whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id })))
.whenActionIsDispatched(initAdHocVariableEditor(key))
.whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true);
@ -483,7 +482,6 @@ describe('adhoc actions', () => {
const tester = await reduxTester<RootReducerType>()
.givenRootReducer(getRootReducer())
.whenActionIsDispatched(createAddVariableAction(variable))
.whenActionIsDispatched(toKeyedAction(key, setIdInEditor({ id: variable.id })))
.whenActionIsDispatched(initAdHocVariableEditor(key))
.whenAsyncActionIsDispatched(changeVariableDatasource(toKeyedVariableIdentifier(variable), datasource), true);

View File

@ -1,13 +1,12 @@
import React, { MouseEvent, PureComponent } from 'react';
import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux';
import { bindActionCreators } from 'redux';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Icon } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { locationService } from '@grafana/runtime';
import { Page } from 'app/core/components/PageNew/Page';
import { SettingsPageProps } from 'app/features/dashboard/components/DashboardSettings/types';
import { StoreState, ThunkDispatch } from '../../../types';
import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton';
import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable';
import { toKeyedAction } from '../state/keyedVariablesReducer';
import { getEditorVariables, getVariablesState } from '../state/selectors';
@ -17,7 +16,7 @@ import { toKeyedVariableIdentifier, toVariablePayload } from '../utils';
import { VariableEditorEditor } from './VariableEditorEditor';
import { VariableEditorList } from './VariableEditorList';
import { switchToEditMode, switchToListMode, switchToNewMode } from './actions';
import { createNewVariable, initListMode } from './actions';
const mapStateToProps = (state: StoreState, ownProps: OwnProps) => {
const { uid } = ownProps.dashboard;
@ -32,7 +31,7 @@ const mapStateToProps = (state: StoreState, ownProps: OwnProps) => {
const mapDispatchToProps = (dispatch: ThunkDispatch) => {
return {
...bindActionCreators({ switchToNewMode, switchToEditMode, switchToListMode }, dispatch),
...bindActionCreators({ createNewVariable, initListMode }, dispatch),
changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) =>
dispatch(
toKeyedAction(
@ -55,30 +54,24 @@ const mapDispatchToProps = (dispatch: ThunkDispatch) => {
};
};
interface OwnProps {
dashboard: DashboardModel;
}
interface OwnProps extends SettingsPageProps {}
const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>;
class VariableEditorContainerUnconnected extends PureComponent<Props> {
componentDidMount(): void {
this.props.switchToListMode(this.props.dashboard.uid);
componentDidMount() {
this.props.initListMode(this.props.dashboard.uid);
}
onChangeToListMode = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.props.switchToListMode(this.props.dashboard.uid);
};
onEditVariable = (identifier: KeyedVariableIdentifier) => {
this.props.switchToEditMode(identifier);
const index = this.props.variables.findIndex((x) => x.id === identifier.id);
locationService.partial({ editIndex: index });
};
onNewVariable = () => {
this.props.switchToNewMode(this.props.dashboard.uid);
this.props.createNewVariable(this.props.dashboard.uid);
};
onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => {
@ -94,41 +87,12 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
};
render() {
const variableToEdit = this.props.variables.find((s) => s.id === this.props.idInEditor) ?? null;
const { editIndex, variables } = this.props;
const variableToEdit = editIndex != null ? variables[editIndex] : undefined;
const subPageNav = variableToEdit ? { text: variableToEdit.name } : undefined;
return (
<div>
<div className="page-action-bar">
<h3 className="dashboard-settings__header">
<a
onClick={this.onChangeToListMode}
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.headerLink}
>
Variables
</a>
{this.props.idInEditor && (
<span>
<Icon name="angle-right" />
Edit
</span>
)}
</h3>
<div className="page-action-bar__spacer" />
{this.props.variables.length > 0 && variableToEdit === null && (
<>
<VariablesDependenciesButton variables={this.props.variables} />
<Button
type="button"
onClick={this.onNewVariable}
aria-label={selectors.pages.Dashboard.Settings.Variables.List.newButton}
>
New
</Button>
</>
)}
</div>
<Page navModel={this.props.sectionNav} pageNav={subPageNav}>
{!variableToEdit && (
<VariableEditorList
variables={this.props.variables}
@ -145,7 +109,7 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
<VariablesUnknownTable variables={this.props.variables} dashboard={this.props.dashboard} />
)}
{variableToEdit && <VariableEditorEditor identifier={toKeyedVariableIdentifier(variableToEdit)} />}
</div>
</Page>
);
}
}

View File

@ -5,7 +5,8 @@ import { bindActionCreators } from 'redux';
import { AppEvents, LoadingState, SelectableValue, VariableType } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Button, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui';
import { locationService } from '@grafana/runtime';
import { Button, HorizontalGroup, Icon, InlineFieldRow, VerticalGroup } from '@grafana/ui';
import { appEvents } from '../../../core/core';
import { StoreState, ThunkDispatch } from '../../../types';
@ -14,7 +15,7 @@ import { hasOptions } from '../guard';
import { updateOptions } from '../state/actions';
import { toKeyedAction } from '../state/keyedVariablesReducer';
import { getVariable, getVariablesState } from '../state/selectors';
import { changeVariableProp, changeVariableType } from '../state/sharedReducer';
import { changeVariableProp, changeVariableType, removeVariable } from '../state/sharedReducer';
import { KeyedVariableIdentifier } from '../state/types';
import { VariableHide } from '../types';
import { toKeyedVariableIdentifier, toVariablePayload } from '../utils';
@ -24,7 +25,7 @@ import { VariableSectionHeader } from './VariableSectionHeader';
import { VariableTextField } from './VariableTextField';
import { VariableTypeSelect } from './VariableTypeSelect';
import { VariableValuesPreview } from './VariableValuesPreview';
import { changeVariableName, onEditorUpdate, variableEditorMount, variableEditorUnMount } from './actions';
import { changeVariableName, variableEditorMount, variableEditorUnMount } from './actions';
import { OnPropChangeArguments } from './types';
const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({
@ -34,10 +35,7 @@ const mapStateToProps = (state: StoreState, ownProps: OwnProps) => ({
const mapDispatchToProps = (dispatch: ThunkDispatch) => {
return {
...bindActionCreators(
{ variableEditorMount, variableEditorUnMount, changeVariableName, onEditorUpdate, updateOptions },
dispatch
),
...bindActionCreators({ variableEditorMount, variableEditorUnMount, changeVariableName, updateOptions }, dispatch),
changeVariableProp: (identifier: KeyedVariableIdentifier, propName: string, propValue: any) =>
dispatch(
toKeyedAction(
@ -47,6 +45,11 @@ const mapDispatchToProps = (dispatch: ThunkDispatch) => {
),
changeVariableType: (identifier: KeyedVariableIdentifier, newType: VariableType) =>
dispatch(toKeyedAction(identifier.rootStateKey, changeVariableType(toVariablePayload(identifier, { newType })))),
removeVariable: (identifier: KeyedVariableIdentifier) => {
dispatch(
toKeyedAction(identifier.rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: true })))
);
},
};
};
@ -100,10 +103,11 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
this.props.changeVariableProp(this.props.identifier, 'hide', option.value);
};
onPropChanged = async ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => {
onPropChanged = ({ propName, propValue, updateOptions = false }: OnPropChangeArguments) => {
this.props.changeVariableProp(this.props.identifier, propName, propValue);
if (updateOptions) {
await this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable));
this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable));
}
};
@ -113,7 +117,16 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
return;
}
await this.props.onEditorUpdate(this.props.identifier);
this.props.updateOptions(toKeyedVariableIdentifier(this.props.variable));
};
onDelete = () => {
this.props.removeVariable(this.props.identifier);
locationService.partial({ editIndex: null });
};
onApply = () => {
locationService.partial({ editIndex: null });
};
render() {
@ -176,18 +189,25 @@ export class VariableEditorEditorUnConnected extends PureComponent<Props> {
{hasOptions(this.props.variable) ? <VariableValuesPreview variable={this.props.variable} /> : null}
<VerticalGroup spacing="none">
<HorizontalGroup spacing="md">
<Button variant="destructive" onClick={this.onDelete}>
Delete
</Button>
<Button
type="submit"
aria-label={selectors.pages.Dashboard.Settings.Variables.Edit.General.submitButton}
disabled={loading}
variant={'secondary'}
>
Update
Run query
{loading ? (
<Icon className="spin-clockwise" name="sync" size="sm" style={{ marginLeft: '2px' }} />
) : null}
</Button>
</VerticalGroup>
<Button variant="primary" onClick={this.onApply}>
Apply
</Button>
</HorizontalGroup>
</VerticalGroup>
</form>
</div>

View File

@ -3,8 +3,10 @@ import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { Button, Stack } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton';
import { UsagesToNetwork, VariableUsageTree } from '../inspect/utils';
import { KeyedVariableIdentifier } from '../state/types';
import { VariableModel } from '../types';
@ -47,7 +49,7 @@ export function VariableEditorList({
{variables.length === 0 && <EmptyVariablesList onAdd={onAdd} />}
{variables.length > 0 && (
<div>
<Stack direction="column" gap={4}>
<table
className="filter-table filter-table--hover"
aria-label={selectors.pages.Dashboard.Settings.Variables.List.table}
@ -81,7 +83,17 @@ export function VariableEditorList({
</Droppable>
</DragDropContext>
</table>
</div>
<Stack>
<VariablesDependenciesButton variables={variables} />
<Button
aria-label={selectors.pages.Dashboard.Settings.Variables.List.newButton}
onClick={onAdd}
icon="plus"
>
New variable
</Button>
</Stack>
</Stack>
)}
</div>
</div>

View File

@ -8,8 +8,7 @@ import { initialKeyedVariablesState, toKeyedAction } from '../state/keyedVariabl
import * as selectors from '../state/selectors';
import { addVariable } from '../state/sharedReducer';
import { getNextAvailableId, switchToListMode, switchToNewMode } from './actions';
import { setIdInEditor } from './reducer';
import { getNextAvailableId, initListMode, createNewVariable } from './actions';
describe('getNextAvailableId', () => {
describe('when called with a custom type and there is already 2 variables', () => {
@ -26,7 +25,7 @@ describe('getNextAvailableId', () => {
});
});
describe('switchToNewMode', () => {
describe('createNewVariable', () => {
variableAdapters.setInit(() => [createConstantVariableAdapter()]);
it('should dispatch with the correct rootStateKey', () => {
@ -37,16 +36,15 @@ describe('switchToNewMode', () => {
const mockDispatch = jest.fn();
const model = { ...initialConstantVariableModelState, name: mockId, id: mockId, rootStateKey: 'null' };
switchToNewMode(null, 'constant')(mockDispatch, mockGetState, undefined);
expect(mockDispatch).toHaveBeenCalledTimes(2);
createNewVariable(null, 'constant')(mockDispatch, mockGetState, undefined);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch.mock.calls[0][0]).toEqual(
toKeyedAction('null', addVariable({ data: { global: false, index: 0, model }, type: 'constant', id: mockId }))
);
expect(mockDispatch.mock.calls[1][0]).toEqual(toKeyedAction('null', setIdInEditor({ id: mockId })));
});
});
describe('switchToListMode', () => {
describe('initListMode', () => {
variableAdapters.setInit(() => [createConstantVariableAdapter()]);
it('should dispatch with the correct rootStateKey', () => {
@ -56,7 +54,7 @@ describe('switchToListMode', () => {
const mockGetState = jest.fn().mockReturnValue({ templating: initialKeyedVariablesState, dashboard: initialState });
const mockDispatch = jest.fn();
switchToListMode(null)(mockDispatch, mockGetState, undefined);
initListMode(null)(mockDispatch, mockGetState, undefined);
const keyedAction = {
type: expect.any(String),
payload: {
@ -64,8 +62,7 @@ describe('switchToListMode', () => {
action: expect.any(Object),
},
};
expect(mockDispatch).toHaveBeenCalledTimes(2);
expect(mockDispatch).toHaveBeenCalledTimes(1);
expect(mockDispatch.mock.calls[0][0]).toMatchObject(keyedAction);
expect(mockDispatch.mock.calls[1][0]).toMatchObject(keyedAction);
});
});

View File

@ -1,12 +1,12 @@
import { cloneDeep } from 'lodash';
import { VariableType } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { ThunkResult } from '../../../types';
import { variableAdapters } from '../adapters';
import { initInspect } from '../inspect/reducer';
import { createUsagesNetwork, transformUsagesToNetwork } from '../inspect/utils';
import { updateOptions } from '../state/actions';
import { toKeyedAction } from '../state/keyedVariablesReducer';
import { getEditorVariables, getNewVariableIndex, getVariable, getVariablesByKey } from '../state/selectors';
import { addVariable, removeVariable } from '../state/sharedReducer';
@ -17,8 +17,6 @@ import { toKeyedVariableIdentifier, toStateKey, toVariablePayload } from '../uti
import {
changeVariableNameFailed,
changeVariableNameSucceeded,
clearIdInEditor,
setIdInEditor,
variableEditorMounted,
variableEditorUnMounted,
} from './reducer';
@ -26,7 +24,9 @@ import {
export const variableEditorMount = (identifier: KeyedVariableIdentifier): ThunkResult<void> => {
return async (dispatch) => {
const { rootStateKey } = identifier;
dispatch(toKeyedAction(rootStateKey, variableEditorMounted({ name: getVariable(identifier).name })));
dispatch(
toKeyedAction(rootStateKey, variableEditorMounted({ name: getVariable(identifier).name, id: identifier.id }))
);
};
};
@ -37,13 +37,6 @@ export const variableEditorUnMount = (identifier: KeyedVariableIdentifier): Thun
};
};
export const onEditorUpdate = (identifier: KeyedVariableIdentifier): ThunkResult<void> => {
return async (dispatch) => {
await dispatch(updateOptions(identifier));
dispatch(switchToListMode(identifier.rootStateKey));
};
};
export const changeVariableName = (identifier: KeyedVariableIdentifier, newName: string): ThunkResult<void> => {
return (dispatch, getState) => {
const { id, rootStateKey: uid } = identifier;
@ -90,11 +83,10 @@ export const completeChangeVariableName =
dispatch(
toKeyedAction(rootStateKey, changeVariableNameSucceeded(toVariablePayload(renamedIdentifier, { newName })))
);
dispatch(switchToEditMode(renamedIdentifier));
dispatch(toKeyedAction(rootStateKey, removeVariable(toVariablePayload(identifier, { reIndex: false }))));
};
export const switchToNewMode =
export const createNewVariable =
(key: string | null | undefined, type: VariableType = 'query'): ThunkResult<void> =>
(dispatch, getState) => {
const rootStateKey = toStateKey(key);
@ -109,21 +101,14 @@ export const switchToNewMode =
dispatch(
toKeyedAction(rootStateKey, addVariable(toVariablePayload<AddVariable>(identifier, { global, model, index })))
);
dispatch(toKeyedAction(rootStateKey, setIdInEditor({ id: identifier.id })));
locationService.partial({ editIndex: index });
};
export const switchToEditMode =
(identifier: KeyedVariableIdentifier): ThunkResult<void> =>
(dispatch) => {
const { rootStateKey } = identifier;
dispatch(toKeyedAction(rootStateKey, setIdInEditor({ id: identifier.id })));
};
export const switchToListMode =
export const initListMode =
(key: string | null | undefined): ThunkResult<void> =>
(dispatch, getState) => {
const rootStateKey = toStateKey(key);
dispatch(toKeyedAction(rootStateKey, clearIdInEditor()));
const state = getState();
const variables = getEditorVariables(rootStateKey, state);
const dashboard = state.dashboard.getModel();

View File

@ -7,10 +7,8 @@ import {
changeVariableNameFailed,
changeVariableNameSucceeded,
cleanEditorState,
clearIdInEditor,
initialVariableEditorState,
removeVariableEditorError,
setIdInEditor,
variableEditorMounted,
variableEditorReducer,
VariableEditorState,
@ -18,39 +16,16 @@ import {
} from './reducer';
describe('variableEditorReducer', () => {
describe('when setIdInEditor is dispatched', () => {
it('then state should be correct', () => {
const payload = { id: '0' };
reducerTester<VariableEditorState>()
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
.whenActionIsDispatched(setIdInEditor(payload))
.thenStateShouldEqual({
...initialVariableEditorState,
id: '0',
});
});
});
describe('when clearIdInEditor is dispatched', () => {
it('then state should be correct', () => {
reducerTester<VariableEditorState>()
.givenReducer(variableEditorReducer, { ...initialVariableEditorState, id: '0' })
.whenActionIsDispatched(clearIdInEditor())
.thenStateShouldEqual({
...initialVariableEditorState,
});
});
});
describe('when variableEditorMounted is dispatched', () => {
it('then state should be correct', () => {
const payload = { name: 'A name' };
const payload = { name: 'A name', id: '123' };
reducerTester<VariableEditorState>()
.givenReducer(variableEditorReducer, { ...initialVariableEditorState })
.whenActionIsDispatched(variableEditorMounted(payload))
.thenStateShouldEqual({
...initialVariableEditorState,
name: 'A name',
id: '123',
});
});
});

View File

@ -41,14 +41,9 @@ const variableEditorReducerSlice = createSlice({
name: 'templating/editor',
initialState: initialVariableEditorState,
reducers: {
setIdInEditor: (state: VariableEditorState, action: PayloadAction<{ id: string }>) => {
state.id = action.payload.id;
},
clearIdInEditor: (state: VariableEditorState, action: PayloadAction<undefined>) => {
state.id = '';
},
variableEditorMounted: (state: VariableEditorState, action: PayloadAction<{ name: string }>) => {
variableEditorMounted: (state: VariableEditorState, action: PayloadAction<{ name: string; id: string }>) => {
state.name = action.payload.name;
state.id = action.payload.id;
},
variableEditorUnMounted: (state: VariableEditorState, action: PayloadAction<VariablePayload>) => {
return initialVariableEditorState;
@ -93,8 +88,6 @@ const variableEditorReducerSlice = createSlice({
export const variableEditorReducer = variableEditorReducerSlice.reducer;
export const {
setIdInEditor,
clearIdInEditor,
changeVariableNameSucceeded,
changeVariableNameFailed,
variableEditorMounted,

View File

@ -15,7 +15,7 @@ import {
changeVariableEditorExtended,
initialVariableEditorState,
removeVariableEditorError,
setIdInEditor,
variableEditorMounted,
} from '../editor/reducer';
import { updateOptions } from '../state/actions';
import { getPreloadedState, getRootReducer, RootReducerType } from '../state/helpers';
@ -167,8 +167,8 @@ describe('query actions', () => {
.whenActionIsDispatched(
toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
)
.whenActionIsDispatched(toKeyedAction('key', variableEditorMounted({ name: variable.name, id: variable.id })))
.whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' })))
.whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id })))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable)), true);
const option = createOption(ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE);
@ -195,13 +195,11 @@ describe('query actions', () => {
toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
)
.whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' })))
.whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id })))
.whenAsyncActionIsDispatched(updateQueryVariableOptions(toKeyedVariableIdentifier(variable), 'search'), true);
const update = { results: optionsMetrics, templatedRegex: '' };
tester.thenDispatchedActionsShouldEqual(
toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })),
toKeyedAction('key', updateVariableOptions(toVariablePayload(variable, update)))
);
});
@ -221,24 +219,19 @@ describe('query actions', () => {
toKeyedAction('key', addVariable(toVariablePayload(variable, { global: false, index: 0, model: variable })))
)
.whenActionIsDispatched(toKeyedAction('key', variablesInitTransaction({ uid: 'key' })))
.whenActionIsDispatched(toKeyedAction('key', setIdInEditor({ id: variable.id })))
.whenAsyncActionIsDispatched(updateOptions(toKeyedVariableIdentifier(variable)), true);
tester.thenDispatchedActionsPredicateShouldEqual((dispatchedActions) => {
const expectedNumberOfActions = 5;
const expectedNumberOfActions = 3;
expect(dispatchedActions[0]).toEqual(toKeyedAction('key', variableStateFetching(toVariablePayload(variable))));
expect(dispatchedActions[1]).toEqual(toKeyedAction('key', removeVariableEditorError({ errorProp: 'update' })));
expect(dispatchedActions[2]).toEqual(
toKeyedAction('key', addVariableEditorError({ errorProp: 'update', errorText: error.message }))
);
expect(dispatchedActions[3]).toEqual(
expect(dispatchedActions[1]).toEqual(
toKeyedAction('key', variableStateFailed(toVariablePayload(variable, { error })))
);
expect(dispatchedActions[4].type).toEqual(notifyApp.type);
expect(dispatchedActions[4].payload.title).toEqual('Templating [0]');
expect(dispatchedActions[4].payload.text).toEqual('Error updating options: failed to fetch metrics');
expect(dispatchedActions[4].payload.severity).toEqual('error');
expect(dispatchedActions[2].type).toEqual(notifyApp.type);
expect(dispatchedActions[2].payload.title).toEqual('Templating [0]');
expect(dispatchedActions[2].payload.text).toEqual('Error updating options: failed to fetch metrics');
expect(dispatchedActions[2].payload.severity).toEqual('error');
return dispatchedActions.length === expectedNumberOfActions;
});

View File

@ -10,12 +10,7 @@ import { createConstantVariableAdapter } from '../constant/adapter';
import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE, NEW_VARIABLE_ID } from '../constants';
import { createCustomVariableAdapter } from '../custom/adapter';
import { changeVariableName } from '../editor/actions';
import {
changeVariableNameFailed,
changeVariableNameSucceeded,
cleanEditorState,
setIdInEditor,
} from '../editor/reducer';
import { changeVariableNameFailed, changeVariableNameSucceeded, cleanEditorState } from '../editor/reducer';
import { cleanPickerState } from '../pickers/OptionsPicker/reducer';
import { setVariableQueryRunner, VariableQueryRunner } from '../query/VariableQueryRunner';
import { createQueryVariableAdapter } from '../query/adapter';
@ -511,7 +506,6 @@ describe('shared actions', () => {
key,
changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } })
),
toKeyedAction(key, setIdInEditor({ id: 'constant1' })),
toKeyedAction(key, removeVariable({ type: 'constant', id: 'constant', data: { reIndex: false } }))
);
});
@ -557,7 +551,6 @@ describe('shared actions', () => {
key,
changeVariableNameSucceeded({ type: 'constant', id: 'constant1', data: { newName: 'constant1' } })
),
toKeyedAction(key, setIdInEditor({ id: 'constant1' })),
toKeyedAction(key, removeVariable({ type: 'constant', id: NEW_VARIABLE_ID, data: { reIndex: false } }))
);
});