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