mirror of https://github.com/grafana/grafana
DashboardScene: Panel edit route basics (#74081)
* DashboardScene: Panel edit route basics * remove unused file * Removed some comments * Minor fix * Update * example of apply changes implementation * SceneObjectRef: Testing scene object ref * Rename to ref suffix * Update * Fix url sync in panel edit * Update * Update * simplify logic when committing change * remove import * Another fix for committing changepull/74646/head
parent
b9c681e1a7
commit
499b02b3c6
@ -0,0 +1,36 @@ |
||||
// Libraries
|
||||
import React, { useEffect } from 'react'; |
||||
|
||||
import { PageLayoutType } from '@grafana/data'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
import PageLoader from 'app/core/components/PageLoader/PageLoader'; |
||||
import { GrafanaRouteComponentProps } from 'app/core/navigation/types'; |
||||
|
||||
import { getDashboardScenePageStateManager } from './DashboardScenePageStateManager'; |
||||
|
||||
export interface Props extends GrafanaRouteComponentProps<{ uid: string; panelId: string }> {} |
||||
|
||||
export function PanelEditPage({ match }: Props) { |
||||
const stateManager = getDashboardScenePageStateManager(); |
||||
const { panelEditor, isLoading, loadError } = stateManager.useState(); |
||||
|
||||
useEffect(() => { |
||||
stateManager.loadPanelEdit(match.params.uid, match.params.panelId); |
||||
return () => { |
||||
stateManager.clearState(); |
||||
}; |
||||
}, [stateManager, match.params.uid, match.params.panelId]); |
||||
|
||||
if (!panelEditor) { |
||||
return ( |
||||
<Page layout={PageLayoutType.Canvas}> |
||||
{isLoading && <PageLoader />} |
||||
{loadError && <h2>{loadError}</h2>} |
||||
</Page> |
||||
); |
||||
} |
||||
|
||||
return <panelEditor.Component model={panelEditor} />; |
||||
} |
||||
|
||||
export default PanelEditPage; |
@ -0,0 +1,134 @@ |
||||
import * as H from 'history'; |
||||
|
||||
import { locationService } from '@grafana/runtime'; |
||||
import { |
||||
getUrlSyncManager, |
||||
SceneFlexItem, |
||||
SceneFlexLayout, |
||||
SceneObject, |
||||
SceneObjectBase, |
||||
SceneObjectRef, |
||||
SceneObjectState, |
||||
sceneUtils, |
||||
SplitLayout, |
||||
VizPanel, |
||||
} from '@grafana/scenes'; |
||||
|
||||
import { DashboardScene } from '../scene/DashboardScene'; |
||||
import { getDashboardUrl } from '../utils/utils'; |
||||
|
||||
import { PanelEditorRenderer } from './PanelEditorRenderer'; |
||||
import { PanelOptionsPane } from './PanelOptionsPane'; |
||||
|
||||
export interface PanelEditorState extends SceneObjectState { |
||||
body: SceneObject; |
||||
controls?: SceneObject[]; |
||||
isDirty?: boolean; |
||||
/** Panel to inspect */ |
||||
inspectPanelId?: string; |
||||
/** Scene object that handles the current drawer */ |
||||
drawer?: SceneObject; |
||||
|
||||
dashboardRef: SceneObjectRef<DashboardScene>; |
||||
sourcePanelRef: SceneObjectRef<VizPanel>; |
||||
panelRef: SceneObjectRef<VizPanel>; |
||||
} |
||||
|
||||
export class PanelEditor extends SceneObjectBase<PanelEditorState> { |
||||
static Component = PanelEditorRenderer; |
||||
|
||||
public constructor(state: PanelEditorState) { |
||||
super(state); |
||||
|
||||
this.addActivationHandler(() => this._activationHandler()); |
||||
} |
||||
|
||||
private _activationHandler() { |
||||
// Deactivation logic
|
||||
return () => { |
||||
getUrlSyncManager().cleanUp(this); |
||||
}; |
||||
} |
||||
|
||||
public startUrlSync() { |
||||
getUrlSyncManager().initSync(this); |
||||
} |
||||
|
||||
public getPageNav(location: H.Location) { |
||||
return { |
||||
text: 'Edit panel', |
||||
parentItem: this.state.dashboardRef.resolve().getPageNav(location), |
||||
}; |
||||
} |
||||
|
||||
public onDiscard = () => { |
||||
// Open question on what to preserve when going back
|
||||
// Preserve time range, and variables state (that might have been changed while in panel edit)
|
||||
// Preserve current panel data? (say if you just changed the time range and have new data)
|
||||
this._navigateBackToDashboard(); |
||||
}; |
||||
|
||||
public onApply = () => { |
||||
this._commitChanges(); |
||||
this._navigateBackToDashboard(); |
||||
}; |
||||
|
||||
public onSave = () => { |
||||
this._commitChanges(); |
||||
// Open dashboard save drawer
|
||||
}; |
||||
|
||||
private _commitChanges() { |
||||
const dashboard = this.state.dashboardRef.resolve(); |
||||
const sourcePanel = this.state.sourcePanelRef.resolve(); |
||||
const panel = this.state.panelRef.resolve(); |
||||
|
||||
if (!dashboard.state.isEditing) { |
||||
dashboard.onEnterEditMode(); |
||||
} |
||||
|
||||
const newState = sceneUtils.cloneSceneObjectState(panel.state); |
||||
sourcePanel.setState(newState); |
||||
|
||||
// preserve time range and variables state
|
||||
dashboard.setState({ |
||||
$timeRange: this.state.$timeRange?.clone(), |
||||
$variables: this.state.$variables?.clone(), |
||||
isDirty: true, |
||||
}); |
||||
} |
||||
|
||||
private _navigateBackToDashboard() { |
||||
locationService.push( |
||||
getDashboardUrl({ |
||||
uid: this.state.dashboardRef.resolve().state.uid, |
||||
currentQueryParams: locationService.getLocation().search, |
||||
}) |
||||
); |
||||
} |
||||
} |
||||
|
||||
export function buildPanelEditScene(dashboard: DashboardScene, panel: VizPanel): PanelEditor { |
||||
const panelClone = panel.clone(); |
||||
const dashboardStateCloned = sceneUtils.cloneSceneObjectState(dashboard.state); |
||||
|
||||
return new PanelEditor({ |
||||
dashboardRef: new SceneObjectRef(dashboard), |
||||
sourcePanelRef: new SceneObjectRef(panel), |
||||
panelRef: new SceneObjectRef(panelClone), |
||||
controls: dashboardStateCloned.controls, |
||||
$variables: dashboardStateCloned.$variables, |
||||
$timeRange: dashboardStateCloned.$timeRange, |
||||
body: new SplitLayout({ |
||||
direction: 'row', |
||||
primary: new SceneFlexLayout({ |
||||
direction: 'column', |
||||
children: [panelClone], |
||||
}), |
||||
secondary: new SceneFlexItem({ |
||||
width: '300px', |
||||
body: new PanelOptionsPane(panelClone), |
||||
}), |
||||
}), |
||||
}); |
||||
} |
@ -0,0 +1,92 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
import { useLocation } from 'react-router-dom'; |
||||
|
||||
import { GrafanaTheme2, PageLayoutType } from '@grafana/data'; |
||||
import { SceneComponentProps } from '@grafana/scenes'; |
||||
import { Button, useStyles2 } from '@grafana/ui'; |
||||
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate'; |
||||
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator'; |
||||
import { Page } from 'app/core/components/Page/Page'; |
||||
|
||||
import { PanelEditor } from './PanelEditor'; |
||||
|
||||
export function PanelEditorRenderer({ model }: SceneComponentProps<PanelEditor>) { |
||||
const { body, controls, drawer } = model.useState(); |
||||
const styles = useStyles2(getStyles); |
||||
const location = useLocation(); |
||||
const pageNav = model.getPageNav(location); |
||||
|
||||
return ( |
||||
<Page navId="scenes" pageNav={pageNav} layout={PageLayoutType.Custom}> |
||||
<AppChromeUpdate actions={getToolbarActions(model)} /> |
||||
<div className={styles.canvasContent}> |
||||
{controls && ( |
||||
<div className={styles.controls}> |
||||
{controls.map((control) => ( |
||||
<control.Component key={control.state.key} model={control} /> |
||||
))} |
||||
</div> |
||||
)} |
||||
<div className={styles.body}> |
||||
<body.Component model={body} /> |
||||
</div> |
||||
</div> |
||||
{drawer && <drawer.Component model={drawer} />} |
||||
</Page> |
||||
); |
||||
} |
||||
|
||||
function getToolbarActions(editor: PanelEditor) { |
||||
return ( |
||||
<> |
||||
<NavToolbarSeparator leftActionsSeparator key="separator" /> |
||||
|
||||
<Button |
||||
onClick={editor.onDiscard} |
||||
tooltip="" |
||||
key="panel-edit-discard" |
||||
variant="destructive" |
||||
fill="outline" |
||||
size="sm" |
||||
> |
||||
Discard |
||||
</Button> |
||||
|
||||
<Button onClick={editor.onApply} tooltip="" key="panel-edit-apply" variant="primary" size="sm"> |
||||
Apply |
||||
</Button> |
||||
</> |
||||
); |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
canvasContent: css({ |
||||
label: 'canvas-content', |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
padding: theme.spacing(0, 2), |
||||
flexBasis: '100%', |
||||
flexGrow: 1, |
||||
minHeight: 0, |
||||
width: '100%', |
||||
}), |
||||
body: css({ |
||||
label: 'body', |
||||
flexGrow: 1, |
||||
display: 'flex', |
||||
position: 'relative', |
||||
minHeight: 0, |
||||
gap: '8px', |
||||
marginBottom: theme.spacing(2), |
||||
}), |
||||
controls: css({ |
||||
display: 'flex', |
||||
flexWrap: 'wrap', |
||||
alignItems: 'center', |
||||
gap: theme.spacing(1), |
||||
padding: theme.spacing(2, 0), |
||||
}), |
||||
}; |
||||
} |
@ -0,0 +1,45 @@ |
||||
import { css } from '@emotion/css'; |
||||
import React from 'react'; |
||||
|
||||
import { GrafanaTheme2 } from '@grafana/data'; |
||||
import { SceneComponentProps, SceneObjectBase, SceneObjectState, VizPanel } from '@grafana/scenes'; |
||||
import { Field, Input, useStyles2 } from '@grafana/ui'; |
||||
|
||||
export interface PanelOptionsPaneState extends SceneObjectState {} |
||||
|
||||
export class PanelOptionsPane extends SceneObjectBase<PanelOptionsPaneState> { |
||||
public panel: VizPanel; |
||||
|
||||
public constructor(panel: VizPanel) { |
||||
super({}); |
||||
|
||||
this.panel = panel; |
||||
} |
||||
|
||||
static Component = ({ model }: SceneComponentProps<PanelOptionsPane>) => { |
||||
const { panel } = model; |
||||
const { title } = panel.useState(); |
||||
const styles = useStyles2(getStyles); |
||||
|
||||
return ( |
||||
<div className={styles.box}> |
||||
<Field label="Title"> |
||||
<Input value={title} onChange={(evt) => panel.setState({ title: evt.currentTarget.value })} /> |
||||
</Field> |
||||
</div> |
||||
); |
||||
}; |
||||
} |
||||
|
||||
function getStyles(theme: GrafanaTheme2) { |
||||
return { |
||||
box: css({ |
||||
display: 'flex', |
||||
flexDirection: 'column', |
||||
padding: theme.spacing(2), |
||||
flexBasis: '100%', |
||||
flexGrow: 1, |
||||
minHeight: 0, |
||||
}), |
||||
}; |
||||
} |
@ -0,0 +1,25 @@ |
||||
import { getDashboardUrl } from './utils'; |
||||
|
||||
describe('dashboard utils', () => { |
||||
it('Can getUrl', () => { |
||||
const url = getDashboardUrl({ uid: 'dash-1', currentQueryParams: '?orgId=1&filter=A' }); |
||||
|
||||
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&filter=A'); |
||||
}); |
||||
|
||||
it('Can getUrl with subpath', () => { |
||||
const url = getDashboardUrl({ uid: 'dash-1', subPath: '/panel-edit/2', currentQueryParams: '?orgId=1&filter=A' }); |
||||
|
||||
expect(url).toBe('/scenes/dashboard/dash-1/panel-edit/2?orgId=1&filter=A'); |
||||
}); |
||||
|
||||
it('Can getUrl with params removed and addded', () => { |
||||
const url = getDashboardUrl({ |
||||
uid: 'dash-1', |
||||
currentQueryParams: '?orgId=1&filter=A', |
||||
updateQuery: { filter: null, new: 'A' }, |
||||
}); |
||||
|
||||
expect(url).toBe('/scenes/dashboard/dash-1?orgId=1&new=A'); |
||||
}); |
||||
}); |
Loading…
Reference in new issue