DashboardOutline: Support renaming by double click (#103785)

* Outline rename

* Outline rename and validation working

* Update

* Restore valid name onBlur
This commit is contained in:
Torkel Ödegaard
2025-04-10 19:23:36 +02:00
committed by GitHub
parent c83c486c9b
commit 532e443017
15 changed files with 283 additions and 98 deletions
+3
View File
@@ -1519,6 +1519,9 @@ exports[`better eslint`] = {
"public/app/features/dashboard-scene/edit-pane/DashboardEditPane.tsx:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
"public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx:5381": [
[0, 0, 0, "No untranslated strings. Wrap text with <Trans />", "0"]
],
"public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [
[0, 0, 0, "Do not use any type assertions.", "0"]
],
@@ -4,7 +4,7 @@ import React, { useEffect, useMemo, useState } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { SceneObject } from '@grafana/scenes';
import { Box, Icon, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { Box, Icon, Stack, Text, useElementSelection, useStyles2 } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
@@ -14,6 +14,7 @@ import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditPane } from './DashboardEditPane';
import { getEditableElementFor } from './shared';
import { useOutlineRename } from './useOutlineRename';
export interface Props {
editPane: DashboardEditPane;
@@ -50,15 +51,19 @@ function DashboardOutlineNode({
const noTitleText = t('dashboard.outline.tree-item.no-title', '<no title>');
const instanceName = elementInfo.instanceName === '' ? noTitleText : elementInfo.instanceName;
const elementCollapsed = editableElement.getCollapsedState?.();
const outlineRename = useOutlineRename(editableElement);
const onPointerDown = (evt: React.PointerEvent) => {
const onNameClicked = (evt: React.PointerEvent) => {
// Only select via clicking outline never deselect
if (!isSelected) {
onSelect?.(evt);
}
setIsCollapsed(!isCollapsed);
editableElement.scrollIntoView?.();
};
const onToggleCollapse = () => {
setIsCollapsed(!isCollapsed);
// Sync expanded state with canvas element
if (editableElement.getCollapsedState) {
@@ -75,18 +80,38 @@ function DashboardOutlineNode({
return (
<>
<button
role="treeitem"
className={cx(styles.nodeButton, isCloned && styles.nodeButtonClone, isSelected && styles.nodeButtonSelected)}
onPointerDown={onPointerDown}
>
{elementInfo.isContainer && <Icon name={!isCollapsed ? 'angle-down' : 'angle-right'} />}
<Icon size="sm" name={elementInfo.icon} />
<span>{instanceName}</span>
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
{/* eslint-disable-next-line @grafana/no-untranslated-strings */}
{elementInfo.isContainer && isCollapsed && <span>({children.length})</span>}
</button>
<Stack gap={0.5}>
{elementInfo.isContainer && (
<button role="treeitem" className={styles.angleButton} onClick={onToggleCollapse}>
<Icon name={!isCollapsed ? 'angle-down' : 'angle-right'} />
</button>
)}
<button
role="button"
className={cx(styles.nodeButton, isCloned && styles.nodeButtonClone, isSelected && styles.nodeButtonSelected)}
onPointerDown={onNameClicked}
onDoubleClick={outlineRename.onNameDoubleClicked}
>
<Icon size="sm" name={elementInfo.icon} />
{outlineRename.isRenaming ? (
<input
ref={outlineRename.renameInputRef}
type="text"
value={elementInfo.instanceName}
className={styles.outlineInput}
onChange={outlineRename.onChangeName}
onBlur={outlineRename.onInputBlur}
onKeyDown={outlineRename.onInputKeyDown}
/>
) : (
<>
<span>{instanceName}</span>
{elementInfo.isHidden && <Icon name="eye-slash" size="sm" className={styles.hiddenIcon} />}
{elementInfo.isContainer && isCollapsed && <span>({children.length})</span>}
</>
)}
</button>
</Stack>
{elementInfo.isContainer && !isCollapsed && (
<div className={styles.container} role="group">
@@ -120,6 +145,15 @@ function getStyles(theme: GrafanaTheme2) {
paddingLeft: theme.spacing(1.5),
borderLeft: `1px solid ${theme.colors.border.medium}`,
}),
angleButton: css({
boxShadow: 'none',
border: 'none',
background: 'transparent',
borderRadius: theme.shape.radius.default,
padding: 0,
color: theme.colors.text.secondary,
lineHeight: 0,
}),
nodeButton: css({
boxShadow: 'none',
border: 'none',
@@ -159,6 +193,15 @@ function getStyles(theme: GrafanaTheme2) {
color: theme.colors.text.secondary,
cursor: 'not-allowed',
}),
outlineInput: css({
border: `1px solid ${theme.colors.primary.border}`,
height: theme.spacing(3),
'&:focus': {
outline: 'none',
boxShadow: 'none',
},
}),
};
}
@@ -13,6 +13,7 @@ import {
PanelBackgroundSwitch,
PanelDescriptionTextArea,
PanelFrameTitleInput,
setPanelTitle,
} from '../panel-edit/getPanelFrameOptions';
import { AutoGridItem } from '../scene/layout-auto-grid/AutoGridItem';
import { DashboardGridItem } from '../scene/layout-default/DashboardGridItem';
@@ -112,6 +113,10 @@ export class VizPanelEditableElement implements EditableDashboardElement, BulkAc
dashboard.copyPanel(this.panel);
}
public onChangeName(name: string) {
setPanelTitle(this.panel, name);
}
public createMultiSelectedElement(items: VizPanelEditableElement[]) {
return new MultiSelectedVizPanelsEditableElement(items);
}
@@ -1,13 +1,14 @@
import { useSessionStorage } from 'react-use';
import { BusEventWithPayload } from '@grafana/data';
import { SceneGridRow, SceneObject, SceneVariable, SceneVariableSet, VizPanel } from '@grafana/scenes';
import { SceneGridRow, SceneObject, SceneVariableSet, VizPanel } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { SceneGridRowEditableElement } from '../scene/layout-default/SceneGridRowEditableElement';
import { EditableDashboardElement, isEditableDashboardElement } from '../scene/types/EditableDashboardElement';
import { VariableEditableElement } from '../settings/variables/VariableEditableElement';
import { VariableSetEditableElement } from '../settings/variables/VariableSetEditableElement';
import { isSceneVariable } from '../settings/variables/utils';
import { DashboardEditableElement } from './DashboardEditableElement';
import { VizPanelEditableElement } from './VizPanelEditableElement';
@@ -48,10 +49,6 @@ export function getEditableElementFor(sceneObj: SceneObject | undefined): Editab
return undefined;
}
export function isSceneVariable(sceneObj: SceneObject): sceneObj is SceneVariable {
return 'getValue' in sceneObj;
}
export class NewObjectAddedToCanvasEvent extends BusEventWithPayload<SceneObject> {
static type = 'new-object-added-to-canvas';
}
@@ -0,0 +1,60 @@
import React, { useState, useMemo } from 'react';
import { EditableDashboardElement } from '../scene/types/EditableDashboardElement';
export interface OutlineRenameState {
isRenaming?: boolean;
originalName?: string;
error?: string;
}
export function useOutlineRename(editableElement: EditableDashboardElement) {
const [state, setState] = useState<OutlineRenameState>({});
const onNameDoubleClicked = (evt: React.MouseEvent) => {
if (!editableElement.onChangeName) {
return;
}
setState({ isRenaming: true, originalName: editableElement.getEditableElementInfo().instanceName });
};
const onInputBlur = () => {
if (state.error) {
editableElement.onChangeName!(state.originalName!);
}
setState({});
};
const renameInputRef = useMemo(() => {
return (ref: HTMLInputElement | null) => {
ref?.focus();
ref?.select();
};
}, []);
const onChangeName = (evt: React.ChangeEvent<HTMLInputElement>) => {
const result = editableElement.onChangeName!(evt.target.value);
if (result?.errorMessage) {
setState({ ...state, error: result.errorMessage });
} else if (state.error) {
setState({ ...state, error: undefined });
}
};
const onInputKeyDown = (evt: React.KeyboardEvent) => {
if (evt.key === 'Enter') {
onInputBlur();
}
};
return {
isRenaming: state.isRenaming,
onNameDoubleClicked,
renameInputRef,
onChangeName,
onInputBlur,
onInputKeyDown,
};
}
@@ -154,7 +154,7 @@ export function PanelBackgroundSwitch({ panel }: { panel: VizPanel }) {
);
}
function setPanelTitle(panel: VizPanel, title: string) {
export function setPanelTitle(panel: VizPanel, title: string) {
panel.setState({ title: title, hoverHeader: getUpdatedHoverHeader(title, panel.state.$timeRange) });
}
@@ -190,6 +190,10 @@ export class RowItem
this.setState({ title });
}
public onChangeName(name: string) {
this.onChangeTitle(name);
}
public onHeaderHiddenToggle(hideHeader = !this.state.hideHeader) {
this.setState({ hideHeader });
}
@@ -172,6 +172,10 @@ export class TabItem
this.setState({ title });
}
public onChangeName(name: string): void {
this.onChangeTitle(name);
}
public setIsDropTarget(isDropTarget: boolean) {
if (!!this.state.isDropTarget !== isDropTarget) {
this.setState({ isDropTarget });
@@ -64,6 +64,11 @@ export interface EditableDashboardElement {
* Used to sync row collapsed state with outline
*/
setCollapsedState?(collapsed: boolean): void;
/**
* Used to change name from outline
*/
onChangeName?(name: string): { errorMessage?: string } | void;
}
export interface EditableDashboardElementInfo {
@@ -10,14 +10,7 @@ import {
} from '@grafana/data';
import { getPanelPlugin } from '@grafana/data/test';
import { setPluginImportUtils, setRunRequest } from '@grafana/runtime';
import {
SceneVariableSet,
CustomVariable,
VizPanel,
AdHocFiltersVariable,
SceneVariableState,
SceneTimeRange,
} from '@grafana/scenes';
import { SceneVariableSet, CustomVariable, VizPanel, AdHocFiltersVariable, SceneTimeRange } from '@grafana/scenes';
import { mockDataSource } from 'app/features/alerting/unified/mocks';
import { LegacyVariableQueryEditor } from 'app/features/variables/editor/LegacyVariableQueryEditor';
@@ -212,49 +205,6 @@ describe('VariablesEditView', () => {
});
});
describe('Variables name validation', () => {
let variableView: VariablesEditView;
let variable1: SceneVariableState;
let variable2: SceneVariableState;
beforeAll(async () => {
const result = await buildTestScene();
variableView = result.variableView;
const variables = variableView.getVariables();
variable1 = variables[0].state;
variable2 = variables[1].state;
});
it('should not return error on same name and key', () => {
expect(variableView.onValidateVariableName(variable1.name, variable1.key)[0]).toBe(false);
});
it('should not return error if name is unique', () => {
expect(variableView.onValidateVariableName('unique_variable_name', variable1.key)[0]).toBe(false);
});
it('should return error if global variable name is used', () => {
expect(variableView.onValidateVariableName('__', variable1.key)[0]).toBe(true);
});
it('should not return error if global variable name is used not at the beginning ', () => {
expect(variableView.onValidateVariableName('test__', variable1.key)[0]).toBe(false);
});
it('should return error if name is empty', () => {
expect(variableView.onValidateVariableName('', variable1.key)[0]).toBe(true);
});
it('should return error if non word characters are used', () => {
expect(variableView.onValidateVariableName('-', variable1.key)[0]).toBe(true);
});
it('should return error if variable name is taken', () => {
expect(variableView.onValidateVariableName(variable2.name, variable1.key)[0]).toBe(true);
});
});
describe('Dashboard Variables dependencies', () => {
let variableView: VariablesEditView;
let dashboard: DashboardScene;
@@ -244,7 +244,6 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps<Variables
navModel={navModel}
dashboard={dashboard}
onDelete={onDelete}
onValidateVariableName={model.onValidateVariableName}
/>
);
}
@@ -276,7 +275,6 @@ interface VariableEditorSettingsEditViewProps {
onTypeChange: (variableType: EditableVariableType) => void;
onGoBack: () => void;
onDelete: (variableName: string) => void;
onValidateVariableName: (name: string, key: string | undefined) => [true, string] | [false, null];
}
function VariableEditorSettingsView({
@@ -287,7 +285,6 @@ function VariableEditorSettingsView({
onTypeChange,
onGoBack,
onDelete,
onValidateVariableName,
}: VariableEditorSettingsEditViewProps) {
const { name } = variable.useState();
@@ -303,7 +300,6 @@ function VariableEditorSettingsView({
onTypeChange={onTypeChange}
onGoBack={onGoBack}
onDelete={onDelete}
onValidateVariableName={onValidateVariableName}
// force refresh when navigating using back/forward between variables
key={variable.state.key}
/>
@@ -1,9 +1,9 @@
import { useMemo } from 'react';
import { FormEvent, useMemo, useState } from 'react';
import { VariableHide } from '@grafana/data';
import { locationService } from '@grafana/runtime';
import { SceneVariable, SceneVariableSet } from '@grafana/scenes';
import { Combobox, Input, TextArea, Stack, Button } from '@grafana/ui';
import { Combobox, Input, TextArea, Stack, Button, Field } from '@grafana/ui';
import { t, Trans } from 'app/core/internationalization';
import { OptionsPaneCategoryDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneCategoryDescriptor';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -13,7 +13,7 @@ import { useEditPaneInputAutoFocus } from '../../scene/layouts-shared/utils';
import { BulkActionElement } from '../../scene/types/BulkActionElement';
import { EditableDashboardElement, EditableDashboardElementInfo } from '../../scene/types/EditableDashboardElement';
import { VariableHideSelect } from '../../settings/variables/components/VariableHideSelect';
import { getVariableTypeSelectOptions } from '../../settings/variables/utils';
import { getVariableTypeSelectOptions, validateVariableName } from '../../settings/variables/utils';
export class VariableEditableElement implements EditableDashboardElement, BulkActionElement {
public readonly isEditableDashboardElement = true;
@@ -37,8 +37,8 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
return new OptionsPaneCategoryDescriptor({ title: '', id: 'panel-options' })
.addItem(
new OptionsPaneItemDescriptor({
title: t('dashboard-scene.variable-editor-form.name', 'Name'),
popularRank: 1,
title: '',
skipField: true,
render: () => <VariableNameInput variable={variable} isNewElement={isNewElement} />,
})
)
@@ -83,6 +83,17 @@ export class VariableEditableElement implements EditableDashboardElement, BulkAc
set.setState({ variables: set.state.variables.filter((v) => v !== this.variable) });
}
}
public onChangeName(name: string) {
this.variable.setState({ name });
const result = validateVariableName(this.variable, name);
if (result.errorMessage) {
return result;
}
return;
}
}
interface VariableInputProps {
@@ -92,7 +103,33 @@ interface VariableInputProps {
function VariableNameInput({ variable, isNewElement }: { variable: SceneVariable; isNewElement: boolean }) {
const { name } = variable.useState();
const ref = useEditPaneInputAutoFocus({ autoFocus: isNewElement });
return <Input ref={ref} value={name} onChange={(e) => variable.setState({ name: e.currentTarget.value })} />;
const [nameError, setNameError] = useState<string>();
const [validName, setValidName] = useState<string>(variable.state.name);
const onChange = (e: FormEvent<HTMLInputElement>) => {
const result = validateVariableName(variable, e.currentTarget.value);
if (result.errorMessage !== nameError) {
setNameError(result.errorMessage);
} else {
setValidName(variable.state.name);
}
variable.setState({ name: e.currentTarget.value });
};
// Restore valid name if bluring while invalid
const onBlur = () => {
if (nameError) {
variable.setState({ name: validName });
setNameError(undefined);
}
};
return (
<Field label={t('dashboard-scene.variable-editor-form.name', 'Name')} invalid={!!nameError} error={nameError}>
<Input ref={ref} value={name} onChange={onChange} required onBlur={onBlur} />
</Field>
);
}
function VariableLabelInput({ variable }: VariableInputProps) {
@@ -18,25 +18,24 @@ import { VariableValuesPreview } from 'app/features/dashboard-scene/settings/var
import { VariableNameConstraints } from 'app/features/variables/editor/types';
import { VariableTypeSelect } from './components/VariableTypeSelect';
import { EditableVariableType, getVariableEditor, hasVariableOptions, isEditableVariableType } from './utils';
import {
EditableVariableType,
getVariableEditor,
hasVariableOptions,
isEditableVariableType,
validateVariableName,
} from './utils';
interface VariableEditorFormProps {
variable: SceneVariable;
onTypeChange: (type: EditableVariableType) => void;
onGoBack: () => void;
onDelete: (variableName: string) => void;
onValidateVariableName: (name: string, key: string | undefined) => [true, string] | [false, null];
}
export function VariableEditorForm({
variable,
onTypeChange,
onGoBack,
onDelete,
onValidateVariableName,
}: VariableEditorFormProps) {
export function VariableEditorForm({ variable, onTypeChange, onGoBack, onDelete }: VariableEditorFormProps) {
const styles = useStyles2(getStyles);
const [nameError, setNameError] = useState<string | null>(null);
const { name, type, label, description, hide, key } = variable.useState();
const [nameError, setNameError] = useState<string>();
const { name, type, label, description, hide } = variable.useState();
const EditorToRender = isEditableVariableType(type) ? getVariableEditor(type) : undefined;
const [runQueryState, onRunQuery] = useAsyncFn(async () => {
await lastValueFrom(variable.validateAndUpdate!());
@@ -49,12 +48,12 @@ export function VariableEditorForm({
const onNameChange = useCallback(
(e: FormEvent<HTMLInputElement>) => {
const [, errorMessage] = onValidateVariableName(e.currentTarget.value, key);
if (nameError !== errorMessage) {
setNameError(errorMessage);
const result = validateVariableName(variable, e.currentTarget.value);
if (result.errorMessage !== nameError) {
setNameError(result.errorMessage);
}
},
[key, nameError, onValidateVariableName]
[variable, nameError]
);
const onNameBlur = (e: FormEvent<HTMLInputElement>) => {
@@ -10,6 +10,7 @@ import {
GroupByVariable,
TextBoxVariable,
SceneVariableSet,
SceneVariable,
} from '@grafana/scenes';
import { DataQuery, DataSourceJsonData, VariableHide, VariableType } from '@grafana/schema';
import { SHARED_DASHBOARD_QUERY, DASHBOARD_DATASOURCE_PLUGIN_ID } from 'app/plugins/datasource/dashboard/constants';
@@ -36,6 +37,7 @@ import {
getNextAvailableId,
getVariableDefault,
isSceneVariableInstance,
validateVariableName,
} from './utils';
const templateSrv = {
@@ -376,3 +378,53 @@ describe('getVariableDefault', () => {
expect(defaultVariable.state.name).toBe('query0');
});
});
describe('Variables name validation', () => {
let variable1: SceneVariable;
let variable2: SceneVariable;
beforeAll(async () => {
variable1 = new CustomVariable({
name: 'customVar',
query: 'test, test2',
value: 'test',
text: 'test',
});
variable2 = new CustomVariable({
name: 'customVar2',
query: 'test3, test4, $customVar',
value: '$customVar',
text: '$customVar',
});
new SceneVariableSet({ variables: [variable1, variable2] });
});
it('should not return error on same name and key', () => {
expect(validateVariableName(variable1, variable1.state.name).isValid).toBe(true);
});
it('should not return error if name is unique', () => {
expect(validateVariableName(variable1, 'unique_variable_name').isValid).toBe(true);
});
it('should return error if global variable name is used', () => {
expect(validateVariableName(variable1, '__').isValid).toBe(false);
});
it('should not return error if global variable name is used not at the beginning ', () => {
expect(validateVariableName(variable1, 'test__').isValid).toBe(true);
});
it('should return error if name is empty', () => {
expect(validateVariableName(variable1, '').isValid).toBe(false);
});
it('should return error if non word characters are used', () => {
expect(validateVariableName(variable1, '-').isValid).toBe(false);
});
it('should return error if variable name is taken', () => {
expect(validateVariableName(variable1, variable2.state.name).isValid).toBe(false);
});
});
@@ -16,6 +16,7 @@ import {
SceneObject,
AdHocFiltersVariable,
SceneVariableState,
SceneVariableSet,
} from '@grafana/scenes';
import { VariableHide, VariableType } from '@grafana/schema';
@@ -202,7 +203,7 @@ export function getOptionDataSourceTypes() {
return optionTypes;
}
function isSceneVariable(sceneObject: SceneObject): sceneObject is SceneVariable {
export function isSceneVariable(sceneObject: SceneObject): sceneObject is SceneVariable {
return 'type' in sceneObject.state && 'getValue' in sceneObject;
}
@@ -225,3 +226,32 @@ export function isSceneVariableInstance(sceneObject: SceneObject): sceneObject i
export const RESERVED_GLOBAL_VARIABLE_NAME_REGEX = /^(?!__).*$/;
export const WORD_CHARACTERS_REGEX = /^\w+$/;
export function validateVariableName(
variable: SceneVariable,
name: string
): { isValid: boolean; errorMessage?: string } {
const set = variable.parent;
if (!(set instanceof SceneVariableSet)) {
throw new Error('Variable parent is not a SceneVariableSet');
}
if (!RESERVED_GLOBAL_VARIABLE_NAME_REGEX.test(name)) {
return {
isValid: false,
errorMessage: "Template names cannot begin with '__', that's reserved for Grafana's global variables",
};
}
if (!WORD_CHARACTERS_REGEX.test(name)) {
return { isValid: false, errorMessage: 'Only word characters are allowed in variable names' };
}
const varLookupByName = set.getByName(name);
if (varLookupByName && varLookupByName !== variable) {
return { isValid: false, errorMessage: 'Variable with the same name already exists' };
}
return { isValid: true };
}