mirror of https://github.com/grafana/grafana
Dashboard: Edit pane in edit mode (#96971)
* Dashboard: Edit pane foundations * Update * fix panel edit padding * Restore scroll pos works when feature toggle is disabled * Update * Update * remember collapsed state * Update * fixed padding issuepull/97025/head
parent
d2fab92d8b
commit
06d0d41183
@ -0,0 +1,84 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
import { useEffect, useRef } from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { SceneObjectState, SceneObjectBase, SceneObject, SceneObjectRef } from '@grafana/scenes'; |
||||||
|
import { ToolbarButton, useStyles2 } from '@grafana/ui'; |
||||||
|
|
||||||
|
import { getDashboardSceneFor } from '../utils/utils'; |
||||||
|
|
||||||
|
import { ElementEditPane } from './ElementEditPane'; |
||||||
|
|
||||||
|
export interface DashboardEditPaneState extends SceneObjectState { |
||||||
|
selectedObject?: SceneObjectRef<SceneObject>; |
||||||
|
} |
||||||
|
|
||||||
|
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {} |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
editPane: DashboardEditPane; |
||||||
|
isCollapsed: boolean; |
||||||
|
onToggleCollapse: () => void; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props |
||||||
|
*/ |
||||||
|
export function DashboardEditPaneRenderer({ editPane, isCollapsed, onToggleCollapse }: Props) { |
||||||
|
// Activate the edit pane
|
||||||
|
useEffect(() => { |
||||||
|
if (!editPane.state.selectedObject) { |
||||||
|
const dashboard = getDashboardSceneFor(editPane); |
||||||
|
editPane.setState({ selectedObject: dashboard.getRef() }); |
||||||
|
} |
||||||
|
editPane.activate(); |
||||||
|
}, [editPane]); |
||||||
|
|
||||||
|
const { selectedObject } = editPane.useState(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
const paneRef = useRef<HTMLDivElement>(null); |
||||||
|
|
||||||
|
if (!selectedObject) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
if (isCollapsed) { |
||||||
|
return ( |
||||||
|
<div className={styles.expandOptionsWrapper}> |
||||||
|
<ToolbarButton |
||||||
|
tooltip={'Open options pane'} |
||||||
|
icon={'arrow-to-right'} |
||||||
|
onClick={onToggleCollapse} |
||||||
|
variant="canvas" |
||||||
|
className={styles.rotate180} |
||||||
|
aria-label={'Open options pane'} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
return ( |
||||||
|
<div className={styles.wrapper} ref={paneRef}> |
||||||
|
<ElementEditPane obj={selectedObject.resolve()} /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
wrapper: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flex: '1 1 0', |
||||||
|
overflow: 'auto', |
||||||
|
}), |
||||||
|
rotate180: css({ |
||||||
|
rotate: '180deg', |
||||||
|
}), |
||||||
|
expandOptionsWrapper: css({ |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
padding: theme.spacing(2, 1), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,154 @@ |
|||||||
|
import { css, cx } from '@emotion/css'; |
||||||
|
import React, { CSSProperties, useEffect } from 'react'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { config, useChromeHeaderHeight } from '@grafana/runtime'; |
||||||
|
import { useStyles2 } from '@grafana/ui'; |
||||||
|
import NativeScrollbar from 'app/core/components/NativeScrollbar'; |
||||||
|
|
||||||
|
import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter'; |
||||||
|
import { DashboardScene } from '../scene/DashboardScene'; |
||||||
|
import { NavToolbarActions } from '../scene/NavToolbarActions'; |
||||||
|
|
||||||
|
import { DashboardEditPaneRenderer } from './DashboardEditPane'; |
||||||
|
import { useEditPaneCollapsed } from './shared'; |
||||||
|
|
||||||
|
interface Props { |
||||||
|
dashboard: DashboardScene; |
||||||
|
isEditing?: boolean; |
||||||
|
body?: React.ReactNode; |
||||||
|
controls?: React.ReactNode; |
||||||
|
} |
||||||
|
|
||||||
|
export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls }: Props) { |
||||||
|
const headerHeight = useChromeHeaderHeight(); |
||||||
|
const styles = useStyles2(getStyles, headerHeight ?? 0); |
||||||
|
const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed(); |
||||||
|
|
||||||
|
if (!config.featureToggles.dashboardNewLayouts) { |
||||||
|
return ( |
||||||
|
<NativeScrollbar onSetScrollRef={dashboard.onSetScrollRef}> |
||||||
|
<div className={styles.canvasWrappperOld}> |
||||||
|
<NavToolbarActions dashboard={dashboard} /> |
||||||
|
<div className={styles.controlsWrapperSticky}>{controls}</div> |
||||||
|
<div className={styles.body}>{body}</div> |
||||||
|
</div> |
||||||
|
</NativeScrollbar> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } = |
||||||
|
useSnappingSplitter({ |
||||||
|
direction: 'row', |
||||||
|
dragPosition: 'end', |
||||||
|
initialSize: 0.8, |
||||||
|
handleSize: 'sm', |
||||||
|
collapsed: isCollapsed, |
||||||
|
|
||||||
|
paneOptions: { |
||||||
|
collapseBelowPixels: 250, |
||||||
|
snapOpenToPixels: 400, |
||||||
|
}, |
||||||
|
}); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
setIsCollapsed(splitterState.collapsed); |
||||||
|
}, [splitterState.collapsed, setIsCollapsed]); |
||||||
|
|
||||||
|
const containerStyle: CSSProperties = {}; |
||||||
|
|
||||||
|
if (!isEditing) { |
||||||
|
primaryProps.style.flexGrow = 1; |
||||||
|
primaryProps.style.width = '100%'; |
||||||
|
primaryProps.style.minWidth = 'unset'; |
||||||
|
containerStyle.overflow = 'unset'; |
||||||
|
} |
||||||
|
|
||||||
|
const onBodyRef = (ref: HTMLDivElement) => { |
||||||
|
dashboard.onSetScrollRef(ref); |
||||||
|
}; |
||||||
|
|
||||||
|
return ( |
||||||
|
<div {...containerProps} style={containerStyle}> |
||||||
|
<div {...primaryProps} className={cx(primaryProps.className, styles.canvasWithSplitter)}> |
||||||
|
<NavToolbarActions dashboard={dashboard} /> |
||||||
|
<div className={cx(!isEditing && styles.controlsWrapperSticky)}>{controls}</div> |
||||||
|
<div className={styles.bodyWrapper}> |
||||||
|
<div className={cx(styles.body, isEditing && styles.bodyEditing)} ref={onBodyRef}> |
||||||
|
{body} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
{isEditing && ( |
||||||
|
<> |
||||||
|
<div {...splitterProps} data-edit-pane-splitter={true} /> |
||||||
|
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}> |
||||||
|
<DashboardEditPaneRenderer |
||||||
|
editPane={dashboard.state.editPane} |
||||||
|
isCollapsed={splitterState.collapsed} |
||||||
|
onToggleCollapse={onToggleCollapse} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2, headerHeight: number) { |
||||||
|
return { |
||||||
|
canvasWrappperOld: css({ |
||||||
|
label: 'canvas-wrapper-old', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
canvasWithSplitter: css({ |
||||||
|
overflow: 'unset', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flexGrow: 1, |
||||||
|
}), |
||||||
|
canvasWithSplitterEditing: css({ |
||||||
|
overflow: 'unset', |
||||||
|
}), |
||||||
|
bodyWrapper: css({ |
||||||
|
label: 'body-wrapper', |
||||||
|
display: 'flex', |
||||||
|
flexDirection: 'column', |
||||||
|
flexGrow: 1, |
||||||
|
position: 'relative', |
||||||
|
}), |
||||||
|
body: css({ |
||||||
|
label: 'body', |
||||||
|
display: 'flex', |
||||||
|
flexGrow: 1, |
||||||
|
gap: '8px', |
||||||
|
boxSizing: 'border-box', |
||||||
|
flexDirection: 'column', |
||||||
|
padding: theme.spacing(0, 2, 2, 2), |
||||||
|
}), |
||||||
|
bodyEditing: css({ |
||||||
|
position: 'absolute', |
||||||
|
left: 0, |
||||||
|
top: 0, |
||||||
|
right: 0, |
||||||
|
bottom: 0, |
||||||
|
overflow: 'auto', |
||||||
|
scrollbarWidth: 'thin', |
||||||
|
}), |
||||||
|
editPane: css({ |
||||||
|
flexDirection: 'column', |
||||||
|
borderLeft: `1px solid ${theme.colors.border.weak}`, |
||||||
|
background: theme.colors.background.primary, |
||||||
|
}), |
||||||
|
controlsWrapperSticky: css({ |
||||||
|
[theme.breakpoints.up('md')]: { |
||||||
|
position: 'sticky', |
||||||
|
zIndex: theme.zIndex.activePanel, |
||||||
|
background: theme.colors.background.canvas, |
||||||
|
top: headerHeight, |
||||||
|
}, |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,60 @@ |
|||||||
|
import { useMemo } from 'react'; |
||||||
|
|
||||||
|
import { Input, TextArea } from '@grafana/ui'; |
||||||
|
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor'; |
||||||
|
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor'; |
||||||
|
|
||||||
|
import { DashboardScene } from '../scene/DashboardScene'; |
||||||
|
import { EditableDashboardElement } from '../scene/types'; |
||||||
|
|
||||||
|
export class DummySelectedObject implements EditableDashboardElement { |
||||||
|
public isEditableDashboardElement: true = true; |
||||||
|
|
||||||
|
constructor(private dashboard: DashboardScene) {} |
||||||
|
|
||||||
|
public useEditPaneOptions(): OptionsPaneCategoryDescriptor[] { |
||||||
|
const dashboard = this.dashboard; |
||||||
|
|
||||||
|
const dashboardOptions = useMemo(() => { |
||||||
|
return new OptionsPaneCategoryDescriptor({ |
||||||
|
title: 'Dashboard options', |
||||||
|
id: 'dashboard-options', |
||||||
|
isOpenDefault: true, |
||||||
|
}) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Title', |
||||||
|
render: function renderTitle() { |
||||||
|
return <DashboardTitleInput dashboard={dashboard} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
) |
||||||
|
.addItem( |
||||||
|
new OptionsPaneItemDescriptor({ |
||||||
|
title: 'Description', |
||||||
|
render: function renderTitle() { |
||||||
|
return <DashboardDescriptionInput dashboard={dashboard} />; |
||||||
|
}, |
||||||
|
}) |
||||||
|
); |
||||||
|
}, [dashboard]); |
||||||
|
|
||||||
|
return [dashboardOptions]; |
||||||
|
} |
||||||
|
|
||||||
|
public getTypeName(): string { |
||||||
|
return 'Dashboard'; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
export function DashboardTitleInput({ dashboard }: { dashboard: DashboardScene }) { |
||||||
|
const { title } = dashboard.useState(); |
||||||
|
|
||||||
|
return <Input value={title} onChange={(e) => dashboard.setState({ title: e.currentTarget.value })} />; |
||||||
|
} |
||||||
|
|
||||||
|
export function DashboardDescriptionInput({ dashboard }: { dashboard: DashboardScene }) { |
||||||
|
const { description } = dashboard.useState(); |
||||||
|
|
||||||
|
return <TextArea value={description} onChange={(e) => dashboard.setState({ title: e.currentTarget.value })} />; |
||||||
|
} |
||||||
@ -0,0 +1,70 @@ |
|||||||
|
import { css } from '@emotion/css'; |
||||||
|
|
||||||
|
import { GrafanaTheme2 } from '@grafana/data'; |
||||||
|
import { SceneObject } from '@grafana/scenes'; |
||||||
|
import { Stack, useStyles2 } from '@grafana/ui'; |
||||||
|
import { OptionsPaneCategory } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategory'; |
||||||
|
|
||||||
|
import { DashboardScene } from '../scene/DashboardScene'; |
||||||
|
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types'; |
||||||
|
|
||||||
|
import { DummySelectedObject } from './DummySelectedObject'; |
||||||
|
|
||||||
|
export interface Props { |
||||||
|
obj: SceneObject; |
||||||
|
} |
||||||
|
|
||||||
|
export function ElementEditPane({ obj }: Props) { |
||||||
|
const element = getEditableElementFor(obj); |
||||||
|
const categories = element.useEditPaneOptions(); |
||||||
|
const styles = useStyles2(getStyles); |
||||||
|
|
||||||
|
return ( |
||||||
|
<Stack direction="column" gap={0}> |
||||||
|
{element.renderActions && ( |
||||||
|
<OptionsPaneCategory |
||||||
|
id="selected-item" |
||||||
|
title={element.getTypeName()} |
||||||
|
isOpenDefault={true} |
||||||
|
className={styles.noBorderTop} |
||||||
|
> |
||||||
|
<div className={styles.actionsBox}>{element.renderActions()}</div> |
||||||
|
</OptionsPaneCategory> |
||||||
|
)} |
||||||
|
{categories.map((cat) => cat.render())} |
||||||
|
</Stack> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function getEditableElementFor(obj: SceneObject): EditableDashboardElement { |
||||||
|
if (isEditableDashboardElement(obj)) { |
||||||
|
return obj; |
||||||
|
} |
||||||
|
|
||||||
|
for (const behavior of obj.state.$behaviors ?? []) { |
||||||
|
if (isEditableDashboardElement(behavior)) { |
||||||
|
return behavior; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Temp thing to show somethin in edit pane
|
||||||
|
if (obj instanceof DashboardScene) { |
||||||
|
return new DummySelectedObject(obj); |
||||||
|
} |
||||||
|
|
||||||
|
throw new Error("Can't find editable element for selected object"); |
||||||
|
} |
||||||
|
|
||||||
|
function getStyles(theme: GrafanaTheme2) { |
||||||
|
return { |
||||||
|
noBorderTop: css({ |
||||||
|
borderTop: 'none', |
||||||
|
}), |
||||||
|
actionsBox: css({ |
||||||
|
display: 'flex', |
||||||
|
alignItems: 'center', |
||||||
|
gap: theme.spacing(1), |
||||||
|
paddingBottom: theme.spacing(1), |
||||||
|
}), |
||||||
|
}; |
||||||
|
} |
||||||
@ -0,0 +1,5 @@ |
|||||||
|
import { useSessionStorage } from 'react-use'; |
||||||
|
|
||||||
|
export function useEditPaneCollapsed() { |
||||||
|
return useSessionStorage('grafana.dashboards.edit-pane.isCollapsed', false); |
||||||
|
} |
||||||
Loading…
Reference in new issue