');
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 };
+}