Compare commits

...

7 Commits

Author SHA1 Message Date
idastambuk
5b0d003e3e Remove comment 2026-01-14 17:51:22 +01:00
idastambuk
48579dc946 Edd e2e 2026-01-14 17:33:24 +01:00
idastambuk
f56ce2da88 Cleanup 2026-01-13 16:36:39 +01:00
idastambuk
3a4540def7 Add landing title 2026-01-08 16:31:39 +01:00
idastambuk
af8100d52a Open sidebar by default 2026-01-08 15:04:48 +01:00
idastambuk
fa50d21811 Add drag & drop for new button 2026-01-08 14:59:15 +01:00
idastambuk
48fe1ec634 Add clickable add button 2026-01-05 10:45:47 +01:00
19 changed files with 476 additions and 108 deletions

View File

@@ -1,5 +1,7 @@
import { test, expect } from '@grafana/plugin-e2e';
import { addNewPanelFromSidebar } from './utils';
test.use({
featureToggles: {
kubernetesDashboards: true,
@@ -44,5 +46,90 @@ test.describe(
.click();
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.General.content)).toBeVisible();
});
test('can add a panel from the sidebar on a new dashboard', async ({ gotoDashboardPage, selectors, page }) => {
const dashboardPage = await gotoDashboardPage({});
// check that the sidebar is open on Add section
expect(await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.newPanelButton)).toBeVisible();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.newPanelButton).click();
// check that new panel has been added
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toBeVisible();
addNewPanelFromSidebar(dashboardPage, selectors);
// check that another has been added
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('New panel'))
).toHaveCount(2);
});
test('adds a new panel from the sidebar into the layout that was selected last', async ({
gotoDashboardPage,
selectors,
page,
}) => {
const dashboardPage = await gotoDashboardPage({});
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.newPanelButton).click();
// group into tab
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.groupPanels).click();
await page.getByText('Group into tab').click();
// add new panel from the sidebar
addNewPanelFromSidebar(dashboardPage, selectors);
// check that another panel has been added inside the tab
const tab = dashboardPage.getByGrafanaSelector(selectors.components.LayoutContainer('tab New tab'));
await expect(tab.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(2);
// add new tab
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addTab).click();
// add new panel from the sidebar
addNewPanelFromSidebar(dashboardPage, selectors);
//check that new panel has been added there
const tab2 = dashboardPage.getByGrafanaSelector(selectors.components.LayoutContainer('tab New tab 1'));
await expect(tab2.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(1);
// panel is selected
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'))
).toBeVisible();
addNewPanelFromSidebar(dashboardPage, selectors);
await expect(tab2.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(2);
// group into row
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.groupPanels).click();
await page.getByText('Group into row').click();
// add into the row
addNewPanelFromSidebar(dashboardPage, selectors);
const row = dashboardPage.getByGrafanaSelector(selectors.components.LayoutContainer('row New row'));
// scroll to the bottom of the row to load all panels
const scrollContainer = page
.getByTestId(selectors.components.DashboardEditPaneSplitter.primaryBody)
.locator('> div')
.first();
await scrollContainer.evaluate((el) => el.scrollTo(0, el.scrollHeight));
await expect(row.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(3);
// add new row and add into it
await dashboardPage.getByGrafanaSelector(selectors.components.CanvasGridAddActions.addRow).click();
addNewPanelFromSidebar(dashboardPage, selectors);
const row1 = dashboardPage.getByGrafanaSelector(selectors.components.LayoutContainer('row New row 1'));
await expect(row1.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(1);
// panel is selected
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.PanelEditor.OptionsPane.fieldInput('Title'))
).toBeVisible();
addNewPanelFromSidebar(dashboardPage, selectors);
await scrollContainer.evaluate((el) => el.scrollTo(0, el.scrollHeight));
// check that the new panel is added next to the last panel selected
await expect(row1.getByTestId(selectors.components.Panels.Panel.title('New panel'))).toHaveCount(2);
});
}
);
);

View File

@@ -249,3 +249,8 @@ export async function switchToAutoGrid(page: Page, dashboardPage: DashboardPage)
await confirmModal.click();
}
}
export async function addNewPanelFromSidebar(dashboardPage: DashboardPage, selectors: E2ESelectorGroups) {
await dashboardPage.getByGrafanaSelector(selectors.pages.Dashboard.Sidebar.addButton).click();
await dashboardPage.getByGrafanaSelector(selectors.components.Sidebar.newPanelButton).click();
}

View File

@@ -64,6 +64,9 @@ export const versionedComponents = {
dockToggle: {
'12.4.0': 'data-testid sidebar-dock-toggle',
},
newPanelButton: {
'12.4.0': 'data-testid sidebar add new panel',
},
},
EditPaneHeader: {
deleteButton: {
@@ -79,6 +82,9 @@ export const versionedComponents = {
'12.1.0': 'data-testid EditPaneHeader duplicate',
},
},
LayoutContainer: {
'12.4.0': (identifier: string) => `data-testid Layout container ${identifier}`,
},
TimePicker: {
openButton: {
[MIN_GRAFANA_VERSION]: 'data-testid TimePicker Open Button',

View File

@@ -190,6 +190,9 @@ export const versionedPages = {
outlineButton: {
'12.4.0': 'data-testid Dashboard Sidebar outline button',
},
addButton: {
'12.4.0': 'data-testid Dashboard Sidebar new button',
}
},
DashNav: {
nav: {

View File

@@ -46,7 +46,7 @@ export function SidebarComp({ children, contextValue }: Props) {
return (
<SidebarContext.Provider value={contextValue}>
<div ref={ref} className={className} style={style}>
<div ref={ref} className={className} style={style} data-testid="dashboard-edit-pane-sidebar">
{!tabsMode && <SidebarResizer />}
{children}
</div>

View File

@@ -16,10 +16,11 @@ export interface Props extends ButtonHTMLAttributes<HTMLButtonElement> {
active?: boolean;
tooltip?: string;
title: string;
isAddButton?: boolean;
}
export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
({ icon, active, onClick, title, tooltip, ...restProps }, ref) => {
({ icon, active, onClick, title, tooltip, isAddButton, ...restProps }, ref) => {
const styles = useStyles2(getStyles);
const context = useContext(SidebarContext);
@@ -31,7 +32,8 @@ export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
styles.button,
context.compact && styles.compact,
active && styles.active,
context.position === 'left' && styles.leftButton
context.position === 'left' && styles.leftButton,
isAddButton && 'addButton'
);
return (
@@ -44,7 +46,7 @@ export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
onClick={onClick}
{...restProps}
>
<div className={styles.iconWrapper}>{renderIcon(icon, context.compact)}</div>
<div>{renderIcon(icon, isAddButton)}</div>
{!context.compact && <div className={cx(styles.title, active && styles.titleActive)}>{title}</div>}
</button>
</Tooltip>
@@ -54,13 +56,13 @@ export const SidebarButton = React.forwardRef<HTMLButtonElement, Props>(
SidebarButton.displayName = 'SidebarButton';
function renderIcon(icon: IconName | React.ReactNode, compact?: boolean) {
function renderIcon(icon: IconName | React.ReactNode, isAddButton?: boolean) {
if (!icon) {
return null;
}
if (isIconName(icon)) {
return <Icon name={icon} size={compact ? `lg` : `lg`} />;
return <Icon name={icon} size={isAddButton ? 'xl' : 'lg'} />;
}
return icon;
@@ -83,7 +85,14 @@ const getStyles = (theme: GrafanaTheme2) => {
color: theme.colors.text.secondary,
background: 'transparent',
border: `none`,
'&.addButton': css({
svg: {
backgroundColor: theme.colors.primary.main,
color: theme.colors.getContrastText(theme.colors.primary.main),
borderRadius: theme.shape.radius.sm,
padding: 2,
},
}),
[theme.transitions.handleMotion('no-preference', 'reduce')]: {
transition: theme.transitions.create(['background-color', 'border-color', 'color'], {
duration: theme.transitions.duration.short,
@@ -145,7 +154,6 @@ const getStyles = (theme: GrafanaTheme2) => {
width: '100%',
whiteSpace: 'nowrap',
}),
iconWrapper: css({}),
title: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,

View File

@@ -30,7 +30,7 @@ export interface DashboardEditPaneState extends SceneObjectState {
isDocked?: boolean;
}
export type DashboardSidebarPaneName = 'element' | 'outline' | 'filters';
export type DashboardSidebarPaneName = 'element' | 'outline' | 'filters' | 'add';
export class DashboardEditPane extends SceneObjectBase<DashboardEditPaneState> {
public constructor() {

View File

@@ -1,38 +1,46 @@
import { useMemo } from 'react';
import { useMemo, useState } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { useSceneObjectState } from '@grafana/scenes';
import { SceneObject, SceneObjectState, useSceneObjectState } from '@grafana/scenes';
import { Sidebar } from '@grafana/ui';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { DashboardScene } from '../scene/DashboardScene';
import { onOpenSnapshotOriginalDashboard } from '../scene/GoToSnapshotOriginButton';
import { ManagedDashboardNavBarBadge } from '../scene/ManagedDashboardNavBarBadge';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { ToolbarActionProps } from '../scene/new-toolbar/types';
import { dynamicDashNavActions } from '../utils/registerDynamicDashNavAction';
import { getDefaultVizPanel, getRowOrTabForSceneObject } from '../utils/utils';
import { DashboardEditPane } from './DashboardEditPane';
import { ShareExportDashboardButton } from './DashboardExportButton';
import { DashboardOutline } from './DashboardOutline';
import { DashboardSidePaneNew } from './DashboardSidePaneNew';
import { ElementEditPane } from './ElementEditPane';
export interface Props {
editPane: DashboardEditPane;
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, dashboard, isDocked }: Props) {
export function DashboardEditPaneRenderer({ editPane, dashboard }: 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;
// the layout element that was selected when opening the 'add' pane
// used when adding new panel from the sidebar
const [selectedLayoutElement, setSelectedLayoutElement] = useState<DashboardScene | SceneObject<SceneObjectState>>(
dashboard
);
const editableElement = useMemo(() => {
if (selection) {
@@ -42,6 +50,25 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro
return undefined;
}, [selection]);
const onSetLayoutElement = (obj: SceneObject<SceneObjectState> | undefined) => {
if (obj) {
setSelectedLayoutElement(getRowOrTabForSceneObject(obj) || dashboard);
} else {
setSelectedLayoutElement(dashboard);
}
};
const onAddNewPanel = () => {
if (selectedLayoutElement) {
const panel = getDefaultVizPanel();
if (selectedLayoutElement instanceof DashboardScene) {
dashboard.addPanel(panel);
} else if (selectedLayoutElement instanceof RowItem || selectedLayoutElement instanceof TabItem) {
selectedLayoutElement.getLayout().addPanel(panel);
}
}
};
return (
<>
{editableElement && (
@@ -54,6 +81,11 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro
/>
</Sidebar.OpenPane>
)}
{openPane === 'add' && (
<Sidebar.OpenPane>
<DashboardSidePaneNew onAddPanel={onAddNewPanel} dashboard={dashboard} />
</Sidebar.OpenPane>
)}
{openPane === 'outline' && (
<Sidebar.OpenPane>
<DashboardOutline editPane={editPane} isEditing={isEditing} />
@@ -68,6 +100,18 @@ export function DashboardEditPaneRenderer({ editPane, dashboard, isDocked }: Pro
<RedoButton dashboard={dashboard} />
</>
)}
<Sidebar.Button
icon="plus"
isAddButton
onClick={() => {
onSetLayoutElement(selectedObject);
editPane.openPane('add');
}}
title={t('dashboard.sidebar.add.title', 'Add')}
tooltip={t('dashboard.sidebar.add.tooltip', 'Add new element')}
data-testid={selectors.pages.Dashboard.Sidebar.addButton}
active={selectedObject === null || openPane === 'add'}
/>
<Sidebar.Button
icon="cog"
onClick={() => editPane.selectObject(dashboard, dashboard.state.key!)}

View File

@@ -1,5 +1,6 @@
import { css, cx } from '@emotion/css';
import React, { useEffect, useLayoutEffect } from 'react';
import { useEffectOnce } from 'react-use';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -25,9 +26,10 @@ interface Props {
isEditing?: boolean;
body?: React.ReactNode;
controls?: React.ReactNode;
isNewEmptyDashboard?: boolean;
}
export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls }: Props) {
export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls, isNewEmptyDashboard = false }: Props) {
const headerHeight = useChromeHeaderHeight();
const { editPane } = dashboard.state;
const styles = useStyles2(getStyles, headerHeight ?? 0);
@@ -63,10 +65,16 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
}
}, [isEditing, editPane]);
useEffectOnce(() => {
if (isNewEmptyDashboard) {
editPane.openPane('add');
}
});
const { selectionContext, openPane } = useSceneObjectState(editPane, { shouldActivateOrKeepAlive: true });
const sidebarContext = useSidebar({
hasOpenPane: Boolean(openPane),
hasOpenPane: Boolean(openPane) || isNewEmptyDashboard,
contentMargin: 1,
position: 'right',
persistanceKey: 'dashboard',
@@ -120,7 +128,7 @@ export function DashboardEditPaneSplitter({ dashboard, isEditing, body, controls
</div>
<Sidebar contextValue={sidebarContext}>
<DashboardEditPaneRenderer editPane={editPane} dashboard={dashboard} isDocked={sidebarContext.isDocked} />
<DashboardEditPaneRenderer editPane={editPane} dashboard={dashboard} />
</Sidebar>
</div>
);

View File

@@ -0,0 +1,92 @@
import { css, cx } from '@emotion/css';
import { DragDropContext, Draggable, Droppable } from '@hello-pangea/dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { SceneObject } from '@grafana/scenes';
import { Sidebar, Text, useStyles2 } from '@grafana/ui';
import addPanelImg from 'img/dashboards/add-panel.png';
import { getDashboardSceneFor } from '../utils/utils';
export function DashboardSidePaneNew({ onAddPanel, dashboard }: { onAddPanel: () => void; dashboard: SceneObject }) {
const styles = useStyles2(getStyles);
const orchestrator = getDashboardSceneFor(dashboard).state.layoutOrchestrator;
return (
<DragDropContext onDragStart={() => orchestrator?.startDraggingNewPanel()} onDragEnd={() => {}}>
<Droppable droppableId="side-drop-id" isDropDisabled>
{(dropProvided) => (
<div className={styles.sidePanel} ref={dropProvided.innerRef} {...dropProvided.droppableProps}>
<Sidebar.PaneHeader title={t('dashboard.add.pane-header', 'Add')} />
<div className={styles.sidePanelContainer}>
<Text weight="medium">{t('dashboard.add.new-panel.title', 'Panel')}</Text>
<Text variant="bodySmall">
{t('dashboard.add.new-panel.description', 'Drag or click to add a panel')}
</Text>
<div className={styles.dragContainer}>
<Draggable draggableId="new-panel-drag" index={0}>
{(dragProvided, dragSnapshot) => {
return (
<div
role="button"
data-testid={selectors.components.Sidebar.newPanelButton}
tabIndex={0}
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
className={cx(styles.imageContainer, dragSnapshot.isDragging && styles.dragging)}
onClick={onAddPanel}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
onAddPanel();
}
}}
aria-label={t('dashboard.add.new-panel.title', 'Panel')}
>
<img alt="Add panel click area" src={addPanelImg} draggable={false} />
</div>
);
}}
</Draggable>
</div>
</div>
</div>
)}
</Droppable>
</DragDropContext>
);
}
function getStyles(theme: GrafanaTheme2) {
return {
sidePanel: css({
borderBottom: `1px solid ${theme.colors.border.weak}`,
}),
dragging: css({
cursor: 'move',
}),
sidePanelContainer: css({
padding: theme.spacing(2),
span: {
display: 'block',
},
}),
dragContainer: css({
height: 150,
}),
imageContainer: css({
cursor: 'pointer',
opacity: 0.8,
overflow: 'hidden',
padding: theme.spacing(2, 0),
borderRadius: theme.shape.radius.sm,
width: '100%',
'&:hover': {
opacity: 1,
},
}),
};
}

View File

@@ -11,11 +11,16 @@ import {
} from '@grafana/scenes';
import { createPointerDistance } from '@grafana/ui';
import { getDefaultVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { RowItem } from './layout-rows/RowItem';
import { TabItem } from './layout-tabs/TabItem';
import { DashboardDropTarget, isDashboardDropTarget } from './types/DashboardDropTarget';
interface DashboardLayoutOrchestratorState extends SceneObjectState {
draggingGridItem?: SceneObjectRef<SceneGridItemLike>;
isDraggingNewPanel?: boolean;
}
export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayoutOrchestratorState> {
@@ -59,10 +64,19 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
this.setState({ draggingGridItem: gridItem.getRef() });
}
public startDraggingNewPanel(): void {
document.body.addEventListener('pointermove', this._onPointerMove);
document.body.addEventListener('pointerup', this._stopDraggingSync, true);
this.setState({ isDraggingNewPanel: true });
}
private _stopDraggingSync(_evt: PointerEvent) {
const gridItem = this.state.draggingGridItem?.resolve();
if (this._sourceDropTarget !== this._lastDropTarget) {
if (this.state.isDraggingNewPanel) {
this._dropNewPanel(_evt);
} else if (this._sourceDropTarget !== this._lastDropTarget) {
// Wrapped in setTimeout to ensure that any event handlers are called
// Useful for allowing react-grid-layout to remove placeholders, etc.
setTimeout(() => {
@@ -76,12 +90,35 @@ export class DashboardLayoutOrchestrator extends SceneObjectBase<DashboardLayout
logWarning(warningMessage);
}
});
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
this.setState({ draggingGridItem: undefined });
}
}
private _dropNewPanel(_evt: PointerEvent) {
const elementsUnderPoint = document.elementsFromPoint(_evt.clientX, _evt.clientY);
// if the cursor is in the sidebar, don't add panel
const isInSidebar = elementsUnderPoint.some((el) => el.closest('[data-testid="dashboard-edit-pane-sidebar"]') !== null);
if (isInSidebar) {
return;
}
const panel = getDefaultVizPanel();
if (!this._lastDropTarget) {
// if no lastDropTarget and not in Sidebar, treat the dashboard itself as the drop target
this._getDashboard().addPanel(panel);
}
if (this._lastDropTarget instanceof RowItem || this._lastDropTarget instanceof TabItem) {
this._lastDropTarget.getLayout().addPanel(panel);
}
document.body.removeEventListener('pointermove', this._onPointerMove);
document.body.removeEventListener('pointerup', this._stopDraggingSync);
document.body.removeEventListener('pointerup', this._stopDraggingSync, true);
this.setState({ draggingGridItem: undefined });
this.setState({ isDraggingNewPanel: false });
}
private _onPointerMove(evt: PointerEvent) {

View File

@@ -103,6 +103,7 @@ export function DashboardSceneRenderer({ model }: SceneComponentProps<DashboardS
{!editPanel && (
<DashboardEditPaneSplitter
dashboard={model}
isNewEmptyDashboard={!model.state.uid}
isEditing={isEditing}
controls={controls && <controls.Component model={controls} />}
body={renderBody()}

View File

@@ -1,10 +1,12 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { SceneComponentProps, sceneGraph } from '@grafana/scenes';
import { useStyles2 } from '@grafana/ui';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { getTestIdForLayout } from '../../utils/test-utils';
import { useDashboardState } from '../../utils/utils';
import { useSoloPanelContext } from '../SoloPanelContext';
import { CanvasGridAddActions } from '../layouts-shared/CanvasGridAddActions';
@@ -33,6 +35,7 @@ export function AutoGridLayoutRenderer({ model }: SceneComponentProps<AutoGridLa
return (
<div
data-testid={selectors.components.LayoutContainer(getTestIdForLayout(model))}
className={cx(styles.container, fillScreen && styles.containerFillScreen, isEditing && styles.containerEditing)}
ref={model.containerRef}
>

View File

@@ -1,6 +1,7 @@
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import {
@@ -31,6 +32,7 @@ import {
import { serializeDefaultGridLayout } from '../../serialization/layoutSerializers/DefaultGridLayoutSerializer';
import { isRepeatCloneOrChildOf } from '../../utils/clone';
import { dashboardSceneGraph } from '../../utils/dashboardSceneGraph';
import { getTestIdForLayout } from '../../utils/test-utils';
import {
forceRenderChildren,
getPanelIdForVizPanel,
@@ -657,7 +659,10 @@ function DefaultGridLayoutManagerRenderer({ model }: SceneComponentProps<Default
}
return (
<div className={cx(styles.container, isEditing && styles.containerEditing)}>
<div
className={cx(styles.container, isEditing && styles.containerEditing)}
data-testid={selectors.components.LayoutContainer(getTestIdForLayout(model))}
>
{model.state.grid.Component && <model.state.grid.Component model={model.state.grid} />}
{showCanvasActions && (
<div className={styles.actionsWrapper}>

View File

@@ -23,12 +23,18 @@ import { ALL_VARIABLE_TEXT, ALL_VARIABLE_VALUE } from 'app/features/variables/co
import { DashboardDTO } from 'app/types/dashboard';
import { VizPanelLinks, VizPanelLinksMenu } from '../scene/PanelLinks';
import { AutoGridLayout } from '../scene/layout-auto-grid/AutoGridLayout';
import { DashboardGridItem, RepeatDirection } from '../scene/layout-default/DashboardGridItem';
import { DefaultGridLayoutManager } from '../scene/layout-default/DefaultGridLayoutManager';
import { RowRepeaterBehavior } from '../scene/layout-default/RowRepeaterBehavior';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { DashboardLayoutGrid } from '../scene/types/DashboardLayoutGrid';
import { transformSaveModelSchemaV2ToScene } from '../serialization/transformSaveModelSchemaV2ToScene';
import { transformSceneToSaveModelSchemaV2 } from '../serialization/transformSceneToSaveModelSchemaV2';
import { getRowOrTabForSceneObject } from './utils';
export function setupLoadDashboardMock(rsp: DeepPartial<DashboardDTO>, spy?: jest.Mock) {
const loadDashboardMock = (spy || jest.fn()).mockResolvedValue(rsp);
const loadSnapshotMock = (spy || jest.fn()).mockResolvedValue(rsp);
@@ -311,3 +317,12 @@ export function getTestDashboardSceneFromSaveModel(spec?: Partial<DashboardV2Spe
return dashboard;
}
// returns e.g. data-testid Layout container row Row title
export function getTestIdForLayout(model: AutoGridLayout | DashboardLayoutGrid) {
const parentRowOrTab = getRowOrTabForSceneObject(model);
const parentType = parentRowOrTab instanceof TabItem ? 'tab' : parentRowOrTab instanceof RowItem ? 'row' : '';
const parentTitle =
parentRowOrTab instanceof TabItem || parentRowOrTab instanceof RowItem ? parentRowOrTab.state.title : '';
return `${parentType} ${parentTitle}`;
}

View File

@@ -27,6 +27,8 @@ import { UNCONFIGURED_PANEL_PLUGIN_ID } from '../scene/UnconfiguredPanel';
import { VizPanelHeaderActions } from '../scene/VizPanelHeaderActions';
import { VizPanelSubHeader } from '../scene/VizPanelSubHeader';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
import { RowItem } from '../scene/layout-rows/RowItem';
import { TabItem } from '../scene/layout-tabs/TabItem';
import { setDashboardPanelContext } from '../scene/setDashboardPanelContext';
import { DashboardLayoutManager, isDashboardLayoutManager } from '../scene/types/DashboardLayoutManager';
@@ -413,6 +415,15 @@ export function getLayoutManagerFor(sceneObject: SceneObject): DashboardLayoutMa
throw new Error('Could not find layout manager for scene object');
}
export function getRowOrTabForSceneObject(sceneObject: SceneObject): SceneObject | null {
if (sceneObject instanceof RowItem || sceneObject instanceof TabItem) {
return sceneObject;
} else if (sceneObject.parent) {
return getRowOrTabForSceneObject(sceneObject.parent);
}
return null;
}
export function getGridItemKeyForPanelId(panelId: number): string {
return `grid-item-${panelId}`;
}

View File

@@ -6,7 +6,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, useStyles2, Text, Box, Stack, TextLink } from '@grafana/ui';
import { Button, useStyles2, Text, Box, Stack, TextLink, Icon } from '@grafana/ui';
import { DashboardModel } from 'app/features/dashboard/state/DashboardModel';
import { DashboardScene } from 'app/features/dashboard-scene/scene/DashboardScene';
@@ -42,105 +42,134 @@ const InternalDashboardEmpty = ({ onAddVisualization, onAddLibraryPanel, onImpor
!dashboardLibraryDatasourceUid,
})}
>
<Stack alignItems="stretch" justifyContent="center" gap={4} direction="column">
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4}>
<Stack direction="column" alignItems="center" gap={2}>
{config.featureToggles.dashboardNewLayouts ? (
<Stack alignItems="stretch" justifyContent="center" gap={4} direction="column">
<Box padding={4}>
<Box marginBottom={2} paddingX={4} display="flex" justifyContent="center">
<Icon name="apps" size="xxl" className={styles.appsIcon} />
</Box>
<Text element="h1" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.add-visualization-header">
Start your new dashboard by adding a visualization
</Trans>
<Trans i18nKey="dashboard.empty.title">New dashboard</Trans>
</Text>
<Box marginBottom={2} paddingX={4}>
<Box marginTop={3} paddingX={4}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.add-visualization-body">
Select a data source and then query and visualize your data with charts, stats and tables or
create lists, markdowns and other widgets.
</Trans>
<Trans i18nKey="dashboard.empty.description">Add a panel to visualize your data</Trans>
</Text>
</Box>
<Button
size="lg"
icon="plus"
data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')}
onClick={onAddVisualization}
disabled={!onAddVisualization}
>
<Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans>
</Button>
</Stack>
</Box>
{/* Suggested Dashboards Section */}
{config.featureToggles.suggestedDashboards &&
config.featureToggles.dashboardLibrary &&
dashboardLibraryDatasourceUid && <SuggestedDashboards datasourceUid={dashboardLibraryDatasourceUid} />}
{/* Basic Provisioned Dashboards Section that don't include community dashboards */}
{config.featureToggles.dashboardLibrary &&
!config.featureToggles.suggestedDashboards &&
dashboardLibraryDatasourceUid && (
<BasicProvisionedDashboardsEmptyPage datasourceUid={dashboardLibraryDatasourceUid} />
)}
<Stack direction={{ xs: 'column', md: 'row' }} wrap="wrap" gap={4}>
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
<Stack direction="column" alignItems="center" gap={1}>
<Text element="h3" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.add-library-panel-header">Import panel</Trans>
</Text>
<Box marginBottom={2}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.add-library-panel-body">
Add visualizations that are shared with other dashboards.
</Trans>
</Text>
</Box>
<Button
icon="plus"
fill="outline"
data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')}
onClick={onAddLibraryPanel}
disabled={!onAddLibraryPanel}
>
<Trans i18nKey="dashboard.empty.add-library-panel-button">Add library panel</Trans>
</Button>
</Stack>
</Box>
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
<Stack direction="column" alignItems="center" gap={1}>
<Text element="h3" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.import-a-dashboard-header">Import a dashboard</Trans>
</Text>
<Box marginBottom={2}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.import-a-dashboard-body">
Import dashboards from files or{' '}
<TextLink external href="https://grafana.com/grafana/dashboards/">
grafana.com
</TextLink>
.
</Trans>
</Text>
</Box>
<Button
icon="upload"
fill="outline"
data-testid={selectors.pages.AddDashboard.itemButton('Import dashboard button')}
onClick={onImportDashboard}
disabled={!onImportDashboard}
>
<Trans i18nKey="dashboard.empty.import-dashboard-button">Import dashboard</Trans>
</Button>
</Stack>
</Box>
<DashboardExtensionsComponents dashboardLibraryDatasourceUid={dashboardLibraryDatasourceUid} />
</Stack>
</Stack>
) : (
<Stack alignItems="stretch" justifyContent="center" gap={4} direction="column">
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={4}>
<Stack direction="column" alignItems="center" gap={2}>
<Text element="h1" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.add-visualization-header">
Start your new dashboard by adding a visualization
</Trans>
</Text>
<Box marginBottom={2} paddingX={4}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.add-visualization-body">
Select a data source and then query and visualize your data with charts, stats and tables or
create lists, markdowns and other widgets.
</Trans>
</Text>
</Box>
<Button
size="lg"
icon="plus"
data-testid={selectors.pages.AddDashboard.itemButton('Create new panel button')}
onClick={onAddVisualization}
disabled={!onAddVisualization}
>
<Trans i18nKey="dashboard.empty.add-visualization-button">Add visualization</Trans>
</Button>
</Stack>
</Box>
<DashboardExtensionsComponents dashboardLibraryDatasourceUid={dashboardLibraryDatasourceUid} />
<Stack direction={{ xs: 'column', md: 'row' }} wrap="wrap" gap={4}>
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
<Stack direction="column" alignItems="center" gap={1}>
<Text element="h3" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.add-library-panel-header">Import panel</Trans>
</Text>
<Box marginBottom={2}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.add-library-panel-body">
Add visualizations that are shared with other dashboards.
</Trans>
</Text>
</Box>
<Button
icon="plus"
fill="outline"
data-testid={selectors.pages.AddDashboard.itemButton('Add a panel from the panel library button')}
onClick={onAddLibraryPanel}
disabled={!onAddLibraryPanel}
>
<Trans i18nKey="dashboard.empty.add-library-panel-button">Add library panel</Trans>
</Button>
</Stack>
</Box>
<Box borderRadius="lg" borderColor="strong" borderStyle="dashed" padding={3} flex={1}>
<Stack direction="column" alignItems="center" gap={1}>
<Text element="h3" textAlignment="center" weight="medium">
<Trans i18nKey="dashboard.empty.import-a-dashboard-header">Import a dashboard</Trans>
</Text>
<Box marginBottom={2}>
<Text element="p" textAlignment="center" color="secondary">
<Trans i18nKey="dashboard.empty.import-a-dashboard-body">
Import dashboards from files or{' '}
<TextLink external href="https://grafana.com/grafana/dashboards/">
grafana.com
</TextLink>
.
</Trans>
</Text>
</Box>
<Button
icon="upload"
fill="outline"
data-testid={selectors.pages.AddDashboard.itemButton('Import dashboard button')}
onClick={onImportDashboard}
disabled={!onImportDashboard}
>
<Trans i18nKey="dashboard.empty.import-dashboard-button">Import dashboard</Trans>
</Button>
</Stack>
</Box>
</Stack>
</Stack>
)}
</div>
</Stack>
</>
);
};
const DashboardExtensionsComponents = ({
dashboardLibraryDatasourceUid,
}: {
dashboardLibraryDatasourceUid: string | null;
}) => (
<>
{/* Suggested Dashboards Section */}
{config.featureToggles.suggestedDashboards &&
config.featureToggles.dashboardLibrary &&
dashboardLibraryDatasourceUid && <SuggestedDashboards datasourceUid={dashboardLibraryDatasourceUid} />}
{/* Basic Provisioned Dashboards Section that don't include community dashboards */}
{config.featureToggles.dashboardLibrary &&
!config.featureToggles.suggestedDashboards &&
dashboardLibraryDatasourceUid && (
<BasicProvisionedDashboardsEmptyPage datasourceUid={dashboardLibraryDatasourceUid} />
)}
</>
);
export interface Props {
dashboard: DashboardModel | DashboardScene;
canCreate: boolean;
@@ -190,5 +219,8 @@ function getStyles(theme: GrafanaTheme2) {
wrapperMaxWidth: css({
maxWidth: '890px',
}),
appsIcon: css({
fill: theme.v1.palette.orange,
}),
};
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

View File

@@ -5216,6 +5216,13 @@
"title-matched_one": "Matched {{count}}/{{totalCount}} options",
"title-matched_other": "Matched {{count}}/{{totalCount}} options"
},
"add": {
"pane-header": "Add",
"new-panel": {
"title": "Panel",
"description": "Drag or click to add a panel"
}
},
"outline": {
"pane-header": "Content outline",
"repeated-item": "Repeat",
@@ -5433,6 +5440,10 @@
"title": "Options",
"tooltip": "Dashboard options"
},
"add": {
"title": "Add",
"tooltip": "Add a new element"
},
"edit-schema": {
"title": "Code",
"tooltip": "Edit as code"