diff --git a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md index f9448baa35a..ff8bc85b14f 100644 --- a/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md +++ b/docs/sources/setup-grafana/configure-grafana/feature-toggles/index.md @@ -79,31 +79,32 @@ Most [generally available](https://grafana.com/docs/release-life-cycle/#general- [Public preview](https://grafana.com/docs/release-life-cycle/#public-preview) features are supported by our Support teams, but might be limited to enablement, configuration, and some troubleshooting. -| Feature toggle name | Description | -| ------------------------------- | ------------------------------------------------------------------------------------------------------ | -| `panelTitleSearch` | Search for dashboards using panel title | -| `grpcServer` | Run the GRPC server | -| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache | -| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained | -| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability | -| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | -| `reportingRetries` | Enables rendering retries for the reporting feature | -| `externalServiceAccounts` | Automatic service account and token setup for plugins | -| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | -| `pdfTables` | Enables generating table data as PDF in reporting | -| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | -| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | -| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. | -| `queryLibrary` | Enables Saved queries (query library) feature | -| `enableSCIM` | Enables SCIM support for user and group management | -| `alertRuleRestore` | Enables the alert rule restore feature | -| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source | -| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown | -| `logsPanelControls` | Enables a control component for the logs panel in Explore | -| `interactiveLearning` | Enables the interactive learning app | -| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | -| `newVizSuggestions` | Enable new visualization suggestions | -| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; | +| Feature toggle name | Description | +| --------------------------------- | ------------------------------------------------------------------------------------------------------ | +| `panelTitleSearch` | Search for dashboards using panel title | +| `grpcServer` | Run the GRPC server | +| `renderAuthJWT` | Uses JWT-based auth for rendering instead of relying on remote cache | +| `refactorVariablesTimeRange` | Refactor time range variables flow to reduce number of API calls made when query variables are chained | +| `faroDatasourceSelector` | Enable the data source selector within the Frontend Apps section of the Frontend Observability | +| `enableDatagridEditing` | Enables the edit functionality in the datagrid panel | +| `reportingRetries` | Enables rendering retries for the reporting feature | +| `externalServiceAccounts` | Automatic service account and token setup for plugins | +| `cloudWatchBatchQueries` | Runs CloudWatch metrics queries as separate batches | +| `pdfTables` | Enables generating table data as PDF in reporting | +| `canvasPanelPanZoom` | Allow pan and zoom in canvas panel | +| `alertingSaveStateCompressed` | Enables the compressed protobuf-based alert state storage. Default is enabled. | +| `sqlExpressions` | Enables SQL Expressions, which can execute SQL queries against data source results. | +| `queryLibrary` | Enables Saved queries (query library) feature | +| `enableSCIM` | Enables SCIM support for user and group management | +| `alertRuleRestore` | Enables the alert rule restore feature | +| `azureMonitorLogsBuilderEditor` | Enables the logs builder mode for the Azure Monitor data source | +| `localeFormatPreference` | Specifies the locale so the correct format for numbers and dates can be shown | +| `logsPanelControls` | Enables a control component for the logs panel in Explore | +| `interactiveLearning` | Enables the interactive learning app | +| `azureResourcePickerUpdates` | Enables the updated Azure Monitor resource picker | +| `newVizSuggestions` | Enable new visualization suggestions | +| `preventPanelChromeOverflow` | Restrict PanelChrome contents with overflow: hidden; | +| `transformationsEmptyPlaceholder` | Show transformation quick-start cards in empty transformations state | ## Development feature toggles diff --git a/packages/grafana-data/src/types/featureToggles.gen.ts b/packages/grafana-data/src/types/featureToggles.gen.ts index b5602037615..b9004cb62be 100644 --- a/packages/grafana-data/src/types/featureToggles.gen.ts +++ b/packages/grafana-data/src/types/featureToggles.gen.ts @@ -1200,4 +1200,8 @@ export interface FeatureToggles { * @default false */ awsDatasourcesHttpProxy?: boolean; + /** + * Show transformation quick-start cards in empty transformations state + */ + transformationsEmptyPlaceholder?: boolean; } diff --git a/packages/grafana-e2e-selectors/src/selectors/components.ts b/packages/grafana-e2e-selectors/src/selectors/components.ts index 240299aa3ea..c31b474588f 100644 --- a/packages/grafana-e2e-selectors/src/selectors/components.ts +++ b/packages/grafana-e2e-selectors/src/selectors/components.ts @@ -915,6 +915,10 @@ export const versionedComponents = { '10.1.0': 'data-testid add transformation button', [MIN_GRAFANA_VERSION]: 'add transformation button', }, + goToQueriesButton: { + '10.4.0': 'data-testid go to queries button', + [MIN_GRAFANA_VERSION]: 'go to queries button', + }, removeAllTransformationsButton: { '10.4.0': 'data-testid remove all transformations button', }, diff --git a/pkg/services/featuremgmt/registry.go b/pkg/services/featuremgmt/registry.go index 8ecc3a481e7..b14026fae4d 100644 --- a/pkg/services/featuremgmt/registry.go +++ b/pkg/services/featuremgmt/registry.go @@ -2082,6 +2082,13 @@ var ( Owner: awsDatasourcesSquad, Expression: "false", }, + { + Name: "transformationsEmptyPlaceholder", + Description: "Show transformation quick-start cards in empty transformations state", + Stage: FeatureStagePublicPreview, + FrontendOnly: true, + Owner: grafanaDataProSquad, + }, } ) diff --git a/pkg/services/featuremgmt/toggles_gen.csv b/pkg/services/featuremgmt/toggles_gen.csv index bba9ab351d2..52b5aed5803 100644 --- a/pkg/services/featuremgmt/toggles_gen.csv +++ b/pkg/services/featuremgmt/toggles_gen.csv @@ -267,3 +267,4 @@ panelTimeSettings,experimental,@grafana/dashboards-squad,false,false,false dashboardTemplates,experimental,@grafana/sharing-squad,false,false,false kubernetesAnnotations,experimental,@grafana/grafana-backend-services-squad,false,false,false awsDatasourcesHttpProxy,experimental,@grafana/aws-datasources,false,false,false +transformationsEmptyPlaceholder,preview,@grafana/datapro,false,false,true diff --git a/pkg/services/featuremgmt/toggles_gen.go b/pkg/services/featuremgmt/toggles_gen.go index 1f5f7b42489..76382b7f6c4 100644 --- a/pkg/services/featuremgmt/toggles_gen.go +++ b/pkg/services/featuremgmt/toggles_gen.go @@ -1077,4 +1077,8 @@ const ( // FlagAwsDatasourcesHttpProxy // Enables http proxy settings for aws datasources FlagAwsDatasourcesHttpProxy = "awsDatasourcesHttpProxy" + + // FlagTransformationsEmptyPlaceholder + // Show transformation quick-start cards in empty transformations state + FlagTransformationsEmptyPlaceholder = "transformationsEmptyPlaceholder" ) diff --git a/pkg/services/featuremgmt/toggles_gen.json b/pkg/services/featuremgmt/toggles_gen.json index 852e0e7f3b3..aa61dbe16c7 100644 --- a/pkg/services/featuremgmt/toggles_gen.json +++ b/pkg/services/featuremgmt/toggles_gen.json @@ -4108,6 +4108,22 @@ "expression": "true" } }, + { + "metadata": { + "name": "transformationsEmptyPlaceholder", + "resourceVersion": "1763373021129", + "creationTimestamp": "2025-11-11T13:14:25Z", + "annotations": { + "grafana.app/updatedTimestamp": "2025-11-17 09:50:21.129721 +0000 UTC" + } + }, + "spec": { + "description": "Show transformation quick-start cards in empty transformations state", + "stage": "preview", + "codeowner": "@grafana/datapro", + "frontend": true + } + }, { "metadata": { "name": "transformationsRedesign", diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx new file mode 100644 index 00000000000..4dab09c2d75 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.test.tsx @@ -0,0 +1,93 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { standardTransformersRegistry } from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; + +import { LegacyEmptyTransformationsMessage, NewEmptyTransformationsMessage } from './EmptyTransformationsMessage'; + +jest.mock('@grafana/runtime', () => ({ + ...jest.requireActual('@grafana/runtime'), +})); + +describe('EmptyTransformationsMessage', () => { + standardTransformersRegistry.setInit(getStandardTransformers); + + const onShowPicker = jest.fn(); + const onGoToQueries = jest.fn(); + const onAddTransformation = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('LegacyEmptyTransformationsMessage', () => { + it('should render the legacy empty state message', () => { + render(); + + expect(screen.getByText('Start transforming data')).toBeInTheDocument(); + expect(screen.getByText(/Transformations allow data to be changed in various ways/)).toBeInTheDocument(); + }); + + it('should call onShowPicker when "Add transformation" button is clicked', async () => { + const user = userEvent.setup(); + render(); + + const button = screen.getByTestId(selectors.components.Transforms.addTransformationButton); + await user.click(button); + + expect(onShowPicker).toHaveBeenCalledTimes(1); + }); + }); + + describe('NewEmptyTransformationsMessage', () => { + it('should render transformation cards when both onGoToQueries and onAddTransformation are provided', () => { + render( + + ); + + // Should show SQL transformation card + expect(screen.getByText('SQL Expressions')).toBeInTheDocument(); + expect(screen.getByText('Organize fields by name')).toBeInTheDocument(); + expect(screen.getByText('Group by')).toBeInTheDocument(); + expect(screen.getByText('Extract fields')).toBeInTheDocument(); + expect(screen.getByText('Filter data by values')).toBeInTheDocument(); + }); + + it('should call onShowPicker when "Show more" button is clicked', async () => { + const user = userEvent.setup(); + render( + + ); + + const button = screen.getByTestId(selectors.components.Transforms.addTransformationButton); + await user.click(button); + + expect(onShowPicker).toHaveBeenCalledTimes(1); + }); + + it('should not show SQL transformation card when onGoToQueries is not provided', () => { + render(); + + expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); + }); + + it('should not show transformation cards grid when neither onGoToQueries nor onAddTransformation are provided', () => { + render(); + + expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument(); + + // But should still show the "Show more" button + expect(screen.getByTestId(selectors.components.Transforms.addTransformationButton)).toBeInTheDocument(); + }); + }); +}); 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 5ac99573a2f..c5b069e577a 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/EmptyTransformationsMessage.tsx @@ -1,11 +1,31 @@ +import { useMemo } from 'react'; + +import { DataTransformerID, standardTransformersRegistry, TransformerRegistryItem } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; -import { Trans } from '@grafana/i18n'; -import { Box, Button, Stack, Text } from '@grafana/ui'; +import { t, Trans } from '@grafana/i18n'; +import { reportInteraction } from '@grafana/runtime'; +import { Box, Button, Grid, Stack, Text } from '@grafana/ui'; +import config from 'app/core/config'; + +import { SqlExpressionCard } from '../../../dashboard/components/TransformationsEditor/SqlExpressionCard'; +import { TransformationCard } from '../../../dashboard/components/TransformationsEditor/TransformationCard'; +import sqlDarkImage from '../../../transformers/images/dark/sqlExpression.svg'; +import sqlLightImage from '../../../transformers/images/light/sqlExpression.svg'; interface EmptyTransformationsProps { onShowPicker: () => void; + onGoToQueries?: () => void; + onAddTransformation?: (transformationId: string) => void; } -export function EmptyTransformationsMessage(props: EmptyTransformationsProps) { + +const TRANSFORMATION_IDS = [ + DataTransformerID.organize, + DataTransformerID.groupBy, + DataTransformerID.extractFields, + DataTransformerID.filterByValue, +]; + +export function LegacyEmptyTransformationsMessage({ onShowPicker }: { onShowPicker: () => void }) { return ( @@ -24,7 +44,7 @@ export function EmptyTransformationsMessage(props: EmptyTransformationsProps) { icon="plus" variant="primary" size="md" - onClick={props.onShowPicker} + onClick={onShowPicker} data-testid={selectors.components.Transforms.addTransformationButton} > Add transformation @@ -33,3 +53,92 @@ export function EmptyTransformationsMessage(props: EmptyTransformationsProps) { ); } + +export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps) { + const hasGoToQueries = props.onGoToQueries != null; + const hasAddTransformation = props.onAddTransformation != null; + + // Get transformations from registry + const transformations = useMemo(() => { + return standardTransformersRegistry.list().filter((t): t is TransformerRegistryItem => { + return TRANSFORMATION_IDS.some((id) => t.id === id); + }); + }, []); + + const handleSqlTransformationClick = () => { + reportInteraction('dashboards_expression_interaction', { + action: 'add_expression', + expression_type: 'sql', + context: 'empty_transformations_placeholder', + }); + props.onGoToQueries?.(); + }; + + const handleTransformationClick = (transformationId: string) => { + reportInteraction('grafana_panel_transformations_clicked', { + type: transformationId, + context: 'empty_transformations_placeholder', + }); + props.onAddTransformation?.(transformationId); + }; + + const handleShowMoreClick = () => { + reportInteraction('grafana_panel_transformations_show_more_clicked', { + context: 'empty_transformations_placeholder', + }); + props.onShowPicker(); + }; + + return ( + + + {(hasAddTransformation || hasGoToQueries) && ( + + {hasGoToQueries && ( + + )} + {hasAddTransformation && + transformations.map((transform) => ( + + ))} + + )} + + + Show more + + + + + ); +} + +export function EmptyTransformationsMessage(props: EmptyTransformationsProps) { + if (config.featureToggles.transformationsEmptyPlaceholder) { + return ; + } + + return ; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx index e06b9fc6da2..da076de57c8 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.test.tsx @@ -12,6 +12,7 @@ import { } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes'; +import config from 'app/core/config'; import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv'; import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; import { DashboardDataDTO } from 'app/types/dashboard'; @@ -165,6 +166,22 @@ describe('PanelDataTransformationsTab', () => { const reduce = screen.queryByTestId(selectors.components.TransformTab.newTransform('Reduce')); expect(reduce).toBeNull(); }); + + it('renders SQL transformation card in empty state when feature toggle is enabled', async () => { + const originalFeatureToggle = config.featureToggles.transformationsEmptyPlaceholder; + config.featureToggles.transformationsEmptyPlaceholder = true; + + try { + const modelMock = createModelMock(mockData); + render(); + + // Should show SQL transformation card in empty state + expect(screen.getByText('SQL Expressions')).toBeInTheDocument(); + expect(screen.getByTestId('go-to-queries-button')).toBeInTheDocument(); + } finally { + config.featureToggles.transformationsEmptyPlaceholder = originalFeatureToggle; + } + }); }); function setupTabScene(panelId: string) { @@ -178,5 +195,5 @@ function setupTabScene(panelId: string) { // @ts-expect-error getDashboardSrv().setCurrent(new DashboardModelCompatibilityWrapper(scene)); - return { transformsTab }; + return { transformsTab, panel }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index 27f434da0a9..af68e86adc4 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -1,6 +1,6 @@ import { css } from '@emotion/css'; import { DragDropContext, DropResult, Droppable } from '@hello-pangea/dnd'; -import { useState } from 'react'; +import { useCallback, useMemo, useState } from 'react'; import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; @@ -16,12 +16,18 @@ import { } from '@grafana/scenes'; import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'; import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows'; +import { ExpressionQueryType } from 'app/features/expressions/types'; import { getQueryRunnerFor } from '../../utils/utils'; import { EmptyTransformationsMessage } from './EmptyTransformationsMessage'; +import { PanelDataPane } from './PanelDataPane'; +import { PanelDataQueriesTab } from './PanelDataQueriesTab'; import { TransformationsDrawer } from './TransformationsDrawer'; import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types'; +import { findSqlExpression, scrollToQueryRow } from './utils'; + +const SET_TIMEOUT = 750; interface PanelDataTransformationsTabState extends SceneObjectState { panelRef: SceneObjectRef; @@ -66,8 +72,16 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro const styles = useStyles2(getStyles); const sourceData = model.getQueryRunner().useState(); const { data, transformations: transformsWrongType } = model.getDataTransformer().useState(); - // eslint-disable-next-line @typescript-eslint/consistent-type-assertions - const transformations: DataTransformerConfig[] = transformsWrongType as unknown as DataTransformerConfig[]; + + // Type guard to ensure transformations are DataTransformerConfig[] + const transformations = useMemo(() => { + return Array.isArray(transformsWrongType) + ? transformsWrongType.filter( + (t): t is DataTransformerConfig => + t !== null && typeof t === 'object' && 'id' in t && typeof t.id === 'string' + ) + : []; + }, [transformsWrongType]); const [drawerOpen, setDrawerOpen] = useState(false); const [confirmModalOpen, setConfirmModalOpen] = useState(false); @@ -75,6 +89,45 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro const openDrawer = () => setDrawerOpen(true); const closeDrawer = () => setDrawerOpen(false); + const onGoToQueries = useCallback(() => { + const parent = model.parent; + if (!(parent instanceof PanelDataPane)) { + return; + } + + const queriesTab = parent.state.tabs.find((tab) => tab.tabId === TabId.Queries); + if (!(queriesTab instanceof PanelDataQueriesTab)) { + return; + } + + const queries = queriesTab.getQueries(); + const existingSqlQuery = findSqlExpression(queries); + + if (!existingSqlQuery) { + // Create new SQL expression + queriesTab.onAddExpressionOfType(ExpressionQueryType.sql); + } + + // Navigate to the Queries tab + parent.onChangeTab(queriesTab); + + // Scroll to SQL query after tab renders + setTimeout(() => { + // If SQL already existed, use it; otherwise find the newly created one + const targetRefId = existingSqlQuery?.refId || findSqlExpression(queriesTab.getQueries())?.refId; + if (targetRefId) { + scrollToQueryRow(targetRefId); + } + }, SET_TIMEOUT); + }, [model]); + + const onAddTransformation = useCallback( + (transformationId: string) => { + model.onChangeTransformations([...transformations, { id: transformationId, options: {} }]); + }, + [model, transformations] + ); + if (!data || !sourceData.data) { return; } @@ -91,13 +144,17 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro }} isOpen={drawerOpen} series={data.series} - > + /> ); if (transformations.length < 1) { return ( <> - + {transformationsDrawer} > ); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/utils.ts b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/utils.ts new file mode 100644 index 00000000000..31a9c548c34 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/utils.ts @@ -0,0 +1,24 @@ +import { DataQuery } from '@grafana/schema'; +import { ExpressionQueryType } from 'app/features/expressions/types'; + +export function findSqlExpression(queries: DataQuery[]) { + return queries.find((query) => { + return typeof query === 'object' && query !== null && 'type' in query && query.type === ExpressionQueryType.sql; + }); +} + +export function scrollToQueryRow(refId: string) { + // Query rows use uniqueId(refId + '_') for their internal id + // The aria-controls attribute will be like "A_1" for refId "A" + // So we need to search for aria-controls starting with "refId_" + const queryRowHeader = document.querySelector(`[aria-controls^="${refId}_"]`); + + if (queryRowHeader) { + // Find the parent query row wrapper + const queryRow = queryRowHeader.closest('[data-testid="query-editor-row"]'); + + if (queryRow instanceof HTMLElement) { + queryRow.scrollIntoView({ behavior: 'smooth', block: 'start' }); + } + } +} diff --git a/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx new file mode 100644 index 00000000000..0cb9302df2e --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/SqlExpressionCard.tsx @@ -0,0 +1,71 @@ +import { css } from '@emotion/css'; + +import { GrafanaTheme2 } from '@grafana/data'; +import { Card, useStyles2 } from '@grafana/ui'; + +export interface SqlExpressionCardProps { + name: string; + description: string; + imageUrl?: string; + onClick: () => void; + testId?: string; +} + +export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) { + const styles = useStyles2(getSqlExpressionCardStyles); + + return ( + + + + {name} + + + + {description} + {imageUrl && ( + + + + )} + + + ); +} + +function getSqlExpressionCardStyles(theme: GrafanaTheme2) { + return { + card: css({ + gridTemplateRows: 'min-content 0 1fr 0', + marginBottom: 0, + }), + heading: css({ + fontWeight: 400, + '> button': { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(1), + }, + }), + titleRow: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flexWrap: 'nowrap', + width: '100%', + }), + description: css({ + fontSize: theme.typography.bodySmall.fontSize, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }), + image: css({ + display: 'block', + maxWidth: '100%', + marginTop: theme.spacing(2), + }), + }; +} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx new file mode 100644 index 00000000000..ad113f1b227 --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationCard.tsx @@ -0,0 +1,148 @@ +import { cx, css } from '@emotion/css'; + +import { + DataFrame, + GrafanaTheme2, + TransformerRegistryItem, + TransformationApplicabilityLevels, + standardTransformersRegistry, +} from '@grafana/data'; +import { selectors } from '@grafana/e2e-selectors'; +import { Badge, Card, IconButton, useStyles2, useTheme2 } from '@grafana/ui'; +import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; + +export interface TransformationCardProps { + transform: TransformerRegistryItem; + onClick: (id: string) => void; + showIllustrations?: boolean; + data?: DataFrame[]; + showPluginState?: boolean; + showTags?: boolean; +} + +export function TransformationCard({ + transform, + showIllustrations, + onClick, + data = [], + showPluginState = true, + showTags = true, +}: TransformationCardProps) { + const theme = useTheme2(); + const styles = useStyles2(getTransformationCardStyles); + + // Check to see if the transform is applicable to the given data + let applicabilityScore = TransformationApplicabilityLevels.Applicable; + if (data.length > 0 && transform.transformation.isApplicable !== undefined) { + applicabilityScore = transform.transformation.isApplicable(data); + } + const isApplicable = applicabilityScore > 0; + + let applicabilityDescription = null; + if (data.length > 0 && transform.transformation.isApplicableDescription !== undefined) { + if (typeof transform.transformation.isApplicableDescription === 'function') { + applicabilityDescription = transform.transformation.isApplicableDescription(data); + } else { + applicabilityDescription = transform.transformation.isApplicableDescription; + } + } + + const cardClasses = !isApplicable && data.length > 0 ? cx(styles.newCard, styles.cardDisabled) : styles.newCard; + const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight; + const description = standardTransformersRegistry.getIfExists(transform.id)?.description; + + return ( + onClick(transform.id)} + noMargin + > + + + {transform.name} + {showPluginState && ( + + + + )} + + {showTags && transform.tags && transform.tags.size > 0 && ( + + {Array.from(transform.tags).map((tag) => ( + + ))} + + )} + + + {description} + {showIllustrations && imageUrl && ( + + + + )} + {!isApplicable && applicabilityDescription !== null && ( + + )} + + + ); +} + +function getTransformationCardStyles(theme: GrafanaTheme2) { + return { + heading: css({ + fontWeight: 400, + '> button': { + width: '100%', + display: 'flex', + flexDirection: 'column', + alignItems: 'flex-start', + gap: theme.spacing(1), + }, + }), + titleRow: css({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + flexWrap: 'nowrap', + width: '100%', + }), + description: css({ + fontSize: theme.typography.bodySmall.fontSize, + display: 'flex', + flexDirection: 'column', + justifyContent: 'space-between', + }), + image: css({ + display: 'block', + maxWidth: '100%', + marginTop: theme.spacing(2), + }), + cardDisabled: css({ + backgroundColor: theme.colors.action.disabledBackground, + img: { + filter: 'grayscale(100%)', + opacity: 0.33, + }, + }), + cardApplicableInfo: css({ + position: 'absolute', + bottom: theme.spacing(1), + right: theme.spacing(1), + }), + newCard: css({ + gridTemplateRows: 'min-content 0 1fr 0', + marginBottom: 0, + }), + pluginStateInfoWrapper: css({ + marginLeft: theme.spacing(0.5), + }), + tagsWrapper: css({ + display: 'flex', + flexWrap: 'wrap', + gap: theme.spacing(0.5), + }), + }; +} diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.test.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.test.tsx new file mode 100644 index 00000000000..76d51c884fd --- /dev/null +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.test.tsx @@ -0,0 +1,106 @@ +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { standardTransformersRegistry } from '@grafana/data'; +import { getStandardTransformers } from 'app/features/transformers/standardTransformers'; + +import { SqlExpressionCard } from './SqlExpressionCard'; +import { TransformationCard } from './TransformationCard'; + +describe('TransformationCard', () => { + standardTransformersRegistry.setInit(getStandardTransformers); + + const onClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders transformation name and description', () => { + const transform = standardTransformersRegistry.get('organize'); + render(); + + expect(screen.getByText('Organize fields by name')).toBeInTheDocument(); + // Description is rendered but we won't assert on exact text since it may change + }); + + it('calls onClick with transformation id when clicked', async () => { + const user = userEvent.setup(); + const transform = standardTransformersRegistry.get('organize'); + render(); + + const card = screen.getByText('Organize fields by name').closest('button'); + await user.click(card!); + + expect(onClick).toHaveBeenCalledWith('organize'); + }); + + it('shows illustration when showIllustrations is true', () => { + const transform = standardTransformersRegistry.get('organize'); + const { container } = render(); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img?.alt).toBe('Organize fields by name'); + }); + + it('hides illustration when showIllustrations is false', () => { + const transform = standardTransformersRegistry.get('organize'); + const { container } = render( + + ); + + expect(container.querySelector('img')).not.toBeInTheDocument(); + }); + + it('hides plugin state when showPluginState is false', () => { + const transform = standardTransformersRegistry.get('organize'); + const { container } = render( + + ); + + expect(container.querySelector('[class*="pluginStateInfoWrapper"]')).not.toBeInTheDocument(); + }); + + it('hides tags when showTags is false', () => { + const transform = standardTransformersRegistry.get('organize'); + const { container } = render(); + + expect(container.querySelector('[class*="tagsWrapper"]')).not.toBeInTheDocument(); + }); +}); + +describe('SqlExpressionCard', () => { + const onClick = jest.fn(); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('renders SQL expression name and description', () => { + render(); + + expect(screen.getByText('SQL Expressions')).toBeInTheDocument(); + expect(screen.getByText('Manipulate data with SQL')).toBeInTheDocument(); + }); + + it('calls onClick when clicked', async () => { + const user = userEvent.setup(); + render(); + + const card = screen.getByText('SQL Expressions').closest('button'); + await user.click(card!); + + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('renders image when imageUrl is provided', () => { + const { container } = render( + + ); + + const img = container.querySelector('img'); + expect(img).toBeInTheDocument(); + expect(img?.src).toContain('/test.svg'); + }); +}); diff --git a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx index c0c59070dc0..fb0be6864f5 100644 --- a/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx +++ b/public/app/features/dashboard/components/TransformationsEditor/TransformationPickerNg.tsx @@ -1,34 +1,16 @@ -import { cx, css } from '@emotion/css'; +import { css } from '@emotion/css'; import { FormEventHandler, KeyboardEventHandler, ReactNode, useCallback } from 'react'; -import { - DataFrame, - TransformerRegistryItem, - TransformationApplicabilityLevels, - GrafanaTheme2, - standardTransformersRegistry, - SelectableValue, -} from '@grafana/data'; +import { DataFrame, GrafanaTheme2, TransformerRegistryItem, SelectableValue } from '@grafana/data'; import { selectors } from '@grafana/e2e-selectors'; import { Trans, t } from '@grafana/i18n'; -import { - Badge, - Card, - Drawer, - FilterPill, - Grid, - IconButton, - Input, - Stack, - Switch, - useStyles2, - useTheme2, -} from '@grafana/ui'; +import { reportInteraction } from '@grafana/runtime'; +import { Drawer, FilterPill, Grid, Input, Stack, Switch, useStyles2 } from '@grafana/ui'; import config from 'app/core/config'; -import { PluginStateInfo } from 'app/features/plugins/components/PluginStateInfo'; import { getCategoriesLabels } from 'app/features/transformers/utils'; import { SqlExpressionsBanner } from './SqlExpressions/SqlExpressionsBanner'; +import { TransformationCard } from './TransformationCard'; import { FilterCategory } from './TransformationsEditor'; const VIEW_ALL_VALUE = 'viewAll'; @@ -132,6 +114,10 @@ export function TransformationPickerNg(props: TransformationPickerNgProps) { transformations={xforms} data={data} onClick={(id) => { + reportInteraction('grafana_panel_transformations_clicked', { + type: id, + context: 'transformations_drawer', + }); onTransformationAdd({ value: id }); }} /> @@ -175,143 +161,17 @@ interface TransformationsGridProps { } function TransformationsGrid({ showIllustrations, transformations, onClick, data }: TransformationsGridProps) { - const theme = useTheme2(); - const styles = useStyles2(getTransformationGridStyles); - return ( - {transformations.map((transform) => { - // Check to see if the transform - // is applicable to the given data - let applicabilityScore = TransformationApplicabilityLevels.Applicable; - if (transform.transformation.isApplicable !== undefined) { - applicabilityScore = transform.transformation.isApplicable(data); - } - const isApplicable = applicabilityScore > 0; - - let applicabilityDescription = null; - if (transform.transformation.isApplicableDescription !== undefined) { - if (typeof transform.transformation.isApplicableDescription === 'function') { - applicabilityDescription = transform.transformation.isApplicableDescription(data); - } else { - applicabilityDescription = transform.transformation.isApplicableDescription; - } - } - - // Add disabled styles to disabled - let cardClasses = styles.newCard; - if (!isApplicable) { - cardClasses = cx(styles.newCard, styles.cardDisabled); - } - - const imageUrl = theme.isDark ? transform.imageDark : transform.imageLight; - - return ( - onClick(transform.id)} - key={transform.id} - noMargin - > - - - {transform.name} - - - - - {transform.tags && transform.tags.size > 0 && ( - - {Array.from(transform.tags).map((tag) => ( - - ))} - - )} - - - {standardTransformersRegistry.getIfExists(transform.id)?.description} - {showIllustrations && ( - - - - )} - {!isApplicable && applicabilityDescription !== null && ( - - )} - - - ); - })} + {transformations.map((transform) => ( + + ))} ); } - -function getTransformationGridStyles(theme: GrafanaTheme2) { - return { - heading: css({ - fontWeight: 400, - '> button': { - width: '100%', - display: 'flex', - flexDirection: 'column', - alignItems: 'flex-start', - gap: theme.spacing(1), - }, - }), - titleRow: css({ - display: 'flex', - justifyContent: 'space-between', - alignItems: 'center', - flexWrap: 'nowrap', - width: '100%', - }), - description: css({ - fontSize: theme.typography.bodySmall.fontSize, - display: 'flex', - flexDirection: 'column', - justifyContent: 'space-between', - }), - image: css({ - display: 'block', - maxWidth: '100%', - marginTop: theme.spacing(2), - }), - grid: css({ - display: 'grid', - gridTemplateColumns: 'repeat(auto-fill, minmax(220px, 1fr))', - gridAutoRows: '1fr', - gap: theme.spacing(1), - width: '100%', - padding: `${theme.spacing(1)} 0`, - }), - cardDisabled: css({ - backgroundColor: theme.colors.action.disabledBackground, - img: { - filter: 'grayscale(100%)', - opacity: 0.33, - }, - }), - cardApplicableInfo: css({ - position: 'absolute', - bottom: theme.spacing(1), - right: theme.spacing(1), - }), - newCard: css({ - gridTemplateRows: 'min-content 0 1fr 0', - marginBottom: 0, - }), - pluginStateInfoWrapper: css({ - marginLeft: theme.spacing(0.5), - }), - tagsWrapper: css({ - display: 'flex', - flexWrap: 'wrap', - gap: theme.spacing(0.5), - }), - }; -} diff --git a/public/app/features/transformers/images/dark/sqlExpression.svg b/public/app/features/transformers/images/dark/sqlExpression.svg new file mode 100644 index 00000000000..deeeb816bab --- /dev/null +++ b/public/app/features/transformers/images/dark/sqlExpression.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + SQL + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/app/features/transformers/images/light/sqlExpression.svg b/public/app/features/transformers/images/light/sqlExpression.svg new file mode 100644 index 00000000000..ca2ac7193be --- /dev/null +++ b/public/app/features/transformers/images/light/sqlExpression.svg @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + SQL + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/public/locales/en-US/grafana.json b/public/locales/en-US/grafana.json index 6db79433e88..fe32f78260b 100644 --- a/public/locales/en-US/grafana.json +++ b/public/locales/en-US/grafana.json @@ -5930,7 +5930,10 @@ } }, "empty-transformations-message": { - "add-transformation": "Add transformation" + "add-transformation": "Add transformation", + "show-more": "Show more", + "sql-name": "SQL Expressions", + "sql-transformation-description": "Manipulate your data using MySQL-like syntax" }, "general-settings-edit-view": { "editable_options": {