diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx index 65207ab6387..441b4262484 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx @@ -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, customOptions?: Record) => 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 ( + + ); + } + if (!selectedItem) { return (
@@ -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
{renderContent()}
; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx index 7c4b3553ff6..0669dcc0a87 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailViewHeader.tsx @@ -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(null); const [isSavedQueriesDrawerOpen, setIsSavedQueriesDrawerOpen] = useState(false); + const [showHelp, setShowHelp] = useState(false); + const [showDebug, setShowDebug] = useState(false); + const [transformInput, setTransformInput] = useState([]); + const [transformOutput, setTransformOutput] = useState([]); // 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 (
@@ -422,6 +497,18 @@ export const DetailViewHeader = ({ {/* Right side: Actions Menu for transformations */} {selectedItem.type === 'transform' && ( + setShowHelp(true)} + /> + setShowDebug(!showDebug)} + /> @@ -471,6 +558,51 @@ export const DetailViewHeader = ({ } /> )} + + {/* Transformation Help Drawer */} + {selectedItem.type === 'transform' && transformerInfo && showHelp && ( + setShowHelp(false)} + > + + + )} + + {/* Transformation Debug Drawer */} + {selectedItem.type === 'transform' && showDebug && ( + setShowDebug(false)} + > +
+
+
+ Input data +
+
+ +
+
+
+ +
+
+
+ Output data +
+
+ +
+
+
+
+ )}
); }; @@ -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), + }), }; }; diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx index 80680aa4e1c..b7438c299f8 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -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} /> diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index a823006c79f..9e1e0ec7e9a 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -100,6 +100,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { open: false, index: null, }); + const [isAddingTransform, setIsAddingTransform] = useState(false); const panel = panelRef.resolve(); @@ -197,6 +198,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { const handleSelect = useCallback((id: string) => { setSelectedId(id); + setIsAddingTransform(false); }, []); const updateQuerySelectionOnStateChange = useCallback( @@ -236,6 +238,16 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { [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) { setSelectedId(!!newTransform ? transformItemId(selectedIndex) : null); setTransformDrawerState({ open: false, index: null }); + setIsAddingTransform(false); unsub.unsubscribe(); }); @@ -429,7 +442,10 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { 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) { tabs={tabs} onRemoveTransform={handleRemoveTransform} onToggleTransformVisibility={handleToggleTransformVisibility} + isAddingTransform={isAddingTransform} + onAddTransformation={handleAddTransform} + onCancelAddTransform={() => setIsAddingTransform(false)} + transformationData={series} + onGoToQueries={handleGoToQueries} />
diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationPickerView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationPickerView.tsx new file mode 100644 index 00000000000..12c54b6b6bc --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/TransformationPickerView.tsx @@ -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, customOptions?: Record) => 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(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) => 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}    + setSearch('')} + tooltip={t('dashboard-scene.transformation-picker-view.clear-search', 'Clear search')} + /> + + ) : undefined; + + return ( +
+
+ +

+ Add transformation +

+ +
+
+ {showAll && ( + <> +
+
+ + + + Show images + + setShowIllustrations(!showIllustrations)} /> + +
+ + {filterCategoriesLabels.map(([slug, label]) => ( + setSelectedFilter(slug)} + label={label} + selected={selectedFilter === slug} + /> + ))} + +
+ + )} + + + {showAll ? ( + // Show all transformations when "show more" clicked + transformations.length === 0 ? ( +
+

+ + No transformations found + +

+
+ ) : ( + + {transformations.map((transform) => ( + + ))} + + ) + ) : ( + // Show empty state with featured transformations when not searching + setShowAll(true)} + onAddTransformation={handleAddTransformation} + onGoToQueries={onGoToQueries} + showIllustrations={true} + /> + )} +
+
+
+ ); +} + +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, + }), + }; +};