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
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": [ "public/app/features/dashboard/components/SaveDashboard/forms/SaveDashboardAsForm.test.tsx:2536713486": [
[1, 17, 13, "RegExp match", "2409514259"] [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"] [1, 17, 13, "RegExp match", "2409514259"]
], ],
"public/app/features/dashboard/components/ShareModal/ShareLink.test.tsx:1044891955": [ "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 { 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 { UseState } from '../../utils/storybook/UseState';
import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; import { withCenteredStory } from '../../utils/storybook/withCenteredStory';
import mdx from './Drawer.mdx'; import mdx from './Drawer.mdx';
@@ -72,6 +72,7 @@ export const Global: Story<Props> = (args) => {
</UseState> </UseState>
); );
}; };
Global.args = { Global.args = {
title: 'Drawer title', title: 'Drawer title',
}; };
@@ -223,7 +224,31 @@ export const InLine: Story<Props> = (args) => {
</UseState> </UseState>
); );
}; };
InLine.args = { InLine.args = {
title: 'Storybook', title: 'Storybook',
inline: true, 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 { GrafanaTheme2 } from '@grafana/data';
import RcDrawer from 'rc-drawer'; import RcDrawer from 'rc-drawer';
import { css } from '@emotion/css'; import { css } from '@emotion/css';
@@ -6,7 +6,7 @@ import { selectors } from '@grafana/e2e-selectors';
import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar'; import { CustomScrollbar } from '../CustomScrollbar/CustomScrollbar';
import { IconButton } from '../IconButton/IconButton'; import { IconButton } from '../IconButton/IconButton';
import { stylesFactory, useTheme2 } from '../../themes'; import { useStyles2 } from '../../themes';
import { FocusScope } from '@react-aria/focus'; import { FocusScope } from '@react-aria/focus';
import { useDialog } from '@react-aria/dialog'; import { useDialog } from '@react-aria/dialog';
import { useOverlay } from '@react-aria/overlays'; import { useOverlay } from '@react-aria/overlays';
@@ -25,71 +25,15 @@ export interface Props {
width?: number | string; width?: number | string;
/** Should the Drawer be expandable to full width */ /** Should the Drawer be expandable to full width */
expandable?: boolean; expandable?: boolean;
/** Tabs */
tabs?: React.ReactNode;
/** Set to true if the component rendered within in drawer content has its own scroll */ /** Set to true if the component rendered within in drawer content has its own scroll */
scrollableContent?: boolean; scrollableContent?: boolean;
/** Callback for closing the drawer */ /** Callback for closing the drawer */
onClose: () => void; onClose: () => void;
} }
const getStyles = stylesFactory((theme: GrafanaTheme2, scrollableContent: boolean) => { export function Drawer({
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> = ({
children, children,
inline = false, inline = false,
onClose, onClose,
@@ -99,9 +43,9 @@ export const Drawer: FC<Props> = ({
subtitle, subtitle,
width = '40%', width = '40%',
expandable = false, expandable = false,
}) => { tabs,
const theme = useTheme2(); }: Props) {
const drawerStyles = getStyles(theme, scrollableContent); const drawerStyles = useStyles2(getStyles);
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
const [isOpen, setIsOpen] = useState(false); const [isOpen, setIsOpen] = useState(false);
const currentWidth = isExpanded ? '100%' : width; const currentWidth = isExpanded ? '100%' : width;
@@ -119,6 +63,8 @@ export const Drawer: FC<Props> = ({
setIsOpen(true); setIsOpen(true);
}, []); }, []);
const content = <div className={drawerStyles.content}>{children}</div>;
return ( return (
<RcDrawer <RcDrawer
level={null} level={null}
@@ -172,15 +118,76 @@ export const Drawer: FC<Props> = ({
<h3 {...titleProps}>{title}</h3> <h3 {...titleProps}>{title}</h3>
{typeof subtitle === 'string' && <div className="muted">{subtitle}</div>} {typeof subtitle === 'string' && <div className="muted">{subtitle}</div>}
{typeof subtitle !== 'string' && subtitle} {typeof subtitle !== 'string' && subtitle}
{tabs && <div className={drawerStyles.tabsWrapper}>{tabs}</div>}
</div> </div>
</div> </div>
)} )}
{typeof title !== 'string' && title} {typeof title !== 'string' && title}
<div className={drawerStyles.content}> <div className={drawerStyles.contentScroll}>
{!scrollableContent ? children : <CustomScrollbar>{children}</CustomScrollbar>} {!scrollableContent ? content : <CustomScrollbar autoHeightMin="100%">{content}</CustomScrollbar>}
</div> </div>
</div> </div>
</FocusScope> </FocusScope>
</RcDrawer> </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 { getExploreUrl } from 'app/core/utils/explore';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { ShareModal } from 'app/features/dashboard/components/ShareModal'; 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 { locationService } from '@grafana/runtime';
import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk'; import { exitKioskMode, toggleKioskMode } from '../navigation/kiosk';
import { import {
@@ -200,7 +200,7 @@ export class KeybindingSrv {
if (dashboard.meta.canSave) { if (dashboard.meta.canSave) {
appEvents.publish( appEvents.publish(
new ShowModalReactEvent({ new ShowModalReactEvent({
component: SaveDashboardModalProxy, component: SaveDashboardDrawer,
props: { props: {
dashboard, dashboard,
}, },

View File

@@ -14,7 +14,7 @@ import { updateTimeZoneForSession } from 'app/features/profile/state/reducers';
import { DashboardModel } from '../../state'; import { DashboardModel } from '../../state';
import { KioskMode } from 'app/types'; import { KioskMode } from 'app/types';
import { ShareModal } from 'app/features/dashboard/components/ShareModal'; 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 { DashboardCommentsModal } from 'app/features/dashboard/components/DashboardComments/DashboardCommentsModal';
import { locationService } from '@grafana/runtime'; import { locationService } from '@grafana/runtime';
import { toggleKioskMode } from 'app/core/navigation/kiosk'; import { toggleKioskMode } from 'app/core/navigation/kiosk';
@@ -228,7 +228,7 @@ class DashNav extends PureComponent<Props> {
tooltip="Save dashboard" tooltip="Save dashboard"
icon="save" icon="save"
onClick={() => { onClick={() => {
showModal(SaveDashboardModalProxy, { showModal(SaveDashboardDrawer, {
dashboard, dashboard,
onDismiss: hideModal, onDismiss: hideModal,
}); });

View File

@@ -1,10 +1,8 @@
import React, { useState } from 'react'; 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 { getTemplateSrv } from '@grafana/runtime';
import { CustomScrollbar, Drawer, TabContent } from '@grafana/ui'; import { Drawer, Tab, TabsBar } from '@grafana/ui';
import { getPanelInspectorStyles } from 'app/features/inspector/styles';
import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab'; import { InspectMetadataTab } from 'app/features/inspector/InspectMetadataTab';
import { InspectSubtitle } from 'app/features/inspector/InspectSubtitle';
import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab'; import { InspectJSONTab } from 'app/features/inspector/InspectJSONTab';
import { QueryInspector } from 'app/features/inspector/QueryInspector'; import { QueryInspector } from 'app/features/inspector/QueryInspector';
import { InspectStatsTab } from 'app/features/inspector/InspectStatsTab'; 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 { InspectTab } from 'app/features/inspector/types';
import { DashboardModel, PanelModel } from '../../state'; import { DashboardModel, PanelModel } from '../../state';
import { GetDataOptions } from '../../../query/state/PanelQueryRunner'; import { GetDataOptions } from '../../../query/state/PanelQueryRunner';
import { InspectActionsTab } from './PanelInspectActions';
interface Props { interface Props {
dashboard: DashboardModel; dashboard: DashboardModel;
@@ -50,7 +47,6 @@ export const InspectContent: React.FC<Props> = ({
return null; return null;
} }
const styles = getPanelInspectorStyles();
const error = data?.error; const error = data?.error;
// Validate that the active tab is actually valid and allowed // 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)) { if (!tabs.find((item) => item.value === currentTab)) {
activeTab = InspectTab.JSON; activeTab = InspectTab.JSON;
} }
const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text'); const title = getTemplateSrv().replace(panel.title, panel.scopedVars, 'text');
return ( return (
<Drawer <Drawer
title={`Inspect: ${title || 'Panel'}`} title={`Inspect: ${title || 'Panel'}`}
subtitle={ subtitle={data && formatStats(data)}
<InspectSubtitle
tabs={tabs}
tab={activeTab}
data={data}
onSelectTab={(item) => setCurrentTab(item.value || InspectTab.Data)}
/>
}
width="50%" width="50%"
onClose={onClose} onClose={onClose}
expandable 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 && ( {activeTab === InspectTab.Data && (
<InspectDataTab <InspectDataTab
@@ -85,23 +90,31 @@ export const InspectContent: React.FC<Props> = ({
timeZone={dashboard.timezone} timeZone={dashboard.timezone}
/> />
)} )}
<CustomScrollbar autoHeightMin="100%"> {data && activeTab === InspectTab.Meta && (
<TabContent className={styles.tabContent}> <InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
{data && activeTab === InspectTab.Meta && ( )}
<InspectMetadataTab data={data} metadataDatasource={metadataDatasource} />
)}
{activeTab === InspectTab.JSON && ( {activeTab === InspectTab.JSON && (
<InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} /> <InspectJSONTab panel={panel} dashboard={dashboard} data={data} onClose={onClose} />
)} )}
{activeTab === InspectTab.Error && <InspectErrorTab error={error} />} {activeTab === InspectTab.Error && <InspectErrorTab error={error} />}
{data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />} {data && activeTab === InspectTab.Stats && <InspectStatsTab data={data} timeZone={dashboard.getTimezone()} />}
{data && activeTab === InspectTab.Query && ( {data && activeTab === InspectTab.Query && (
<QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} /> <QueryInspector panel={panel} data={data.series} onRefreshQuery={() => panel.refresh()} />
)} )}
{activeTab === InspectTab.Actions && <InspectActionsTab panel={panel} data={data} />}
</TabContent>
</CustomScrollbar>
</Drawer> </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 { OptionsPane } from './OptionsPane';
import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems'; import { SubMenuItems } from 'app/features/dashboard/components/SubMenu/SubMenuItems';
import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper'; import { SplitPaneWrapper } from 'app/core/components/SplitPaneWrapper/SplitPaneWrapper';
import { SaveDashboardModalProxy } from '../SaveDashboard/SaveDashboardModalProxy'; import { SaveDashboardDrawer } from '../SaveDashboard/SaveDashboardDrawer';
import { DashboardPanel } from '../../dashgrid/DashboardPanel'; import { DashboardPanel } from '../../dashgrid/DashboardPanel';
import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions'; import { discardPanelChanges, initPanelEditor, updatePanelEditorUIState } from './state/actions';
@@ -145,7 +145,7 @@ export class PanelEditorUnconnected extends PureComponent<Props> {
onSaveDashboard = () => { onSaveDashboard = () => {
appEvents.publish( appEvents.publish(
new ShowModalReactEvent({ new ShowModalReactEvent({
component: SaveDashboardModalProxy, component: SaveDashboardDrawer,
props: { dashboard: this.props.dashboard }, 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 React from 'react';
import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui'; import { Button, ButtonVariant, ModalsController, FullWidthButtonContainer } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardAsModal } from './SaveDashboardAsModal'; import { SaveDashboardDrawer } from './SaveDashboardDrawer';
import { SaveDashboardModalProxy } from './SaveDashboardModalProxy';
import { selectors } from '@grafana/e2e-selectors'; import { selectors } from '@grafana/e2e-selectors';
interface SaveDashboardButtonProps { interface SaveDashboardButtonProps {
@@ -17,7 +16,7 @@ export const SaveDashboardButton: React.FC<SaveDashboardButtonProps> = ({ dashbo
return ( return (
<Button <Button
onClick={() => { onClick={() => {
showModal(SaveDashboardModalProxy, { showModal(SaveDashboardDrawer, {
dashboard, dashboard,
onSaveSuccess, onSaveSuccess,
onDismiss: hideModal, onDismiss: hideModal,
@@ -45,10 +44,11 @@ export const SaveDashboardAsButton: React.FC<SaveDashboardButtonProps & { varian
<FullWidthButtonContainer> <FullWidthButtonContainer>
<Button <Button
onClick={() => { onClick={() => {
showModal(SaveDashboardAsModal, { showModal(SaveDashboardDrawer, {
dashboard, dashboard,
onSaveSuccess, onSaveSuccess,
onDismiss: hideModal, onDismiss: hideModal,
isCopy: true,
}); });
}} }}
variant={variant} 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 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 { DashboardModel, PanelModel } from 'app/features/dashboard/state';
import { FolderPicker } from 'app/core/components/Select/FolderPicker'; import { FolderPicker } from 'app/core/components/Select/FolderPicker';
import { SaveDashboardFormProps } from '../types'; import { SaveDashboardFormProps } from '../types';
@@ -119,17 +119,19 @@ export const SaveDashboardAsForm: React.FC<SaveDashboardAsFormProps> = ({
name="$folder" name="$folder"
/> />
</Field> </Field>
<Field label="Copy tags"> {!isNew && (
<Switch {...register('copyTags')} /> <Field label="Copy tags">
</Field> <Switch {...register('copyTags')} />
<Modal.ButtonRow> </Field>
)}
<HorizontalGroup>
<Button type="button" variant="secondary" onClick={onCancel} fill="outline"> <Button type="button" variant="secondary" onClick={onCancel} fill="outline">
Cancel Cancel
</Button> </Button>
<Button type="submit" aria-label="Save dashboard button"> <Button type="submit" aria-label="Save dashboard button">
Save Save
</Button> </Button>
</Modal.ButtonRow> </HorizontalGroup>
</> </>
)} )}
</Form> </Form>

View File

@@ -3,6 +3,7 @@ import { mount } from 'enzyme';
import { act } from 'react-dom/test-utils'; import { act } from 'react-dom/test-utils';
import { DashboardModel } from 'app/features/dashboard/state'; import { DashboardModel } from 'app/features/dashboard/state';
import { SaveDashboardForm } from './SaveDashboardForm'; import { SaveDashboardForm } from './SaveDashboardForm';
import { SaveDashboardOptions } from '../types';
const prepareDashboardMock = ( const prepareDashboardMock = (
timeChanged: boolean, timeChanged: boolean,
@@ -36,6 +37,16 @@ const renderAndSubmitForm = async (dashboard: any, submitSpy: any) => {
submitSpy(jsonModel); submitSpy(jsonModel);
return { status: 'success' }; return { status: 'success' };
}} }}
saveModel={{
clone: dashboard,
diff: {},
diffCount: 0,
hasChanges: true,
}}
options={{}}
onOptionsChange={(opts: SaveDashboardOptions) => {
return;
}}
/> />
); );
@@ -56,6 +67,16 @@ describe('SaveDashboardAsForm', () => {
onSubmit={async () => { onSubmit={async () => {
return {}; 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 { 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; 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 hasTimeChanged = useMemo(() => dashboard.hasTimeChanged(), [dashboard]);
const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]); const hasVariableChanged = useMemo(() => dashboard.hasVariableValuesChanged(), [dashboard]);
const [saving, setSaving] = useState(false);
return ( return (
<Form <Form
onSubmit={async (data: SaveDashboardFormDTO) => { onSubmit={async (data: FormDTO) => {
if (!onSubmit) { if (!onSubmit) {
return; return;
} }
setSaving(true);
const result = await onSubmit(dashboard.getSaveModelClone(data), data, dashboard); const result = await onSubmit(saveModel.clone, options, dashboard);
if (result.status === 'success') { if (result.status === 'success') {
if (data.saveVariables) { if (options.saveVariables) {
dashboard.resetOriginalVariables(); dashboard.resetOriginalVariables();
} }
if (data.saveTimerange) { if (options.saveTimerange) {
dashboard.resetOriginalTime(); dashboard.resetOriginalTime();
} }
onSuccess(); onSuccess();
} }
setSaving(false);
}} }}
> >
{({ register, errors }) => ( {({ register, errors }) => (
<> <Stack direction="column" gap={2}>
<div> {hasTimeChanged && (
{hasTimeChanged && ( <Checkbox
<Checkbox checked={options.saveTimerange}
{...register('saveTimerange')} onChange={() =>
label="Save current time range as dashboard default" onOptionsChange({
aria-label={selectors.pages.SaveDashboardModal.saveTimerange} ...options,
/> saveTimerange: !options.saveTimerange,
)} })
{hasVariableChanged && ( }
<Checkbox label="Save current time range as dashboard default"
{...register('saveVariables')} aria-label={selectors.pages.SaveDashboardModal.saveTimerange}
label="Save current variable values as dashboard default" />
aria-label={selectors.pages.SaveDashboardModal.saveVariables} )}
/> {hasVariableChanged && (
)} <Checkbox
{(hasVariableChanged || hasTimeChanged) && <div className="gf-form-group" />} 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 /> <TextArea {...register('message')} placeholder="Add a note to describe your changes." autoFocus rows={5} />
</div>
<Modal.ButtonRow> <Stack alignItems="center">
<Button variant="secondary" onClick={onCancel} fill="outline"> <Button variant="secondary" onClick={onCancel} fill="outline">
Cancel Cancel
</Button> </Button>
<Button type="submit" aria-label={selectors.pages.SaveDashboardModal.save}> <Button
Save type="submit"
disabled={!saveModel.hasChanges}
icon={saving ? 'fa fa-spinner' : undefined}
aria-label={selectors.pages.SaveDashboardModal.save}
>
{saving ? '' : 'Save'}
</Button> </Button>
</Modal.ButtonRow> {!saveModel.hasChanges && <div>No changes to save</div>}
</> </Stack>
</Stack>
)} )}
</Form> </Form>
); );

View File

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

View File

@@ -1,4 +1,12 @@
import { CloneOptions, DashboardModel } from 'app/features/dashboard/state/DashboardModel'; 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 { export interface SaveDashboardOptions extends CloneOptions {
folderId?: number; folderId?: number;
@@ -18,4 +26,5 @@ export interface SaveDashboardModalProps {
dashboard: DashboardModel; dashboard: DashboardModel;
onDismiss: () => void; onDismiss: () => void;
onSaveSuccess?: () => void; onSaveSuccess?: () => void;
isCopy?: boolean;
} }

View File

@@ -26,6 +26,7 @@ import {
AnnotationQuery, AnnotationQuery,
AppEvent, AppEvent,
DashboardCursorSync, DashboardCursorSync,
dateTime,
dateTimeFormat, dateTimeFormat,
dateTimeFormatTimeAgo, dateTimeFormatTimeAgo,
DateTimeInput, DateTimeInput,
@@ -1077,7 +1078,16 @@ export class DashboardModel implements TimeModel {
} }
hasTimeChanged() { 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) { resetOriginalVariables(initial = false) {

View File

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

View File

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

View File

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

View File

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