Dashboards: show changes in save dialog (#46557)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Ryan McKinley 2022-03-16 09:28:09 -07:00 committed by GitHub
parent a338c78ca8
commit 15ca294be0
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
26 changed files with 475 additions and 382 deletions

View File

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

View File

@ -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<Props> = (args) => {
</UseState>
);
};
Global.args = {
title: 'Drawer title',
};
@ -223,7 +224,31 @@ export const InLine: Story<Props> = (args) => {
</UseState>
);
};
InLine.args = {
title: 'Storybook',
inline: true,
};
export function WithTabs() {
const [activeTab, setActiveTab] = useState('options');
const tabs = (
<TabsBar>
<Tab label={'Options'} active={activeTab === 'options'} onChangeTab={() => setActiveTab('options')} />
<Tab
label={'Changes'}
active={activeTab === 'changes'}
onChangeTab={() => setActiveTab('changes')}
counter={10}
/>
</TabsBar>
);
return (
<Drawer title={'Main title'} subtitle={'Sub title'} width={700} onClose={() => {}} tabs={tabs}>
{activeTab === 'options' && <div>Here are some options</div>}
{activeTab === 'changes' && <div>Here are some changes</div>}
</Drawer>
);
}

View File

@ -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<Props> = ({
export function Drawer({
children,
inline = false,
onClose,
@ -99,9 +43,9 @@ export const Drawer: FC<Props> = ({
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<Props> = ({
setIsOpen(true);
}, []);
const content = <div className={drawerStyles.content}>{children}</div>;
return (
<RcDrawer
level={null}
@ -172,15 +118,76 @@ export const Drawer: FC<Props> = ({
<h3 {...titleProps}>{title}</h3>
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
{typeof subtitle !== 'string' && subtitle}
{tabs && <div className={drawerStyles.tabsWrapper}>{tabs}</div>}
</div>
</div>
)}
{typeof title !== 'string' && title}
<div className={drawerStyles.content}>
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>}
<div className={drawerStyles.contentScroll}>
{!scrollableContent ? content : <CustomScrollbar autoHeightMin="100%">{content}</CustomScrollbar>}
</div>
</div>
</FocusScope>
</RcDrawer>
);
}
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),
}),
};
};

View File

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

View File

@ -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<Props> {
tooltip="Save dashboard"
icon="save"
onClick={() => {
showModal(SaveDashboardModalProxy, {
showModal(SaveDashboardDrawer, {
dashboard,
onDismiss: hideModal,
});

View File

@ -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<Props> = ({
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<Props> = ({
if (!tabs.find((item) => item.value === currentTab)) {
activeTab = InspectTab.JSON;
}
const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text');
return (
<Drawer
title={`Inspect: ${title || 'Panel'}`}
subtitle={
<InspectSubtitle
tabs={tabs}
tab={activeTab}
data={data}
onSelectTab={(item) => setCurrentTab(item.value || InspectTab.Data)}
/>
}
subtitle={data && formatStats(data)}
width="50%"
onClose={onClose}
expandable
scrollableContent
tabs={
<TabsBar>
{tabs.map((t, index) => {
return (
<Tab
key={`${t.value}-${index}`}
label={t.label}
active={t.value === activeTab}
onChangeTab={() => setCurrentTab(t.value || InspectTab.Data)}
/>
);
})}
</TabsBar>
}
>
{activeTab === InspectTab.Data && (
<InspectDataTab
@ -85,23 +90,31 @@ export const InspectContent: React.FC<Props> = ({
timeZone={dashboard.timezone}
/>
)}
<CustomScrollbar autoHeightMin="100%">
<TabContent className={styles.tabContent}>
{data && activeTab === InspectTab.Meta && (
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
)}
{data && activeTab === InspectTab.Meta && (
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
)}
{activeTab === InspectTab.JSON && (
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
)}
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
{data && activeTab === InspectTab.Query && (
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
)}
{activeTab === InspectTab.Actions && <InspectActionsTab panel={panel} data={data} />}
</TabContent>
</CustomScrollbar>
{activeTab === InspectTab.JSON && (
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
)}
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
{data && activeTab === InspectTab.Query && (
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
)}
</Drawer>
);
};
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}`;
}

View File

@ -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<Props> {
onSaveDashboard = () => {
appEvents.publish(
new ShowModalReactEvent({
component: SaveDashboardModalProxy,
component: SaveDashboardDrawer,
props: { dashboard: this.props.dashboard },
})
);

View File

@ -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 && (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={dashboardSaveModelClone}
onDismiss={onDismiss}
/>
)}
{!state.error && (
<Modal
isOpen={true}
title="Save dashboard as..."
icon="copy"
onDismiss={onDismiss}
className={css`
width: 500px;
`}
>
<SaveDashboardAsForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={onDismiss}
onSubmit={(clone, options, dashboard) => {
setDashboardSaveModelClone(clone);
return onDashboardSave(clone, options, dashboard);
}}
isNew={isNew}
/>
</Modal>
)}
</>
);
};

View File

@ -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<SaveDashboardButtonProps> = ({ dashbo
return (
<Button
onClick={() => {
showModal(SaveDashboardModalProxy, {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
@ -45,10 +44,11 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
<FullWidthButtonContainer>
<Button
onClick={() => {
showModal(SaveDashboardAsModal, {
showModal(SaveDashboardDrawer, {
dashboard,
onSaveSuccess,
onDismiss: hideModal,
isCopy: true,
});
}}
variant={variant}

View File

@ -0,0 +1,55 @@
import React from 'react';
import { css } from '@emotion/css';
import { Spinner, useStyles2 } from '@grafana/ui';
import { Diffs } from '../VersionHistory/utils';
import { DiffGroup } from '../VersionHistory/DiffGroup';
import { DiffViewer } from '../VersionHistory/DiffViewer';
import { GrafanaTheme2 } from '@grafana/data';
import { useAsync } from 'react-use';
interface SaveDashboardDiffProps {
oldValue?: any;
newValue?: any;
// calculated by parent so we can see summary in tabs
diff?: Diffs;
}
export const SaveDashboardDiff = ({ diff, oldValue, newValue }: SaveDashboardDiffProps) => {
const styles = useStyles2(getStyles);
const loader = useAsync(async () => {
const oldJSON = JSON.stringify(oldValue ?? {}, null, 2);
const newJSON = JSON.stringify(newValue ?? {}, null, 2);
return {
oldJSON,
newJSON,
diffs: Object.entries(diff ?? []).map(([key, diffs]) => (
<DiffGroup diffs={diffs} key={key} title={key} /> // this takes a long time for large diffs
)),
};
}, [diff, oldValue, newValue]);
const { value } = loader;
if (!value || !oldValue) {
return <Spinner />;
}
if (!value.diffs.length) {
return <div>No changes in this dashboard</div>;
}
return (
<div>
<div className={styles.spacer}>{value.diffs}</div>
<h4>JSON Diff</h4>
<DiffViewer oldValue={value.oldJSON} newValue={value.newJSON} />
</div>
);
};
const getStyles = (theme: GrafanaTheme2) => ({
spacer: css`
margin-bottom: ${theme.v1.spacing.xl};
`,
});

View File

@ -0,0 +1,128 @@
import React, { useMemo, useState } from 'react';
import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { SaveDashboardData, SaveDashboardModalProps, SaveDashboardOptions } from './types';
import { jsonDiff } from '../VersionHistory/utils';
import { useAsync } from 'react-use';
import { backendSrv } from 'app/core/services/backend_srv';
import { useDashboardSave } from './useDashboardSave';
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { SaveDashboardAsForm } from './forms/SaveDashboardAsForm';
import { SaveDashboardForm } from './forms/SaveDashboardForm';
import { SaveDashboardDiff } from './SaveDashboardDiff';
export const SaveDashboardDrawer = ({ dashboard, onDismiss, onSaveSuccess, isCopy }: SaveDashboardModalProps) => {
const [options, setOptions] = useState<SaveDashboardOptions>({});
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const previous = useAsync(async () => {
if (isNew) {
return undefined;
}
const result = await backendSrv.getDashboardByUid(dashboard.uid);
return result.dashboard;
}, [dashboard, isNew]);
const data = useMemo<SaveDashboardData>(() => {
const clone = dashboard.getSaveModelClone({
saveTimerange: Boolean(options.saveTimerange),
saveVariables: Boolean(options.saveVariables),
});
if (!previous.value) {
return { clone, diff: {}, diffCount: 0, hasChanges: false };
}
const cloneJSON = JSON.stringify(clone, null, 2);
const cloneSafe = JSON.parse(cloneJSON); // avoids undefined issues
const diff = jsonDiff(previous.value, cloneSafe);
let diffCount = 0;
for (const d of Object.values(diff)) {
diffCount += d.length;
}
return {
clone,
diff,
diffCount,
hasChanges: diffCount > 0 && !isNew,
};
}, [dashboard, previous.value, options, isNew]);
const [showDiff, setShowDiff] = useState(false);
const { state, onDashboardSave } = useDashboardSave(dashboard);
const onSuccess = onSaveSuccess
? () => {
onDismiss();
onSaveSuccess();
}
: onDismiss;
const renderBody = () => {
if (showDiff) {
return <SaveDashboardDiff diff={data.diff} oldValue={previous.value} newValue={data.clone} />;
}
if (isNew || isCopy) {
return (
<SaveDashboardAsForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={onSuccess}
onSubmit={onDashboardSave}
isNew={isNew}
/>
);
}
if (isProvisioned) {
return <SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onSuccess} />;
}
return (
<SaveDashboardForm
dashboard={dashboard}
saveModel={data}
onCancel={onDismiss}
onSuccess={onSuccess}
onSubmit={onDashboardSave}
options={options}
onOptionsChange={setOptions}
/>
);
};
if (state.error) {
return (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={data.clone}
onDismiss={onDismiss}
/>
);
}
return (
<Drawer
title={isCopy ? 'Save dashboard copy' : 'Save dashboard'}
onClose={onDismiss}
width={'40%'}
subtitle={dashboard.title}
tabs={
<TabsBar>
<Tab label={'Details'} active={!showDiff} onChangeTab={() => setShowDiff(false)} />
<Tab label={'Changes'} active={showDiff} onChangeTab={() => setShowDiff(true)} counter={data.diffCount} />
</TabsBar>
}
expandable
scrollableContent
>
{renderBody()}
</Drawer>
);
};

View File

@ -1,51 +0,0 @@
import React, { useState } from 'react';
import { Modal } from '@grafana/ui';
import { css } from '@emotion/css';
import { SaveDashboardForm } from './forms/SaveDashboardForm';
import { SaveDashboardErrorProxy } from './SaveDashboardErrorProxy';
import { useDashboardSave } from './useDashboardSave';
import { SaveDashboardModalProps } from './types';
export const SaveDashboardModal: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const { state, onDashboardSave } = useDashboardSave(dashboard);
const [dashboardSaveModelClone, setDashboardSaveModelClone] = useState();
return (
<>
{state.error && (
<SaveDashboardErrorProxy
error={state.error}
dashboard={dashboard}
dashboardSaveModel={dashboardSaveModelClone}
onDismiss={onDismiss}
/>
)}
{!state.error && (
<Modal
isOpen={true}
title="Save dashboard"
icon="copy"
onDismiss={onDismiss}
className={css`
width: 500px;
`}
>
<SaveDashboardForm
dashboard={dashboard}
onCancel={onDismiss}
onSuccess={() => {
onDismiss();
if (onSaveSuccess) {
onSaveSuccess();
}
}}
onSubmit={(clone, options, dashboard) => {
setDashboardSaveModelClone(clone);
return onDashboardSave(clone, options, dashboard);
}}
/>
</Modal>
)}
</>
);
};

View File

@ -1,25 +0,0 @@
import React from 'react';
import { SaveProvisionedDashboard } from './SaveProvisionedDashboard';
import { SaveDashboardAsModal } from './SaveDashboardAsModal';
import { SaveDashboardModalProps } from './types';
import { SaveDashboardModal } from './SaveDashboardModal';
export const SaveDashboardModalProxy: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss, onSaveSuccess }) => {
const isProvisioned = dashboard.meta.provisioned;
const isNew = dashboard.version === 0;
const isChanged = dashboard.version > 0;
const modalProps = {
dashboard,
onDismiss,
onSaveSuccess,
};
return (
<>
{isChanged && !isProvisioned && <SaveDashboardModal {...modalProps} />}
{isProvisioned && <SaveProvisionedDashboard {...modalProps} />}
{isNew && <SaveDashboardAsModal {...modalProps} isNew />}
</>
);
};

View File

@ -1,12 +0,0 @@
import React from 'react';
import { Modal } from '@grafana/ui';
import { SaveProvisionedDashboardForm } from './forms/SaveProvisionedDashboardForm';
import { SaveDashboardModalProps } from './types';
export const SaveProvisionedDashboard: React.FC<SaveDashboardModalProps> = ({ dashboard, onDismiss }) => {
return (
<Modal isOpen={true} title="Cannot save provisioned dashboard" icon="copy" onDismiss={onDismiss}>
<SaveProvisionedDashboardForm dashboard={dashboard} onCancel={onDismiss} onSuccess={onDismiss} />
</Modal>
);
};

View File

@ -1,5 +1,5 @@
import React from 'react';
import { Button, Input, Switch, Form, Field, InputControl, Modal } from '@grafana/ui';
import { Button, Input, Switch, Form, Field, InputControl, HorizontalGroup } from '@grafana/ui';
import { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SaveDashboardFormProps } from '../types';
@ -119,17 +119,19 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardAsFormProps> = ({
name="$folder"
/>
</Field>
<Field label="Copy tags">
<Switch {...register('copyTags')} />
</Field>
<Modal.ButtonRow>
{!isNew && (
<Field label="Copy tags">
<Switch {...register('copyTags')} />
</Field>
)}
<HorizontalGroup>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit" aria-label="Save dashboard button">
Save
</Button>
</Modal.ButtonRow>
</HorizontalGroup>
</>
)}
</Form>

View File

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

View File

@ -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<SaveDashboardFormProps> = ({ 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<any>;
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 (
<Form
onSubmit={async (data: SaveDashboardFormDTO) => {
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 }) => (
<>
<div>
{hasTimeChanged && (
<Checkbox
{...register('saveTimerange')}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
{...register('saveVariables')}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
{(hasVariableChanged || hasTimeChanged) && <div className="gf-form-group" />}
<Stack direction="column" gap={2}>
{hasTimeChanged && (
<Checkbox
checked={options.saveTimerange}
onChange={() =>
onOptionsChange({
...options,
saveTimerange: !options.saveTimerange,
})
}
label="Save current time range as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
/>
)}
{hasVariableChanged && (
<Checkbox
checked={options.saveVariables}
onChange={() =>
onOptionsChange({
...options,
saveVariables: !options.saveVariables,
})
}
label="Save current variable values as dashboard default"
aria-label={selectors.pages.SaveDashboardModal.saveVariables}
/>
)}
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus />
</div>
<TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
<Modal.ButtonRow>
<Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
<Button type="submit" aria-label={selectors.pages.SaveDashboardModal.save}>
Save
<Button
type="submit"
disabled={!saveModel.hasChanges}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
{saving ? '' : 'Save'}
</Button>
</Modal.ButtonRow>
</>
{!saveModel.hasChanges && <div>No changes to save</div>}
</Stack>
</Stack>
)}
</Form>
);

View File

@ -1,10 +1,11 @@
import React, { useCallback, useState } from 'react';
import { css } from '@emotion/css';
import { saveAs } from 'file-saver';
import { Button, ClipboardButton, Modal, stylesFactory, TextArea, useTheme } from '@grafana/ui';
import { Button, ClipboardButton, HorizontalGroup, stylesFactory, TextArea, useTheme } from '@grafana/ui';
import { SaveDashboardFormProps } from '../types';
import { GrafanaTheme } from '@grafana/data';
import { useAppNotification } from 'app/core/copy/appNotification';
import { Stack } from '@grafana/experimental';
export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({ dashboard, onCancel }) => {
const theme = useTheme();
@ -29,7 +30,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
const styles = getStyles(theme);
return (
<>
<div>
<Stack direction="column" gap={2}>
<div>
This dashboard cannot be saved from the Grafana UI because it has been provisioned from another source. Copy
the JSON or save it to a file below, then you can update your dashboard in the provisioning source.
@ -57,7 +58,7 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
}}
className={styles.json}
/>
<Modal.ButtonRow>
<HorizontalGroup>
<Button variant="secondary" onClick={onCancel} fill="outline">
Cancel
</Button>
@ -65,8 +66,8 @@ export const SaveProvisionedDashboardForm: React.FC<SaveDashboardFormProps> = ({
Copy JSON to clipboard
</ClipboardButton>
<Button onClick={saveToFile}>Save JSON to file</Button>
</Modal.ButtonRow>
</div>
</HorizontalGroup>
</Stack>
</>
);
};

View File

@ -1,4 +1,12 @@
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { Diffs } from '../VersionHistory/utils';
export interface SaveDashboardData {
clone: DashboardModel; // cloned copy
diff: Diffs;
diffCount: number; // cumulative count
hasChanges: boolean; // not new and has changes
}
export interface SaveDashboardOptions extends CloneOptions {
folderId?: number;
@ -18,4 +26,5 @@ export interface SaveDashboardModalProps {
dashboard: DashboardModel;
onDismiss: () => void;
onSaveSuccess?: () => void;
isCopy?: boolean;
}

View File

@ -26,6 +26,7 @@ import {
AnnotationQuery,
AppEvent,
DashboardCursorSync,
dateTime,
dateTimeFormat,
dateTimeFormatTimeAgo,
DateTimeInput,
@ -1077,7 +1078,16 @@ export class DashboardModel implements TimeModel {
}
hasTimeChanged() {
return !isEqual(this.time, this.originalTime);
const { time, originalTime } = this;
if (isEqual(time, originalTime)) {
return false;
}
// Compare momemt values vs strings values
return !(
isEqual(dateTime(time?.from), dateTime(originalTime?.from)) &&
isEqual(dateTime(time?.to), dateTime(originalTime?.to))
);
}
resetOriginalVariables(initial = false) {

View File

@ -14,7 +14,7 @@ import {
transformDataFrame,
TimeZone,
} from '@grafana/data';
import { Button, Container, Spinner, Table } from '@grafana/ui';
import { Button, Spinner, Table } from '@grafana/ui';
import { selectors } from '@grafana/e2e-selectors';
import { InspectDataOptions } from './InspectDataOptions';
import { getPanelInspectorStyles } from './styles';
@ -232,8 +232,8 @@ export class InspectDataTab extends PureComponent<Props, State> {
const hasTraces = dataFrames.some((df) => df?.meta?.preferredVisualisationType === 'trace');
return (
<div className={styles.dataTabContent} aria-label={selectors.components.PanelInspector.Data.content}>
<div className={styles.actionsWrapper}>
<div className={styles.wrap} aria-label={selectors.components.PanelInspector.Data.content}>
<div className={styles.toolbar}>
<InspectDataOptions
data={data}
panel={panel}
@ -281,7 +281,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
</Button>
)}
</div>
<Container grow={1}>
<div className={styles.content}>
<AutoSizer>
{({ width, height }) => {
if (width === 0) {
@ -295,7 +295,7 @@ export class InspectDataTab extends PureComponent<Props, State> {
);
}}
</AutoSizer>
</Container>
</div>
</div>
);
}

View File

@ -26,7 +26,7 @@ export const InspectErrorTab: React.FC<InspectErrorTabProps> = ({ error }) => {
if (error.data) {
return (
<>
<h3>{error.data.message}</h3>
<h4>{error.data.message}</h4>
<JSONFormatter json={error} open={2} />
</>
);

View File

@ -129,7 +129,7 @@ export class InspectJSONTab extends PureComponent<Props, State> {
const styles = getPanelInspectorStyles();
return (
<>
<div className={styles.wrap}>
<div className={styles.toolbar} aria-label={selectors.components.PanelInspector.Json.content}>
<Field label="Select source" className="flex-grow-1">
<Select
@ -162,7 +162,7 @@ export class InspectJSONTab extends PureComponent<Props, State> {
)}
</AutoSizer>
</div>
</>
</div>
);
}
}

View File

@ -1,57 +0,0 @@
import React, { FC } from 'react';
import { css } from '@emotion/css';
import { stylesFactory, useTheme, Tab, TabsBar } from '@grafana/ui';
import { GrafanaTheme, SelectableValue, PanelData, getValueFormat, formattedValueToString } from '@grafana/data';
import { InspectTab } from '../inspector/types';
interface Props {
tab: InspectTab;
tabs: Array<{ label: string; value: InspectTab }>;
data?: PanelData;
onSelectTab: (tab: SelectableValue<InspectTab>) => void;
}
export const InspectSubtitle: FC<Props> = ({ tab, tabs, onSelectTab, data }) => {
const theme = useTheme();
const styles = getStyles(theme);
return (
<>
{data && <div className="muted">{formatStats(data)}</div>}
<TabsBar className={styles.tabsBar}>
{tabs.map((t, index) => {
return (
<Tab
key={`${t.value}-${index}`}
label={t.label}
active={t.value === tab}
onChangeTab={() => onSelectTab(t)}
/>
);
})}
</TabsBar>
</>
);
};
const getStyles = stylesFactory((theme: GrafanaTheme) => {
return {
tabsBar: css`
padding-left: ${theme.spacing.md};
margin: ${theme.spacing.lg} -${theme.spacing.sm} -${theme.spacing.lg} -${theme.spacing.lg};
`,
};
});
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}`;
}

View File

@ -264,7 +264,7 @@ export class QueryInspector extends PureComponent<Props, State> {
}
return (
<>
<div className={styles.wrap}>
<div aria-label={selectors.components.PanelInspector.Query.content}>
<h3 className="section-heading">Query inspector</h3>
<p className="small muted">
@ -306,7 +306,7 @@ export class QueryInspector extends PureComponent<Props, State> {
)}
<div className="flex-grow-1" />
</div>
<div className={styles.contentQueryInspector}>
<div className={styles.content}>
{isLoading && <LoadingPlaceholder text="Loading query inspector..." />}
{!isLoading && haveData && (
<JSONFormatter json={response} open={openNodes} onDidRender={this.setFormattedJson} />
@ -315,7 +315,7 @@ export class QueryInspector extends PureComponent<Props, State> {
<p className="muted">No request and response collected yet. Hit refresh button</p>
)}
</div>
</>
</div>
);
}
}

View File

@ -25,11 +25,6 @@ export const getPanelInspectorStyles = stylesFactory(() => {
content: css`
flex-grow: 1;
height: 100%;
padding-bottom: 16px;
`,
contentQueryInspector: css`
flex-grow: 1;
padding: ${config.theme.spacing.md} 0;
`,
editor: css`
font-family: monospace;
@ -42,20 +37,6 @@ export const getPanelInspectorStyles = stylesFactory(() => {
dataFrameSelect: css`
flex-grow: 2;
`,
tabContent: css`
height: 100%;
display: flex;
flex-direction: column;
`,
dataTabContent: css`
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
`,
actionsWrapper: css`
display: flex;
`,
leftActions: css`
display: flex;
flex-grow: 1;