diff --git a/.betterer.results b/.betterer.results index 1c46bde437e..dcb22c40562 100644 --- a/.betterer.results +++ b/.betterer.results @@ -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 ", "0"] + ], "public/app/features/dashboard-scene/inspect/HelpWizard/utils.ts:5381": [ [0, 0, 0, "Do not use any type assertions.", "0"] ], diff --git a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx index 69c550d9fa1..e231c286720 100644 --- a/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx +++ b/public/app/features/dashboard-scene/edit-pane/DashboardOutline.tsx @@ -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', ''); 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 ( <> - + + {elementInfo.isContainer && ( + + )} + + {elementInfo.isContainer && !isCollapsed && (
@@ -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', + }, + }), }; } diff --git a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx index 19644cf099b..a0784a982c4 100644 --- a/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx +++ b/public/app/features/dashboard-scene/edit-pane/VizPanelEditableElement.tsx @@ -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); } diff --git a/public/app/features/dashboard-scene/edit-pane/shared.ts b/public/app/features/dashboard-scene/edit-pane/shared.ts index 9a0e0561368..a53b35208b6 100644 --- a/public/app/features/dashboard-scene/edit-pane/shared.ts +++ b/public/app/features/dashboard-scene/edit-pane/shared.ts @@ -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 { static type = 'new-object-added-to-canvas'; } diff --git a/public/app/features/dashboard-scene/edit-pane/useOutlineRename.tsx b/public/app/features/dashboard-scene/edit-pane/useOutlineRename.tsx new file mode 100644 index 00000000000..81f3a4556f0 --- /dev/null +++ b/public/app/features/dashboard-scene/edit-pane/useOutlineRename.tsx @@ -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({}); + + 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) => { + 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, + }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx index 1e80ce97156..a7bcb4a6bea 100644 --- a/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx +++ b/public/app/features/dashboard-scene/panel-edit/getPanelFrameOptions.tsx @@ -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) }); } diff --git a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx index dd83c4b4604..4ba6753075c 100644 --- a/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-rows/RowItem.tsx @@ -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 }); } diff --git a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx index cb26022c2e2..39e1de86928 100644 --- a/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx +++ b/public/app/features/dashboard-scene/scene/layout-tabs/TabItem.tsx @@ -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 }); diff --git a/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts index 672a188b32b..b58d3f2ae80 100644 --- a/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts +++ b/public/app/features/dashboard-scene/scene/types/EditableDashboardElement.ts @@ -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 { diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx index d8a1945dc3c..d9236bb59e6 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.test.tsx @@ -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; diff --git a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx index 18e4382eca4..2d5152b175e 100644 --- a/public/app/features/dashboard-scene/settings/VariablesEditView.tsx +++ b/public/app/features/dashboard-scene/settings/VariablesEditView.tsx @@ -244,7 +244,6 @@ function VariableEditorSettingsListView({ model }: SceneComponentProps ); } @@ -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} /> diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx index 9a86aff4c2d..7c0da2add37 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditableElement.tsx @@ -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: () => , }) ) @@ -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 variable.setState({ name: e.currentTarget.value })} />; + const [nameError, setNameError] = useState(); + const [validName, setValidName] = useState(variable.state.name); + + const onChange = (e: FormEvent) => { + 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 ( + + + + ); } function VariableLabelInput({ variable }: VariableInputProps) { diff --git a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx index f4a7d5b65ed..7dc5e74c9ce 100644 --- a/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx +++ b/public/app/features/dashboard-scene/settings/variables/VariableEditorForm.tsx @@ -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(null); - const { name, type, label, description, hide, key } = variable.useState(); + const [nameError, setNameError] = useState(); + 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) => { - 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) => { diff --git a/public/app/features/dashboard-scene/settings/variables/utils.test.ts b/public/app/features/dashboard-scene/settings/variables/utils.test.ts index c17d6350acd..777e5761351 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.test.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.test.ts @@ -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); + }); +}); diff --git a/public/app/features/dashboard-scene/settings/variables/utils.ts b/public/app/features/dashboard-scene/settings/variables/utils.ts index 7ad16d5b988..9b03c8e18f5 100644 --- a/public/app/features/dashboard-scene/settings/variables/utils.ts +++ b/public/app/features/dashboard-scene/settings/variables/utils.ts @@ -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 }; +}