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
pull/54218/head
Torkel Ödegaard 3 years ago committed by GitHub
parent fe61a97c9d
commit 264645eecd
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
  1. 3
      .betterer.results
  2. 3
      e2e/dashboards-suite/dashboard-time-zone.spec.ts
  3. 1
      packages/grafana-data/src/types/navModel.ts
  4. 2
      packages/grafana-e2e-selectors/src/selectors/pages.ts
  5. 2
      packages/grafana-ui/src/components/QueryEditor/Stack.tsx
  6. 4
      packages/grafana-ui/src/components/Tabs/VerticalTab.tsx
  7. 11
      packages/grafana-ui/src/themes/GlobalStyles/page.ts
  8. 4
      public/app/core/components/Breadcrumbs/utils.ts
  9. 6
      public/app/core/components/PageNew/SectionNav.tsx
  10. 2
      public/app/core/selectors/navModel.ts
  11. 2
      public/app/core/services/keybindingSrv.ts
  12. 104
      public/app/features/dashboard/components/AnnotationSettings/AnnotationSettingsEdit.tsx
  13. 15
      public/app/features/dashboard/components/DashboardPermissions/AccessControlDashboardPermissions.tsx
  14. 33
      public/app/features/dashboard/components/DashboardPermissions/DashboardPermissions.tsx
  15. 124
      public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.test.tsx
  16. 53
      public/app/features/dashboard/components/DashboardSettings/AnnotationsSettings.tsx
  17. 34
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.test.tsx
  18. 361
      public/app/features/dashboard/components/DashboardSettings/DashboardSettings.tsx
  19. 5
      public/app/features/dashboard/components/DashboardSettings/DashboardSettingsHeader.tsx
  20. 18
      public/app/features/dashboard/components/DashboardSettings/GeneralSettings.test.tsx
  21. 152
      public/app/features/dashboard/components/DashboardSettings/GeneralSettings.tsx
  22. 75
      public/app/features/dashboard/components/DashboardSettings/JsonEditorSettings.tsx
  23. 172
      public/app/features/dashboard/components/DashboardSettings/LinksSettings.test.tsx
  24. 17
      public/app/features/dashboard/components/DashboardSettings/LinksSettings.tsx
  25. 37
      public/app/features/dashboard/components/DashboardSettings/VersionsSettings.test.tsx
  26. 18
      public/app/features/dashboard/components/DashboardSettings/VersionsSettings.tsx
  27. 20
      public/app/features/dashboard/components/DashboardSettings/types.ts
  28. 24
      public/app/features/dashboard/components/LinksSettings/LinkSettingsList.tsx
  29. 32
      public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx
  30. 6
      public/app/features/dashboard/components/VersionHistory/VersionHistoryButtons.tsx
  31. 18
      public/app/features/dashboard/components/VersionHistory/VersionHistoryHeader.tsx
  32. 3
      public/app/features/dashboard/containers/DashboardPage.test.tsx
  33. 169
      public/app/features/dashboard/containers/DashboardPage.tsx
  34. 4
      public/app/features/variables/adhoc/actions.test.ts
  35. 70
      public/app/features/variables/editor/VariableEditorContainer.tsx
  36. 46
      public/app/features/variables/editor/VariableEditorEditor.tsx
  37. 16
      public/app/features/variables/editor/VariableEditorList.tsx
  38. 17
      public/app/features/variables/editor/actions.test.ts
  39. 29
      public/app/features/variables/editor/actions.ts
  40. 29
      public/app/features/variables/editor/reducer.test.ts
  41. 11
      public/app/features/variables/editor/reducer.ts
  42. 23
      public/app/features/variables/query/actions.test.tsx
  43. 9
      public/app/features/variables/state/actions.test.ts

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

@ -61,7 +61,8 @@ e2e.scenario({
e2e.components.Select.option().should('be.visible').contains(toTimeZone).click(); e2e.components.Select.option().should('be.visible').contains(toTimeZone).click();
// click to go back to the dashboard. // 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) { for (const title of panelsToCheck) {
e2e.components.Panels.Panel.containerByTitle(title) e2e.components.Panels.Panel.containerByTitle(title)

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

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

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

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

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

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

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

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

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

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

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

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

@ -2,15 +2,35 @@ import { within } from '@testing-library/dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors'; 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 { mockDataSource, MockDataSourceSrv } from 'app/features/alerting/unified/mocks';
import { DashboardModel } from '../../state/DashboardModel'; import { DashboardModel } from '../../state/DashboardModel';
import { AnnotationsSettings } from './AnnotationsSettings'; 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', () => { describe('AnnotationsSettings', () => {
let dashboard: DashboardModel; let dashboard: DashboardModel;
@ -20,7 +40,6 @@ describe('AnnotationsSettings', () => {
name: 'Grafana', name: 'Grafana',
uid: 'uid1', uid: 'uid1',
type: 'grafana', type: 'grafana',
isDefault: true,
}, },
{ annotations: true } { annotations: true }
), ),
@ -79,52 +98,28 @@ describe('AnnotationsSettings', () => {
}); });
}); });
test('it renders a header and cta if no annotations or only builtIn annotation', async () => { test('it renders empty list cta if only builtIn annotation', async () => {
render(<AnnotationsSettings dashboard={dashboard} />); setup(dashboard);
expect(screen.getByRole('heading', { name: /annotations/i })).toBeInTheDocument();
expect(screen.queryByRole('table')).toBeInTheDocument(); expect(screen.queryByRole('table')).toBeInTheDocument();
expect(screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana/i })).toBeInTheDocument(); expect(screen.getByRole('row', { name: /annotations & alerts \(built\-in\) grafana/i })).toBeInTheDocument();
expect( expect(
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query')) screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
).toBeInTheDocument(); ).toBeInTheDocument();
expect(screen.queryByRole('link', { name: /annotations documentation/i })).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 = [];
const heading = screen.getByRole('heading', { setup(dashboard);
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( expect(
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query')) screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))
).toBeInTheDocument(); ).toBeInTheDocument();
}); });
test('it renders the anotation names or uid if annotation doesnt exist', async () => { test('it renders the annotation names or uid if annotation doesnt exist', async () => {
const annotationsList = [ dashboard.annotations.list = [
...dashboard.annotations.list, ...dashboard.annotations.list,
{ {
builtIn: 0, builtIn: 0,
@ -145,20 +140,14 @@ describe('AnnotationsSettings', () => {
type: 'dashboard', type: 'dashboard',
}, },
]; ];
const dashboardWithAnnotations = new DashboardModel({ setup(dashboard);
...dashboard,
annotations: {
list: [...annotationsList],
},
});
render(<AnnotationsSettings dashboard={dashboardWithAnnotations} />);
// Check that we have the correct annotations // Check that we have the correct annotations
expect(screen.queryByText(/prometheus/i)).toBeInTheDocument(); expect(screen.queryByText(/prometheus/i)).toBeInTheDocument();
expect(screen.queryByText(/deletedAnnotationId/i)).toBeInTheDocument(); expect(screen.queryByText(/deletedAnnotationId/i)).toBeInTheDocument();
}); });
test('it renders a sortable table of annotations', async () => { test('it renders a sortable table of annotations', async () => {
const annotationsList = [ dashboard.annotations.list = [
...dashboard.annotations.list, ...dashboard.annotations.list,
{ {
builtIn: 0, builtIn: 0,
@ -179,13 +168,9 @@ describe('AnnotationsSettings', () => {
type: 'dashboard', type: 'dashboard',
}, },
]; ];
const dashboardWithAnnotations = new DashboardModel({
...dashboard, setup(dashboard);
annotations: {
list: [...annotationsList],
},
});
render(<AnnotationsSettings dashboard={dashboardWithAnnotations} />);
// Check that we have sorting buttons // 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-up' })).not.toBeInTheDocument();
expect(within(getTableBodyRows()[0]).queryByRole('button', { name: 'arrow-down' })).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(); expect(within(getTableBodyRows()[2]).queryByText(/annotations & alerts/i)).toBeInTheDocument();
}); });
test('it renders a form for adding/editing annotations', async () => { test('Adding a new annotation', async () => {
render(<AnnotationsSettings dashboard={dashboard} />); setup(dashboard);
await userEvent.click(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query'))); await userEvent.click(screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add annotation query')));
const heading = screen.getByRole('heading', { expect(locationService.getSearchObject().editIndex).toBe('1');
name: /annotations edit/i, 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,
}); });
const nameInput = screen.getByRole('textbox', { name: /name/i });
expect(heading).toBeInTheDocument(); setup(dashboard, 1);
const nameInput = screen.getByRole('textbox', { name: /name/i });
await userEvent.clear(nameInput); await userEvent.clear(nameInput);
await userEvent.type(nameInput, 'My Prometheus Annotation'); await userEvent.type(nameInput, 'My Prometheus Annotation');
@ -234,25 +227,14 @@ describe('AnnotationsSettings', () => {
await userEvent.click(screen.getByText(/prometheus/i)); await userEvent.click(screen.getByText(/prometheus/i));
expect(screen.getByRole('checkbox', { name: /hidden/i })).not.toBeChecked(); 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' })); await userEvent.click(screen.getByRole('button', { name: 'Delete' }));
expect(within(screen.getAllByRole('rowgroup')[1]).getAllByRole('row').length).toBe(2); expect(locationService.getSearchObject().editIndex).toBe(undefined);
expect(dashboard.annotations.list.length).toBe(0);
}); });
}); });

@ -1,24 +1,15 @@
import React, { useState } from 'react'; import React from 'react';
import { AnnotationQuery, getDataSourceRef } from '@grafana/data'; import { AnnotationQuery, getDataSourceRef, NavModelItem } from '@grafana/data';
import { getDataSourceSrv } from '@grafana/runtime'; 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 { AnnotationSettingsEdit, AnnotationSettingsList, newAnnotationName } from '../AnnotationSettings';
import { DashboardSettingsHeader } from './DashboardSettingsHeader'; import { SettingsPageProps } from './types';
interface Props {
dashboard: DashboardModel;
}
export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
const [editIdx, setEditIdx] = useState<number | null>(null);
const onGoBack = () => {
setEditIdx(null);
};
export function AnnotationsSettings({ dashboard, editIndex, sectionNav }: SettingsPageProps) {
const onNew = () => { const onNew = () => {
const newAnnotation: AnnotationQuery = { const newAnnotation: AnnotationQuery = {
name: newAnnotationName, name: newAnnotationName,
@ -28,20 +19,34 @@ export const AnnotationsSettings: React.FC<Props> = ({ dashboard }) => {
}; };
dashboard.annotations.list = [...dashboard.annotations.list, { ...newAnnotation }]; 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) => { const onEdit = (idx: number) => {
setEditIdx(idx); locationService.partial({ editIndex: idx });
}; };
const isEditing = editIdx !== null; const isEditing = editIndex != null && editIndex < dashboard.annotations.list.length;
return ( return (
<> <Page navModel={sectionNav} pageNav={getSubPageNav(dashboard, editIndex)}>
<DashboardSettingsHeader title="Annotations" onGoBack={onGoBack} isEditing={isEditing} />
{!isEditing && <AnnotationSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />} {!isEditing && <AnnotationSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
{isEditing && <AnnotationSettingsEdit dashboard={dashboard} editIdx={editIdx!} />} {isEditing && <AnnotationSettingsEdit dashboard={dashboard} editIdx={editIndex!} />}
</> </Page>
); );
}; }
function getSubPageNav(dashboard: DashboardModel, editIndex: number | undefined): NavModelItem | undefined {
if (editIndex == null) {
return undefined;
}
const editItem = dashboard.annotations.list[editIndex];
if (editItem) {
return {
text: editItem.name,
};
}
return undefined;
}

@ -1,10 +1,12 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { Provider } from 'react-redux'; import { Provider } from 'react-redux';
import { BrowserRouter } from 'react-router-dom'; 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 { configureStore } from 'app/store/configureStore';
import { DashboardModel } from '../../state'; import { DashboardModel } from '../../state';
@ -24,7 +26,6 @@ setBackendSrv({
describe('DashboardSettings', () => { describe('DashboardSettings', () => {
it('pressing escape navigates away correctly', async () => { it('pressing escape navigates away correctly', async () => {
jest.spyOn(locationService, 'partial');
const dashboard = new DashboardModel( const dashboard = new DashboardModel(
{ {
title: 'Foo', title: 'Foo',
@ -33,23 +34,22 @@ describe('DashboardSettings', () => {
folderId: 1, folderId: 1,
} }
); );
const store = configureStore(); const store = configureStore();
const context = getGrafanaContextMock();
const sectionNav: NavModel = { main: { text: 'Dashboards' }, node: { text: 'Dashboards' } };
const pageNav: NavModelItem = { text: 'My cool dashboard' };
render( render(
<Provider store={store}> <GrafanaContext.Provider value={context}>
<BrowserRouter> <Provider store={store}>
<DashboardSettings editview="settings" dashboard={dashboard} /> <BrowserRouter>
</BrowserRouter> <DashboardSettings editview="settings" dashboard={dashboard} sectionNav={sectionNav} pageNav={pageNav} />
</Provider> </BrowserRouter>
</Provider>
</GrafanaContext.Provider>
); );
expect( expect(await screen.findByRole('heading', { name: 'Settings' })).toBeInTheDocument();
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 });
}); });
}); });

@ -1,13 +1,11 @@
import { css, cx } from '@emotion/css'; import * as H from 'history';
import { useDialog } from '@react-aria/dialog'; import React, { useMemo } from 'react';
import { FocusScope } from '@react-aria/focus'; import { useLocation } from 'react-router-dom';
import { useOverlay } from '@react-aria/overlays';
import React, { useCallback, useMemo, useRef } from 'react'; import { locationUtil, NavModel, NavModelItem } from '@grafana/data';
import { Link } from 'react-router-dom'; import { locationService } from '@grafana/runtime';
import { Button, PageToolbar } from '@grafana/ui';
import { GrafanaTheme2, locationUtil } from '@grafana/data'; import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { locationService, reportInteraction } from '@grafana/runtime';
import { Button, CustomScrollbar, Icon, IconName, PageToolbar, stylesFactory, useForceUpdate } from '@grafana/ui';
import config from 'app/core/config'; import config from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv'; import { contextSrv } from 'app/core/services/context_srv';
import { AccessControlAction } from 'app/types'; import { AccessControlAction } from 'app/types';
@ -23,204 +21,205 @@ import { GeneralSettings } from './GeneralSettings';
import { JsonEditorSettings } from './JsonEditorSettings'; import { JsonEditorSettings } from './JsonEditorSettings';
import { LinksSettings } from './LinksSettings'; import { LinksSettings } from './LinksSettings';
import { VersionsSettings } from './VersionsSettings'; import { VersionsSettings } from './VersionsSettings';
import { SettingsPage, SettingsPageProps } from './types';
export interface Props { export interface Props {
dashboard: DashboardModel; dashboard: DashboardModel;
sectionNav: NavModel;
pageNav: NavModelItem;
editview: string; editview: string;
} }
export interface SettingsPage { const onClose = () => locationService.partial({ editview: null, editIndex: null });
id: string;
title: string;
icon: IconName;
component: React.ReactNode;
}
const onClose = () => locationService.partial({ editview: null }); export function DashboardSettings({ dashboard, editview, pageNav, sectionNav }: Props) {
const pages = useMemo(() => getSettingsPages(dashboard), [dashboard]);
const MakeEditable = (props: { onMakeEditable: () => any }) => (
<div> const onPostSave = () => {
<div className="dashboard-settings__header">Dashboard not editable</div> dashboard.meta.hasUnsavedFolderChange = false;
<Button type="submit" onClick={props.onMakeEditable}> };
Make editable
</Button> const folderTitle = dashboard.meta.folderTitle;
</div> const currentPage = pages.find((page) => page.id === editview) ?? pages[0];
); const canSaveAs = contextSrv.hasEditPermissionInFolders;
const canSave = dashboard.meta.canSave;
export function DashboardSettings({ dashboard, editview }: Props) { const location = useLocation();
const ref = useRef<HTMLDivElement>(null); const editIndex = getEditIndex(location);
const { overlayProps } = useOverlay( const subSectionNav = getSectionNav(pageNav, sectionNav, pages, currentPage, location);
{
isOpen: true, const actions = [
onClose, canSaveAs && (
}, <SaveDashboardAsButton dashboard={dashboard} onSaveSuccess={onPostSave} variant="secondary" key="save as" />
ref ),
); canSave && <SaveDashboardButton dashboard={dashboard} onSaveSuccess={onPostSave} key="Save" />,
const { dialogProps } = useDialog( ];
{
'aria-label': 'Dashboard settings', return (
}, <>
ref {!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 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[] => { function getSettingsPages(dashboard: DashboardModel) {
const pages: SettingsPage[] = []; const pages: SettingsPage[] = [];
if (dashboard.meta.canEdit) { if (dashboard.meta.canEdit) {
pages.push({ pages.push({
title: 'General', title: 'General',
id: 'settings', id: 'settings',
icon: 'sliders-v-alt', icon: 'sliders-v-alt',
component: <GeneralSettings dashboard={dashboard} />, component: GeneralSettings,
}); });
pages.push({ pages.push({
title: 'Annotations', title: 'Annotations',
id: 'annotations', id: 'annotations',
icon: 'comment-alt', icon: 'comment-alt',
component: <AnnotationsSettings dashboard={dashboard} />, component: AnnotationsSettings,
}); subTitle:
'Annotation queries return events that can be visualized as event markers in graphs across the dashboard.',
});
pages.push({ pages.push({
title: 'Variables', title: 'Variables',
id: 'templating', id: 'templating',
icon: 'calculator-alt', icon: 'calculator-alt',
component: <VariableEditorContainer dashboard={dashboard} />, component: VariableEditorContainer,
}); subTitle: 'Variables can make your dashboard more dynamic and act as global filters.',
});
pages.push({ pages.push({
title: 'Links', title: 'Links',
id: 'links', id: 'links',
icon: 'link', icon: 'link',
component: <LinksSettings dashboard={dashboard} />, component: LinksSettings,
}); });
} }
if (dashboard.meta.canMakeEditable) {
pages.push({
title: 'General',
icon: 'sliders-v-alt',
id: 'settings',
component: MakeEditable,
});
}
if (dashboard.meta.canMakeEditable) { 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({ pages.push({
title: 'General', title: 'Permissions',
icon: 'sliders-v-alt', id: 'permissions',
id: 'settings', icon: 'lock',
component: <MakeEditable onMakeEditable={onMakeEditable} />, component: DashboardPermissions,
}); });
} } else if (contextSrv.hasPermission(AccessControlAction.DashboardsPermissionsRead)) {
if (dashboard.id && dashboard.meta.canSave) {
pages.push({ pages.push({
title: 'Versions', title: 'Permissions',
id: 'versions', id: 'permissions',
icon: 'history', icon: 'lock',
component: <VersionsSettings dashboard={dashboard} />, component: AccessControlDashboardPermissions,
}); });
} }
}
if (dashboard.id && dashboard.meta.canAdmin) { pages.push({
if (!config.rbacEnabled) { title: 'JSON Model',
pages.push({ id: 'dashboard_json',
title: 'Permissions', icon: 'arrow',
id: 'permissions', component: JsonEditorSettings,
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({ return pages;
title: 'JSON Model', }
id: 'dashboard_json',
icon: 'arrow',
component: <JsonEditorSettings dashboard={dashboard} />,
});
return pages; function getSectionNav(
}, [dashboard, onMakeEditable]); pageNav: NavModelItem,
sectionNav: NavModel,
pages: SettingsPage[],
currentPage: SettingsPage,
location: H.Location
): NavModel {
const main: NavModelItem = {
text: 'Settings',
children: [],
icon: 'apps',
hideFromBreadcrumbs: true,
};
const onPostSave = () => { main.children = pages.map((page) => ({
dashboard.meta.hasUnsavedFolderChange = false; 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)!,
}; };
}
const folderTitle = dashboard.meta.folderTitle; function MakeEditable({ dashboard }: SettingsPageProps) {
const currentPage = pages.find((page) => page.id === editview) ?? pages[0]; const onMakeEditable = () => {
const canSaveAs = contextSrv.hasEditPermissionInFolders; dashboard.editable = true;
const canSave = dashboard.meta.canSave; dashboard.meta.canMakeEditable = false;
const styles = getStyles(config.theme2); dashboard.meta.canEdit = true;
dashboard.meta.canSave = true;
// TODO add some kind of reload
};
return ( return (
<FocusScope contain autoFocus> <div>
<div className="dashboard-settings" ref={ref} {...overlayProps} {...dialogProps}> <div className="dashboard-settings__header">Dashboard not editable</div>
<PageToolbar <Button type="submit" onClick={onMakeEditable}>
className={styles.toolbar} Make editable
title={dashboard.title} </Button>
section="Settings" </div>
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>
); );
} }
const getStyles = stylesFactory((theme: GrafanaTheme2) => ({ function getEditIndex(location: H.Location): number | undefined {
scrollInner: css` const editIndex = new URLSearchParams(location.search).get('editIndex');
min-width: 100%; if (editIndex != null) {
display: flex; return parseInt(editIndex, 10);
`, }
toolbar: css` return undefined;
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()};
`,
}));

@ -1,5 +1,6 @@
import React from 'react'; import React from 'react';
import { config } from '@grafana/runtime';
import { Icon, HorizontalGroup } from '@grafana/ui'; import { Icon, HorizontalGroup } from '@grafana/ui';
type Props = { type Props = {
@ -9,6 +10,10 @@ type Props = {
}; };
export const DashboardSettingsHeader: React.FC<Props> = ({ onGoBack, isEditing, title }) => { export const DashboardSettingsHeader: React.FC<Props> = ({ onGoBack, isEditing, title }) => {
if (config.featureToggles.topnav) {
return null;
}
return ( return (
<div className="dashboard-settings__header"> <div className="dashboard-settings__header">
<HorizontalGroup align="center" justify="space-between"> <HorizontalGroup align="center" justify="space-between">

@ -1,11 +1,14 @@
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { selectOptionInTest } from 'test/helpers/selectOptionInTest'; import { selectOptionInTest } from 'test/helpers/selectOptionInTest';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { byRole } from 'testing-library-selector'; import { byRole } from 'testing-library-selector';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { setBackendSrv } from '@grafana/runtime'; import { setBackendSrv } from '@grafana/runtime';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { DashboardModel } from '../../state'; import { DashboardModel } from '../../state';
@ -34,10 +37,23 @@ const setupTestContext = (options: Partial<Props>) => {
), ),
updateTimeZone: jest.fn(), updateTimeZone: jest.fn(),
updateWeekStart: jest.fn(), updateWeekStart: jest.fn(),
sectionNav: {
main: { text: 'Dashboard' },
node: {
text: 'Settings',
},
},
}; };
const props = { ...defaults, ...options }; 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 }; return { rerender, props };
}; };

@ -2,23 +2,19 @@ import React, { useState } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { TimeZone } from '@grafana/data'; import { TimeZone } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config } from '@grafana/runtime'; import { config } from '@grafana/runtime';
import { CollapsableSection, Field, Input, RadioButtonGroup, TagsInput } from '@grafana/ui'; 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 { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions'; import { updateTimeZoneDashboard, updateWeekStartDashboard } from 'app/features/dashboard/state/actions';
import { DashboardModel } from '../../state/DashboardModel';
import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton'; import { DeleteDashboardButton } from '../DeleteDashboard/DeleteDashboardButton';
import { PreviewSettings } from './PreviewSettings'; import { PreviewSettings } from './PreviewSettings';
import { TimePickerSettings } from './TimePickerSettings'; import { TimePickerSettings } from './TimePickerSettings';
import { SettingsPageProps } from './types';
interface OwnProps { export type Props = SettingsPageProps & ConnectedProps<typeof connector>;
dashboard: DashboardModel;
}
export type Props = OwnProps & ConnectedProps<typeof connector>;
const GRAPH_TOOLTIP_OPTIONS = [ const GRAPH_TOOLTIP_OPTIONS = [
{ value: 0, label: 'Default' }, { value: 0, label: 'Default' },
@ -26,7 +22,12 @@ const GRAPH_TOOLTIP_OPTIONS = [
{ value: 2, label: 'Shared Tooltip' }, { 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 [renderCounter, setRenderCounter] = useState(0);
const onFolderChange = (folder: { id: number; title: string }) => { const onFolderChange = (folder: { id: number; title: string }) => {
@ -90,73 +91,76 @@ export function GeneralSettingsUnconnected({ dashboard, updateTimeZone, updateWe
]; ];
return ( return (
<div style={{ maxWidth: '600px' }}> <Page navModel={sectionNav}>
<h3 className="dashboard-settings__header" aria-label={selectors.pages.Dashboard.Settings.General.title}> <div style={{ maxWidth: '600px' }}>
General <div className="gf-form-group">
</h3> <Field label="Name">
<div className="gf-form-group"> <Input id="title-input" name="title" onBlur={onBlur} defaultValue={dashboard.title} />
<Field label="Name"> </Field>
<Input id="title-input" name="title" onBlur={onBlur} defaultValue={dashboard.title} /> <Field label="Description">
</Field> <Input id="description-input" name="description" onBlur={onBlur} defaultValue={dashboard.description} />
<Field label="Description"> </Field>
<Input id="description-input" name="description" onBlur={onBlur} defaultValue={dashboard.description} /> <Field label="Tags">
</Field> <TagsInput id="tags-input" tags={dashboard.tags} onChange={onTagsChange} />
<Field label="Tags"> </Field>
<TagsInput id="tags-input" tags={dashboard.tags} onChange={onTagsChange} /> <Field label="Folder">
</Field> <FolderPicker
<Field label="Folder"> inputId="dashboard-folder-input"
<FolderPicker initialTitle={dashboard.meta.folderTitle}
inputId="dashboard-folder-input" initialFolderId={dashboard.meta.folderId}
initialTitle={dashboard.meta.folderTitle} onChange={onFolderChange}
initialFolderId={dashboard.meta.folderId} enableCreateNew={true}
onChange={onFolderChange} dashboardId={dashboard.id}
enableCreateNew={true} skipInitialLoad={true}
dashboardId={dashboard.id} />
skipInitialLoad={true} </Field>
/>
</Field> <Field
label="Editable"
<Field description="Set to read-only to disable all editing. Reload the dashboard for changes to take effect"
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>
<RadioButtonGroup value={dashboard.editable} options={editableOptions} onChange={onEditableChange} /> </div>
</Field>
</div> {config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && (
<PreviewSettings uid={dashboard.uid} />
{config.featureToggles.dashboardPreviews && config.featureToggles.dashboardPreviewsAdmin && ( )}
<PreviewSettings uid={dashboard.uid} />
)} <TimePickerSettings
onTimeZoneChange={onTimeZoneChange}
<TimePickerSettings onWeekStartChange={onWeekStartChange}
onTimeZoneChange={onTimeZoneChange} onRefreshIntervalChange={onRefreshIntervalChange}
onWeekStartChange={onWeekStartChange} onNowDelayChange={onNowDelayChange}
onRefreshIntervalChange={onRefreshIntervalChange} onHideTimePickerChange={onHideTimePickerChange}
onNowDelayChange={onNowDelayChange} onLiveNowChange={onLiveNowChange}
onHideTimePickerChange={onHideTimePickerChange} refreshIntervals={dashboard.timepicker.refresh_intervals}
onLiveNowChange={onLiveNowChange} timePickerHidden={dashboard.timepicker.hidden}
refreshIntervals={dashboard.timepicker.refresh_intervals} nowDelay={dashboard.timepicker.nowDelay}
timePickerHidden={dashboard.timepicker.hidden} timezone={dashboard.timezone}
nowDelay={dashboard.timepicker.nowDelay} weekStart={dashboard.weekStart}
timezone={dashboard.timezone} liveNow={dashboard.liveNow}
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}>
{/* @todo: Update "Graph tooltip" description to remove prompt about reloading when resolving #46581 */} <Field
<CollapsableSection label="Panel options" isOpen={true}> label="Graph tooltip"
<Field description="Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect"
label="Graph tooltip" >
description="Controls tooltip and hover highlight behavior across different panels. Reload the dashboard for changes to take effect" <RadioButtonGroup
> onChange={onTooltipChange}
<RadioButtonGroup onChange={onTooltipChange} options={GRAPH_TOOLTIP_OPTIONS} value={dashboard.graphTooltip} /> options={GRAPH_TOOLTIP_OPTIONS}
</Field> value={dashboard.graphTooltip}
</CollapsableSection> />
</Field>
<div className="gf-form-button-row"> </CollapsableSection>
{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}
<div className="gf-form-button-row">
{dashboard.meta.canDelete && <DeleteDashboardButton dashboard={dashboard} />}
</div>
</div> </div>
</div> </Page>
); );
} }

@ -3,17 +3,15 @@ import React, { useState } from 'react';
import AutoSizer from 'react-virtualized-auto-sizer'; import AutoSizer from 'react-virtualized-auto-sizer';
import { GrafanaTheme2 } from '@grafana/data'; 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 { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { getDashboardSrv } from '../../services/DashboardSrv'; import { getDashboardSrv } from '../../services/DashboardSrv';
import { DashboardModel } from '../../state/DashboardModel';
interface Props { import { SettingsPageProps } from './types';
dashboard: DashboardModel;
}
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 [dashboardJson, setDashboardJson] = useState<string>(JSON.stringify(dashboard.getSaveModelClone(), null, 2));
const onBlur = (value: string) => { const onBlur = (value: string) => {
setDashboardJson(value); setDashboardJson(value);
@ -28,44 +26,41 @@ export const JsonEditorSettings: React.FC<Props> = ({ dashboard }) => {
}; };
const styles = useStyles2(getStyles); 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 ( return (
<div> <Page navModel={sectionNav} subTitle={subTitle}>
<h3 className="dashboard-settings__header">JSON Model</h3> <div className="dashboard-settings__subheader"></div>
<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>
<div className={styles.editWrapper}> <Stack direction="column" gap={4} flexGrow={1}>
<AutoSizer> <div className={styles.editWrapper}>
{({ width, height }) => ( <AutoSizer>
<CodeEditor {({ width, height }) => (
value={dashboardJson} <CodeEditor
language="json" value={dashboardJson}
width={width} language="json"
height={height} width={width}
showMiniMap={true} height={height}
showLineNumbers={true} showMiniMap={true}
onBlur={onBlur} showLineNumbers={true}
/> onBlur={onBlur}
/>
)}
</AutoSizer>
</div>
<div>
{dashboard.meta.canSave && (
<Button type="submit" onClick={onClick}>
Save changes
</Button>
)} )}
</AutoSizer> </div>
</div> </Stack>
{dashboard.meta.canSave && ( </Page>
<HorizontalGroup>
<Button type="submit" onClick={onClick}>
Save changes
</Button>
</HorizontalGroup>
)}
</div>
); );
}; }
const getStyles = (theme: GrafanaTheme2) => ({ const getStyles = (_: GrafanaTheme2) => ({
editWrapper: css` editWrapper: css({ flexGrow: 1 }),
height: calc(100vh - 250px);
margin-bottom: 10px;
`,
}); });

@ -2,72 +2,88 @@ import { within } from '@testing-library/dom';
import { render, screen } from '@testing-library/react'; import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import React from 'react'; import React from 'react';
import { BrowserRouter } from 'react-router-dom';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
import { GrafanaContext } from 'app/core/context/GrafanaContext';
import { DashboardModel } from '../../state';
import { LinksSettings } from './LinksSettings'; import { LinksSettings } from './LinksSettings';
describe('LinksSettings', () => { function setup(dashboard: DashboardModel) {
let dashboard = {}; const sectionNav = {
const links = [ main: { text: 'Dashboard' },
{ node: {
asDropdown: false, text: 'Links',
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',
}, },
]; };
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 getTableBody = () => screen.getAllByRole('rowgroup')[1];
const getTableBodyRows = () => within(getTableBody()).getAllByRole('row'); const getTableBodyRows = () => within(getTableBody()).getAllByRole('row');
const assertRowHasText = (index: number, text: string) => { const assertRowHasText = (index: number, text: string) => {
expect(within(getTableBodyRows()[index]).queryByText(text)).toBeInTheDocument(); 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', () => { test('it renders a header and cta if no links', () => {
const linklessDashboard = { ...dashboard, links: [] }; const linklessDashboard = new DashboardModel({ links: [] });
// @ts-ignore setup(linklessDashboard);
render(<LinksSettings dashboard={linklessDashboard} />);
expect(screen.getByRole('heading', { name: 'Dashboard links' })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: 'Links' })).toBeInTheDocument();
expect( expect(
screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link')) screen.getByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
).toBeInTheDocument(); ).toBeInTheDocument();
@ -75,18 +91,19 @@ describe('LinksSettings', () => {
}); });
test('it renders a table of links', () => { test('it renders a table of links', () => {
// @ts-ignore const dashboard = buildTestDashboard();
render(<LinksSettings dashboard={dashboard} />); setup(dashboard);
expect(getTableBodyRows().length).toBe(links.length); expect(getTableBodyRows().length).toBe(dashboard.links.length);
expect( expect(
screen.queryByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link')) screen.queryByTestId(selectors.components.CallToActionCard.buttonV2('Add dashboard link'))
).not.toBeInTheDocument(); ).not.toBeInTheDocument();
}); });
test('it rearranges the order of dashboard links', async () => { test('it rearranges the order of dashboard links', async () => {
// @ts-ignore const dashboard = buildTestDashboard();
render(<LinksSettings dashboard={dashboard} />); const links = dashboard.links;
setup(dashboard);
// Check that we have sorting buttons // 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-up' })).not.toBeInTheDocument();
@ -114,33 +131,36 @@ describe('LinksSettings', () => {
}); });
test('it duplicates dashboard links', async () => { test('it duplicates dashboard links', async () => {
// @ts-ignore const dashboard = buildTestDashboard();
render(<LinksSettings dashboard={dashboard} />); 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]); await userEvent.click(within(getTableBody()).getAllByRole('button', { name: /copy/i })[0]);
expect(getTableBodyRows().length).toBe(links.length + 1); expect(getTableBodyRows().length).toBe(4);
expect(within(getTableBody()).getAllByText(links[0].title).length).toBe(2); expect(within(getTableBody()).getAllByText(dashboard.links[0].title).length).toBe(2);
}); });
test('it deletes dashboard links', async () => { test('it deletes dashboard links', async () => {
// @ts-ignore const dashboard = buildTestDashboard();
render(<LinksSettings dashboard={dashboard} />); 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()).getAllByLabelText(/Delete link with title/)[0]);
await userEvent.click(within(getTableBody()).getByRole('button', { name: 'Delete' })); await userEvent.click(within(getTableBody()).getByRole('button', { name: 'Delete' }));
expect(getTableBodyRows().length).toBe(links.length - 1); expect(getTableBodyRows().length).toBe(2);
expect(within(getTableBody()).queryByText(links[0].title)).not.toBeInTheDocument(); expect(within(getTableBody()).queryByText(originalLinks[0].title)).not.toBeInTheDocument();
}); });
test('it renders a form which modifies dashboard links', async () => { test('it renders a form which modifies dashboard links', async () => {
// @ts-ignore const dashboard = buildTestDashboard();
render(<LinksSettings dashboard={dashboard} />); const originalLinks = dashboard.links;
setup(dashboard);
await userEvent.click(screen.getByRole('button', { name: /new/i })); await userEvent.click(screen.getByRole('button', { name: /new/i }));
expect(screen.queryByText('Type')).toBeInTheDocument(); expect(screen.queryByText('Type')).toBeInTheDocument();
@ -164,21 +184,19 @@ describe('LinksSettings', () => {
await userEvent.clear(screen.getByRole('textbox', { name: /title/i })); await userEvent.clear(screen.getByRole('textbox', { name: /title/i }));
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'New Dashboard Link'); 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(); 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.clear(screen.getByRole('textbox', { name: /title/i }));
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), 'The first dashboard link'); 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(); expect(within(getTableBody()).queryByText('The first dashboard link')).toBeInTheDocument();
}); });
}); });

@ -1,17 +1,15 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { DashboardModel } from '../../state/DashboardModel'; import { Page } from 'app/core/components/PageNew/Page';
import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings'; import { LinkSettingsEdit, LinkSettingsList } from '../LinksSettings';
import { newLink } from '../LinksSettings/LinkSettingsEdit'; import { newLink } from '../LinksSettings/LinkSettingsEdit';
import { DashboardSettingsHeader } from './DashboardSettingsHeader'; import { SettingsPageProps } from './types';
interface Props {
dashboard: DashboardModel;
}
export type LinkSettingsMode = 'list' | 'new' | 'edit'; 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 [editIdx, setEditIdx] = useState<number | null>(null);
const onGoBack = () => { const onGoBack = () => {
@ -30,10 +28,9 @@ export const LinksSettings: React.FC<Props> = ({ dashboard }) => {
const isEditing = editIdx !== null; const isEditing = editIdx !== null;
return ( return (
<> <Page navModel={sectionNav}>
<DashboardSettingsHeader onGoBack={onGoBack} title="Dashboard links" isEditing={isEditing} />
{!isEditing && <LinkSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />} {!isEditing && <LinkSettingsList dashboard={dashboard} onNew={onNew} onEdit={onEdit} />}
{isEditing && <LinkSettingsEdit dashboard={dashboard} editLinkIdx={editIdx!} onGoBack={onGoBack} />} {isEditing && <LinkSettingsEdit dashboard={dashboard} editLinkIdx={editIdx!} onGoBack={onGoBack} />}
</> </Page>
); );
}; }

@ -3,6 +3,10 @@ import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event'; import userEvent from '@testing-library/user-event';
import { UserEvent } from '@testing-library/user-event/dist/types/setup'; import { UserEvent } from '@testing-library/user-event/dist/types/setup';
import React from 'react'; 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 { DashboardModel } from '../../state/DashboardModel';
import { historySrv } from '../VersionHistory/HistorySrv'; import { historySrv } from '../VersionHistory/HistorySrv';
@ -23,7 +27,7 @@ const queryByFullText = (text: string) =>
return false; return false;
}); });
describe('VersionSettings', () => { function setup() {
const dashboard = new DashboardModel({ const dashboard = new DashboardModel({
id: 74, id: 74,
version: 11, version: 11,
@ -31,6 +35,23 @@ describe('VersionSettings', () => {
getRelativeTime: jest.fn(() => 'time ago'), 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; let user: UserEvent;
beforeEach(() => { beforeEach(() => {
@ -48,7 +69,7 @@ describe('VersionSettings', () => {
test('renders a header and a loading indicator followed by results in a table', async () => { test('renders a header and a loading indicator followed by results in a table', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions); historySrv.getHistoryList.mockResolvedValue(versions);
render(<VersionsSettings dashboard={dashboard} />); setup();
expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument(); expect(screen.getByRole('heading', { name: /versions/i })).toBeInTheDocument();
expect(screen.queryByText(/fetching history list/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 () => { test('does not render buttons if versions === 1', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, 1)); 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: /show more versions/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /compare 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 () => { test('does not render show more button if versions < VERSIONS_FETCH_LIMIT', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT - 5)); 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: /show more versions|/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /compare 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 () => { test('renders buttons if versions >= VERSIONS_FETCH_LIMIT', async () => {
// @ts-ignore // @ts-ignore
historySrv.getHistoryList.mockResolvedValue(versions.slice(0, VERSIONS_FETCH_LIMIT)); 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: /show more versions/i })).not.toBeInTheDocument();
expect(screen.queryByRole('button', { name: /compare 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)) () => new Promise((resolve) => setTimeout(() => resolve(versions.slice(VERSIONS_FETCH_LIMIT)), 1000))
); );
render(<VersionsSettings dashboard={dashboard} />); setup();
expect(historySrv.getHistoryList).toBeCalledTimes(1); expect(historySrv.getHistoryList).toBeCalledTimes(1);
@ -148,7 +169,7 @@ describe('VersionSettings', () => {
.mockImplementationOnce(() => Promise.resolve(diffs.lhs)) .mockImplementationOnce(() => Promise.resolve(diffs.lhs))
.mockImplementationOnce(() => Promise.resolve(diffs.rhs)); .mockImplementationOnce(() => Promise.resolve(diffs.rhs));
render(<VersionsSettings dashboard={dashboard} />); setup();
expect(historySrv.getHistoryList).toBeCalledTimes(1); expect(historySrv.getHistoryList).toBeCalledTimes(1);
@ -163,7 +184,7 @@ describe('VersionSettings', () => {
await user.click(compareButton); 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 11 updated by admin')).toBeInTheDocument();
expect(queryByFullText('Version 2 updated by admin')).toBeInTheDocument(); expect(queryByFullText('Version 2 updated by admin')).toBeInTheDocument();

@ -1,8 +1,8 @@
import React, { PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { Spinner, HorizontalGroup } from '@grafana/ui'; import { Spinner, HorizontalGroup } from '@grafana/ui';
import { Page } from 'app/core/components/PageNew/Page';
import { DashboardModel } from '../../state/DashboardModel';
import { import {
historySrv, historySrv,
RevisionsModel, RevisionsModel,
@ -12,9 +12,9 @@ import {
VersionHistoryComparison, VersionHistoryComparison,
} from '../VersionHistory'; } from '../VersionHistory';
interface Props { import { SettingsPageProps } from './types';
dashboard: DashboardModel;
} interface Props extends SettingsPageProps {}
type State = { type State = {
isLoading: boolean; isLoading: boolean;
@ -141,9 +141,8 @@ export class VersionsSettings extends PureComponent<Props, State> {
if (viewMode === 'compare') { if (viewMode === 'compare') {
return ( return (
<div> <Page navModel={this.props.sectionNav}>
<VersionHistoryHeader <VersionHistoryHeader
isComparing
onClick={this.reset} onClick={this.reset}
baseVersion={baseInfo?.version} baseVersion={baseInfo?.version}
newVersion={newInfo?.version} newVersion={newInfo?.version}
@ -159,13 +158,12 @@ export class VersionsSettings extends PureComponent<Props, State> {
diffData={diffData} diffData={diffData}
/> />
)} )}
</div> </Page>
); );
} }
return ( return (
<div> <Page navModel={this.props.sectionNav}>
<VersionHistoryHeader />
{isLoading ? ( {isLoading ? (
<VersionsHistorySpinner msg="Fetching history list&hellip;" /> <VersionsHistorySpinner msg="Fetching history list&hellip;" />
) : ( ) : (
@ -181,7 +179,7 @@ export class VersionsSettings extends PureComponent<Props, State> {
isLastPage={!!this.isLastPage()} isLastPage={!!this.isLastPage()}
/> />
)} )}
</div> </Page>
); );
} }
} }

@ -0,0 +1,20 @@
import { ComponentType } from 'react';
import { NavModel } from '@grafana/data';
import { IconName } from '@grafana/ui';
import { DashboardModel } from '../../state';
export interface SettingsPage {
id: string;
title: string;
icon: IconName;
component: ComponentType<SettingsPageProps>;
subTitle?: string;
}
export interface SettingsPageProps {
dashboard: DashboardModel;
sectionNav: NavModel;
editIndex?: number;
}

@ -35,17 +35,19 @@ export const LinkSettingsList: React.FC<LinkSettingsListProps> = ({ dashboard, o
if (isEmptyList) { if (isEmptyList) {
return ( return (
<EmptyListCTA <div>
onClick={onNew} <EmptyListCTA
title="There are no dashboard links added yet" onClick={onNew}
buttonIcon="link" title="There are no dashboard links added yet"
buttonTitle="Add dashboard link" buttonIcon="link"
infoBoxTitle="What are dashboard links?" buttonTitle="Add dashboard link"
infoBox={{ infoBoxTitle="What are dashboard links?"
__html: infoBox={{
'<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>', __html:
}} '<p>Dashboard Links allow you to place links to other dashboards and web sites directly below the dashboard header.</p>',
/> }}
/>
</div>
); );
} }

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

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

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

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

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

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

@ -1,13 +1,12 @@
import React, { MouseEvent, PureComponent } from 'react'; import React, { PureComponent } from 'react';
import { connect, ConnectedProps } from 'react-redux'; import { connect, ConnectedProps } from 'react-redux';
import { bindActionCreators } from 'redux'; import { bindActionCreators } from 'redux';
import { selectors } from '@grafana/e2e-selectors'; import { locationService } from '@grafana/runtime';
import { Button, Icon } from '@grafana/ui'; import { Page } from 'app/core/components/PageNew/Page';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { SettingsPageProps } from 'app/features/dashboard/components/DashboardSettings/types';
import { StoreState, ThunkDispatch } from '../../../types'; import { StoreState, ThunkDispatch } from '../../../types';
import { VariablesDependenciesButton } from '../inspect/VariablesDependenciesButton';
import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable'; import { VariablesUnknownTable } from '../inspect/VariablesUnknownTable';
import { toKeyedAction } from '../state/keyedVariablesReducer'; import { toKeyedAction } from '../state/keyedVariablesReducer';
import { getEditorVariables, getVariablesState } from '../state/selectors'; import { getEditorVariables, getVariablesState } from '../state/selectors';
@ -17,7 +16,7 @@ import { toKeyedVariableIdentifier, toVariablePayload } from '../utils';
import { VariableEditorEditor } from './VariableEditorEditor'; import { VariableEditorEditor } from './VariableEditorEditor';
import { VariableEditorList } from './VariableEditorList'; import { VariableEditorList } from './VariableEditorList';
import { switchToEditMode, switchToListMode, switchToNewMode } from './actions'; import { createNewVariable, initListMode } from './actions';
const mapStateToProps = (state: StoreState, ownProps: OwnProps) => { const mapStateToProps = (state: StoreState, ownProps: OwnProps) => {
const { uid } = ownProps.dashboard; const { uid } = ownProps.dashboard;
@ -32,7 +31,7 @@ const mapStateToProps = (state: StoreState, ownProps: OwnProps) => {
const mapDispatchToProps = (dispatch: ThunkDispatch) => { const mapDispatchToProps = (dispatch: ThunkDispatch) => {
return { return {
...bindActionCreators({ switchToNewMode, switchToEditMode, switchToListMode }, dispatch), ...bindActionCreators({ createNewVariable, initListMode }, dispatch),
changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => changeVariableOrder: (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) =>
dispatch( dispatch(
toKeyedAction( toKeyedAction(
@ -55,30 +54,24 @@ const mapDispatchToProps = (dispatch: ThunkDispatch) => {
}; };
}; };
interface OwnProps { interface OwnProps extends SettingsPageProps {}
dashboard: DashboardModel;
}
const connector = connect(mapStateToProps, mapDispatchToProps); const connector = connect(mapStateToProps, mapDispatchToProps);
type Props = OwnProps & ConnectedProps<typeof connector>; type Props = OwnProps & ConnectedProps<typeof connector>;
class VariableEditorContainerUnconnected extends PureComponent<Props> { class VariableEditorContainerUnconnected extends PureComponent<Props> {
componentDidMount(): void { componentDidMount() {
this.props.switchToListMode(this.props.dashboard.uid); this.props.initListMode(this.props.dashboard.uid);
} }
onChangeToListMode = (event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
this.props.switchToListMode(this.props.dashboard.uid);
};
onEditVariable = (identifier: KeyedVariableIdentifier) => { onEditVariable = (identifier: KeyedVariableIdentifier) => {
this.props.switchToEditMode(identifier); const index = this.props.variables.findIndex((x) => x.id === identifier.id);
locationService.partial({ editIndex: index });
}; };
onNewVariable = () => { onNewVariable = () => {
this.props.switchToNewMode(this.props.dashboard.uid); this.props.createNewVariable(this.props.dashboard.uid);
}; };
onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => { onChangeVariableOrder = (identifier: KeyedVariableIdentifier, fromIndex: number, toIndex: number) => {
@ -94,41 +87,12 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
}; };
render() { 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 ( return (
<div> <Page navModel={this.props.sectionNav} pageNav={subPageNav}>
<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>
{!variableToEdit && ( {!variableToEdit && (
<VariableEditorList <VariableEditorList
variables={this.props.variables} variables={this.props.variables}
@ -145,7 +109,7 @@ class VariableEditorContainerUnconnected extends PureComponent<Props> {
<VariablesUnknownTable variables={this.props.variables} dashboard={this.props.dashboard} /> <VariablesUnknownTable variables={this.props.variables} dashboard={this.props.dashboard} />
)} )}
{variableToEdit && <VariableEditorEditor identifier={toKeyedVariableIdentifier(variableToEdit)} />} {variableToEdit && <VariableEditorEditor identifier={toKeyedVariableIdentifier(variableToEdit)} />}
</div> </Page>
); );
} }
} }

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

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

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

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

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

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

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

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

Loading…
Cancel
Save