Compare commits

...

5 Commits

Author SHA1 Message Date
idastambuk
817c3cc6c1 Focus on input search when changing visualizations 2025-12-17 12:59:08 +01:00
Dominik Prokop
52205fbf4f CustomVariable: Remove dead code and incorrect label (#115340)
* CustomVariable: Remove dead code and incorrect label

* Update i18n translations
2025-12-16 15:56:16 +01:00
Kristina Demeshchik
321e60e69c Dashboards: Add missing keyboard shortcuts for new layouts (#115377)
* missing key bindings

* Collapse repeted panels
2025-12-16 09:34:10 -05:00
Kristina Demeshchik
c6dda2dfc3 Dashboards: exit edit mode after saving changes modal (#115380)
Exit edit mode with unsaved changes
2025-12-16 09:33:54 -05:00
Marc M.
e03f7fe878 DynamicDashboards: prevent nested repeats based on the same variable (#114953) 2025-12-16 15:11:23 +01:00
14 changed files with 167 additions and 44 deletions

View File

@@ -0,0 +1,59 @@
import { test, expect } from '@grafana/plugin-e2e';
test.use({
featureToggles: {
kubernetesDashboards: true,
dashboardNewLayouts: true,
},
});
test.describe('Dashboard keybindings with new layouts', { tag: ['@dashboards'] }, () => {
test.use({
viewport: { width: 1280, height: 1080 },
});
test('should collapse and expand all rows', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: 'Repeating-rows-uid/repeating-rows' });
const panelContents = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.content);
await expect(panelContents).toHaveCount(5);
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('server = A, pod = Bob'))
).toBeVisible();
await expect(
dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('server = B, pod = Bob'))
).toBeVisible();
// Collapse all rows using keyboard shortcut: d + Shift+C
await page.keyboard.press('d');
await page.keyboard.press('Shift+C');
await expect(panelContents).toHaveCount(0);
await expect(page.getByText('server = A, pod = Bob')).toBeHidden();
await expect(page.getByText('server = B, pod = Bob')).toBeHidden();
// Expand all rows using keyboard shortcut: d + Shift+E
await page.keyboard.press('d');
await page.keyboard.press('Shift+E');
await expect(panelContents).toHaveCount(6);
await expect(page.getByText('server = A, pod = Bob')).toBeVisible();
await expect(page.getByText('server = B, pod = Bob')).toBeVisible();
});
test('should open panel inspect', async ({ gotoDashboardPage, page, selectors }) => {
const dashboardPage = await gotoDashboardPage({ uid: 'edediimbjhdz4b/a-tall-dashboard' });
// Find Panel #1 and press 'i' to open inspector
const panel1 = dashboardPage.getByGrafanaSelector(selectors.components.Panels.Panel.title('Panel #1'));
await expect(panel1).toBeVisible();
await panel1.press('i');
await expect(dashboardPage.getByGrafanaSelector(selectors.components.PanelInspector.Json.content)).toBeVisible();
// Press Escape to close inspector
await page.keyboard.press('Escape');
await expect(page.getByTestId(selectors.components.PanelInspector.Json.content)).toBeHidden();
});
});

View File

@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { debounce } from 'lodash';
import { useCallback, useId, useMemo, useState } from 'react';
import { useCallback, useEffect, useId, useMemo, useState } from 'react';
import { useSessionStorage } from 'react-use';
import { GrafanaTheme2, PanelData } from '@grafana/data';
@@ -45,6 +45,13 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
const styles = useStyles2(getStyles);
const panelModel = useMemo(() => new PanelModelCompatibilityWrapper(panel), [panel]);
const filterId = useId();
const [searchInputRef, setSearchInputRef] = useState<HTMLInputElement | null>(null);
useEffect(() => {
if (searchInputRef) {
searchInputRef.focus();
}
}, [searchInputRef]);
/** SEARCH */
const [searchQuery, setSearchQuery] = useState('');
@@ -124,6 +131,9 @@ export function PanelVizTypePicker({ panel, data, onChange, onClose, showBackBut
)}
<FilterInput
id={filterId}
ref={(ref) => {
setSearchInputRef(ref);
}}
className={styles.filter}
value={searchQuery}
onChange={setSearchQuery}

View File

@@ -30,8 +30,10 @@ import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { dashboardWatcher } from 'app/features/live/dashboard/dashboardWatcher';
import { DashboardEventAction } from 'app/features/live/dashboard/types';
import { VariablesChanged } from 'app/features/variables/types';
import { ShowConfirmModalEvent } from 'app/types/events';
import { buildPanelEditScene } from '../panel-edit/PanelEditor';
import { SaveDashboardDrawer } from '../saving/SaveDashboardDrawer';
import { createWorker } from '../saving/createDetectChangesWorker';
import { buildGridItemForPanel, transformSaveModelToScene } from '../serialization/transformSaveModelToScene';
import { DecoratedRevisionModel } from '../settings/VersionsEditView';
@@ -39,6 +41,7 @@ import { historySrv } from '../settings/version-history/HistorySrv';
import { getCloneKey } from '../utils/clone';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { findVizPanelByKey, getLibraryPanelBehavior, isLibraryPanel } from '../utils/utils';
import * as utils from '../utils/utils';
import { DashboardControls } from './DashboardControls';
import { DashboardScene, DashboardSceneState } from './DashboardScene';
@@ -162,6 +165,38 @@ describe('DashboardScene', () => {
expect(scene.state.meta.version).toEqual(2);
});
it('Should exit edit mode after saving from unsaved changes modal when dashboardNewLayouts is enabled', () => {
const originalFeatureToggle = config.featureToggles.dashboardNewLayouts;
config.featureToggles.dashboardNewLayouts = true;
const publishSpy = jest.spyOn(appEvents, 'publish');
const hasActualSaveChangesSpy = jest.spyOn(utils, 'hasActualSaveChanges').mockReturnValue(true);
scene.setState({ title: 'Updated title' });
expect(scene.state.isDirty).toBe(true);
scene.exitEditMode({ skipConfirm: false });
const modalCall = publishSpy.mock.calls.find((call) => call[0] instanceof ShowConfirmModalEvent);
expect(modalCall).toBeDefined();
const modalEvent = modalCall![0] as ShowConfirmModalEvent;
expect(modalEvent.payload.altActionText).toBeDefined();
modalEvent.payload.onAltAction?.();
expect(scene.state.overlay).toBeDefined();
const overlay = scene.state.overlay as SaveDashboardDrawer;
expect(overlay.state.onSaveSuccess).toBeDefined();
overlay.state.onSaveSuccess!();
expect(scene.state.isEditing).toBe(false);
publishSpy.mockRestore();
hasActualSaveChangesSpy.mockRestore();
config.featureToggles.dashboardNewLayouts = originalFeatureToggle;
});
it('Should start the detect changes worker', () => {
expect(worker.onmessage).toBeDefined();
});

View File

@@ -335,7 +335,11 @@ export class DashboardScene extends SceneObjectBase<DashboardSceneState> impleme
yesText: t('dashboard-scene.dashboard-scene.modal.discard', 'Discard'),
yesButtonVariant: 'destructive',
onAltAction: () => {
this.openSaveDrawer({});
this.openSaveDrawer({
onSaveSuccess: () => {
this.exitEditModeConfirmed(false);
},
});
},
onConfirm: () => {
this.exitEditModeConfirmed();

View File

@@ -18,6 +18,7 @@ import { getPanelIdForVizPanel } from '../utils/utils';
import { DashboardScene } from './DashboardScene';
import { onRemovePanel, toggleVizPanelLegend } from './PanelMenuBehavior';
import { DefaultGridLayoutManager } from './layout-default/DefaultGridLayoutManager';
import { RowsLayoutManager } from './layout-rows/RowsLayoutManager';
export function setupKeyboardShortcuts(scene: DashboardScene) {
const keybindings = new KeybindingSet();
@@ -265,6 +266,8 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => {
if (scene.state.body instanceof DefaultGridLayoutManager) {
scene.state.body.collapseAllRows();
} else if (scene.state.body instanceof RowsLayoutManager) {
scene.state.body.collapseAllRows();
}
},
});
@@ -275,6 +278,8 @@ export function setupKeyboardShortcuts(scene: DashboardScene) {
onTrigger: () => {
if (scene.state.body instanceof DefaultGridLayoutManager) {
scene.state.body.expandAllRows();
} else if (scene.state.body instanceof RowsLayoutManager) {
scene.state.body.expandAllRows();
}
},
});

View File

@@ -107,7 +107,7 @@ function RowRepeatSelect({ row, dashboard, id }: { row: SceneGridRow; dashboard:
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={row}
repeat={repeatBehavior?.state.variableName}
onChange={(repeat) => {
if (repeat) {

View File

@@ -8,7 +8,7 @@ import { Icon, useStyles2 } from '@grafana/ui';
import { SHARED_DASHBOARD_QUERY } from 'app/plugins/datasource/dashboard/constants';
import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSource';
import { getQueryRunnerFor, useDashboard, useDashboardState } from '../../../utils/utils';
import { getQueryRunnerFor, useDashboardState } from '../../../utils/utils';
import { DashboardGridItem } from '../DashboardGridItem';
import { RowRepeaterBehavior } from '../RowRepeaterBehavior';
@@ -18,7 +18,6 @@ import { RowOptionsButton } from './RowOptionsButton';
export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
const row = model.getParent();
const { title, children } = row.useState();
const dashboard = useDashboard(model);
const { meta, isEditing } = useDashboardState(model);
const styles = useStyles2(getStyles);
@@ -53,7 +52,7 @@ export function RowActionsRenderer({ model }: SceneComponentProps<RowActions>) {
<RowOptionsButton
title={title}
repeat={behaviour instanceof RowRepeaterBehavior ? behaviour.state.variableName : undefined}
parent={dashboard}
parent={row}
onUpdate={(title, repeat) => model.onUpdate(title, repeat)}
isUsingDashboardDS={isUsingDashboardDS}
/>

View File

@@ -2,7 +2,7 @@ import { useId, useMemo, useRef } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Alert, Input, Switch, TextLink, Field } from '@grafana/ui';
import { Alert, Field, Input, Switch, TextLink } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
@@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor';
import { dashboardEditActions } from '../../edit-pane/shared';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { getQueryRunnerFor } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@@ -128,7 +128,6 @@ function FillScreenSwitch({ row, id }: { row: RowItem; id?: string }) {
function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) {
const { layout } = row.useState();
const dashboard = useDashboard(row);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
@@ -143,7 +142,7 @@ function RowRepeatSelect({ row, id }: { row: RowItem; id?: string }) {
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={row}
repeat={row.state.repeatByVariable}
onChange={(repeat) => row.onChangeRepeat(repeat)}
/>

View File

@@ -390,4 +390,30 @@ export class RowsLayoutManager extends SceneObjectBase<RowsLayoutManagerState> i
return duplicateTitles;
}
public collapseAllRows() {
this.state.rows.forEach((row) => {
if (!row.getCollapsedState()) {
row.setCollapsedState(true);
}
row.state.repeatedRows?.forEach((repeatedRow) => {
if (!repeatedRow.getCollapsedState()) {
repeatedRow.setCollapsedState(true);
}
});
});
}
public expandAllRows() {
this.state.rows.forEach((row) => {
if (row.getCollapsedState()) {
row.setCollapsedState(false);
}
row.state.repeatedRows?.forEach((repeatedRow) => {
if (repeatedRow.getCollapsedState()) {
repeatedRow.setCollapsedState(false);
}
});
});
}
}

View File

@@ -2,7 +2,7 @@ import { useMemo, useRef } from 'react';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { Alert, Input, Field, TextLink } from '@grafana/ui';
import { Alert, Field, Input, TextLink } from '@grafana/ui';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { RepeatRowSelect2 } from 'app/features/dashboard/components/RepeatRowSelect/RepeatRowSelect';
@@ -11,7 +11,7 @@ import { MIXED_DATASOURCE_NAME } from 'app/plugins/datasource/mixed/MixedDataSou
import { useConditionalRenderingEditor } from '../../conditional-rendering/hooks/useConditionalRenderingEditor';
import { dashboardEditActions } from '../../edit-pane/shared';
import { getQueryRunnerFor, useDashboard } from '../../utils/utils';
import { getQueryRunnerFor } from '../../utils/utils';
import { useLayoutCategory } from '../layouts-shared/DashboardLayoutSelector';
import { generateUniqueTitle, useEditPaneInputAutoFocus } from '../layouts-shared/utils';
@@ -99,7 +99,6 @@ function TabTitleInput({ tab, isNewElement, id }: { tab: TabItem; isNewElement:
function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) {
const { layout } = tab.useState();
const dashboard = useDashboard(tab);
const isAnyPanelUsingDashboardDS = layout.getVizPanels().some((vizPanel) => {
const runner = getQueryRunnerFor(vizPanel);
@@ -114,7 +113,7 @@ function TabRepeatSelect({ tab, id }: { tab: TabItem; id?: string }) {
<>
<RepeatRowSelect2
id={id}
sceneContext={dashboard}
sceneContext={tab}
repeat={tab.state.repeatByVariable}
onChange={(repeat) => tab.onChangeRepeat(repeat)}
/>

View File

@@ -1,13 +1,9 @@
import { FormEvent, useCallback } from 'react';
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { CustomVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { CustomVariableForm } from '../../components/CustomVariableForm';
import { PaneItem } from './PaneItem';
interface CustomVariableEditorProps {
variable: CustomVariable;
onRunQuery: () => void;
@@ -67,17 +63,3 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
/>
);
}
export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneItemDescriptor[] {
if (!(variable instanceof CustomVariable)) {
return [];
}
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
];
}

View File

@@ -1,4 +1,3 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),

View File

@@ -2,7 +2,7 @@ import { useCallback, useMemo } from 'react';
import { SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneObject, sceneGraph } from '@grafana/scenes';
import { LocalValueVariable, SceneObject, sceneGraph } from '@grafana/scenes';
import { Combobox, ComboboxOption, Select } from '@grafana/ui';
import { useSelector } from 'app/types/store';
@@ -59,10 +59,18 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
const variables = sceneVars.useState().variables;
const variableOptions = useMemo(() => {
const options: ComboboxOption[] = variables.map((item) => ({
label: item.state.name,
value: item.state.name,
}));
const options: ComboboxOption[] = variables
.filter((item) => {
if (sceneContext.parent) {
// filter out local value variables (which are only set on repeated items)
return !(sceneGraph.lookupVariable(item.state.name, sceneContext.parent) instanceof LocalValueVariable);
}
return true;
})
.map((item) => ({
label: item.state.name,
value: item.state.name,
}));
options.unshift({
label: t('dashboard.repeat-row-select2.variable-options.label.disable-repeating', 'Disable repeating'),
@@ -70,7 +78,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
});
return options;
}, [variables]);
}, [sceneContext, variables]);
const onSelectChange = useCallback((value: ComboboxOption | null) => value && onChange(value.value), [onChange]);
@@ -79,7 +87,7 @@ export const RepeatRowSelect2 = ({ sceneContext, repeat, id, onChange }: Props2)
return (
<Combobox
id={id}
value={repeat}
value={repeat || ''}
onChange={onSelectChange}
options={variableOptions}
disabled={isDisabled}

View File

@@ -4821,8 +4821,7 @@
"apply": "Apply",
"change-value": "Change variable value",
"discard": "Discard",
"modal-title": "Custom Variable",
"values": "Values separated by comma"
"modal-title": "Custom Variable"
},
"datasource-options": {
"name-filter": "Name filter",