Dashboards: Use new sidebar in dynamic dashboards (#114245)

* Dynamic dashboards sidebar wip

* Progress

* Outline in view mode cannot change name

* Only one pane at a time

* Adding starbutton and custom precence

* undo / redo working

* Progress

* Update

* Progress

* Update

* Public badge, provisining badge

* playlist stop

* Update

* some initial unit tests

* Fix export tooltip

* Close pane when leaving edit mode if pane is an element

* Update

* e2e fixes

* fixing e2e

* Fix lint suppressions

* e2e fixes

* fixing more e2e

* fixing e2e

* fix e2e

* e2e fixes

* Fixinfg e2e

* fixing e2e
This commit is contained in:
Torkel Ödegaard
2025-11-26 14:30:18 +01:00
committed by GitHub
parent c94bf34d0b
commit f0a394e67b
41 changed files with 634 additions and 650 deletions
@@ -414,13 +414,13 @@ test.describe(
).toBeVisible();
// Go back to dashboard options
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true });
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
// Expand layouts section
await page.getByLabel('Expand Group layout category').click();
// Select tabs layout
await page.getByLabel('Tabs').click();
await page.getByLabel('layout-selection-option-Tabs').click();
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row'))).toBeVisible();
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New row 1'))).toBeVisible();
@@ -518,14 +518,14 @@ test.describe(
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
.fill('Test row 1');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// clear the title input to simulate no title and click away to trigger onBlur
await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 1')).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
.fill('');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// title should be set to a default name
await expect(
@@ -543,14 +543,14 @@ test.describe(
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
.fill('Test row 2');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// clear the title input to simulate no title and click away to trigger onBlur
await dashboardPage.getByGrafanaSelector(selectors.components.DashboardRow.title('Test row 2')).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.RowsLayout.titleInput)
.fill('');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// title should be set to a default name + 1 to avoid duplicates
await expect(
@@ -755,13 +755,13 @@ test.describe(
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 2'))).toBeVisible();
// Go back to dashboard options
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click({ force: true });
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
// Expand layouts section
await page.getByLabel('Expand Group layout category').click();
// Select rows layout
await page.getByLabel('Rows').click();
await page.getByLabel('layout-selection-option-Rows').click();
await dashboardPage
.getByGrafanaSelector(selectors.components.DashboardRow.wrapper('New tab 1'))
@@ -903,14 +903,14 @@ test.describe(
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
.fill('Test tab 1');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// clear the title input to simulate no title and click away to trigger onBlur
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 1')).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
.fill('');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// title should be set to a default name
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab'))).toBeVisible();
@@ -923,14 +923,14 @@ test.describe(
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
.fill('Test tab 2');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// clear the title input to simulate no title and click away to trigger onBlur
await dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('Test tab 2')).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.TabsLayout.titleInput)
.fill('');
await dashboardPage.getByGrafanaSelector(selectors.components.EditPaneHeader.backButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.closePane).click();
// title should be set to a default name + 1 to avoid duplicates
await expect(dashboardPage.getByGrafanaSelector(selectors.components.Tab.title('New tab 1'))).toBeVisible();
@@ -21,6 +21,7 @@ test.describe(
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
// Should be able to click Variables item in outline to see add variable button
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
@@ -28,6 +29,8 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
// Clicking a panel should scroll that panel in view
await expect(page.getByText('Dashboard panel 48')).toBeHidden();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Panel #48')).click();
@@ -22,6 +22,9 @@ test.describe(
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await expect(page.getByText(DASHBOARD_NAME)).toBeVisible();
const undockButton = page.getByRole('button', { name: 'Undock menu' });
await undockButton.click();
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await page.evaluate(() => {
@@ -199,6 +199,7 @@ test.describe(
.click();
// Open the modal editor in the side pane
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.node('Variables')).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('foo')).click();
await openModal(dashboardPage, selectors);
@@ -32,9 +32,9 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
@@ -50,6 +50,7 @@ test.describe(
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await checkAutoGridLayoutInputs(dashboardPage, selectors);
});
@@ -63,9 +64,10 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
// Get initial positions - standard width should have panels on different rows
const firstPanelTop = await getPanelTop(dashboardPage, selectors);
@@ -98,6 +100,7 @@ test.describe(
await page.reload();
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(
@@ -123,9 +126,10 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.minColumnWidth)
@@ -134,7 +138,7 @@ test.describe(
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth)
.fill('900');
.fill('1100');
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth)
.blur();
@@ -148,12 +152,13 @@ test.describe(
await verifyPanelsStackedVertically(dashboardPage, selectors);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(
selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.customMinColumnWidth
)
).toHaveValue('900');
).toHaveValue('1100');
await verifyPanelsStackedVertically(dashboardPage, selectors);
@@ -180,9 +185,9 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns)
@@ -198,6 +203,7 @@ test.describe(
await verifyPanelsStackedVertically(dashboardPage, selectors);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.maxColumns)
@@ -215,9 +221,9 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
@@ -250,6 +256,7 @@ test.describe(
}).toPass();
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.rowHeight)
@@ -270,9 +277,9 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
const regularRowHeight = await getPanelHeight(dashboardPage, selectors);
@@ -303,6 +310,7 @@ test.describe(
}).toPass();
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(
@@ -327,9 +335,9 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(3);
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
// Set narrow column width first to ensure panels fit horizontally
await dashboardPage
@@ -357,6 +365,7 @@ test.describe(
}).toPass();
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.AutoGridLayout.fillScreen)
@@ -37,9 +37,10 @@ test.describe(
},
() => {
test('can enable repeats', async ({ dashboardPage, selectors, page }) => {
await importTestDashboard(page, selectors, 'Auto grid repeats - add repeats');
await importTestDashboard(page, selectors, 'Auto-grid repeats - add repeats');
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
@@ -70,11 +71,12 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - update on variable change',
'Auto-grid repeats - update on variable change',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
@@ -113,6 +115,7 @@ test.describe(
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
@@ -138,11 +141,13 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - update through panel editor',
'Auto-grid repeats - update through panel editor',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
await page.reload();
@@ -202,11 +207,13 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - update through directly loaded panel editor',
'Auto-grid repeats - update through directly loaded panel editor',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
@@ -257,11 +264,12 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - move repeated panels',
'Auto-grid repeats - move repeated panels',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
@@ -304,11 +312,13 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - move repeated panels',
'Auto-grid repeats - move repeated panels 2',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
await page.reload();
@@ -332,9 +342,7 @@ test.describe(
const repeatedPanelUrl = page.url();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`))
@@ -367,11 +375,13 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - view embedded repeated panel',
'Auto-grid repeats - view embedded repeated panel',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
await page.reload();
@@ -393,11 +403,13 @@ test.describe(
await importTestDashboard(
page,
selectors,
'Auto grid repeats - remove repeats',
'Auto-grid repeats - remove repeats',
JSON.stringify(testV2DashWithRepeats)
);
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
await switchToAutoGrid(page);
await saveDashboard(dashboardPage, page, selectors);
await page.reload();
@@ -453,5 +465,5 @@ test.describe(
async function switchToAutoGrid(page: Page) {
await page.getByLabel('Expand Panel layout category').click();
await page.getByLabel('Auto grid').click();
await page.getByLabel('layout-selection-option-Auto grid').click();
}
@@ -303,9 +303,7 @@ test.describe(
const repeatedPanelUrl = page.url();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
await dashboardPage
.getByGrafanaSelector(selectors.components.Panels.Panel.title(`${repeatTitleBase}${repeatOptions.at(0)}`))
@@ -316,9 +316,7 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
// repeated panel in original tab repeat
await dashboardPage
@@ -341,9 +339,7 @@ test.describe(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Tab 1 - Row 2 - Panel repeat 2'))
).toBeVisible();
await dashboardPage
.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.backToDashboardButton)
.click();
await page.keyboard.press('Escape');
// repeated panel in repeated tab
await dashboardPage
@@ -21,11 +21,7 @@ test.describe(
const dashboardPage = await gotoDashboardPage({ uid: PAGE_UNDER_TEST });
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
// Check that current dashboard title is visible in breadcrumb
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Breadcrumbs.breadcrumb('Annotation filtering'))
).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.optionsButton).click();
const titleInput = page.locator('[aria-label="dashboard-options Title field property editor"] input');
await expect(titleInput).toHaveValue('Annotation filtering');
@@ -48,6 +48,7 @@ export const flows = {
},
async newEditPaneVariableClick(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) {
await dashboardPage.getByGrafanaSelector(selectors.components.NavToolbar.editDashboard.editButton).click();
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.outlineButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.Outline.item('Variables')).click();
await dashboardPage
.getByGrafanaSelector(selectors.components.PanelEditor.ElementEditPane.addVariableButton)
-5
View File
@@ -1907,11 +1907,6 @@
"count": 2
}
},
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneRenderer.tsx": {
"@typescript-eslint/consistent-type-assertions": {
"count": 1
}
},
"public/app/features/dashboard-scene/edit-pane/DashboardEditPaneSplitter.tsx": {
"react-hooks/rules-of-hooks": {
"count": 4
@@ -57,6 +57,11 @@ export const versionedComponents = {
'12.1.0': 'data-testid DashboardEditPaneSplitter primary body',
},
},
Sidebar: {
closePane: {
'12.4.0': 'data-testid Sidebar close pane',
},
},
EditPaneHeader: {
deleteButton: {
'12.1.0': 'data-testid EditPaneHeader delete panel',
@@ -70,9 +75,6 @@ export const versionedComponents = {
duplicate: {
'12.1.0': 'data-testid EditPaneHeader duplicate',
},
backButton: {
'12.1.0': 'data-testid EditPaneHeader back',
},
},
TimePicker: {
openButton: {
@@ -183,6 +183,14 @@ export const versionedPages = {
url: {
[MIN_GRAFANA_VERSION]: (uid: string) => `/d/${uid}`,
},
Sidebar: {
optionsButton: {
'12.4.0': 'data-testid Dashboard Sidebar options button',
},
outlineButton: {
'12.4.0': 'data-testid Dashboard Sidebar outline button',
},
},
DashNav: {
nav: {
[MIN_GRAFANA_VERSION]: 'Dashboard navigation',
@@ -59,8 +59,9 @@ export function SiderbarToolbar({ children }: SiderbarToolbarProps) {
{context.hasOpenPane && (
<SidebarButton
icon={'web-section-alt'}
onClick={context.onDockChange}
onClick={context.onToggleDock}
title={context.isDocked ? t('grafana-ui.sidebar.undock', 'Undock') : t('grafana-ui.sidebar.dock', 'Dock')}
data-testid="sidebar-dock-toggle"
/>
)}
</div>
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { useContext } from 'react';
import React, { ButtonHTMLAttributes, useContext } from 'react';
import { GrafanaTheme2, IconName, isIconName } from '@grafana/data';
@@ -11,38 +11,48 @@ import { Tooltip } from '../Tooltip/Tooltip';
import { SidebarContext } from './useSidebar';
export interface Props {
export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
icon: IconName;
active?: boolean;
onClick?: () => void;
title: string;
tooltip?: string;
title: string;
}
export function SidebarButton({ icon, active, onClick, title, tooltip }: Props) {
const styles = useStyles2(getStyles);
const context = useContext(SidebarContext);
export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
({ icon, active, onClick, title, tooltip, ...restProps }, ref) => {
const styles = useStyles2(getStyles);
const context = useContext(SidebarContext);
if (!context) {
throw new Error('Sidebar.Button must be used within a Sidebar component');
if (!context) {
throw new Error('Sidebar.Button must be used within a Sidebar component');
}
const buttonClass = cx(
styles.button,
context.compact && styles.compact,
active && styles.active,
context.position === 'left' && styles.leftButton
);
return (
<Tooltip ref={ref} content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
<button
className={buttonClass}
aria-label={title}
aria-expanded={active}
type="button"
onClick={onClick}
{...restProps}
>
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
</button>
</Tooltip>
);
}
);
const buttonClass = cx(
styles.button,
context.compact && styles.compact,
active && styles.active,
context.position === 'left' && styles.leftButton
);
return (
<Tooltip content={tooltip ?? title} placement={context.position === 'left' ? 'right' : 'left'}>
<button className={buttonClass} aria-label={title} aria-expanded={active} type="button" onClick={onClick}>
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
</button>
</Tooltip>
);
}
SidebarButton.displayName = 'SidebarButton';
function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) {
if (!icon) {
@@ -2,6 +2,7 @@ import { css } from '@emotion/css';
import { ReactNode } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { useStyles2 } from '../../themes/ThemeContext';
@@ -27,6 +28,7 @@ export function SidebarPaneHeader({ children, onClose, title }: Props) {
onClick={onClose}
aria-label={t('grafana-ui.sidebar.close', 'Close')}
tooltip={t('grafana-ui.sidebar.close', 'Close')}
data-testid={selectors.components.Sidebar.closePane}
/>
)}
<Text weight="medium" variant="h6" truncate data-testid="sidebar-pane-header-title">
@@ -16,7 +16,7 @@ export interface SidebarContextValue {
bottomMargin: number;
edgeMargin: number;
contentMargin: number;
onDockChange: () => void;
onToggleDock: () => void;
onResize: (diff: number) => void;
}
@@ -56,7 +56,7 @@ export function useSidebar({
// Used to accumulate drag distance to know when to change compact mode
const [_, setCompactDrag] = React.useState(0);
const onDockChange = useCallback(() => setIsDocked((prev) => !prev), []);
const onToggleDock = useCallback(() => setIsDocked((prev) => !prev), []);
const prop = position === 'right' ? 'paddingRight' : 'paddingLeft';
const toolbarWidth =
@@ -98,7 +98,7 @@ export function useSidebar({
return {
isDocked,
onDockChange,
onToggleDock,
onResize,
outerWrapperProps,
position,
@@ -26,8 +26,12 @@ export interface DashboardEditPaneState extends SceneObjectState {
undoStack: DashboardEditActionEventPayload[];
redoStack: DashboardEditActionEventPayload[];
openPane?: DashboardSidebarPaneName;
isDocked?: boolean;
}
export type DashboardSidebarPaneName = 'element' | 'outline' | 'filters';
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public constructor() {
super({
@@ -192,6 +196,7 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
this.setState({
selectionContext: { ...this.state.selectionContext, selected: [], enabled: false },
selection: undefined,
openPane: this.state.openPane === 'element' ? undefined : this.state.openPane,
});
}
@@ -227,7 +232,6 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
}
const elementSelection = this.state.selection ?? new ElementSelection([[id, obj.getRef()]]);
const { selection, contextItems: selected } = elementSelection.getStateWithValue(id, obj, !!multi);
this.updateSelection(new ElementSelection(selection), selected);
@@ -255,17 +259,58 @@ export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
document.activeElement.blur();
}
this.setState({ selection, selectionContext: { ...this.state.selectionContext, selected } });
this.setState({
selection,
selectionContext: { ...this.state.selectionContext, selected },
openPane: selection ? 'element' : undefined,
});
}
public clearSelection() {
/**
* @param force If force = true it will clear selection even when docked
* @returns
*/
public clearSelection(force = false) {
if (!this.state.selection) {
return;
}
// If we are docked then clearing selection should select dashboard itself
// Unless the user explicitly closes pane
if (this.state.isDocked && !force) {
const obj = this.state.selection?.getFirstObject();
const dashboard = getDashboardSceneFor(this);
if (obj !== dashboard) {
this.selectObject(dashboard, dashboard.state.key!);
}
return;
}
this.updateSelection(undefined, []);
}
public openPane(openPane: DashboardSidebarPaneName) {
if (this.state.selection) {
this.clearSelection(true);
}
if (openPane === this.state.openPane) {
this.setState({ openPane: undefined });
} else {
this.setState({ openPane });
}
}
public closePane() {
if (this.state.selection) {
this.clearSelection(true);
}
if (this.state.openPane) {
this.setState({ openPane: undefined });
}
}
private newObjectAddedToCanvas(obj: SceneObject) {
this.selectObject(obj, obj.state.key!);
this.state.selection?.markAsNewElement();
@@ -1,18 +1,17 @@
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { act, screen } from '@testing-library/react';
import { render } from 'test/test-utils';
import { getPanelPlugin } from '@grafana/data/test';
import { selectors } from '@grafana/e2e-selectors';
import { setPluginImportUtils } from '@grafana/runtime';
import { setPluginImportUtils, config } from '@grafana/runtime';
import { SceneGridLayout, SceneTimeRange, SceneVariableSet, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { DashboardInteractions } from '../utils/interactions';
import { activateFullSceneTree } from '../utils/test-utils';
import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer';
import { DashboardEditPaneSplitter } from './DashboardEditPaneSplitter';
setPluginImportUtils({
importPanelPlugin: (id: string) => Promise.resolve(getPanelPlugin({})),
@@ -26,14 +25,9 @@ jest.mock('../utils/interactions', () => ({
},
}));
jest.mock('react-router-dom-v5-compat', () => ({
...jest.requireActual('react-router-dom-v5-compat'),
useLocation: () => ({
pathname: '/dashboard/test',
search: '',
hash: '',
state: null,
}),
jest.mock('@grafana/runtime', () => ({
...jest.requireActual('@grafana/runtime'),
useChromeHeaderHeight: jest.fn().mockReturnValue(80),
}));
export function buildTestScene() {
@@ -47,26 +41,43 @@ export function buildTestScene() {
}),
}),
});
activateFullSceneTree(testScene);
return testScene;
}
describe('DashboardEditPaneRenderer', () => {
describe('outline interactions tracking', () => {
it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => {
const user = userEvent.setup();
const scene = buildTestScene();
render(
<DashboardEditPaneRenderer
editPane={scene.state.editPane}
isEditPaneCollapsed={false}
onToggleCollapse={() => {}}
/>
);
const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section);
await user.click(outlineButton);
config.featureToggles.dashboardNewLayouts = true;
expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled();
});
it('Should render sidebar', async () => {
const scene = buildTestScene();
act(() => activateFullSceneTree(scene));
render(<DashboardEditPaneSplitter dashboard={scene} />);
expect(await screen.findByTestId(selectors.pages.Dashboard.Sidebar.outlineButton)).toBeInTheDocument();
});
it('Should sync sidebar docked state with edit pane state', async () => {
const scene = buildTestScene();
render(<DashboardEditPaneSplitter dashboard={scene} />);
act(() => screen.getByLabelText('Outline').click());
expect(await screen.findByTestId('sidebar-dock-toggle')).toBeInTheDocument();
act(() => screen.getByTestId('sidebar-dock-toggle').click());
expect(scene.state.editPane.state.isDocked).toBe(true);
});
// describe('outline interactions tracking', () => {
// it('should call DashboardInteractions.outlineClicked when clicking on dashboard outline', async () => {
// const user = userEvent.setup();
// const scene = buildTestScene();
// render(<DashboardEditPaneRenderer editPane={scene.state.editPane} dashboard={scene} />);
// const outlineButton = screen.getByTestId(selectors.components.PanelEditor.Outline.section);
// await user.click(outlineButton);
// expect(DashboardInteractions.dashboardOutlineClicked).toHaveBeenCalled();
// });
// });
});
@@ -1,211 +1,160 @@
import { css, cx } from '@emotion/css';
import { Resizable } from 're-resizable';
import { useLocalStorage } from 'react-use';
import { useMemo } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useSceneObjectState } from '@grafana/scenes';
import { useStyles2, useSplitter, ToolbarButton, ScrollContainer, Text, Icon, clearButtonStyles } from '@grafana/ui';
import { Sidebar } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardInteractions } from '../utils/interactions';
import { DashboardScene } from '../scene/DashboardScene';
import { onOpenSnapshotOriginalDashboard } from '../scene/GoToSnapshotOriginButton';
import { ManagedDashboardNavBarBadge } from '../scene/ManagedDashboardNavBarBadge';
import { ToolbarActionProps } from '../scene/new-toolbar/types';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardEditPane } from './DashboardEditPane';
import { ShareExportDashboardButton } from './DashboardExportButton';
import { DashboardOutline } from './DashboardOutline';
import { ElementEditPane } from './ElementEditPane';
import { useEditableElement } from './useEditableElement';
export interface Props {
editPane: DashboardEditPane;
isEditPaneCollapsed: boolean;
openOverlay?: boolean;
onToggleCollapse: () => void;
dashboard: DashboardScene;
isDocked?: boolean;
}
/**
* Making the EditPane rendering completely standalone (not using editPane.Component) in order to pass custom react props
*/
export function DashboardEditPaneRenderer({ editPane, isEditPaneCollapsed, onToggleCollapse, openOverlay }: Props) {
const { selection } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const styles = useStyles2(getStyles);
const clearButton = useStyles2(clearButtonStyles);
const editableElement = useEditableElement(selection, editPane);
export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Props) {
const { selection, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const { isEditing, meta, uid } = dashboard.useState();
const hasUid = Boolean(uid);
const selectedObject = selection?.getFirstObject();
const isNewElement = selection?.isNewElement() ?? false;
const [outlineCollapsed, setOutlineCollapsed] = useLocalStorage(
'grafana.dashboard.edit-pane.outline.collapsed',
false
);
const [outlinePaneSize = 0.4, setOutlinePaneSize] = useLocalStorage('grafana.dashboard.edit-pane.outline.size', 0.4);
// splitter for template and payload editor
const splitter = useSplitter({
direction: 'column',
handleSize: 'sm',
// if Grafana Alertmanager, split 50/50, otherwise 100/0 because there is no payload editor
initialSize: 1 - outlinePaneSize,
dragPosition: 'middle',
onSizeChanged: (size) => {
setOutlinePaneSize(1 - size);
},
});
const editableElement = useMemo(() => {
if (selection) {
return selection.createSelectionElement();
}
if (!editableElement) {
return null;
}
if (isEditPaneCollapsed) {
return (
<>
<div className={styles.expandOptionsWrapper}>
<ToolbarButton
tooltip={t('dashboard.edit-pane.open', 'Open options pane')}
icon="arrow-to-right"
onClick={onToggleCollapse}
variant="canvas"
narrow={true}
className={styles.rotate180}
aria-label={t('dashboard.edit-pane.open', 'Open options pane')}
/>
</div>
{openOverlay && (
<Resizable className={styles.overlayWrapper} defaultSize={{ height: '100%', width: '300px' }}>
<ElementEditPane
element={editableElement}
key={selectedObject?.state.key}
editPane={editPane}
isNewElement={isNewElement}
/>
</Resizable>
)}
</>
);
}
if (outlineCollapsed) {
splitter.primaryProps.style.flexGrow = 1;
splitter.primaryProps.style.minHeight = 'unset';
splitter.secondaryProps.style.flexGrow = 0;
splitter.secondaryProps.style.minHeight = 'min-content';
} else {
splitter.primaryProps.style.minHeight = 'unset';
splitter.secondaryProps.style.minHeight = 'unset';
}
return undefined;
}, [selection]);
return (
<div className={styles.wrapper}>
<div {...splitter.containerProps}>
<div {...splitter.primaryProps} className={cx(splitter.primaryProps.className, styles.paneContent)}>
<>
{editableElement && (
<Sidebar.OpenPane>
<ElementEditPane
element={editableElement}
key={selectedObject?.state.key}
editPane={editPane}
element={editableElement}
isNewElement={isNewElement}
/>
</div>
<div
{...splitter.splitterProps}
className={cx(splitter.splitterProps.className, styles.splitter)}
data-edit-pane-splitter={true}
/>
<div {...splitter.secondaryProps} className={cx(splitter.secondaryProps.className, styles.paneContent)}>
<button
type="button"
onClick={() => {
DashboardInteractions.dashboardOutlineClicked();
setOutlineCollapsed(!outlineCollapsed);
}}
className={cx(clearButton, styles.outlineCollapseButton)}
data-testid={selectors.components.PanelEditor.Outline.section}
>
<Text weight="medium">
<Trans i18nKey="dashboard-scene.dashboard-edit-pane-renderer.outline">Outline</Trans>
</Text>
<Icon name={outlineCollapsed ? 'angle-up' : 'angle-down'} />
</button>
{!outlineCollapsed && (
<div className={styles.outlineContainer}>
<ScrollContainer showScrollIndicators={true}>
<DashboardOutline editPane={editPane} />
</ScrollContainer>
</div>
)}
</div>
</div>
</div>
</Sidebar.OpenPane>
)}
{openPane === 'outline' && (
<Sidebar.OpenPane>
<DashboardOutline editPane={editPane} isEditing={isEditing} />
</Sidebar.OpenPane>
)}
<Sidebar.Toolbar>
{isEditing && (
<>
{config.featureToggles.dashboardUndoRedo && (
<>
<UndoButton dashboard={dashboard} />
<RedoButton dashboard={dashboard} />
</>
)}
<Sidebar.Button
icon="cog"
onClick={() => editPane.selectObject(dashboard, dashboard.state.key!)}
title={t('dashboard.sidebar.dashboard-options.title', 'Options')}
tooltip={t('dashboard.sidebar.dashboard-options.tooltip', 'Dashboard options')}
data-testid={selectors.pages.Dashboard.Sidebar.optionsButton}
active={selectedObject === dashboard ? true : false}
/>
<Sidebar.Button
tooltip={t('dashboard.sidebar.edit-schema.tooltip', 'Edit as code')}
title={t('dashboard.sidebar.edit-schema.title', 'Code')}
icon="brackets-curly"
onClick={() => dashboard.openV2SchemaEditor()}
/>
<Sidebar.Divider />
</>
)}
{hasUid && <ShareExportDashboardButton dashboard={dashboard} />}
<Sidebar.Button
icon="list-ui-alt"
onClick={() => editPane.openPane('outline')}
title={t('dashboard.sidebar.outline.title', 'Outline')}
tooltip={t('dashboard.sidebar.outline.tooltip', 'Content outline')}
data-testid={selectors.pages.Dashboard.Sidebar.outlineButton}
active={openPane === 'outline'}
></Sidebar.Button>
{dashboard.isManaged() && Boolean(meta.canEdit) && <ManagedDashboardNavBarBadge dashboard={dashboard} />}
{renderEnterpriseItems()}
{Boolean(meta.isSnapshot) && (
<Sidebar.Button
data-testid="button-snapshot"
tooltip={t('dashboard.sidebar.snapshot.tooltip', 'Open original dashboard')}
title={t('dashboard.toolbar.snapshot.title', 'Source')}
icon="link"
onClick={() => onOpenSnapshotOriginalDashboard(dashboard.getSnapshotUrl())}
/>
)}
</Sidebar.Toolbar>
</>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
flexDirection: 'column',
flex: '1 1 0',
marginTop: theme.spacing(2),
borderLeft: `1px solid ${theme.colors.border.weak}`,
borderTop: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
borderTopLeftRadius: theme.shape.radius.default,
}),
overlayWrapper: css({
right: 0,
bottom: 0,
top: theme.spacing(2),
position: 'absolute !important' as 'absolute',
background: theme.colors.background.primary,
borderLeft: `1px solid ${theme.colors.border.weak}`,
borderTop: `1px solid ${theme.colors.border.weak}`,
boxShadow: theme.shadows.z3,
zIndex: theme.zIndex.navbarFixed,
flexGrow: 1,
}),
paneContent: css({
overflow: 'hidden',
display: 'flex',
flexDirection: 'column',
}),
rotate180: css({
rotate: '180deg',
}),
tabsbar: css({
padding: theme.spacing(0, 1),
margin: theme.spacing(0.5, 0),
}),
expandOptionsWrapper: css({
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2, 1, 2, 0),
}),
splitter: css({
'&::after': {
background: 'transparent',
transform: 'unset',
width: '100%',
height: '1px',
top: '100%',
left: '0',
},
}),
outlineCollapseButton: css({
display: 'flex',
padding: theme.spacing(0.5, 2),
gap: theme.spacing(1),
justifyContent: 'space-between',
alignItems: 'center',
background: theme.colors.background.secondary,
function renderEnterpriseItems() {
const dashboard = getDashboardSrv().getCurrent()!;
const showProps = { dashboard };
'&:hover': {
background: theme.colors.action.hover,
},
}),
outlineContainer: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
overflow: 'hidden',
}),
};
return dynamicDashNavActions.right.map((action, index) => {
if (action.show(showProps)) {
const ActionComponent = action.component;
return <ActionComponent key={index} dashboard={dashboard} />;
}
return null;
});
}
function UndoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { undoStack } = editPane.useState();
const undoAction = undoStack[undoStack.length - 1];
const undoWord = t('dashboard.sidebar.undo', 'Undo');
const tooltip = `${undoWord}${undoAction?.description ? ` ${undoAction.description}` : ''}`;
return (
<Sidebar.Button
icon="corner-up-left"
disabled={undoStack.length === 0}
onClick={() => editPane.undoAction()}
title={undoWord}
tooltip={tooltip}
/>
);
}
function RedoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { redoStack } = editPane.useState();
const redoAction = redoStack[redoStack.length - 1];
const redoWord = t('dashboard.sidebar.redo', 'Redo');
const tooltip = `${redoWord}${redoAction?.description ? ` ${redoAction.description}` : ''}`;
return (
<Sidebar.Button
icon="corner-up-right"
disabled={redoStack.length === 0}
title={redoWord}
tooltip={tooltip}
onClick={() => editPane.redoAction()}
/>
);
}
@@ -1,19 +1,22 @@
import { css, cx } from '@emotion/css';
import React, { CSSProperties, useEffect } from 'react';
import React, { useEffect } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { config, useChromeHeaderHeight } from '@grafana/runtime';
import { useSceneObjectState } from '@grafana/scenes';
import { ElementSelectionContext, useStyles2 } from '@grafana/ui';
import { ElementSelectionContext, useSidebar, useStyles2, Sidebar } from '@grafana/ui';
import { AppChromeUpdate } from 'app/core/components/AppChrome/AppChromeUpdate';
import NativeScrollbar, { DivScrollElement } from 'app/core/components/NativeScrollbar';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { useSnappingSplitter } from '../panel-edit/splitter/useSnappingSplitter';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { PublicDashboardBadge } from '../scene/new-toolbar/actions/PublicDashboardBadge';
import { StarButton } from '../scene/new-toolbar/actions/StarButton';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { DashboardEditPaneRenderer } from './DashboardEditPaneRenderer';
import { useEditPaneCollapsed } from './shared';
interface Props {
dashboard: DashboardScene;
@@ -26,7 +29,10 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
const headerHeight = useChromeHeaderHeight();
const { editPane } = dashboard.state;
const styles = useStyles2(getStyles, headerHeight ?? 0);
const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
const hasUid = Boolean(dashboard.state.uid);
const canStar = Boolean(dashboard.state.meta.canStar);
//const [isCollapsed, setIsCollapsed] = useEditPaneCollapsed();
if (!config.featureToggles.dashboardNewLayouts) {
return (
@@ -40,21 +46,6 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
);
}
const { containerProps, primaryProps, secondaryProps, splitterProps, splitterState, onToggleCollapse } =
useSnappingSplitter({
direction: 'row',
dragPosition: 'end',
initialSize: 330,
handleSize: 'sm',
usePixels: true,
collapseBelowPixels: 250,
collapsed: isCollapsed,
});
useEffect(() => {
setIsCollapsed(splitterState.collapsed);
}, [splitterState.collapsed, setIsCollapsed]);
/**
* Enable / disable selection based on dashboard isEditing state
*/
@@ -66,15 +57,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
}
}, [isEditing, editPane]);
const { selectionContext } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const containerStyle: CSSProperties = {};
if (!isEditing) {
primaryProps.style.flexGrow = 1;
primaryProps.style.width = '100%';
primaryProps.style.minWidth = 'unset';
containerStyle.overflow = 'unset';
}
const { selectionContext, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const onBodyRef = (ref: HTMLDivElement | null) => {
if (ref) {
@@ -82,54 +65,73 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
}
};
return (
<div {...containerProps} style={containerStyle}>
<ElementSelectionContext.Provider value={selectionContext}>
<div
{...primaryProps}
className={cx(primaryProps.className, styles.canvasWithSplitter)}
onPointerDown={(evt) => {
if (evt.shiftKey) {
return;
}
const sidebarContext = useSidebar({
hasOpenPane: Boolean(openPane),
contentMargin: 1,
position: 'right',
});
editPane.clearSelection();
}}
>
<NavToolbarActions dashboard={dashboard} />
<div className={cx(!isEditing && styles.controlsWrapperSticky)}>{controls}</div>
<div className={styles.bodyWrapper}>
<div
className={cx(styles.body, isEditing && styles.bodyEditing)}
data-testid={selectors.components.DashboardEditPaneSplitter.primaryBody}
ref={onBodyRef}
>
{body}
</div>
/**
* Sync docked state to editPane state
*/
useEffect(() => {
editPane.setState({ isDocked: sidebarContext.isDocked });
}, [sidebarContext.isDocked, editPane]);
const onClearSelection: React.PointerEventHandler<HTMLDivElement> = (evt) => {
if (evt.shiftKey) {
return;
}
editPane.clearSelection();
};
return (
<div className={styles.container}>
<ElementSelectionContext.Provider value={selectionContext}>
<AppChromeUpdate
breadcrumbActions={
<>
{hasUid && canStar && <StarButton dashboard={dashboard} />}
{hasUid && canStar && <PublicDashboardBadge dashboard={dashboard} />}
{renderDynamicNavActions()}
</>
}
/>
<div className={cx(styles.controlsWrapperSticky)} onPointerDown={onClearSelection}>
{controls}
</div>
<div className={styles.bodyWrapper} {...sidebarContext.outerWrapperProps}>
<div
className={styles.bodyWithToolbar}
data-testid={selectors.components.DashboardEditPaneSplitter.primaryBody}
ref={onBodyRef}
onPointerDown={onClearSelection}
>
{body}
</div>
<Sidebar contextValue={sidebarContext}>
<DashboardEditPaneRenderer editPane={editPane} dashboard={dashboard} isDocked={sidebarContext.isDocked} />
</Sidebar>
</div>
{isEditing && (
<>
<div
{...splitterProps}
className={cx(splitterProps.className, styles.splitter)}
data-edit-pane-splitter={true}
/>
<div {...secondaryProps} className={cx(secondaryProps.className, styles.editPane)}>
<DashboardEditPaneRenderer
editPane={editPane}
isEditPaneCollapsed={isCollapsed}
onToggleCollapse={onToggleCollapse}
openOverlay={selectionContext.selected.length > 0}
/>
</div>
</>
)}
</ElementSelectionContext.Provider>
</div>
);
}
function renderDynamicNavActions() {
const dashboard = getDashboardSrv().getCurrent()!;
const showProps = { dashboard };
return dynamicDashNavActions.left.map((action, index) => {
if (action.show(showProps)) {
const ActionComponent = action.component;
return <ActionComponent key={index} dashboard={dashboard} />;
}
return null;
});
}
function getStyles(theme: GrafanaTheme2, headerHeight: number) {
return {
canvasWrappperOld: css({
@@ -138,22 +140,33 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) {
flexDirection: 'column',
flexGrow: 1,
}),
canvasWithSplitter: css({
overflow: 'unset',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
}),
canvasWithSplitterEditing: css({
overflow: 'unset',
}),
bodyWrapper: css({
label: 'body-wrapper',
container: css({
label: 'container',
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
position: 'relative',
}),
bodyWrapper: css({
label: 'body-wrapper',
display: 'flex',
flexDirection: 'row',
flexGrow: 1,
position: 'relative',
flex: '1 1 0',
overflow: 'hidden',
}),
bodyWithToolbar: css({
display: 'flex',
flexDirection: 'column',
flexGrow: 1,
minHeight: 0,
overflow: 'auto',
scrollbarWidth: 'thin',
scrollbarGutter: 'stable',
// without top padding the fixed controls headers is rendered over the selection outline.
padding: theme.spacing(0.125, 1, 2, 2),
}),
body: css({
label: 'body',
display: 'flex',
@@ -181,11 +194,6 @@ function getStyles(theme: GrafanaTheme2, headerHeight: number) {
// borderLeft: `1px solid ${theme.colors.border.weak}`,
// background: theme.colors.background.primary,
}),
splitter: css({
'&:after': {
display: 'none',
},
}),
controlsWrapperSticky: css({
[theme.breakpoints.up('md')]: {
position: 'sticky',
@@ -8,10 +8,9 @@ import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/Pan
import { DashboardScene } from '../scene/DashboardScene';
import { useLayoutCategory } from '../scene/layouts-shared/DashboardLayoutSelector';
import { EditSchemaV2Button } from '../scene/new-toolbar/actions/EditSchemaV2Button';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../scene/types/EditableDashboardElement';
import { dashboardEditActions, undoRedoWasClicked } from './shared';
import { dashboardEditActions } from './shared';
function useEditPaneOptions(
this: DashboardEditableElement,
@@ -69,19 +68,16 @@ export class DashboardEditableElement implements EditableDashboardElement {
public renderActions(): ReactNode {
return (
<>
<EditSchemaV2Button dashboard={this.dashboard} />
<Button
variant="secondary"
size="sm"
onClick={() => this.dashboard.onOpenSettings()}
tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')}
icon="sliders-v-alt"
iconPlacement="right"
>
<Trans i18nKey="dashboard.actions.open-settings">Settings</Trans>
</Button>
</>
<Button
variant="secondary"
size="sm"
onClick={() => this.dashboard.onOpenSettings()}
tooltip={t('dashboard.toolbar.dashboard-settings.tooltip', 'Dashboard settings')}
icon="sliders-v-alt"
iconPlacement="right"
>
<Trans i18nKey="dashboard.actions.open-settings">Settings</Trans>
</Button>
);
}
}
@@ -104,7 +100,7 @@ export function DashboardTitleInput({ dashboard, id }: { dashboard: DashboardSce
}}
onBlur={(e) => {
const titleUnchanged = valueBeforeEdit.current === e.currentTarget.value;
const shouldSkip = titleUnchanged || undoRedoWasClicked(e);
const shouldSkip = titleUnchanged;
if (shouldSkip) {
return;
}
@@ -135,7 +131,7 @@ export function DashboardDescriptionInput({ dashboard, id }: { dashboard: Dashbo
}}
onBlur={(e) => {
const descriptionUnchanged = valueBeforeEdit.current === e.currentTarget.value;
const shouldSkip = descriptionUnchanged || undoRedoWasClicked(e);
const shouldSkip = descriptionUnchanged;
if (shouldSkip) {
return;
}
@@ -0,0 +1,57 @@
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { locationService } from '@grafana/runtime';
import { Dropdown, Sidebar } from '@grafana/ui';
import { appEvents } from 'app/core/app_events';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import { ShowConfirmModalEvent } from 'app/types/events';
import { DashboardScene } from '../scene/DashboardScene';
import ExportMenu from '../sharing/ExportButton/ExportMenu';
import { DashboardInteractions } from '../utils/interactions';
interface Props {
dashboard: DashboardScene;
}
const newExportButtonSelector = selectors.pages.Dashboard.DashNav.NewExportButton;
export function ShareExportDashboardButton({ dashboard }: Props) {
return (
<Dropdown overlay={<ExportMenu dashboard={dashboard} />} placement="left-end">
<Sidebar.Button
icon="download-alt"
data-testid={newExportButtonSelector.Menu.container}
title={t('dashboard.sidebar.export.title', 'Export')}
onPointerDown={(evt) => {
if (dashboard.state.isEditing && dashboard.state.isDirty) {
evt.preventDefault();
evt.stopPropagation();
appEvents.publish(
new ShowConfirmModalEvent({
title: t('dashboard.sidebar.export.unsaved-modal.title', 'Save changes to dashboard?'),
text: t(
'dashboard.sidebar.export.unsaved-modal.text',
'You have unsaved changes to this dashboard. You need to save them before you can share it.'
),
icon: 'exclamation-triangle',
noText: t('common.discard', 'Discard'),
yesText: t('common.save', 'Save'),
yesButtonVariant: 'primary',
onConfirm: () => dashboard.openSaveDrawer({}),
})
);
} else {
locationService.partial({ shareView: shareDashboardType.export });
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}
}}
/>
</Dropdown>
);
}
@@ -101,7 +101,7 @@ describe('DashboardOutline', () => {
render(
<ElementSelectionContext.Provider value={scene.state.editPane.state.selectionContext}>
<DashboardOutline editPane={scene.state.editPane} />
<DashboardOutline editPane={scene.state.editPane} isEditing={true} />
</ElementSelectionContext.Provider>
);
// select Row lvl 1
@@ -5,7 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { Box, Icon, Sidebar, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../utils/clone';
import { DashboardInteractions } from '../utils/interactions';
@@ -17,28 +17,36 @@ import { useOutlineRename } from './useOutlineRename';
export interface Props {
editPane: DashboardEditPane;
isEditing: boolean | undefined;
}
export function DashboardOutline({ editPane }: Props) {
export function DashboardOutline({ editPane, isEditing }: Props) {
const dashboard = getDashboardSceneFor(editPane);
return (
<Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} editPane={editPane} depth={0} index={0} />
</Box>
<>
<Sidebar.PaneHeader
title={t('dashboard.outline.pane-header', 'Content outline')}
onClose={() => editPane.closePane()}
/>
<Box padding={1} gap={0} display="flex" direction="column" element="ul" role="tree" position="relative">
<DashboardOutlineNode sceneObject={dashboard} isEditing={isEditing} editPane={editPane} depth={0} index={0} />
</Box>
</>
);
}
interface DashboardOutlineNodeProps {
sceneObject: SceneObject;
editPane: DashboardEditPane;
isEditing: boolean | undefined;
depth: number;
index: number;
}
function DashboardOutlineNode({ sceneObject, editPane, depth, index }: DashboardOutlineNodeProps) {
function DashboardOutlineNode({ sceneObject, editPane, isEditing, depth, index }: DashboardOutlineNodeProps) {
const styles = useStyles2(getStyles);
const { key } = sceneObject.useState();
const key = sceneObject.state.key;
const [isCollapsed, setIsCollapsed] = useState(depth > 0);
const { isSelected, onSelect } = useElementSelection(key);
const isCloned = useMemo(() => isRepeatCloneOrChildOf(sceneObject), [sceneObject]);
@@ -49,7 +57,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard
const children = editableElement.getOutlineChildren?.() ?? [];
const elementInfo = editableElement.getEditableElementInfo();
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const outlineRename = useOutlineRename(editableElement);
const outlineRename = useOutlineRename(editableElement, isEditing);
const isContainer = editableElement.getOutlineChildren ? true : false;
const onNodeClicked = (e: React.MouseEvent) => {
@@ -131,6 +139,7 @@ function DashboardOutlineNode({ sceneObject, editPane, depth, index }: Dashboard
sceneObject={child}
editPane={editPane}
depth={depth + 1}
isEditing={isEditing}
index={i}
/>
))
@@ -1,9 +1,6 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { Button, Menu, Stack, Text, useStyles2, Dropdown, Icon, IconButton } from '@grafana/ui';
import { Button, Menu, Stack, Dropdown, Icon, Sidebar } from '@grafana/ui';
import { trackDeleteDashboardElement } from 'app/features/dashboard-scene/utils/tracking';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
@@ -17,15 +14,11 @@ interface EditPaneHeaderProps {
export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
const elementInfo = element.getEditableElementInfo();
const styles = useStyles2(getStyles);
const onCopy = element.onCopy?.bind(element);
const onDuplicate = element.onDuplicate?.bind(element);
const onDelete = element.onDelete?.bind(element);
const onConfirmDelete = element.onConfirmDelete?.bind(element);
// temporary simple solution, should select parent element
const onGoBack = () => editPane.clearSelection();
const canGoBack = editPane.state.selection;
const onDeleteElement = () => {
if (onConfirmDelete) {
@@ -37,20 +30,7 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
};
return (
<div className={styles.wrapper}>
<Stack direction="row" gap={0.5}>
{canGoBack && (
<IconButton
name="arrow-left"
size="lg"
onClick={onGoBack}
tooltip={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
aria-label={t('grafana.dashboard.edit-pane.go-back', 'Go back')}
data-testid={selectors.components.EditPaneHeader.backButton}
/>
)}
<Text>{elementInfo.typeName}</Text>
</Stack>
<Sidebar.PaneHeader title={elementInfo.typeName} onClose={() => editPane.closePane()}>
<Stack direction="row" gap={1}>
{element.renderActions && element.renderActions()}
{(onCopy || onDuplicate) && (
@@ -95,18 +75,6 @@ export function EditPaneHeader({ element, editPane }: EditPaneHeaderProps) {
/>
)}
</Stack>
</div>
</Sidebar.PaneHeader>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
wrapper: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
};
}
@@ -14,7 +14,6 @@ import {
import { DashboardScene } from '../scene/DashboardScene';
import { SceneGridRowEditableElement } from '../scene/layout-default/SceneGridRowEditableElement';
import { redoButtonId, undoButtonID } from '../scene/new-toolbar/RightActions';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { LocalVariableEditableElement } from '../settings/variables/LocalVariableEditableElement';
import { VariableAdd, VariableAddEditableElement } from '../settings/variables/VariableAddEditableElement';
@@ -299,7 +298,3 @@ function makeEditAction<Source extends SceneObject, T extends keyof Source['stat
});
};
}
export function undoRedoWasClicked(e: React.FocusEvent) {
return e.relatedTarget && (e.relatedTarget.id === undoButtonID || e.relatedTarget.id === redoButtonId);
}
@@ -1,21 +0,0 @@
import { useMemo } from 'react';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditPane } from './DashboardEditPane';
import { ElementSelection } from './ElementSelection';
export function useEditableElement(
selection: ElementSelection | undefined,
editPane: DashboardEditPane
): EditableDashboardElement | undefined {
return useMemo(() => {
if (!selection) {
const dashboard = getDashboardSceneFor(editPane);
return new ElementSelection([[dashboard.state.uid!, dashboard.getRef()]]).createSelectionElement();
}
return selection.createSelectionElement();
}, [selection, editPane]);
}
@@ -8,10 +8,14 @@ export interface OutlineRenameState {
error?: string;
}
export function useOutlineRename(editableElement: EditableDashboardElement) {
export function useOutlineRename(editableElement: EditableDashboardElement, isEditing: boolean | undefined) {
const [state, setState] = useState<OutlineRenameState>({});
const onNameDoubleClicked = (evt: React.MouseEvent) => {
if (!isEditing) {
return;
}
if (!editableElement.onChangeName) {
return;
}
@@ -2,6 +2,8 @@ import { css, cx } from '@emotion/css';
import { GrafanaTheme2, VariableHide } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import {
SceneObjectState,
SceneObjectBase,
@@ -15,7 +17,8 @@ import {
SceneObjectUrlValues,
CancelActivationHandler,
} from '@grafana/scenes';
import { Box, useStyles2 } from '@grafana/ui';
import { Box, Button, useStyles2 } from '@grafana/ui';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
import { PanelEditControls } from '../panel-edit/PanelEditControls';
import { getDashboardSceneFor } from '../utils/utils';
@@ -25,6 +28,9 @@ import { DashboardDataLayerControls } from './DashboardDataLayerControls';
import { DashboardLinksControls } from './DashboardLinksControls';
import { DashboardScene } from './DashboardScene';
import { VariableControls } from './VariableControls';
import { EditDashboardSwitch } from './new-toolbar/actions/EditDashboardSwitch';
import { SaveDashboard } from './new-toolbar/actions/SaveDashboard';
import { ShareDashboardButton } from './new-toolbar/actions/ShareDashboardButton';
export interface DashboardControlsState extends SceneObjectState {
timePicker: SceneTimePicker;
@@ -32,7 +38,7 @@ export interface DashboardControlsState extends SceneObjectState {
hideTimeControls?: boolean;
hideVariableControls?: boolean;
hideLinksControls?: boolean;
// Hides the dashbaord-controls dropdown menu
// Hides the dashboard-controls dropdown menu
hideDashboardControls?: boolean;
}
@@ -171,6 +177,7 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
</div>
)}
{!hideDashboardControls && model.hasDashboardControls() && <DashboardControlsButton dashboard={dashboard} />}
{config.featureToggles.dashboardNewLayouts && <DashboardControlActions dashboard={dashboard} />}
</div>
{!hideVariableControls && (
<>
@@ -185,6 +192,37 @@ function DashboardControlsRenderer({ model }: SceneComponentProps<DashboardContr
);
}
function DashboardControlActions({ dashboard }: { dashboard: DashboardScene }) {
const { isEditing, editPanel, uid, meta } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
if (editPanel) {
return null;
}
const canEditDashboard = dashboard.canEditDashboard();
const hasUid = Boolean(uid);
const isSnapshot = Boolean(meta.isSnapshot);
const showShareButton = hasUid && !isSnapshot && !isPlaying;
return (
<>
{showShareButton && <ShareDashboardButton dashboard={dashboard} />}
{isEditing && <SaveDashboard dashboard={dashboard} />}
{!isPlaying && canEditDashboard && <EditDashboardSwitch dashboard={dashboard} />}
{isPlaying && (
<Button
variant="secondary"
onClick={() => playlistSrv.stop()}
data-testid={selectors.pages.Dashboard.DashNav.playlistControls.stop}
>
<Trans i18nKey="dashboard.toolbar.new.playlist-stop">Stop playlist</Trans>
</Button>
)}
</>
);
}
function renderHiddenVariables(dashboard: DashboardScene) {
const { variables } = sceneGraph.getVariables(dashboard).useState();
const renderAsHiddenVariables = variables.filter((v) => v.UNSAFE_renderAsHidden);
@@ -275,7 +275,7 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
this._initialUrlState = locationService.getLocation();
// Switch to edit mode
this.setState({ isEditing: true });
this.setState({ isEditing: true, editable: true });
// Propagate change edit mode change to children
this.state.body.editModeChanged?.(true);
@@ -692,16 +692,16 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
canEditDashboard() {
const { meta } = this.state;
return Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit);
return !meta.isSnapshot && Boolean(meta.canEdit || meta.canMakeEditable || config.viewersCanEdit);
}
public getInitialSaveModel() {
return this.serializer.initialSaveModel;
}
public getSnapshotUrl = () => {
return this.serializer.getSnapshotUrl();
};
public getSnapshotUrl() {
return this.serializer.getSnapshotUrl() ?? '';
}
/** Hacky temp function until we refactor transformSaveModelToScene a bit */
setInitialSaveModel(model?: Dashboard, meta?: DashboardMeta, apiVersion?: string): void;
@@ -20,7 +20,7 @@ export function GoToSnapshotOriginButton(props: { originalURL: string }) {
);
}
const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
export const onOpenSnapshotOriginalDashboard = (originalUrl: string) => {
const relativeURL = originalUrl ?? '';
const sanitizedRelativeURL = textUtil.sanitizeUrl(relativeURL);
try {
@@ -153,9 +153,7 @@ export function ToolbarActions({ dashboard }: Props) {
toolbarActions.push({
group: 'icon-actions',
condition: meta.isSnapshot && !isEditing,
render: () => (
<GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={dashboard.getSnapshotUrl() ?? ''} />
),
render: () => <GoToSnapshotOriginButton key="go-to-snapshot-origin" originalURL={dashboard.getSnapshotUrl()} />,
});
if (!isEditingPanel && !isEditing) {
@@ -1,8 +1,7 @@
import { css } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { config } from '@grafana/runtime';
import { ToolbarButton, ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { ToolbarButtonRow, useStyles2 } from '@grafana/ui';
import { contextSrv } from 'app/core/services/context_srv';
import { playlistSrv } from 'app/features/playlist/PlaylistSrv';
@@ -11,45 +10,35 @@ import { isLibraryPanel } from '../../utils/utils';
import { DashboardScene } from '../DashboardScene';
import { BackToDashboardButton } from './actions/BackToDashboardButton';
import { DashboardSettingsButton } from './actions/DashboardSettingsButton';
import { DiscardLibraryPanelButton } from './actions/DiscardLibraryPanelButton';
import { DiscardPanelButton } from './actions/DiscardPanelButton';
import { EditDashboardSwitch } from './actions/EditDashboardSwitch';
import { ExportDashboardButton } from './actions/ExportDashboardButton';
import { MakeDashboardEditableButton } from './actions/MakeDashboardEditableButton';
import { PlayListNextButton } from './actions/PlayListNextButton';
import { PlayListPreviousButton } from './actions/PlayListPreviousButton';
import { PlayListStopButton } from './actions/PlayListStopButton';
import { SaveDashboard } from './actions/SaveDashboard';
import { SaveLibraryPanelButton } from './actions/SaveLibraryPanelButton';
import { ShareDashboardButton } from './actions/ShareDashboardButton';
import { UnlinkLibraryPanelButton } from './actions/UnlinkLibraryPanelButton';
import { ToolbarActionProps } from './types';
import { getDynamicActions, renderActionElements } from './utils';
export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
const { editPanel, editable, editview, isEditing, uid, meta, viewPanel } = dashboard.useState();
const { editPanel, editable, editview, isEditing, meta, viewPanel } = dashboard.useState();
const { isPlaying } = playlistSrv.useState();
const styles = useStyles2(getStyles);
const isEditable = Boolean(editable);
const canSave = Boolean(meta.canSave);
const hasUid = Boolean(uid);
const isEditingDashboard = Boolean(isEditing);
const hasEditView = Boolean(editview);
const isEditingPanel = Boolean(editPanel);
const isViewingPanel = Boolean(viewPanel);
const isEditingLibraryPanel = isEditingPanel && isLibraryPanel(editPanel!.state.panelRef.resolve());
const isShowingDashboard = !hasEditView && !isViewingPanel && !isEditingPanel;
const isEditingAndShowingDashboard = isEditingDashboard && isShowingDashboard;
const isSnapshot = Boolean(meta.isSnapshot);
const canSaveInFolder = contextSrv.hasEditPermissionInFolders;
const canEditDashboard = dashboard.canEditDashboard();
const showPanelButtons = isEditingPanel && !hasEditView && !isViewingPanel;
const showPlayButtons = isPlaying && isShowingDashboard && !isEditingDashboard;
const showShareButton = hasUid && !isSnapshot && !isPlaying && !isEditingPanel;
const showUndoRedoButtons = isEditingAndShowingDashboard && !!config.featureToggles.dashboardUndoRedo;
return (
<ToolbarButtonRow alignment="right" className={styles.container}>
@@ -106,28 +95,10 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
group: 'panel',
condition: showPanelButtons && isEditingLibraryPanel,
},
{
key: 'dashboard-undo',
component: UndoButton,
group: 'dashboard',
condition: showUndoRedoButtons,
},
{
key: 'dashboard-redo',
component: RedoButton,
group: 'dashboard',
condition: showUndoRedoButtons,
},
{
key: 'dashboard-settings',
component: DashboardSettingsButton,
group: 'dashboard',
condition: isEditingAndShowingDashboard && canEditDashboard,
},
{
key: 'save-dashboard',
component: SaveDashboard,
group: 'save-edit',
group: 'panel',
condition: isEditingDashboard && !isEditingLibraryPanel && (canSave || canSaveInFolder),
},
{
@@ -136,31 +107,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
group: 'save-edit',
condition: !isEditing && canEditDashboard && !isViewingPanel && !isEditable && !isPlaying,
},
{
key: 'edit-dashboard-switch',
component: EditDashboardSwitch,
group: 'save-edit',
condition:
canEditDashboard &&
!isEditingPanel &&
!isEditingLibraryPanel &&
!isViewingPanel &&
isEditable &&
!isPlaying &&
!isEditingPanel,
},
{
key: 'new-export-dashboard-button',
component: ExportDashboardButton,
group: 'export-share',
condition: showShareButton,
},
{
key: 'new-share-dashboard-button',
component: ShareDashboardButton,
group: 'export-share',
condition: showShareButton,
},
],
dashboard
)}
@@ -168,42 +114,6 @@ export const RightActions = ({ dashboard }: { dashboard: DashboardScene }) => {
);
};
export const undoButtonID = 'undo-button';
function UndoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { undoStack } = editPane.useState();
const undoAction = undoStack[undoStack.length - 1];
const tooltip = `Undo${undoAction?.description ? ` '${undoAction.description}'` : ''}`;
return (
<ToolbarButton
id={undoButtonID}
icon="corner-up-left"
disabled={undoStack.length === 0}
onClick={() => editPane.undoAction()}
tooltip={tooltip}
/>
);
}
export const redoButtonId = 'redo-button';
function RedoButton({ dashboard }: ToolbarActionProps) {
const editPane = dashboard.state.editPane;
const { redoStack } = editPane.useState();
const redoAction = redoStack[redoStack.length - 1];
const tooltip = `Redo${redoAction?.description ? ` '${redoAction.description}'` : ''}`;
return (
<ToolbarButton
id={redoButtonId}
icon="corner-up-right"
disabled={redoStack.length === 0}
tooltip={tooltip}
onClick={() => editPane.redoAction()}
/>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({ paddingLeft: theme.spacing(0.5) }),
});
@@ -1,39 +0,0 @@
import { selectors as e2eSelectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config, locationService } from '@grafana/runtime';
import { getTrackingSource, shareDashboardType } from 'app/features/dashboard/components/ShareModal/utils';
import ExportMenu from '../../../sharing/ExportButton/ExportMenu';
import { DashboardInteractions } from '../../../utils/interactions';
import { ToolbarActionProps } from '../types';
import { ShareExportDashboardButton } from './ShareExportDashboardButton';
const newExportButtonSelector = e2eSelectors.pages.Dashboard.DashNav.NewExportButton;
export const ExportDashboardButton = ({ dashboard }: ToolbarActionProps) => {
const buttonTooltip = config.featureToggles.kubernetesDashboards
? t('dashboard.toolbar.new.export.tooltip.as-code', 'Export as code')
: t('dashboard.toolbar.new.export.tooltip.json', 'Export as JSON');
return (
<ShareExportDashboardButton
menu={() => <ExportMenu dashboard={dashboard} />}
groupTestId={newExportButtonSelector.container}
buttonLabel={t('dashboard.toolbar.new.export.title', 'Export')}
buttonTooltip={buttonTooltip}
buttonTestId={newExportButtonSelector.container}
onButtonClick={() => {
locationService.partial({ shareView: shareDashboardType.export });
DashboardInteractions.sharingCategoryClicked({
item: shareDashboardType.export,
shareResource: getTrackingSource(),
});
}}
arrowLabel={t('dashboard.toolbar.new.export.arrow', 'Export')}
arrowTestId={newExportButtonSelector.arrowMenu}
dashboard={dashboard}
/>
);
};
@@ -2,5 +2,5 @@ import { GoToSnapshotOriginButton } from '../../GoToSnapshotOriginButton';
import { ToolbarActionProps } from '../types';
export const OpenSnapshotOriginButton = ({ dashboard }: ToolbarActionProps) => (
<GoToSnapshotOriginButton originalURL={dashboard.getSnapshotUrl() ?? ''} />
<GoToSnapshotOriginButton originalURL={dashboard.getSnapshotUrl()} />
);
@@ -6,10 +6,12 @@ import { contextSrv } from 'app/core/services/context_srv';
import { ToolbarActionProps } from '../types';
export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
const { meta, isDirty, uid } = dashboard.state;
const { meta, isDirty, uid, editview, editPanel } = dashboard.state;
const isNew = !Boolean(uid || dashboard.isManaged());
const isManaged = dashboard.isManaged();
// In dashboard settings we still use the nav toolbar for a short while
const buttonSize = Boolean(editview) || editPanel ? 'sm' : 'md';
// if we only can save
if (isNew) {
@@ -17,7 +19,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({})}
tooltip={t('dashboard.toolbar.new.save-dashboard.tooltip', 'Save changes')}
size="sm"
size={buttonSize}
variant="primary"
data-testid={selectors.components.NavToolbar.editDashboard.saveButton}
>
@@ -32,7 +34,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({ saveAsCopy: true })}
tooltip={t('dashboard.toolbar.new.save-dashboard-copy.tooltip', 'Save as copy')}
size="sm"
size={buttonSize}
variant={isDirty ? 'primary' : 'secondary'}
>
<Trans i18nKey="dashboard.toolbar.new.save-dashboard-copy.label">Save as copy</Trans>
@@ -45,7 +47,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
<Button
onClick={() => dashboard.openSaveDrawer({})}
tooltip={t('dashboard.toolbar.new.save-dashboard.tooltip', 'Save changes')}
size="sm"
size={buttonSize}
data-testid={selectors.components.NavToolbar.editDashboard.saveButton}
variant={isDirty ? 'primary' : 'secondary'}
>
@@ -71,7 +73,7 @@ export const SaveDashboard = ({ dashboard }: ToolbarActionProps) => {
aria-label={t('dashboard.toolbar.new.more-save-options', 'More save options')}
icon="angle-down"
variant={isDirty ? 'primary' : 'secondary'}
size="sm"
size={buttonSize}
/>
</Dropdown>
</ButtonGroup>
@@ -60,7 +60,7 @@ export const ShareExportDashboardButton = ({
}
}}
>
<Button data-testid={buttonTestId} size="sm" tooltip={buttonTooltip} variant={variant} onClick={onButtonClick}>
<Button data-testid={buttonTestId} size="md" tooltip={buttonTooltip} variant={variant} onClick={onButtonClick}>
{buttonLabel}
</Button>
<Dropdown
@@ -79,7 +79,7 @@ export const ShareExportDashboardButton = ({
<Button
aria-label={arrowLabel}
data-testid={arrowTestId}
size="sm"
size="md"
icon={isOpen ? 'angle-up' : 'angle-down'}
variant={variant}
/>
@@ -43,7 +43,7 @@ export const ToolbarSwitch = ({
onClick={disabled ? undefined : onClick}
>
<div className={cx(styles.box, checked && styles.boxChecked)}>
<Icon name={iconName} size="xs" />
<Icon name={iconName} size="md" />
</div>
</button>
</Tooltip>
@@ -53,11 +53,11 @@ export const ToolbarSwitch = ({
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
border: `1px solid ${theme.components.input.borderColor}`,
padding: theme.spacing(0.25),
padding: theme.spacing(0.5),
backgroundColor: theme.components.input.background,
borderRadius: theme.shape.radius.default,
width: theme.spacing(5.5),
height: theme.spacing(3),
width: theme.spacing(6.5),
height: theme.spacing(theme.components.height.md),
cursor: 'pointer',
display: 'flex',
flexDirection: 'row',
@@ -90,19 +90,19 @@ const getStyles = (theme: GrafanaTheme2) => ({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: theme.spacing(2.5),
width: theme.spacing(3.5),
height: '100%',
transform: 'translateX(0)',
position: 'relative',
borderRadius: styleMixins.getInternalRadius(theme, 2),
border: `1px solid ${theme.colors.secondary.border}`,
border: `1px solid ${theme.colors.border.weak}`,
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: 'all 0.2s ease-in-out',
},
}),
boxChecked: css({
transform: `translateX(calc(100% - ${theme.spacing(0.25)}))`,
transform: `translateX(calc(100% - 14px))`,
borderColor: 'transparent',
}),
});
@@ -9,7 +9,7 @@ import { Input, TextArea, Button, Field, Box, Stack } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { dashboardEditActions, undoRedoWasClicked } from '../../edit-pane/shared';
import { dashboardEditActions } from '../../edit-pane/shared';
import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
@@ -161,7 +161,7 @@ function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable
onChange={onChange}
onBlur={(e) => {
const labelUnchanged = oldName.current === name;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (nameError) {
setNameError(undefined);
@@ -200,7 +200,7 @@ function VariableLabelInput({ variable, id }: VariableInputProps) {
onChange={(e) => variable.setState({ label: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldLabel.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (shouldSkip) {
return;
@@ -232,7 +232,7 @@ function VariableDescriptionTextArea({ variable, id }: VariableInputProps) {
onChange={(e) => variable.setState({ description: e.currentTarget.value })}
onBlur={(e) => {
const labelUnchanged = oldDescription.current === e.currentTarget.value;
const shouldSkip = labelUnchanged || undoRedoWasClicked(e);
const shouldSkip = labelUnchanged;
if (shouldSkip) {
return;
+32 -15
View File
@@ -4172,6 +4172,7 @@
"clear": "Clear",
"collapse": "Collapse",
"disabled": "Disabled",
"discard": "Discard",
"edit": "Edit",
"help": "Help",
"loading": "Loading...",
@@ -4789,7 +4790,6 @@
"variable": "{{type}} variable",
"variable-set": "Variables"
},
"open": "Open options pane",
"row": {
"header": {
"hide": "Hide",
@@ -5140,6 +5140,7 @@
"title-matched_other": "Matched {{count}}/{{totalCount}} options"
},
"outline": {
"pane-header": "Content outline",
"repeated-item": "Repeat",
"tree-item": {
"empty": "(empty)",
@@ -5351,6 +5352,32 @@
"share-public-dashboard-loader": {
"loading-configuration": "Loading configuration"
},
"sidebar": {
"dashboard-options": {
"title": "Options",
"tooltip": "Dashboard options"
},
"edit-schema": {
"title": "Code",
"tooltip": "Edit as code"
},
"export": {
"title": "Export",
"unsaved-modal": {
"text": "You have unsaved changes to this dashboard. You need to save them before you can share it.",
"title": "Save changes to dashboard?"
}
},
"outline": {
"title": "Outline",
"tooltip": "Content outline"
},
"redo": "Redo",
"snapshot": {
"tooltip": "Open original dashboard"
},
"undo": "Undo"
},
"solo-panel": {
"loading-initializing-dashboard": "Loading & initializing dashboard",
"title-not-found": "Panel with id {{panelId}} not found"
@@ -5454,11 +5481,8 @@
"tooltip": "This dashboard was marked as read only"
},
"export": {
"arrow": "Export",
"title": "Export",
"tooltip": {
"as-code": "Export as code",
"json": "Export as JSON"
"as-code": "Export as code"
}
},
"more-save-options": "More save options",
@@ -5510,6 +5534,9 @@
"save-library-panel": "Save library panel",
"settings": "Dashboard settings",
"share-button": "Share",
"snapshot": {
"title": "Source"
},
"star-add-error": "Failed to add to starred",
"star-added": "Added to starred",
"star-remove-error": "Failed to remove from starred",
@@ -5839,9 +5866,6 @@
"name-values-separated-comma": "Values separated by comma",
"selection-options": "Selection options"
},
"dashboard-edit-pane-renderer": {
"outline": "Outline"
},
"dashboard-link-form": {
"back-to-list": "Back to list",
"label-icon": "Icon",
@@ -8121,13 +8145,6 @@
}
}
},
"grafana": {
"dashboard": {
"edit-pane": {
"go-back": "Go back"
}
}
},
"grafana-data": {
"datetime": {
"rangeutils": {