DashboardScene: Action toolbar progress (#81664)

* DashboardScene: Action toolbar progress

* Add discard confirmation modal

* minor fix

* Update

* tweaked

* Updating

* Progress

* Update

* Update

* Added some unit tests

* fix test

* Change name to Exit edit

* Tweaks

* fix test

* Minor margin fix

* Move share to left of edit
This commit is contained in:
Torkel Ödegaard 2024-02-05 16:08:12 +01:00 committed by GitHub
parent 9b18a4d45e
commit 61c7fcc270
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
10 changed files with 370 additions and 135 deletions

View File

@ -117,7 +117,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment:
alignItems: 'center',
backgroundColor: theme.colors.background.primary,
borderRadius: theme.shape.radius.default,
boxShadow: theme.shadows.z3,
boxShadow: theme.shadows.z2,
display: 'flex',
flexWrap: 'wrap',
gap: theme.spacing(1),
@ -128,7 +128,7 @@ const getStyles = (theme: GrafanaTheme2, overflowButtonOrder: number, alignment:
right: 0,
top: '100%',
width: 'max-content',
zIndex: theme.zIndex.sidemenu,
zIndex: theme.zIndex.dropdown,
}),
container: css({
alignItems: 'center',

View File

@ -62,7 +62,6 @@ export function NavToolbar({
<Breadcrumbs breadcrumbs={breadcrumbs} className={styles.breadcrumbsWrapper} />
<div className={styles.actions}>
{actions}
{actions && <NavToolbarSeparator />}
{searchBarHidden && (
<ToolbarButton
onClick={onToggleKioskMode}
@ -71,6 +70,7 @@ export function NavToolbar({
icon="monitor"
/>
)}
{actions && <NavToolbarSeparator />}
<ToolbarButton
onClick={onToggleSearchBar}
narrow

View File

@ -12,7 +12,7 @@ export function initAlerting() {
addCustomRightAction({
show: () => config.unifiedAlertingEnabled,
component: ({ dashboard }) => (
<React.Suspense fallback={null}>
<React.Suspense fallback={null} key="alert-rules-button">
{dashboard && <AlertRulesToolbarButton dashboardUid={dashboard.uid} />}
</React.Suspense>
),

View File

@ -1,9 +1,7 @@
import React from 'react';
import { SceneObjectState, SceneObject, SceneObjectBase, SceneComponentProps } from '@grafana/scenes';
import { Box, Stack, ToolbarButton } from '@grafana/ui';
import { getDashboardSceneFor } from '../utils/utils';
import { Box, Stack } from '@grafana/ui';
import { DashboardLinksControls } from './DashboardLinksControls';
@ -18,9 +16,7 @@ export class DashboardControls extends SceneObjectBase<DashboardControlsState> {
}
function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardControls>) {
const dashboard = getDashboardSceneFor(model);
const { variableControls, linkControls, timeControls, hideTimeControls } = model.useState();
const { isEditing } = dashboard.useState();
return (
<Stack
@ -38,9 +34,6 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
<linkControls.Component model={linkControls} />
</Stack>
<Stack justifyContent={'flex-end'}>
{isEditing && (
<ToolbarButton variant="canvas" icon="cog" tooltip="Dashboard settings" onClick={dashboard.onOpenSettings} />
)}
{!hideTimeControls && timeControls.map((c) => <c.Component model={c} key={c.state.key} />)}
</Stack>
</Stack>

View File

@ -57,7 +57,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
scene.exitEditMode({ skipConfirm: true });
const gridItem2 = sceneGraph.findObject(scene, (p) => p.state.key === 'griditem-1') as SceneGridItem;
expect(gridItem2.state.x).toBe(0);
});
@ -77,7 +77,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
scene.exitEditMode({ skipConfirm: true });
expect(scene.state[prop]).toEqual(prevState);
}
);
@ -89,7 +89,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
scene.exitEditMode({ skipConfirm: true });
expect(dashboardSceneGraph.getRefreshPicker(scene)!.state.intervals).toEqual(prevState);
});
@ -100,7 +100,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
scene.exitEditMode({ skipConfirm: true });
expect(dashboardSceneGraph.getDashboardControls(scene)!.state.hideTimeControls).toEqual(prevState);
});
@ -111,7 +111,7 @@ describe('DashboardScene', () => {
expect(scene.state.isDirty).toBe(true);
scene.onDiscard();
scene.exitEditMode({ skipConfirm: true });
expect(sceneGraph.getTimeRange(scene)!.state.timeZone).toBe(prevState);
});
});

View File

@ -26,6 +26,7 @@ import { DashboardModel } from 'app/features/dashboard/state';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { VariablesChanged } from 'app/features/variables/types';
import { DashboardDTO, DashboardMeta, SaveDashboardResponseDTO } from 'app/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { PanelEditor } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
@ -213,12 +214,29 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
}
}
public onDiscard = () => {
public exitEditMode({ skipConfirm }: { skipConfirm: boolean }) {
if (!this.canDiscard()) {
console.error('Trying to discard back to a state that does not exist, initialState undefined');
return;
}
if (!this.state.isDirty || skipConfirm) {
this.exitEditModeConfirmed();
return;
}
appEvents.publish(
new ShowConfirmModalEvent({
title: 'Discard changes to dashboard?',
text: `You have unsaved changes to this dashboard. Are you sure you want to discard them?`,
icon: 'trash-alt',
yesText: 'Discard',
onConfirm: this.exitEditModeConfirmed.bind(this),
})
);
}
private exitEditModeConfirmed() {
// No need to listen to changes anymore
this.stopTrackingChanges();
// Stop url sync before updating url
@ -244,7 +262,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
this.startUrlSync();
// Disable grid dragging
this.propagateEditModeChange();
};
}
public canDiscard() {
return this._initialState !== undefined;
@ -266,7 +284,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> {
newState.version = versionRsp.version;
this._initialState = newState;
this.onDiscard();
this.exitEditMode({ skipConfirm: false });
return true;
};

View File

@ -20,6 +20,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
const pageNav = model.getPageNav(location, navIndex);
const bodyToRender = model.getBodyToRender();
const navModel = getNavModel(navIndex, 'dashboards/browse');
const showDebugger = location.search.includes('scene-debugger');
if (editview) {
return (
@ -43,7 +44,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
{controls.map((control) => (
<control.Component key={control.state.key} model={control} />
))}
<SceneDebugger scene={model} key={'scene-debugger'} />
{showDebugger && <SceneDebugger scene={model} key={'scene-debugger'} />}
</div>
)}
<div className={cx(styles.body)}>
@ -83,7 +84,7 @@ function getStyles(theme: GrafanaTheme2) {
position: 'sticky',
top: 0,
background: theme.colors.background.canvas,
zIndex: theme.zIndex.navbarFixed,
zIndex: theme.zIndex.activePanel,
padding: theme.spacing(2, 0),
}),
};

View File

@ -0,0 +1,83 @@
import { screen, render } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import React from 'react';
import { TestProvider } from 'test/helpers/TestProvider';
import { getGrafanaContextMock } from 'test/mocks/getGrafanaContextMock';
import { transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { transformSceneToSaveModel } from '../serialization/transformSceneToSaveModel';
import { ToolbarActions } from './NavToolbarActions';
describe('NavToolbarActions', () => {
describe('Give an already saved dashboard', () => {
it('Should show correct buttons when not in editing', async () => {
setup();
expect(screen.queryByText('Save dashboard')).not.toBeInTheDocument();
expect(await screen.findByText('Edit')).toBeInTheDocument();
expect(await screen.findByText('Share')).toBeInTheDocument();
});
it('Should show correct buttons when editing', async () => {
setup();
await userEvent.click(await screen.findByText('Edit'));
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
expect(await screen.findByText('Exit edit')).toBeInTheDocument();
expect(screen.queryByText('Edit')).not.toBeInTheDocument();
expect(screen.queryByText('Share')).not.toBeInTheDocument();
});
it('Should show correct buttons when in settings menu', async () => {
setup();
await userEvent.click(await screen.findByText('Edit'));
await userEvent.click(await screen.findByText('Settings'));
expect(await screen.findByText('Save dashboard')).toBeInTheDocument();
expect(await screen.findByText('Back to dashboard')).toBeInTheDocument();
});
});
});
let cleanUp = () => {};
function setup() {
const dashboard = transformSaveModelToScene({
dashboard: {
title: 'hello',
uid: 'my-uid',
schemaVersion: 30,
panels: [],
version: 10,
},
meta: {
canSave: true,
},
});
// Clear any data layers
dashboard.setState({ $data: undefined });
const initialSaveModel = transformSceneToSaveModel(dashboard);
dashboard.setInitialSaveModel(initialSaveModel);
dashboard.startUrlSync();
cleanUp();
cleanUp = dashboard.activate();
const context = getGrafanaContextMock();
render(
<TestProvider grafanaContext={context}>
<ToolbarActions dashboard={dashboard} />
</TestProvider>
);
const actions = context.chrome.state.getValue().actions;
return { dashboard, actions };
}

View File

@ -1,11 +1,13 @@
import { css } from '@emotion/css';
import React from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { Button } from '@grafana/ui';
import { Button, ButtonGroup, Dropdown, Icon, Menu, ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import { NavToolbarSeparator } from 'app/core/components/AppChrome/NavToolbar/NavToolbarSeparator';
import { contextSrv } from 'app/core/core';
import { t } from 'app/core/internationalization';
import { DashNavButton } from 'app/features/dashboard/components/DashNav/DashNavButton';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { ShareModal } from '../sharing/ShareModal';
@ -19,164 +21,300 @@ interface Props {
}
export const NavToolbarActions = React.memo<Props>(({ dashboard }) => {
const { actions = [], isEditing, viewPanelScene, isDirty, uid, meta, editview } = dashboard.useState();
const toolbarActions = (actions ?? []).map((action) => <action.Component key={action.state.key} model={action} />);
const rightToolbarActions: JSX.Element[] = [];
const _legacyDashboardModel = getDashboardSrv().getCurrent();
const actions = (
<ToolbarButtonRow alignment="right">
<ToolbarActions dashboard={dashboard} />
</ToolbarButtonRow>
);
if (uid && !editview) {
if (meta.canStar) {
return <AppChromeUpdate actions={actions} />;
});
NavToolbarActions.displayName = 'NavToolbarActions';
/**
* This part is split into a separate componet to help test this
*/
export function ToolbarActions({ dashboard }: Props) {
const { isEditing, viewPanelScene, isDirty, uid, meta, editview } = dashboard.useState();
const canSaveAs = contextSrv.hasEditPermissionInFolders;
const toolbarActions: ToolbarAction[] = [];
const buttonWithExtraMargin = useStyles2(getStyles);
toolbarActions.push({
group: 'icon-actions',
condition: uid && !editview && Boolean(meta.canStar),
render: () => {
let desc = meta.isStarred
? t('dashboard.toolbar.unmark-favorite', 'Unmark as favorite')
: t('dashboard.toolbar.mark-favorite', 'Mark as favorite');
toolbarActions.push(
<DashNavButton
key="star-dashboard-button"
return (
<ToolbarButton
tooltip={desc}
icon={meta.isStarred ? 'favorite' : 'star'}
iconType={meta.isStarred ? 'mono' : 'default'}
iconSize="lg"
icon={
<Icon name={meta.isStarred ? 'favorite' : 'star'} size="lg" type={meta.isStarred ? 'mono' : 'default'} />
}
key="star-dashboard-button"
onClick={() => {
DashboardInteractions.toolbarFavoritesClick();
dashboard.onStarDashboard();
}}
/>
);
}
toolbarActions.push(
<DashNavButton
key="share-dashboard-button"
tooltip={t('dashboard.toolbar.share', 'Share dashboard')}
icon="share-alt"
iconSize="lg"
onClick={() => {
DashboardInteractions.toolbarShareClick();
dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() }));
}}
/>
);
},
});
toolbarActions.push(
<DashNavButton
toolbarActions.push({
group: 'icon-actions',
condition: uid && !editview,
render: () => (
<ToolbarButton
key="view-in-old-dashboard-button"
tooltip={'View as dashboard'}
tooltip={'Switch to old dashboard page'}
icon="apps"
onClick={() => locationService.push(`/d/${uid}`)}
/>
);
if (dynamicDashNavActions.left.length > 0) {
dynamicDashNavActions.left.map((action, index) => {
),
});
if (dynamicDashNavActions.left.length > 0) {
dynamicDashNavActions.left.map((action, index) => {
const props = { dashboard: getDashboardSrv().getCurrent()! };
if (action.show(props)) {
const Component = action.component;
const element = <Component dashboard={_legacyDashboardModel} />;
typeof action.index === 'number'
? toolbarActions.splice(action.index, 0, element)
: toolbarActions.push(element);
});
}
}
toolbarActions.push(<NavToolbarSeparator leftActionsSeparator key="separator" />);
if (dynamicDashNavActions.right.length > 0) {
dynamicDashNavActions.right.map((action, index) => {
const Component = action.component;
const element = <Component dashboard={_legacyDashboardModel} key={`button-custom-${index}`} />;
typeof action.index === 'number'
? rightToolbarActions.splice(action.index, 0, element)
: rightToolbarActions.push(element);
toolbarActions.push({
group: 'icon-actions',
condition: true,
render: () => <Component {...props} />,
});
}
});
toolbarActions.push(...rightToolbarActions);
}
if (viewPanelScene) {
toolbarActions.push(
toolbarActions.push({
group: 'back-button',
condition: Boolean(viewPanelScene),
render: () => (
<Button
onClick={() => {
locationService.partial({ viewPanel: null });
}}
tooltip=""
key="back"
variant="primary"
fill="text"
variant="secondary"
size="sm"
icon="arrow-left"
>
Back to dashboard
</Button>
);
),
});
return <AppChromeUpdate actions={toolbarActions} />;
}
toolbarActions.push({
group: 'back-button',
condition: Boolean(editview),
render: () => (
<Button
onClick={() => {
locationService.partial({ editview: null });
}}
tooltip=""
key="back"
fill="text"
variant="secondary"
size="sm"
icon="arrow-left"
>
Back to dashboard
</Button>
),
});
if (!isEditing) {
if (dashboard.canEditDashboard()) {
toolbarActions.push(
<Button
onClick={() => {
dashboard.onEnterEditMode();
}}
tooltip="Enter edit mode"
key="edit"
variant="primary"
icon="pen"
fill="text"
size="sm"
>
Edit
</Button>
);
}
} else {
if (dashboard.canEditDashboard()) {
if (!dashboard.state.meta.isNew) {
toolbarActions.push(
toolbarActions.push({
group: 'main-buttons',
condition: uid && !isEditing,
render: () => (
<Button
key="share-dashboard-button"
tooltip={t('dashboard.toolbar.share', 'Share dashboard')}
size="sm"
className={buttonWithExtraMargin}
fill="outline"
onClick={() => {
DashboardInteractions.toolbarShareClick();
dashboard.showModal(new ShareModal({ dashboardRef: dashboard.getRef() }));
}}
>
Share
</Button>
),
});
toolbarActions.push({
group: 'main-buttons',
condition: !isEditing && dashboard.canEditDashboard() && !viewPanelScene,
render: () => (
<Button
onClick={() => {
dashboard.onEnterEditMode();
}}
tooltip="Enter edit mode"
key="edit"
className={buttonWithExtraMargin}
variant="primary"
size="sm"
>
Edit
</Button>
),
});
toolbarActions.push({
group: 'settings',
condition: isEditing && dashboard.canEditDashboard() && !viewPanelScene && !editview,
render: () => (
<Button
onClick={() => {
dashboard.onOpenSettings();
}}
tooltip="Dashboard settings"
fill="text"
size="sm"
key="settings"
variant="secondary"
>
Settings
</Button>
),
});
toolbarActions.push({
group: 'main-buttons',
condition: isEditing && !editview && !meta.isNew,
render: () => (
<Button
onClick={() => dashboard.exitEditMode({ skipConfirm: false })}
tooltip="Exits edit mode and discards unsaved changes"
size="sm"
key="discard"
fill="text"
variant="primary"
>
Exit edit
</Button>
),
});
toolbarActions.push({
group: 'main-buttons',
condition: isEditing && (meta.canSave || canSaveAs),
render: () => {
// if we only can save
if (meta.isNew) {
return (
<Button
onClick={() => {
DashboardInteractions.toolbarSaveClick();
dashboard.openSaveDrawer({});
}}
className={buttonWithExtraMargin}
tooltip="Save changes"
key="save"
size="sm"
variant={'primary'}
>
Save dashboard
</Button>
);
}
// If we only can save as copy
if (canSaveAs && !meta.canSave) {
return (
<Button
onClick={() => {
DashboardInteractions.toolbarSaveClick();
dashboard.openSaveDrawer({ saveAsCopy: true });
}}
size="sm"
className={buttonWithExtraMargin}
tooltip="Save as copy"
fill="text"
key="save-as"
key="save"
size="sm"
variant={isDirty ? 'primary' : 'secondary'}
>
Save as
Save as copy
</Button>
);
}
if (dashboard.canDiscard()) {
toolbarActions.push(
// If we can do both save and save as copy we show a button group with dropdown menu
const menu = (
<Menu>
<Menu.Item
label="Save"
icon="save"
onClick={() => {
DashboardInteractions.toolbarSaveClick();
dashboard.openSaveDrawer({});
}}
/>
<Menu.Item
label="Save as copy"
icon="copy"
onClick={() => {
DashboardInteractions.toolbarSaveAsClick();
dashboard.openSaveDrawer({ saveAsCopy: true });
}}
/>
</Menu>
);
return (
<ButtonGroup className={buttonWithExtraMargin} key="save">
<Button
onClick={() => {
dashboard.onDiscard();
DashboardInteractions.toolbarSaveClick();
dashboard.openSaveDrawer({});
}}
tooltip="Discard changes"
fill="text"
tooltip="Save changes"
size="sm"
key="discard"
variant="destructive"
variant={isDirty ? 'primary' : 'secondary'}
>
Discard
Save dashboard
</Button>
);
}
toolbarActions.push(
<Button
onClick={() => {
DashboardInteractions.toolbarSaveClick();
dashboard.openSaveDrawer({});
}}
tooltip="Save changes"
key="save"
size="sm"
variant={isDirty ? 'primary' : 'secondary'}
>
Save
</Button>
<Dropdown overlay={menu}>
<Button icon="angle-down" variant={isDirty ? 'primary' : 'secondary'} size="sm" />
</Dropdown>
</ButtonGroup>
);
},
});
const actionElements: React.ReactNode[] = [];
let lastGroup = '';
for (const action of toolbarActions) {
if (!action.condition) {
continue;
}
if (lastGroup && lastGroup !== action.group) {
lastGroup && actionElements.push(<NavToolbarSeparator key={`${action.group}-separator`} />);
}
actionElements.push(action.render());
lastGroup = action.group;
}
return <AppChromeUpdate actions={toolbarActions} />;
});
return actionElements;
}
NavToolbarActions.displayName = 'NavToolbarActions';
interface ToolbarAction {
group: string;
condition?: boolean | string;
render: () => React.ReactNode;
}
function getStyles(theme: GrafanaTheme2) {
return css({ margin: theme.spacing(0, 0.5) });
}

View File

@ -135,7 +135,9 @@ export const DashboardInteractions = {
toolbarSaveClick: () => {
reportDashboardInteraction('toolbar_actions_clicked', { item: 'save' });
},
toolbarSaveAsClick: () => {
reportDashboardInteraction('toolbar_actions_clicked', { item: 'save_as' });
},
toolbarAddClick: () => {
reportDashboardInteraction('toolbar_actions_clicked', { item: 'add' });
},