chore: alex - transformation ui improvements

This commit is contained in:
Alex Spencer
2025-12-04 07:28:35 -08:00
parent d185b73e41
commit c731335e98
5 changed files with 481 additions and 12 deletions
@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { memo, useCallback } from 'react';
import { GrafanaTheme2 } from '@grafana/data';
import { DataFrame, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { VizPanel } from '@grafana/scenes';
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
@@ -11,6 +11,7 @@ import { DetailViewHeader } from './DetailViewHeader';
import { ExpressionDetailView } from './ExpressionDetailView';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
import { QueryDetailView } from './QueryDetailView';
import { TransformationPickerView } from './TransformationPickerView';
import { TabId, QueryTransformItem } from './types';
interface DetailViewProps {
@@ -19,13 +20,41 @@ interface DetailViewProps {
tabs: Array<{ tabId: TabId }>;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
isAddingTransform?: boolean;
onAddTransformation?: (selectedItem: SelectableValue<string>, customOptions?: Record<string, unknown>) => void;
onCancelAddTransform?: () => void;
transformationData?: DataFrame[];
onGoToQueries?: () => void;
}
export const DetailView = memo(
({ selectedItem, panel, tabs, onRemoveTransform, onToggleTransformVisibility }: DetailViewProps) => {
({
selectedItem,
panel,
tabs,
onRemoveTransform,
onToggleTransformVisibility,
isAddingTransform,
onAddTransformation,
onCancelAddTransform,
transformationData,
onGoToQueries,
}: DetailViewProps) => {
const styles = useStyles2(getStyles);
const renderContent = useCallback(() => {
// Show transformation picker when in add mode
if (isAddingTransform && onAddTransformation && onCancelAddTransform) {
return (
<TransformationPickerView
data={transformationData || []}
onAddTransformation={onAddTransformation}
onCancel={onCancelAddTransform}
onGoToQueries={onGoToQueries}
/>
);
}
if (!selectedItem) {
return (
<div className={styles.emptyState}>
@@ -82,7 +111,19 @@ export const DetailView = memo(
}
return null;
}, [selectedItem, panel, tabs, styles.emptyState, onRemoveTransform, onToggleTransformVisibility]);
}, [
selectedItem,
panel,
tabs,
styles.emptyState,
onRemoveTransform,
onToggleTransformVisibility,
isAddingTransform,
onAddTransformation,
onCancelAddTransform,
transformationData,
onGoToQueries,
]);
return <div className={styles.container}>{renderContent()}</div>;
}
@@ -1,29 +1,38 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { mergeMap } from 'rxjs/operators';
import {
CoreApp,
DataFrame,
DataQuery,
DataSourceInstanceSettings,
DataTransformerConfig,
DataTransformContext,
GrafanaTheme2,
standardTransformersRegistry,
transformDataFrame,
} from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { t, Trans } from '@grafana/i18n';
import { getDataSourceSrv, getTemplateSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import {
Button,
Drawer,
Dropdown,
FieldValidationMessage,
Icon,
IconButton,
Input,
JSONFormatter,
Menu,
Stack,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { OperationRowHelp } from 'app/core/components/QueryOperationRow/OperationRowHelp';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { FALLBACK_DOCS_LINK } from 'app/features/transformers/docs/constants';
import { getQueryRunnerFor } from '../../utils/utils';
@@ -65,6 +74,10 @@ export const DetailViewHeader = ({
const [isEditing, setIsEditing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [isSavedQueriesDrawerOpen, setIsSavedQueriesDrawerOpen] = useState(false);
const [showHelp, setShowHelp] = useState(false);
const [showDebug, setShowDebug] = useState(false);
const [transformInput, setTransformInput] = useState<DataFrame[]>([]);
const [transformOutput, setTransformOutput] = useState<DataFrame[]>([]);
// Helper to update queries with consistent pattern
const updateQueries = useCallback(
@@ -293,16 +306,78 @@ export const DetailViewHeader = ({
const isTransformDisabled =
selectedItem.type === 'transform' && 'disabled' in selectedItem.data && selectedItem.data.disabled;
// Get transformation display name
const transformationName = useMemo(() => {
// Get transformation display name and transformer info
const transformerInfo = useMemo(() => {
if (selectedItem.type === 'transform' && 'id' in selectedItem.data) {
const transformId = selectedItem.data.id;
const transformer = standardTransformersRegistry.get(transformId);
return transformer?.name || transformId;
return transformer;
}
return '';
return undefined;
}, [selectedItem]);
const transformationName = transformerInfo?.name || '';
// Calculate transformation input/output for debug mode
useEffect(() => {
if (selectedItem.type !== 'transform' || !showDebug || !('disabled' in selectedItem.data)) {
return;
}
// Get the query runner for source data
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
// Get the source data (before any transformations)
const sourceData = queryRunner.state.data;
if (!sourceData?.series || sourceData.series.length === 0) {
setTransformInput([]);
setTransformOutput([]);
return;
}
// Get all transformations from the panel's data transformer
const $data = panel.state.$data;
if (!$data || !('state' in $data) || !('transformations' in $data.state)) {
return;
}
const transformations = $data.state.transformations;
if (!Array.isArray(transformations)) {
return;
}
const allTransformations: DataTransformerConfig[] = transformations;
const currentIndex = selectedItem.index;
// Get transformations before and including current one
const inputTransforms = allTransformations.slice(0, currentIndex);
const outputTransforms = allTransformations.slice(currentIndex, currentIndex + 1);
const ctx: DataTransformContext = {
interpolate: (v: string) => getTemplateSrv().replace(v),
};
// Input: Apply all transformations before this one to the source data
const inputSubscription = transformDataFrame(inputTransforms, sourceData.series, ctx).subscribe((frames) => {
setTransformInput(frames);
});
// Output: Apply input transforms, then apply the current transform to get the output
const outputSubscription = transformDataFrame(inputTransforms, sourceData.series, ctx)
.pipe(mergeMap((before) => transformDataFrame(outputTransforms, before, ctx)))
.subscribe((frames) => {
setTransformOutput(frames);
});
return () => {
inputSubscription.unsubscribe();
outputSubscription.unsubscribe();
};
}, [selectedItem, showDebug, panel]);
return (
<div className={styles.header}>
<div className={styles.headerContent}>
@@ -422,6 +497,18 @@ export const DetailViewHeader = ({
{/* Right side: Actions Menu for transformations */}
{selectedItem.type === 'transform' && (
<Stack gap={0.5} alignItems="center">
<IconButton
name="question-circle"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.show-documentation', 'Show documentation')}
onClick={() => setShowHelp(true)}
/>
<IconButton
name="bug"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.debug', 'Debug transformation')}
onClick={() => setShowDebug(!showDebug)}
/>
<Dropdown
overlay={
<Menu>
@@ -471,6 +558,51 @@ export const DetailViewHeader = ({
}
/>
)}
{/* Transformation Help Drawer */}
{selectedItem.type === 'transform' && transformerInfo && showHelp && (
<Drawer
title={transformerInfo.name}
subtitle={t('dashboard-scene.detail-view-header.transformation-help', 'Transformation help')}
onClose={() => setShowHelp(false)}
>
<OperationRowHelp
markdown={transformerInfo.help || FALLBACK_DOCS_LINK}
styleOverrides={{ borderTop: '2px solid' }}
/>
</Drawer>
)}
{/* Transformation Debug Drawer */}
{selectedItem.type === 'transform' && showDebug && (
<Drawer
title={t('dashboard-scene.detail-view-header.debug-transformation', 'Debug transformation')}
subtitle={transformationName}
onClose={() => setShowDebug(false)}
>
<div className={styles.debugWrapper}>
<div className={styles.debug}>
<div className={styles.debugTitle}>
<Trans i18nKey="dashboard-scene.detail-view-header.input-data">Input data</Trans>
</div>
<div className={styles.debugJson}>
<JSONFormatter json={transformInput} />
</div>
</div>
<div className={styles.debugSeparator}>
<Icon name="arrow-right" />
</div>
<div className={styles.debug}>
<div className={styles.debugTitle}>
<Trans i18nKey="dashboard-scene.detail-view-header.output-data">Output data</Trans>
</div>
<div className={styles.debugJson}>
<JSONFormatter json={transformOutput} />
</div>
</div>
</div>
</Drawer>
)}
</div>
);
};
@@ -493,6 +625,7 @@ const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
alignItems: 'center',
gap: theme.spacing(2),
height: '100%',
paddingLeft: theme.spacing(1),
}),
icon: css({
color: theme.colors.text.secondary,
@@ -548,5 +681,46 @@ const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
color: theme.colors.text.primary,
fontSize: theme.typography.body.fontSize,
}),
debugWrapper: css({
display: 'flex',
flexDirection: 'row',
}),
debugSeparator: css({
width: '48px',
minHeight: '300px',
display: 'flex',
alignItems: 'center',
alignSelf: 'stretch',
justifyContent: 'center',
margin: `0 ${theme.spacing(0.5)}`,
color: theme.colors.primary.text,
}),
debugTitle: css({
padding: `${theme.spacing(1)} ${theme.spacing(0.25)}`,
fontFamily: theme.typography.fontFamilyMonospace,
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.primary,
borderBottom: `1px solid ${theme.colors.border.weak}`,
flexGrow: 0,
flexShrink: 1,
}),
debug: css({
marginTop: theme.spacing(1),
padding: `0 ${theme.spacing(1, 1, 1)}`,
border: `1px solid ${theme.colors.border.weak}`,
background: `${theme.isLight ? theme.v1.palette.white : theme.v1.palette.gray05}`,
borderRadius: theme.shape.radius.default,
width: '100%',
minHeight: '300px',
display: 'flex',
flexDirection: 'column',
alignSelf: 'stretch',
}),
debugJson: css({
flexGrow: 1,
height: '100%',
overflow: 'hidden',
padding: theme.spacing(0.5),
}),
};
};
@@ -16,6 +16,7 @@ interface EmptyTransformationsProps {
onShowPicker: () => void;
onGoToQueries?: () => void;
onAddTransformation?: (transformationId: string) => void;
showIllustrations?: boolean;
}
const TRANSFORMATION_IDS = [
@@ -60,6 +61,7 @@ export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPick
export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) {
const hasGoToQueries = props.onGoToQueries != null;
const hasAddTransformation = props.onAddTransformation != null;
const showIllustrations = props.showIllustrations ?? true;
// Get transformations from registry
const transformations = useMemo(() => {
@@ -118,7 +120,7 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
key={transform.id}
transform={transform}
onClick={handleTransformationClick}
showIllustrations={true}
showIllustrations={showIllustrations}
showPluginState={false}
showTags={false}
/>
@@ -100,6 +100,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
open: false,
index: null,
});
const [isAddingTransform, setIsAddingTransform] = useState(false);
const panel = panelRef.resolve();
@@ -197,6 +198,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
setIsAddingTransform(false);
}, []);
const updateQuerySelectionOnStateChange = useCallback(
@@ -236,6 +238,16 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
[queriesTab, updateQuerySelectionOnStateChange, queries]
);
const handleGoToQueries = useCallback(() => {
// Close the transformation picker
setIsAddingTransform(false);
// Add a SQL expression
if (queriesTab) {
updateQuerySelectionOnStateChange(queries?.length ?? 0);
queriesTab.onAddExpressionOfType(ExpressionQueryType.sql);
}
}, [queriesTab, updateQuerySelectionOnStateChange, queries]);
const handleDuplicateQuery = useCallback(
(index: number) => {
if (queryRunner && queriesTab) {
@@ -343,6 +355,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
setSelectedId(!!newTransform ? transformItemId(selectedIndex) : null);
setTransformDrawerState({ open: false, index: null });
setIsAddingTransform(false);
unsub.unsubscribe();
});
@@ -429,7 +442,10 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => setSavedQueriesDrawerState({ open: true, index: index ?? null })}
onAddTransform={(index) => setTransformDrawerState({ open: true, index: index ?? null })}
onAddTransform={(index) => {
setIsAddingTransform(true);
setSelectedId(null);
}}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
@@ -470,6 +486,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
tabs={tabs}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
isAddingTransform={isAddingTransform}
onAddTransformation={handleAddTransform}
onCancelAddTransform={() => setIsAddingTransform(false)}
transformationData={series}
onGoToQueries={handleGoToQueries}
/>
</div>
</div>
@@ -0,0 +1,231 @@
import { css } from '@emotion/css';
import { FormEvent, useMemo, useState } from 'react';
import { DataFrame, GrafanaTheme2, SelectableValue, standardTransformersRegistry } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { t, Trans } from '@grafana/i18n';
import { reportInteraction } from '@grafana/runtime';
import { Box, FilterPill, Grid, IconButton, Input, ScrollContainer, Stack, Switch, useStyles2 } from '@grafana/ui';
import { getCategoriesLabels } from 'app/features/transformers/utils';
import { TransformationCard } from '../../../dashboard/components/TransformationsEditor/TransformationCard';
import { FilterCategory } from '../../../dashboard/components/TransformationsEditor/TransformationsEditor';
import { NewEmptyTransformationsMessage } from './EmptyTransformationsMessage';
const VIEW_ALL_VALUE = 'viewAll';
interface TransformationPickerViewProps {
data: DataFrame[];
onAddTransformation: (selectedItem: SelectableValue<string>, customOptions?: Record<string, unknown>) => void;
onCancel: () => void;
onGoToQueries?: () => void;
}
export function TransformationPickerView({
data,
onAddTransformation,
onCancel,
onGoToQueries,
}: TransformationPickerViewProps) {
const styles = useStyles2(getStyles);
const [search, setSearch] = useState('');
const [showAll, setShowAll] = useState(false);
const [showIllustrations, setShowIllustrations] = useState(false);
const [selectedFilter, setSelectedFilter] = useState<FilterCategory>(VIEW_ALL_VALUE);
const allTransformations = useMemo(
() => standardTransformersRegistry.list().sort((a, b) => (a.name > b.name ? 1 : b.name > a.name ? -1 : 0)),
[]
);
const filterCategoriesLabels: Array<[FilterCategory, string]> = useMemo(
() => [
[VIEW_ALL_VALUE, t('dashboard.transformation-picker-ng.view-all', 'View all')],
// eslint-disable-next-line @typescript-eslint/consistent-type-assertions
...(Object.entries(getCategoriesLabels()) as Array<[FilterCategory, string]>),
],
[]
);
const transformations = allTransformations.filter((t) => {
// Filter by category
if (selectedFilter && selectedFilter !== VIEW_ALL_VALUE && !t.categories?.has(selectedFilter)) {
return false;
}
// Filter by search
const searchLower = search.toLocaleLowerCase();
const textMatch =
t.name.toLocaleLowerCase().includes(searchLower) || t.description?.toLocaleLowerCase().includes(searchLower);
const tagMatch = t.tags?.size
? Array.from(t.tags).some((tag) => tag.toLocaleLowerCase().includes(searchLower))
: false;
return textMatch || tagMatch;
});
const onSearchChange = (e: FormEvent<HTMLInputElement>) => setSearch(e.currentTarget.value);
const handleAddTransformation = (transformationId: string) => {
reportInteraction('grafana_panel_transformations_clicked', {
type: transformationId,
context: 'transformation_picker_view',
});
onAddTransformation({ value: transformationId });
};
const searchBoxSuffix = search ? (
<>
{transformations.length} / {allTransformations.length} &nbsp;&nbsp;
<IconButton
name="times"
onClick={() => setSearch('')}
tooltip={t('dashboard-scene.transformation-picker-view.clear-search', 'Clear search')}
/>
</>
) : undefined;
return (
<div className={styles.container}>
<div className={styles.header}>
<Stack direction="row" gap={1} alignItems="center" justifyContent="space-between">
<h3 className={styles.title}>
<Trans i18nKey="dashboard-scene.transformation-picker-view.title">Add transformation</Trans>
</h3>
<IconButton
name="times"
onClick={onCancel}
tooltip={t('dashboard-scene.transformation-picker-view.close', 'Close')}
size="lg"
/>
</Stack>
</div>
{showAll && (
<>
<div className={styles.searchContainer}>
<div className={styles.searchWrapper}>
<Input
value={search}
onChange={onSearchChange}
placeholder={t(
'dashboard-scene.transformation-picker-view.search-placeholder',
'Search for transformation'
)}
suffix={search ? searchBoxSuffix : undefined}
autoFocus
data-testid={selectors.components.Transforms.searchInput}
className={styles.searchInput}
/>
<Stack direction="row" alignItems="center" gap={0.5}>
<span className={styles.switchLabel}>
<Trans i18nKey="dashboard.transformation-picker-ng.show-images">Show images</Trans>
</span>
<Switch value={showIllustrations} onChange={() => setShowIllustrations(!showIllustrations)} />
</Stack>
</div>
<Stack direction="row" wrap="wrap" rowGap={1} columnGap={0.5}>
{filterCategoriesLabels.map(([slug, label]) => (
<FilterPill
key={slug}
onClick={() => setSelectedFilter(slug)}
label={label}
selected={selectedFilter === slug}
/>
))}
</Stack>
</div>
</>
)}
<ScrollContainer>
<Box padding={2}>
{showAll ? (
// Show all transformations when "show more" clicked
transformations.length === 0 ? (
<div className={styles.noResults}>
<p>
<Trans i18nKey="dashboard-scene.transformation-picker-view.no-results">
No transformations found
</Trans>
</p>
</div>
) : (
<Grid columns={3} gap={1}>
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
showPluginState={false}
showTags={true}
onClick={handleAddTransformation}
data={data}
/>
))}
</Grid>
)
) : (
// Show empty state with featured transformations when not searching
<NewEmptyTransformationsMessage
onShowPicker={() => setShowAll(true)}
onAddTransformation={handleAddTransformation}
onGoToQueries={onGoToQueries}
showIllustrations={true}
/>
)}
</Box>
</ScrollContainer>
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => {
return {
container: css({
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
background: theme.colors.background.primary,
}),
header: css({
padding: theme.spacing(2, 2, 0, 2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
paddingBottom: theme.spacing(2),
}),
title: css({
margin: 0,
fontSize: theme.typography.h4.fontSize,
fontWeight: theme.typography.h4.fontWeight,
}),
searchContainer: css({
padding: theme.spacing(2),
borderBottom: `1px solid ${theme.colors.border.weak}`,
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
}),
searchWrapper: css({
display: 'flex',
flexWrap: 'wrap',
columnGap: theme.spacing(2),
rowGap: theme.spacing(1),
width: '100%',
paddingBottom: theme.spacing(1),
}),
searchInput: css({
flexGrow: 1,
width: 'initial',
}),
switchLabel: css({
whiteSpace: 'nowrap',
}),
noResults: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
height: '200px',
color: theme.colors.text.secondary,
fontSize: theme.typography.h5.fontSize,
}),
};
};