From 15ca294be055225da24e5327efaa116d4f7fa166 Mon Sep 17 00:00:00 2001 From: Ryan McKinley Date: Wed, 16 Mar 2022 09:28:09 -0700 Subject: [PATCH] Dashboards: show changes in save dialog (#46557) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Torkel Ödegaard --- .betterer.results | 2 +- .../src/components/Drawer/Drawer.story.tsx | 29 +++- .../src/components/Drawer/Drawer.tsx | 139 +++++++++--------- public/app/core/services/keybindingSrv.ts | 4 +- .../dashboard/components/DashNav/DashNav.tsx | 4 +- .../components/Inspector/InspectContent.tsx | 73 +++++---- .../components/PanelEditor/PanelEditor.tsx | 4 +- .../SaveDashboard/SaveDashboardAsModal.tsx | 50 ------- .../SaveDashboard/SaveDashboardButton.tsx | 8 +- .../SaveDashboard/SaveDashboardDiff.tsx | 55 +++++++ .../SaveDashboard/SaveDashboardDrawer.tsx | 128 ++++++++++++++++ .../SaveDashboard/SaveDashboardModal.tsx | 51 ------- .../SaveDashboard/SaveDashboardModalProxy.tsx | 25 ---- .../SaveProvisionedDashboard.tsx | 12 -- .../forms/SaveDashboardAsForm.tsx | 14 +- .../forms/SaveDashboardForm.test.tsx | 21 +++ .../SaveDashboard/forms/SaveDashboardForm.tsx | 108 +++++++++----- .../forms/SaveProvisionedDashboardForm.tsx | 11 +- .../components/SaveDashboard/types.ts | 9 ++ .../dashboard/state/DashboardModel.ts | 12 +- .../app/features/inspector/InspectDataTab.tsx | 10 +- .../features/inspector/InspectErrorTab.tsx | 2 +- .../app/features/inspector/InspectJSONTab.tsx | 4 +- .../features/inspector/InspectSubtitle.tsx | 57 ------- .../app/features/inspector/QueryInspector.tsx | 6 +- public/app/features/inspector/styles.ts | 19 --- 26 files changed, 475 insertions(+), 382 deletions(-) delete mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardDiff.tsx create mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer.tsx delete mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardModal.tsx delete mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy.tsx delete mode 100644 public/app/features/dashboard/components/SaveDashboard/SaveProvisionedDashboard.tsx delete mode 100644 public/app/features/inspector/InspectSubtitle.tsx diff --git a/.betterer.results b/.betterer.results index baea248389d..9bc89b713c3 100644 --- a/.betterer.results +++ b/.betterer.results @@ -197,7 +197,7 @@ exports[`no enzyme tests`] = { "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx:2536713486": [ [1, 17, 13, "RegExp match", "2409514259"] ], - "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:4134073823": [ + "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx:1262111696": [ [1, 17, 13, "RegExp match", "2409514259"] ], "public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [ diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx index b72914b8a9e..db92c2864b3 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.story.tsx @@ -1,6 +1,6 @@ -import React from 'react'; +import React, { useState } from 'react'; import { Story } from '@storybook/react'; -import { Button, Drawer } from '@grafana/ui'; +import { Button, Drawer, Tab, TabsBar } from '@grafana/ui'; import { UseState } from '../../utils/storybook/UseState'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import mdx from './Drawer.mdx'; @@ -72,6 +72,7 @@ export const Global: Story = (args) => { ); }; + Global.args = { title: 'Drawer title', }; @@ -223,7 +224,31 @@ export const InLine: Story = (args) => { ); }; + InLine.args = { title: 'Storybook', inline: true, }; + +export function WithTabs() { + const [activeTab, setActiveTab] = useState('options'); + + const tabs = ( + + setActiveTab('options')} /> + setActiveTab('changes')} + counter={10} + /> + + ); + + return ( + {}} tabs={tabs}> + {activeTab === 'options' &&
Here are some options
} + {activeTab === 'changes' &&
Here are some changes
} +
+ ); +} diff --git a/packages/grafana-ui/src/components/Drawer/Drawer.tsx b/packages/grafana-ui/src/components/Drawer/Drawer.tsx index d0c4600bc30..1f515190742 100644 --- a/packages/grafana-ui/src/components/Drawer/Drawer.tsx +++ b/packages/grafana-ui/src/components/Drawer/Drawer.tsx @@ -1,4 +1,4 @@ -import React, { CSSProperties, FC, ReactNode, useState, useEffect } from 'react'; +import React, { CSSProperties, ReactNode, useState, useEffect } from 'react'; import { GrafanaTheme2 } from '@grafana/data'; import RcDrawer from 'rc-drawer'; import { css } from '@emotion/css'; @@ -6,7 +6,7 @@ import { selectors } from '@grafana/e2e-selectors'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { IconButton } from '../IconButton/IconButton'; -import { stylesFactory, useTheme2 } from '../../themes'; +import { useStyles2 } from '../../themes'; import { FocusScope } from '@react-aria/focus'; import { useDialog } from '@react-aria/dialog'; import { useOverlay } from '@react-aria/overlays'; @@ -25,71 +25,15 @@ export interface Props { width?: number | string; /** Should the Drawer be expandable to full width */ expandable?: boolean; - + /** Tabs */ + tabs?: React.ReactNode; /** Set to true if the component rendered within in drawer content has its own scroll */ scrollableContent?: boolean; - /** Callback for closing the drawer */ onClose: () => void; } -const getStyles = stylesFactory((theme: GrafanaTheme2, scrollableContent: boolean) => { - return { - container: css` - display: flex; - flex-direction: column; - height: 100%; - `, - drawer: css` - .drawer-content { - background-color: ${theme.colors.background.primary}; - display: flex; - flex-direction: column; - overflow: hidden; - } - &.drawer-open .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - opacity: 1; - } - .drawer-mask { - background-color: ${theme.components.overlay.background}; - backdrop-filter: blur(1px); - } - .drawer-open .drawer-content-wrapper { - box-shadow: ${theme.shadows.z3}; - } - z-index: ${theme.zIndex.dropdown}; - `, - header: css` - background-color: ${theme.colors.background.canvas}; - z-index: 1; - flex-grow: 0; - padding-top: ${theme.spacing(0.5)}; - `, - actions: css` - display: flex; - align-items: baseline; - justify-content: flex-end; - `, - titleWrapper: css` - margin-bottom: ${theme.spacing(3)}; - padding: ${theme.spacing(0, 1, 0, 3)}; - overflow-wrap: break-word; - `, - titleSpacing: css` - margin-bottom: ${theme.spacing(2)}; - `, - content: css` - padding: ${theme.spacing(2)}; - flex: 1; - overflow: ${!scrollableContent ? 'hidden' : 'auto'}; - z-index: 0; - `, - }; -}); - -export const Drawer: FC = ({ +export function Drawer({ children, inline = false, onClose, @@ -99,9 +43,9 @@ export const Drawer: FC = ({ subtitle, width = '40%', expandable = false, -}) => { - const theme = useTheme2(); - const drawerStyles = getStyles(theme, scrollableContent); + tabs, +}: Props) { + const drawerStyles = useStyles2(getStyles); const [isExpanded, setIsExpanded] = useState(false); const [isOpen, setIsOpen] = useState(false); const currentWidth = isExpanded ? '100%' : width; @@ -119,6 +63,8 @@ export const Drawer: FC = ({ setIsOpen(true); }, []); + const content =
{children}
; + return ( = ({

{title}

{typeof subtitle === 'string' &&
{subtitle}
} {typeof subtitle !== 'string' && subtitle} + {tabs &&
{tabs}
} )} {typeof title !== 'string' && title} -
- {!scrollableContent ? children : {children}} +
+ {!scrollableContent ? content : {content}}
); +} + +const getStyles = (theme: GrafanaTheme2) => { + return { + container: css` + display: flex; + flex-direction: column; + height: 100%; + flex: 1 1 0; + `, + drawer: css` + .drawer-content { + background-color: ${theme.colors.background.primary}; + display: flex; + flex-direction: column; + overflow: hidden; + } + &.drawer-open .drawer-mask { + background-color: ${theme.components.overlay.background}; + backdrop-filter: blur(1px); + opacity: 1; + } + .drawer-mask { + background-color: ${theme.components.overlay.background}; + backdrop-filter: blur(1px); + } + .drawer-open .drawer-content-wrapper { + box-shadow: ${theme.shadows.z3}; + } + z-index: ${theme.zIndex.dropdown}; + `, + header: css` + background-color: ${theme.colors.background.canvas}; + flex-grow: 0; + padding-top: ${theme.spacing(0.5)}; + `, + actions: css` + display: flex; + align-items: baseline; + justify-content: flex-end; + `, + titleWrapper: css` + margin-bottom: ${theme.spacing(3)}; + padding: ${theme.spacing(0, 1, 0, 3)}; + overflow-wrap: break-word; + `, + content: css({ + padding: theme.spacing(2), + height: '100%', + flexGrow: 1, + }), + contentScroll: css({ + minHeight: 0, + flex: 1, + }), + tabsWrapper: css({ + paddingLeft: theme.spacing(2), + margin: theme.spacing(3, -1, -3, -3), + }), + }; }; diff --git a/public/app/core/services/keybindingSrv.ts b/public/app/core/services/keybindingSrv.ts index 765b2a09b61..ef83ca261bc 100644 --- a/public/app/core/services/keybindingSrv.ts +++ b/public/app/core/services/keybindingSrv.ts @@ -5,7 +5,7 @@ import appEvents from 'app/core/app_events'; import { getExploreUrl } from 'app/core/utils/explore'; import { DashboardModel } from 'app/features/dashboard/state'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; -import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; +import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { locationService } from '@grafana/runtime'; import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk'; import { @@ -200,7 +200,7 @@ export class KeybindingSrv { if (dashboard.meta.canSave) { appEvents.publish( new ShowModalReactEvent({ - component: SaveDashboardModalProxy, + component: SaveDashboardDrawer, props: { dashboard, }, diff --git a/public/app/features/dashboard/components/DashNav/DashNav.tsx b/public/app/features/dashboard/components/DashNav/DashNav.tsx index f35d7f6eb1e..9b445819720 100644 --- a/public/app/features/dashboard/components/DashNav/DashNav.tsx +++ b/public/app/features/dashboard/components/DashNav/DashNav.tsx @@ -14,7 +14,7 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers'; import { DashboardModel } from '../../state'; import { KioskMode } from 'app/types'; import { ShareModal } from 'app/features/dashboard/components/ShareModal'; -import { SaveDashboardModalProxy } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardModalProxy'; +import { SaveDashboardDrawer } from 'app/features/dashboard/components/SaveDashboard/SaveDashboardDrawer'; import { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal'; import { locationService } from '@grafana/runtime'; import { toggleKioskMode } from 'app/core/navigation/kiosk'; @@ -228,7 +228,7 @@ class DashNav extends PureComponent { tooltip="Save dashboard" icon="save" onClick={() => { - showModal(SaveDashboardModalProxy, { + showModal(SaveDashboardDrawer, { dashboard, onDismiss: hideModal, }); diff --git a/public/app/features/dashboard/components/Inspector/InspectContent.tsx b/public/app/features/dashboard/components/Inspector/InspectContent.tsx index 311059c79d0..b9852f42821 100644 --- a/public/app/features/dashboard/components/Inspector/InspectContent.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectContent.tsx @@ -1,10 +1,8 @@ import React, { useState } from 'react'; -import { DataSourceApi, PanelData, PanelPlugin } from '@grafana/data'; +import { DataSourceApi, formattedValueToString, getValueFormat, PanelData, PanelPlugin } from '@grafana/data'; import { getTemplateSrv } from '@grafana/runtime'; -import { CustomScrollbar, Drawer, TabContent } from '@grafana/ui'; -import { getPanelInspectorStyles } from 'app/features/inspector/styles'; +import { Drawer, Tab, TabsBar } from '@grafana/ui'; import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab'; -import { InspectSubtitle } from 'app/features/inspector/InspectSubtitle'; import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab'; import { QueryInspector } from 'app/features/inspector/QueryInspector'; import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab'; @@ -13,7 +11,6 @@ import { InspectDataTab } from 'app/features/inspector/InspectDataTab'; import { InspectTab } from 'app/features/inspector/types'; import { DashboardModel, PanelModel } from '../../state'; import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; -import { InspectActionsTab } from './PanelInspectActions'; interface Props { dashboard: DashboardModel; @@ -50,7 +47,6 @@ export const InspectContent: React.FC = ({ return null; } - const styles = getPanelInspectorStyles(); const error = data?.error; // Validate that the active tab is actually valid and allowed @@ -58,22 +54,31 @@ export const InspectContent: React.FC = ({ if (!tabs.find((item) => item.value === currentTab)) { activeTab = InspectTab.JSON; } + const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text'); return ( setCurrentTab(item.value || InspectTab.Data)} - /> - } + subtitle={data && formatStats(data)} width="50%" onClose={onClose} expandable + scrollableContent + tabs={ + + {tabs.map((t, index) => { + return ( + setCurrentTab(t.value || InspectTab.Data)} + /> + ); + })} + + } > {activeTab === InspectTab.Data && ( = ({ timeZone={dashboard.timezone} /> )} - - - {data && activeTab === InspectTab.Meta && ( - - )} + {data && activeTab === InspectTab.Meta && ( + + )} - {activeTab === InspectTab.JSON && ( - - )} - {activeTab === InspectTab.Error && } - {data && activeTab === InspectTab.Stats && } - {data && activeTab === InspectTab.Query && ( - panel.refresh()} /> - )} - {activeTab === InspectTab.Actions && } - - + {activeTab === InspectTab.JSON && ( + + )} + {activeTab === InspectTab.Error && } + {data && activeTab === InspectTab.Stats && } + {data && activeTab === InspectTab.Query && ( + panel.refresh()} /> + )} ); }; + +function formatStats(data: PanelData) { + const { request } = data; + if (!request) { + return ''; + } + + const queryCount = request.targets.length; + const requestTime = request.endTime ? request.endTime - request.startTime : 0; + const formatted = formattedValueToString(getValueFormat('ms')(requestTime)); + + return `${queryCount} queries with total query time of ${formatted}`; +} diff --git a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx index 1f50138843c..bdef1ca6501 100644 --- a/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx +++ b/public/app/features/dashboard/components/PanelEditor/PanelEditor.tsx @@ -25,7 +25,7 @@ import { DashNavTimeControls } from '../DashNav/DashNavTimeControls'; import { OptionsPane } from './OptionsPane'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; -import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy'; +import { SaveDashboardDrawer } from '../SaveDashboard/SaveDashboardDrawer'; import { DashboardPanel } from '../../dashgrid/DashboardPanel'; import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions'; @@ -145,7 +145,7 @@ export class PanelEditorUnconnected extends PureComponent { onSaveDashboard = () => { appEvents.publish( new ShowModalReactEvent({ - component: SaveDashboardModalProxy, + component: SaveDashboardDrawer, props: { dashboard: this.props.dashboard }, }) ); diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx deleted file mode 100644 index ca9c0364b7c..00000000000 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardAsModal.tsx +++ /dev/null @@ -1,50 +0,0 @@ -import React, { useState } from 'react'; -import { css } from '@emotion/css'; -import { Modal } from '@grafana/ui'; -import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm'; -import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy'; -import { useDashboardSave } from './useDashboardSave'; -import { SaveDashboardModalProps } from './types'; - -export const SaveDashboardAsModal: React.FC< - SaveDashboardModalProps & { - isNew?: boolean; - } -> = ({ dashboard, onDismiss, isNew }) => { - const { state, onDashboardSave } = useDashboardSave(dashboard); - const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState(); - return ( - <> - {state.error && ( - - )} - {!state.error && ( - - { - setDashboardSaveModelClone(clone); - return onDashboardSave(clone, options, dashboard); - }} - isNew={isNew} - /> - - )} - - ); -}; diff --git a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx index 54b6f15c51f..3e3b19780d5 100644 --- a/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/SaveDashboardButton.tsx @@ -1,8 +1,7 @@ import React from 'react'; import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui'; import { DashboardModel } from 'app/features/dashboard/state'; -import { SaveDashboardAsModal } from './SaveDashboardAsModal'; -import { SaveDashboardModalProxy } from './SaveDashboardModalProxy'; +import { SaveDashboardDrawer } from './SaveDashboardDrawer'; import { selectors } from '@grafana/e2e-selectors'; interface SaveDashboardButtonProps { @@ -17,7 +16,7 @@ export const SaveDashboardButton: React.FC = ({ dashbo return ( - + )} diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx index f67c5905d91..ff7a020d637 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.test.tsx @@ -3,6 +3,7 @@ import { mount } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { DashboardModel } from 'app/features/dashboard/state'; import { SaveDashboardForm } from './SaveDashboardForm'; +import { SaveDashboardOptions } from '../types'; const prepareDashboardMock = ( timeChanged: boolean, @@ -36,6 +37,16 @@ const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => { submitSpy(jsonModel); return { status: 'success' }; }} + saveModel={{ + clone: dashboard, + diff: {}, + diffCount: 0, + hasChanges: true, + }} + options={{}} + onOptionsChange={(opts: SaveDashboardOptions) => { + return; + }} /> ); @@ -56,6 +67,16 @@ describe('SaveDashboardAsForm', () => { onSubmit={async () => { return {}; }} + saveModel={{ + clone: prepareDashboardMock(true, true, jest.fn(), jest.fn()) as any, + diff: {}, + diffCount: 0, + hasChanges: true, + }} + options={{}} + onOptionsChange={(opts: SaveDashboardOptions) => { + return; + }} /> ); diff --git a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx index e9259f2147e..3807609231c 100644 --- a/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx +++ b/public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardForm.tsx @@ -1,70 +1,106 @@ -import React, { useMemo } from 'react'; +import React, { useMemo, useState } from 'react'; -import { Button, Checkbox, Form, Modal, TextArea } from '@grafana/ui'; +import { Button, Checkbox, Form, TextArea } from '@grafana/ui'; import { selectors } from '@grafana/e2e-selectors'; -import { SaveDashboardFormProps } from '../types'; +import { DashboardModel } from 'app/features/dashboard/state'; +import { SaveDashboardData, SaveDashboardOptions } from '../types'; +import { Stack } from '@grafana/experimental'; -interface SaveDashboardFormDTO { +interface FormDTO { message: string; - saveVariables: boolean; - saveTimerange: boolean; } -export const SaveDashboardForm: React.FC = ({ dashboard, onCancel, onSuccess, onSubmit }) => { +type Props = { + dashboard: DashboardModel; // original + saveModel: SaveDashboardData; // already cloned + onCancel: () => void; + onSuccess: () => void; + onSubmit?: (clone: any, options: SaveDashboardOptions, dashboard: DashboardModel) => Promise; + options: SaveDashboardOptions; + onOptionsChange: (opts: SaveDashboardOptions) => void; +}; + +export const SaveDashboardForm = ({ + dashboard, + saveModel, + options, + onSubmit, + onCancel, + onSuccess, + onOptionsChange, +}: Props) => { const hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]); const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]); + const [saving, setSaving] = useState(false); + return (
{ + onSubmit={async (data: FormDTO) => { if (!onSubmit) { return; } - - const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard); + setSaving(true); + const result = await onSubmit(saveModel.clone, options, dashboard); if (result.status === 'success') { - if (data.saveVariables) { + if (options.saveVariables) { dashboard.resetOriginalVariables(); } - if (data.saveTimerange) { + if (options.saveTimerange) { dashboard.resetOriginalTime(); } onSuccess(); } + setSaving(false); }} > {({ register, errors }) => ( - <> -
- {hasTimeChanged && ( - - )} - {hasVariableChanged && ( - - )} - {(hasVariableChanged || hasTimeChanged) &&
} + + {hasTimeChanged && ( + + onOptionsChange({ + ...options, + saveTimerange: !options.saveTimerange, + }) + } + label="Save current time range as dashboard default" + aria-label={selectors.pages.SaveDashboardModal.saveTimerange} + /> + )} + {hasVariableChanged && ( + + onOptionsChange({ + ...options, + saveVariables: !options.saveVariables, + }) + } + label="Save current variable values as dashboard default" + aria-label={selectors.pages.SaveDashboardModal.saveVariables} + /> + )} -