DashboardOutline: Support renaming by double click (#103785)
* Outline rename * Outline rename and validation working * Update * Restore valid name onBlur
This commit is contained in:
@@ -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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user