feat: Update dynamic dashboards editors

This commit is contained in:
grafakus
2025-11-25 12:06:47 +01:00
parent 400f3a91d0
commit 18c4f5b875
7 changed files with 212 additions and 86 deletions
@@ -48,27 +48,7 @@ export function CustomVariableForm({
<Trans i18nKey="dashboard-scene.custom-variable-form.custom-options">Custom options</Trans>
</VariableLegend>
<Stack direction="row" gap={1}>
<RadioButtonGroup
value={valuesFormat}
onChange={onValuesFormatChange}
options={[
{
value: 'csv',
label: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
},
{
value: 'json',
label: t('dashboard-scene.custom-variable-form.name-json-values', 'Object values in a JSON array'),
},
]}
/>
{valuesFormat === 'json' && (
<Tooltip content={TooltipJsonFormat} placement="top" interactive>
<Icon name="info-circle" />
</Tooltip>
)}
</Stack>
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<VariableTextAreaField
// we don't use a controlled component so we make sure the textarea content is cleared when changing format by providing a key
@@ -108,16 +88,47 @@ export function CustomVariableForm({
);
}
function TooltipJsonFormat() {
interface ValuesFormatSelectorProps {
valuesFormat?: CustomVariableModel['valuesFormat'];
onValuesFormatChange?: (format: CustomVariableModel['valuesFormat']) => void;
}
export function ValuesFormatSelector({ valuesFormat, onValuesFormatChange }: ValuesFormatSelectorProps) {
return (
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
Provide a JSON representing an array of objects, where each object can have any number of properties.
<br />
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
<Stack direction="row" gap={1}>
<RadioButtonGroup
value={valuesFormat}
onChange={onValuesFormatChange}
options={[
{
value: 'csv',
label: t('dashboard-scene.custom-variable-form.name-values-separated-comma', 'Values separated by comma'),
},
{
value: 'json',
label: t('dashboard-scene.custom-variable-form.name-json-values', 'Object values in a JSON array'),
},
]}
/>
{valuesFormat === 'json' && (
<Tooltip
content={
<Trans i18nKey="dashboard-scene.custom-variable-form.json-values-tooltip">
Provide a JSON representing an array of objects, where each object can have any number of properties.
<br />
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
placement="top"
interactive
>
<Icon name="info-circle" />
</Tooltip>
)}
</Stack>
);
}
@@ -36,7 +36,13 @@ function VariableValuesWithPropsPreview({ options }: { options: VariableValueOpt
<Text variant="bodySmall" weight="medium">
<Trans i18nKey="dashboard-scene.variable-values-preview.preview-of-values">Preview of values</Trans>
</Text>
<InteractiveTable columns={columns} data={data} getRowId={(r) => String(r.value)} pageSize={10} />
<InteractiveTable
className={styles.table}
columns={columns}
data={data}
getRowId={(r) => String(r.value)}
pageSize={8}
/>
</div>
);
}
@@ -97,5 +103,10 @@ function getStyles(theme: GrafanaTheme2) {
textOverflow: 'ellipsis',
maxWidth: '50vw',
}),
table: css({
td: css({
padding: theme.spacing(0.5, 1),
}),
}),
};
}
@@ -23,6 +23,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onValuesFormatChange = useCallback(
(format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
@@ -33,7 +34,7 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
setPrevQuery(query);
}
},
[onRunQuery, prevQuery, query, variable]
[isMulti, onRunQuery, prevQuery, query, variable]
);
const onMultiChange = useCallback(
@@ -52,6 +53,8 @@ export function CustomVariableEditor({ variable, onRunQuery }: CustomVariableEdi
const onQueryChange = useCallback(
(event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
@@ -113,7 +116,7 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
];
}
const validateJsonQuery = (rawQuery: string): Error | undefined => {
export const validateJsonQuery = (rawQuery: string): Error | undefined => {
const query = rawQuery.trim();
if (!query) {
return;
@@ -1,15 +1,16 @@
import { useCallback, useRef } from 'react';
import { FormEvent, useCallback, useState } from 'react';
import { lastValueFrom } from 'rxjs';
import { CustomVariableModel } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { CustomVariable } from '@grafana/scenes';
import { Button, Modal, Stack } from '@grafana/ui';
import { Button, FieldValidationMessage, Modal, Stack, TextArea } from '@grafana/ui';
import { VariableStaticOptionsFormRef } from '../../components/VariableStaticOptionsForm';
import { VariableStaticOptionsFormAddButton } from '../../components/VariableStaticOptionsFormAddButton';
import { ValuesFormatSelector } from '../../components/CustomVariableForm';
import { VariableValuesPreview } from '../../components/VariableValuesPreview';
import { ValuesBuilder } from './ValuesBuilder';
import { ValuesPreview } from './ValuesPreview';
import { validateJsonQuery } from './CustomVariableEditor';
interface ModalEditorProps {
variable: CustomVariable;
@@ -18,9 +19,49 @@ interface ModalEditorProps {
}
export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
const formRef = useRef<VariableStaticOptionsFormRef | null>(null);
const { query, valuesFormat, isMulti } = variable.useState();
const [prevQuery, setPrevQuery] = useState('');
const [queryValidationError, setQueryValidationError] = useState<Error>();
const handleOnAdd = useCallback(() => formRef.current?.addItem(), []);
const onValuesFormatChange = useCallback(
async (format: CustomVariableModel['valuesFormat']) => {
variable.setState({ query: prevQuery });
variable.setState({ value: isMulti ? [] : undefined });
variable.setState({ valuesFormat: format });
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: undefined });
await lastValueFrom(variable.validateAndUpdate());
setQueryValidationError(undefined);
if (query !== prevQuery) {
setPrevQuery(query);
}
},
[isMulti, prevQuery, query, variable]
);
const onQueryChange = useCallback(
async (event: FormEvent<HTMLTextAreaElement>) => {
setPrevQuery('');
if (valuesFormat === 'json') {
const validationError = validateJsonQuery(event.currentTarget.value);
setQueryValidationError(validationError);
if (validationError) {
return;
}
}
variable.setState({ query: event.currentTarget.value });
await lastValueFrom(variable.validateAndUpdate());
},
[valuesFormat, variable]
);
const optionsForSelect = variable.getOptionsForSelect(false);
const hasJsonValuesFormat = variable.state.valuesFormat === 'json';
const hasMultiProps = hasJsonValuesFormat || optionsForSelect.every((o) => Boolean(o.properties));
return (
<Modal
@@ -29,10 +70,31 @@ export function ModalEditor({ variable, isOpen, onClose }: ModalEditorProps) {
onDismiss={onClose}
>
<Stack direction="column" gap={2}>
<ValuesBuilder variable={variable} ref={formRef} />
<ValuesPreview variable={variable} />
<ValuesFormatSelector valuesFormat={valuesFormat} onValuesFormatChange={onValuesFormatChange} />
<div>
<TextArea
id={valuesFormat}
key={valuesFormat}
rows={4}
defaultValue={query}
onBlur={onQueryChange}
placeholder={
valuesFormat === 'json'
? // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'[{ "text":"text1", "value":"val1", "propA":"a1", "propB":"b1" },\n{ "text":"text2", "value":"val2", "propA":"a2", "propB":"b2" }]'
: // eslint-disable-next-line @grafana/i18n/no-untranslated-strings
'1, 10, mykey : myvalue, myvalue, escaped\,value'
}
required
data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.CustomVariable.customValueInput}
/>
{queryValidationError && <FieldValidationMessage>{queryValidationError.message}</FieldValidationMessage>}
</div>
<div>
<VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />
</div>
</Stack>
<Modal.ButtonRow leftItems={<VariableStaticOptionsFormAddButton onAdd={handleOnAdd} />}>
<Modal.ButtonRow>
<Button
variant="secondary"
fill="outline"
@@ -1,4 +1,3 @@
import { t } from '@grafana/i18n';
import { CustomVariable, SceneVariable } from '@grafana/scenes';
import { OptionsPaneItemDescriptor } from '../../../../../dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
@@ -12,7 +11,6 @@ export function getCustomVariableOptions(variable: SceneVariable): OptionsPaneIt
return [
new OptionsPaneItemDescriptor({
title: t('dashboard.edit-pane.variable.custom-options.values', 'Values separated by comma'),
id: 'custom-variable-values',
render: ({ props }) => <PaneItem id={props.id} variable={variable} />,
}),
@@ -7,7 +7,7 @@ import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { QueryVariable, sceneGraph, SceneVariable } from '@grafana/scenes';
import { VariableRefresh, VariableSort } from '@grafana/schema';
import { Box, Button, Field, Modal, TextLink } from '@grafana/ui';
import { Box, Button, Field, Modal, Switch, TextLink } from '@grafana/ui';
import { OptionsPaneItemDescriptor } from 'app/features/dashboard/components/PanelEditor/OptionsPaneItemDescriptor';
import { QueryEditor } from 'app/features/dashboard-scene/settings/variables/components/QueryEditor';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
@@ -250,6 +250,19 @@ export function Editor({ variable }: { variable: QueryVariable }) {
const isHasVariableOptions = hasVariableOptions(variable);
// TODO: remove me after finished testing - each DS can/should implement their own UI
const [returnsMultiProps, setReturnsMultiProps] = useState(false);
const onChangeReturnsMultipleProps = (e: FormEvent<HTMLInputElement>) => {
setReturnsMultiProps(e.currentTarget.checked);
variable.setState({ allowCustomValue: false });
variable.setState({ allValue: '' });
variable.setState({ regex: '' });
onStaticOptionsChange?.([]);
};
const optionsForSelect = variable.getOptionsForSelect(false);
const hasMultiProps = returnsMultiProps || optionsForSelect.every((o) => Boolean(o.properties));
return (
<div data-testid={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.editor}>
<Field
@@ -261,43 +274,64 @@ export function Editor({ variable }: { variable: QueryVariable }) {
</Field>
{selectedDatasource && VariableQueryEditor && (
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
<Box marginBottom={2}>
<QueryEditor
onQueryChange={onQueryChange}
onLegacyQueryChange={onQueryChange}
datasource={selectedDatasource}
query={query}
VariableQueryEditor={VariableQueryEditor}
timeRange={timeRange}
/>
{/* TODO: remove me after finished testing - each DS can/should implement their own UI */}
<Field
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
label="Enable access to all the fields of the query results"
description={
<Trans i18nKey="">
Check{' '}
<TextLink href="https://grafana.com/docs/grafana/latest/variables/xxx" external>
our docs
</TextLink>{' '}
for more information.
</Trans>
}
noMargin
>
<Switch onChange={onChangeReturnsMultipleProps} />
</Field>
</Box>
)}
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
{!returnsMultiProps && (
<VariableTextAreaField
defaultValue={regex ?? ''}
name={t('dashboard-scene.query-variable-editor-form.name-regex', 'Regex')}
description={
<div>
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-optional">
Optional, if you want to extract part of a series name or metric node segment.
</Trans>
<br />
<Trans i18nKey="dashboard-scene.query-variable-editor-form.description-examples">
Named capture groups can be used to separate the display text and value (
<TextLink
href="https://grafana.com/docs/grafana/latest/variables/filter-variables-with-regex#filter-and-modify-using-named-text-and-value-capture-groups"
external
>
see examples
</TextLink>
).
</Trans>
</div>
}
// eslint-disable-next-line @grafana/i18n/no-untranslated-strings
placeholder="/.*-(?<text>.*)-(?<value>.*)-.*/"
onBlur={onRegExChange}
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsRegExInputV2}
width={52}
/>
)}
<QueryVariableSortSelect
testId={selectors.pages.Dashboard.Settings.Variables.Edit.QueryVariable.queryOptionsSortSelectV2}
@@ -311,7 +345,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
refresh={refresh}
/>
{onStaticOptionsChange && onStaticOptionsOrderChange && (
{!returnsMultiProps && onStaticOptionsChange && onStaticOptionsOrderChange && (
<QueryVariableStaticOptions
staticOptions={staticOptions}
staticOptionsOrder={staticOptionsOrder}
@@ -320,7 +354,7 @@ export function Editor({ variable }: { variable: QueryVariable }) {
/>
)}
{isHasVariableOptions && <VariableValuesPreview options={variable.getOptionsForSelect(false)} />}
{isHasVariableOptions && <VariableValuesPreview options={optionsForSelect} hasMultiProps={hasMultiProps} />}
</div>
);
}
@@ -45,7 +45,9 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'A wildcard regex or other value to represent All'
),
useShowIf: () => {
return variable.useState().includeAll ?? false;
const state = variable.useState();
const hasJsonValuesFormat = 'valuesFormat' in state && state.valuesFormat === 'json';
return hasJsonValuesFormat ? false : (state.includeAll ?? false);
},
render: (descriptor) => <CustomAllValueInput id={descriptor.props.id} variable={variable} />,
})
@@ -58,6 +60,11 @@ export function useVariableSelectionOptionsCategory(variable: MultiValueVariable
'dashboard.edit-pane.variable.selection-options.allow-custom-values-description',
'Enables users to enter values'
),
useShowIf: () => {
const state = variable.useState();
const hasJsonValuesFormat = 'valuesFormat' in state && state.valuesFormat === 'json';
return !hasJsonValuesFormat;
},
render: (descriptor) => <AllowCustomSwitch id={descriptor.props.id} variable={variable} />,
})
);