Dashboard: Migration - Dashboard Settings Variables (List, Duplicate, Delete) (#78917)

Co-authored-by: Torkel Ödegaard <torkel@grafana.com>
This commit is contained in:
Alexa V 2023-12-14 12:54:15 +01:00 committed by GitHub
parent f3cdb44898
commit 4c6bbabc1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 601 additions and 42 deletions

View File

@ -36,7 +36,7 @@ export class DashboardSceneUrlSync implements SceneObjectUrlSyncHandler {
const update: Partial<DashboardSceneState> = {};
if (typeof values.editview === 'string' && meta.canEdit) {
update.editview = createDashboardEditViewFor(values.editview, this._scene.getRef());
update.editview = createDashboardEditViewFor(values.editview);
// If we are not in editing (for example after full page reload)
if (!isEditing) {

View File

@ -122,6 +122,25 @@ describe('transformSaveModelToScene', () => {
expect(scene.state.$behaviors![1]).toBeInstanceOf(behaviors.CursorSync);
expect((scene.state.$behaviors![1] as behaviors.CursorSync).state.sync).toEqual(DashboardCursorSync.Crosshair);
});
it('should initialize the Dashboard Scene with empty template variables', () => {
const dash = {
...defaultDashboard,
title: 'test empty dashboard with no variables',
uid: 'test-uid',
time: { from: 'now-10h', to: 'now' },
weekStart: 'saturday',
fiscalYearStartMonth: 2,
timezone: 'America/New_York',
templating: {
list: [],
},
};
const oldModel = new DashboardModel(dash);
const scene = createDashboardSceneFromDashboardModel(oldModel);
expect(scene.state.$variables?.state.variables).toBeDefined();
});
});
describe('when organizing panels as scene children', () => {

View File

@ -195,6 +195,11 @@ export function createDashboardSceneFromDashboardModel(oldModel: DashboardModel)
variables = new SceneVariableSet({
variables: variableObjects,
});
} else {
// Create empty variable set
variables = new SceneVariableSet({
variables: [],
});
}
if (oldModel.annotations?.list?.length) {

View File

@ -9,6 +9,7 @@ import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { EditListViewSceneUrlSync } from './EditListViewSceneUrlSync';
import { DashboardEditView, DashboardEditListViewState, useDashboardEditPageNav } from './utils';
@ -26,8 +27,8 @@ export class DashboardLinksEditView extends SceneObjectBase<DashboardLinksEditVi
}
function DashboardLinksEditViewRenderer({ model }: SceneComponentProps<DashboardLinksEditView>) {
const { dashboardRef, editIndex } = model.useState();
const dashboard = dashboardRef.resolve();
const { editIndex } = model.useState();
const dashboard = getDashboardSceneFor(model);
const links = dashboard.state.links || [];
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());

View File

@ -112,6 +112,7 @@ describe('GeneralSettingsEditView', () => {
});
async function buildTestScene() {
const settings = new GeneralSettingsEditView({});
const dashboard = new DashboardScene({
$timeRange: new SceneTimeRange({}),
$behaviors: [new behaviors.CursorSync({ sync: DashboardCursorSync.Off })],
@ -143,10 +144,7 @@ async function buildTestScene() {
}),
],
}),
});
const settings = new GeneralSettingsEditView({
dashboardRef: dashboard.getRef(),
editview: settings,
});
activateFullSceneTree(dashboard);

View File

@ -1,14 +1,7 @@
import React, { ChangeEvent } from 'react';
import { PageLayoutType } from '@grafana/data';
import {
behaviors,
SceneComponentProps,
SceneObjectBase,
SceneObjectRef,
SceneTimePicker,
sceneGraph,
} from '@grafana/scenes';
import { behaviors, SceneComponentProps, SceneObjectBase, SceneTimePicker, sceneGraph } from '@grafana/scenes';
import { TimeZone } from '@grafana/schema';
import {
Box,
@ -31,12 +24,11 @@ import { DashboardControls } from '../scene/DashboardControls';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { dashboardSceneGraph } from '../utils/dashboardSceneGraph';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
export interface GeneralSettingsEditViewState extends DashboardEditViewState {
dashboardRef: SceneObjectRef<DashboardScene>;
}
export interface GeneralSettingsEditViewState extends DashboardEditViewState {}
const EDITABLE_OPTIONS = [
{ label: 'Editable', value: true },
@ -54,7 +46,7 @@ export class GeneralSettingsEditView
implements DashboardEditView
{
private get _dashboard(): DashboardScene {
return this.state.dashboardRef.resolve();
return getDashboardSceneFor(this);
}
public getUrlKey(): string {

View File

@ -0,0 +1,148 @@
import { SceneVariableSet, CustomVariable, SceneGridItem, SceneGridLayout } from '@grafana/scenes';
import { DashboardScene } from '../scene/DashboardScene';
import { activateFullSceneTree } from '../utils/test-utils';
import { VariablesEditView } from './VariablesEditView';
describe('VariablesEditView', () => {
describe('Dashboard Variables state', () => {
let dashboard: DashboardScene;
let variableView: VariablesEditView;
beforeEach(async () => {
const result = await buildTestScene();
dashboard = result.dashboard;
variableView = result.variableView;
});
it('should return the correct urlKey', () => {
expect(variableView.getUrlKey()).toBe('variables');
});
it('should return the dashboard', () => {
expect(variableView.getDashboard()).toBe(dashboard);
});
it('should return the list of variables', () => {
const expectedVariables = [
{
type: 'custom',
name: 'customVar',
query: 'test, test2',
value: 'test',
},
{
type: 'custom',
name: 'customVar2',
query: 'test3, test4',
value: 'test3',
},
];
const variables = variableView.getVariables();
expect(variables).toHaveLength(2);
expect(variables[0].state).toMatchObject(expectedVariables[0]);
expect(variables[1].state).toMatchObject(expectedVariables[1]);
});
});
describe('Dashboard Variables actions', () => {
let variableView: VariablesEditView;
beforeEach(async () => {
const result = await buildTestScene();
variableView = result.variableView;
});
it('should duplicate a variable', () => {
const variables = variableView.getVariables();
const variable = variables[0];
variableView.onDuplicated(variable.state.name);
expect(variableView.getVariables()).toHaveLength(3);
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar');
});
it('should handle name when duplicating a variable twice', () => {
const variableIdentifier = 'customVar';
variableView.onDuplicated(variableIdentifier);
variableView.onDuplicated(variableIdentifier);
expect(variableView.getVariables()).toHaveLength(4);
expect(variableView.getVariables()[1].state.name).toBe('copy_of_customVar_1');
expect(variableView.getVariables()[2].state.name).toBe('copy_of_customVar');
});
it('should delete a variable', () => {
const variableIdentifier = 'customVar';
variableView.onDelete(variableIdentifier);
expect(variableView.getVariables()).toHaveLength(1);
expect(variableView.getVariables()[0].state.name).toBe('customVar2');
});
it('should change order of variables', () => {
const fromIndex = 0; // customVar is first
const toIndex = 1;
variableView.onOrderChanged(fromIndex, toIndex);
expect(variableView.getVariables()[0].state.name).toBe('customVar2');
expect(variableView.getVariables()[1].state.name).toBe('customVar');
});
it('should keep the same order of variables with invalid indexes', () => {
const fromIndex = 0;
const toIndex = 2;
const errorSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
variableView.onOrderChanged(fromIndex, toIndex);
expect(errorSpy).toHaveBeenCalledTimes(1);
expect(variableView.getVariables()[0].state.name).toBe('customVar');
expect(variableView.getVariables()[1].state.name).toBe('customVar2');
errorSpy.mockRestore();
});
});
});
async function buildTestScene() {
const variableView = new VariablesEditView({});
const dashboard = new DashboardScene({
title: 'Dashboard with variables',
uid: 'dash-variables',
meta: {
canEdit: true,
},
$variables: new SceneVariableSet({
variables: [
new CustomVariable({
name: 'customVar',
query: 'test, test2',
}),
new CustomVariable({
name: 'customVar2',
query: 'test3, test4',
}),
],
}),
body: new SceneGridLayout({
children: [
new SceneGridItem({
key: 'griditem-1',
x: 0,
y: 0,
width: 10,
height: 12,
body: undefined,
}),
],
}),
editview: variableView,
});
activateFullSceneTree(dashboard);
await new Promise((r) => setTimeout(r, 1));
dashboard.onEnterEditMode();
variableView.activate();
return { dashboard, variableView };
}

View File

@ -1,30 +1,133 @@
import React from 'react';
import { PageLayoutType } from '@grafana/data';
import { SceneComponentProps, SceneObjectBase } from '@grafana/scenes';
import { SceneComponentProps, SceneObjectBase, SceneVariables, sceneGraph } from '@grafana/scenes';
import { Page } from 'app/core/components/Page/Page';
import { DashboardScene } from '../scene/DashboardScene';
import { NavToolbarActions } from '../scene/NavToolbarActions';
import { getDashboardSceneFor } from '../utils/utils';
import { DashboardEditView, DashboardEditViewState, useDashboardEditPageNav } from './utils';
import { VariableEditorList } from './variables/VariableEditorList';
export interface VariablesEditViewState extends DashboardEditViewState {}
export class VariablesEditView extends SceneObjectBase<VariablesEditViewState> implements DashboardEditView {
public static Component = VariableEditorSettingsListView;
public getUrlKey(): string {
return 'variables';
}
static Component = ({ model }: SceneComponentProps<VariablesEditView>) => {
const dashboard = getDashboardSceneFor(model);
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
public getDashboard(): DashboardScene {
return getDashboardSceneFor(this);
}
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<div>variables todo</div>
</Page>
);
public getVariableSet(): SceneVariables {
return sceneGraph.getVariables(this.getDashboard());
}
private getVariableIndex = (identifier: string) => {
const variables = this.getVariables();
return variables.findIndex((variable) => variable.state.name === identifier);
};
public onDelete = (identifier: string) => {
// Find the index of the variable to be deleted
const variableIndex = this.getVariableIndex(identifier);
const { variables } = this.getVariableSet().state;
if (variableIndex === -1) {
// Handle the case where the variable is not found
console.error('Variable not found');
return;
}
// Create a new array excluding the variable to be deleted
const updatedVariables = [...variables.slice(0, variableIndex), ...variables.slice(variableIndex + 1)];
// Update the state or the variables array
this.getVariableSet().setState({ variables: updatedVariables });
};
public getVariables() {
return this.getVariableSet().state.variables;
}
public onDuplicated = (identifier: string) => {
const variableIndex = this.getVariableIndex(identifier);
const variables = this.getVariableSet().state.variables;
if (variableIndex === -1) {
console.error('Variable not found');
return;
}
const originalVariable = variables[variableIndex];
let copyNumber = 0;
let newName = `copy_of_${originalVariable.state.name}`;
// Check if the name is unique, if not, increment the copy number
while (variables.some((v) => v.state.name === newName)) {
copyNumber++;
newName = `copy_of_${originalVariable.state.name}_${copyNumber}`;
}
//clone the original variable
const newVariable = originalVariable.clone(originalVariable.state);
// update state name of the new variable
newVariable.setState({ name: newName });
const updatedVariables = [
...variables.slice(0, variableIndex + 1),
newVariable,
...variables.slice(variableIndex + 1),
];
this.getVariableSet().setState({ variables: updatedVariables });
};
public onOrderChanged = (fromIndex: number, toIndex: number) => {
const variables = this.getVariableSet().state.variables;
if (!this.getVariableSet()) {
return;
}
// check the index are within the variables array
if (fromIndex < 0 || fromIndex >= variables.length || toIndex < 0 || toIndex >= variables.length) {
console.error('Invalid index');
return;
}
const updatedVariables = [...variables];
// Remove the variable from the array
const movedItem = updatedVariables.splice(fromIndex, 1);
updatedVariables.splice(toIndex, 0, movedItem[0]);
const variablesScene = this.getVariableSet();
variablesScene.setState({ variables: updatedVariables });
};
public onEdit = (identifier: string) => {
return 'not implemented';
};
}
function VariableEditorSettingsListView({ model }: SceneComponentProps<VariablesEditView>) {
const dashboard = model.getDashboard();
const { navModel, pageNav } = useDashboardEditPageNav(dashboard, model.getUrlKey());
// get variables from dashboard state
const { onDelete, onDuplicated, onOrderChanged, onEdit } = model;
const { variables } = model.getVariableSet().useState();
return (
<Page navModel={navModel} pageNav={pageNav} layout={PageLayoutType.Standard}>
<NavToolbarActions dashboard={dashboard} />
<VariableEditorList
variables={variables}
onDelete={onDelete}
onDuplicate={onDuplicated}
onChangeOrder={onOrderChanged}
onAdd={() => {}}
onEdit={onEdit}
/>
</Page>
);
}

View File

@ -1,7 +1,7 @@
import { useLocation } from 'react-router-dom';
import { locationUtil, NavModelItem } from '@grafana/data';
import { SceneObject, SceneObjectRef, SceneObjectState } from '@grafana/scenes';
import { SceneObject, SceneObjectState } from '@grafana/scenes';
import { t } from 'app/core/internationalization';
import { getNavModel } from 'app/core/selectors/navModel';
import { useSelector } from 'app/types';
@ -13,9 +13,7 @@ import { DashboardLinksEditView } from './DashboardLinksEditView';
import { GeneralSettingsEditView } from './GeneralSettingsEditView';
import { VariablesEditView } from './VariablesEditView';
export interface DashboardEditViewState extends SceneObjectState {
dashboardRef: SceneObjectRef<DashboardScene>;
}
export interface DashboardEditViewState extends SceneObjectState {}
export interface DashboardEditListViewState extends DashboardEditViewState {
/** Index of the list item to edit */
@ -63,19 +61,16 @@ export function useDashboardEditPageNav(dashboard: DashboardScene, currentEditVi
return { navModel, pageNav };
}
export function createDashboardEditViewFor(
editview: string,
dashboardRef: SceneObjectRef<DashboardScene>
): DashboardEditView {
export function createDashboardEditViewFor(editview: string): DashboardEditView {
switch (editview) {
case 'annotations':
return new AnnotationsEditView({ dashboardRef });
return new AnnotationsEditView({});
case 'variables':
return new VariablesEditView({ dashboardRef });
return new VariablesEditView({});
case 'links':
return new DashboardLinksEditView({ dashboardRef });
return new DashboardLinksEditView({});
case 'settings':
default:
return new GeneralSettingsEditView({ dashboardRef });
return new GeneralSettingsEditView({});
}
}

View File

@ -0,0 +1,125 @@
import { css } from '@emotion/css';
import React, { ReactElement } from 'react';
import { DragDropContext, Droppable, DropResult } from 'react-beautiful-dnd';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { SceneVariable, SceneVariableState } from '@grafana/scenes';
import { useStyles2, Stack } from '@grafana/ui';
import EmptyListCTA from 'app/core/components/EmptyListCTA/EmptyListCTA';
import { VariableEditorListRow } from './VariableEditorListRow';
export interface Props {
variables: Array<SceneVariable<SceneVariableState>>;
onAdd: () => void;
onChangeOrder: (fromIndex: number, toIndex: number) => void;
onDuplicate: (identifier: string) => void;
onDelete: (identifier: string) => void;
onEdit: (identifier: string) => void;
}
export function VariableEditorList({
variables,
onChangeOrder,
onDelete,
onDuplicate,
onAdd,
onEdit,
}: Props): ReactElement {
const styles = useStyles2(getStyles);
const onDragEnd = (result: DropResult) => {
if (!result.destination || !result.source) {
return;
}
reportInteraction('Variable drag and drop');
onChangeOrder(result.source.index, result.destination.index);
};
return (
<div>
<div>
{variables.length === 0 && <EmptyVariablesList onAdd={onAdd} />}
{variables.length > 0 && (
<Stack direction="column" gap={4}>
<div className={styles.tableContainer}>
<table
className="filter-table filter-table--hover"
data-testid={selectors.pages.Dashboard.Settings.Variables.List.table}
role="grid"
>
<thead>
<tr>
<th>Variable</th>
<th>Definition</th>
<th colSpan={5} />
</tr>
</thead>
<DragDropContext onDragEnd={onDragEnd}>
<Droppable droppableId="variables-list" direction="vertical">
{(provided) => (
<tbody ref={provided.innerRef} {...provided.droppableProps}>
{variables.map((variableScene, index) => {
const variableState = variableScene.state;
return (
<VariableEditorListRow
index={index}
key={`${variableState.name}-${index}`}
variable={variableScene}
onDelete={onDelete}
onDuplicate={onDuplicate}
onEdit={onEdit}
/>
);
})}
{provided.placeholder}
</tbody>
)}
</Droppable>
</DragDropContext>
</table>
</div>
</Stack>
)}
</div>
</div>
);
}
function EmptyVariablesList({ onAdd }: { onAdd: () => void }): ReactElement {
return (
<div>
<EmptyListCTA
title="There are no variables yet"
buttonIcon="calculator-alt"
buttonTitle="Add variable"
buttonDisabled
infoBox={{
__html: ` <p>
Variables enable more interactive and dynamic dashboards. Instead of hard-coding things like server
or sensor names in your metric queries you can use variables in their place. Variables are shown as
list boxes at the top of the dashboard. These drop-down lists make it easy to change the data
being displayed in your dashboard. Check out the
<a class="external-link" href="https://grafana.com/docs/grafana/latest/variables/" target="_blank">
Templates and variables documentation
</a>
for more information.
</p>`,
}}
infoBoxTitle="What do variables do?"
onClick={(event) => {
event.preventDefault();
onAdd();
}}
/>
</div>
);
}
const getStyles = () => ({
tableContainer: css({
overflow: 'scroll',
width: '100%',
}),
});

View File

@ -0,0 +1,173 @@
import { css } from '@emotion/css';
import React, { ReactElement, useState } from 'react';
import { Draggable } from 'react-beautiful-dnd';
import { GrafanaTheme2 } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { reportInteraction } from '@grafana/runtime';
import { QueryVariable, SceneVariable } from '@grafana/scenes';
import { Button, ConfirmModal, Icon, IconButton, useStyles2, useTheme2 } from '@grafana/ui';
import { hasOptions } from 'app/features/variables/guard';
export interface VariableEditorListRowProps {
index: number;
variable: SceneVariable;
onEdit: (identifier: string) => void;
onDuplicate: (identifier: string) => void;
onDelete: (identifier: string) => void;
}
export function VariableEditorListRow({
index,
variable,
onEdit: propsOnEdit,
onDuplicate: propsOnDuplicate,
onDelete: propsOnDelete,
}: VariableEditorListRowProps): ReactElement {
const theme = useTheme2();
const styles = useStyles2(getStyles);
const definition = getDefinition(variable);
const variableState = variable.state;
const identifier = variableState.name;
const [showDeleteModal, setShowDeleteModal] = useState(false);
const handleDeleteVariableModal = (show: boolean) => () => {
setShowDeleteModal(show);
};
const onDeleteVariable = () => {
reportInteraction('Delete variable');
propsOnDelete(identifier);
};
return (
<Draggable draggableId={JSON.stringify(identifier)} index={index}>
{(provided, snapshot) => (
<tr
ref={provided.innerRef}
{...provided.draggableProps}
style={{
userSelect: snapshot.isDragging ? 'none' : 'auto',
background: snapshot.isDragging ? theme.colors.background.secondary : undefined,
...provided.draggableProps.style,
}}
>
<td role="gridcell" className={styles.column}>
<Button
size="xs"
fill="text"
onClick={(event) => {
event.preventDefault();
propsOnEdit(identifier);
}}
className={styles.nameLink}
data-testid={selectors.pages.Dashboard.Settings.Variables.List.tableRowNameFields(variableState.name)}
>
{variableState.name}
</Button>
</td>
<td
role="gridcell"
className={styles.definitionColumn}
onClick={(event) => {
event.preventDefault();
propsOnEdit(identifier);
}}
data-testid={selectors.pages.Dashboard.Settings.Variables.List.tableRowDefinitionFields(variableState.name)}
>
{definition}
</td>
<td role="gridcell" className={styles.column}>
<div className={styles.icons}>
<IconButton
onClick={(event) => {
event.preventDefault();
reportInteraction('Duplicate variable');
propsOnDuplicate(identifier);
}}
name="copy"
tooltip="Duplicate variable"
data-testid={selectors.pages.Dashboard.Settings.Variables.List.tableRowDuplicateButtons(
variableState.name
)}
/>
<IconButton
onClick={(event) => {
event.preventDefault();
setShowDeleteModal(true);
}}
name="trash-alt"
tooltip="Remove variable"
data-testid={selectors.pages.Dashboard.Settings.Variables.List.tableRowRemoveButtons(
variableState.name
)}
/>
<ConfirmModal
isOpen={showDeleteModal}
title="Delete variable"
body={`Are you sure you want to delete: ${variableState.name}?`}
confirmText="Delete variable"
onConfirm={onDeleteVariable}
onDismiss={handleDeleteVariableModal(false)}
/>
<div {...provided.dragHandleProps} className={styles.dragHandle}>
<Icon name="draggabledots" size="lg" />
</div>
</div>
</td>
</tr>
)}
</Draggable>
);
}
function getDefinition(model: SceneVariable): string {
let definition = '';
if (model instanceof QueryVariable) {
if (model.state.definition) {
definition = model.state.definition;
} else if (typeof model.state.query === 'string') {
definition = model.state.query;
}
} else if (hasOptions(model.state)) {
definition = model.state.query;
}
return definition;
}
function getStyles(theme: GrafanaTheme2) {
return {
dragHandle: css({
cursor: 'grab',
marginLeft: theme.spacing(1),
}),
column: css({
width: '1%',
}),
nameLink: css({
cursor: 'pointer',
color: theme.colors.primary.text,
}),
definitionColumn: css({
width: '100%',
maxWidth: '200px',
cursor: 'pointer',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
iconPassed: css({
color: theme.v1.palette.greenBase,
marginRight: theme.spacing(2),
}),
iconFailed: css({
color: theme.v1.palette.orange,
marginRight: theme.spacing(2),
}),
icons: css({
display: 'flex',
gap: theme.spacing(2),
alignItems: 'center',
}),
};
}