Compare commits

...

7 Commits

Author SHA1 Message Date
Develer
fa0092ad74 revert transformation grayed-out fix 2025-12-17 18:54:58 +01:00
Develer
4be0055b97 revert transformation grayed-out fix 2025-12-17 18:11:58 +01:00
Develer
af9a0d3598 add tests 2025-12-16 20:01:16 +01:00
Develer
59cbd4c7d8 allow sql expression on non-frontend ds 2025-12-16 19:35:12 +01:00
Develer
6bdf2dd569 clean comments 2025-12-16 15:56:25 +01:00
Develer
3fb47efc7f chore(transformations): optimisation 2025-12-16 15:00:02 +01:00
Develer
157fc6315b Transformations: Add applicability indicators to empty state 2025-11-25 13:20:05 +01:00
6 changed files with 354 additions and 6 deletions

View File

@@ -117,4 +117,102 @@ describe('EmptyTransformationsMessage', () => {
expect(screen.getByTestId(selectors.components.Transforms.addTransformationButton)).toBeInTheDocument();
});
});
describe('SQL card disabled state', () => {
beforeEach(() => {
config.featureToggles.transformationsEmptyPlaceholder = true;
config.featureToggles.sqlExpressions = true;
});
// Helper to check if the info icon button is present (rendered when disabled)
const getInfoIconButton = (container: HTMLElement) => {
// The IconButton for info renders as a button with an SVG icon
// When disabled, there are 2 buttons: the card button and the info icon button
const buttons = container.querySelectorAll('button');
return buttons.length > 1 ? buttons[1] : null;
};
it('should show disabled SQL card with info icon when isSqlApplicable is false', () => {
render(
<EmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
isSqlApplicable={false}
/>
);
const sqlCard = screen.getByTestId('go-to-queries-button');
// Should show info icon button when disabled (2 buttons total)
expect(getInfoIconButton(sqlCard)).toBeInTheDocument();
});
it('should not call onGoToQueries when SQL card is disabled and clicked', async () => {
const user = userEvent.setup();
render(
<EmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
isSqlApplicable={false}
/>
);
const sqlCard = screen.getByTestId('go-to-queries-button');
const button = sqlCard.querySelector('button');
await user.click(button!);
// onGoToQueries should NOT be called when disabled
expect(onGoToQueries).not.toHaveBeenCalled();
});
it('should call onGoToQueries when SQL card is enabled and clicked', async () => {
const user = userEvent.setup();
render(
<EmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
isSqlApplicable={true}
/>
);
const sqlCard = screen.getByTestId('go-to-queries-button');
const button = sqlCard.querySelector('button');
await user.click(button!);
expect(onGoToQueries).toHaveBeenCalledTimes(1);
});
it('should not show info icon when SQL card is enabled', () => {
render(
<EmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
isSqlApplicable={true}
/>
);
const sqlCard = screen.getByTestId('go-to-queries-button');
// Should NOT show info icon button when enabled (only 1 button)
expect(getInfoIconButton(sqlCard)).toBeNull();
});
it('should default to enabled when isSqlApplicable is not provided', () => {
render(
<EmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
);
const sqlCard = screen.getByTestId('go-to-queries-button');
// Should NOT show info icon button when default (enabled)
expect(getInfoIconButton(sqlCard)).toBeNull();
});
});
});

View File

@@ -16,6 +16,7 @@ interface EmptyTransformationsProps {
onShowPicker: () => void;
onGoToQueries?: () => void;
onAddTransformation?: (transformationId: string) => void;
isSqlApplicable?: 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 isSqlApplicable = props.isSqlApplicable ?? true; // Default to true if not provided
// Get transformations from registry
const transformations = useMemo(() => {
@@ -69,6 +71,9 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
}, []);
const handleSqlTransformationClick = () => {
if (!isSqlApplicable) {
return;
}
reportInteraction('dashboards_expression_interaction', {
action: 'add_expression',
expression_type: 'sql',
@@ -110,6 +115,11 @@ export function NewEmptyTransformationsMessage(props: EmptyTransformationsProps)
imageUrl={config.theme2.isDark ? sqlDarkImage : sqlLightImage}
onClick={handleSqlTransformationClick}
testId="go-to-queries-button"
isDisabled={!isSqlApplicable}
disabledTooltip={t(
'dashboard-scene.empty-transformations-message.sql-not-applicable',
'SQL expressions require backend data sources'
)}
/>
)}
{hasAddTransformation &&

View File

@@ -11,9 +11,11 @@ import {
toDataFrame,
} from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { DataSourceSrv, getDataSourceSrv } from '@grafana/runtime';
import { SceneDataTransformer, SceneQueryRunner } from '@grafana/scenes';
import config from 'app/core/config';
import { getDashboardSrv } from 'app/features/dashboard/services/DashboardSrv';
import { ExpressionDatasourceUID } from 'app/features/expressions/types';
import { getStandardTransformers } from 'app/features/transformers/standardTransformers';
import { DashboardDataDTO } from 'app/types/dashboard';
@@ -24,14 +26,40 @@ import { testDashboard } from '../testfiles/testDashboard';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
// Mock getDataSourceSrv
jest.mock('@grafana/runtime', () => {
const actual = jest.requireActual('@grafana/runtime');
return {
...actual,
getDataSourceSrv: jest.fn(() => ({
getInstanceSettings: jest.fn(),
})),
};
});
const getDataSourceSrvMock = getDataSourceSrv as jest.MockedFunction<typeof getDataSourceSrv>;
// Helper to create DataSourceSrv mock with custom getInstanceSettings
const createMockDataSourceSrv = (
getInstanceSettingsFn: (ref: { uid?: string; type?: string } | undefined) => unknown
): DataSourceSrv =>
({
get: jest.fn(),
getList: jest.fn(),
getInstanceSettings: getInstanceSettingsFn,
reload: jest.fn(),
registerRuntimeDataSource: jest.fn(),
}) as unknown as DataSourceSrv;
function createModelMock(
panelData: PanelData,
transformations?: DataTransformerConfig[],
onChangeTransformationsMock?: Function
onChangeTransformationsMock?: Function,
queries: Array<{ refId: string; datasource?: { uid?: string; type?: string } }> = []
) {
return {
getDataTransformer: () => new SceneDataTransformer({ data: panelData, transformations: transformations || [] }),
getQueryRunner: () => new SceneQueryRunner({ queries: [], data: panelData }),
getQueryRunner: () => new SceneQueryRunner({ queries, data: panelData }),
onChangeTransformations: onChangeTransformationsMock,
} as unknown as PanelDataTransformationsTab;
}
@@ -208,6 +236,149 @@ describe('PanelDataTransformationsTab', () => {
});
});
describe('SQL Expression applicability', () => {
standardTransformersRegistry.setInit(getStandardTransformers);
let originalTransformationsToggle: boolean | undefined;
let originalSqlToggle: boolean | undefined;
beforeEach(() => {
originalTransformationsToggle = config.featureToggles.transformationsEmptyPlaceholder;
originalSqlToggle = config.featureToggles.sqlExpressions;
config.featureToggles.transformationsEmptyPlaceholder = true;
config.featureToggles.sqlExpressions = true;
});
afterEach(() => {
config.featureToggles.transformationsEmptyPlaceholder = originalTransformationsToggle;
config.featureToggles.sqlExpressions = originalSqlToggle;
jest.clearAllMocks();
});
// Helper to check if the info icon button is present (rendered when disabled)
// The IconButton for info renders as a button with an SVG icon
// When disabled, there are 2 buttons: the card button and the info icon button
const getInfoIconButton = (container: HTMLElement) => {
const buttons = container.querySelectorAll('button');
return buttons.length > 1 ? buttons[1] : null;
};
it('should enable SQL card when datasource is a backend datasource', async () => {
getDataSourceSrvMock.mockReturnValue(
createMockDataSourceSrv(() => ({
uid: 'prometheus',
name: 'Prometheus',
meta: { backend: true },
}))
);
const modelMock = createModelMock(mockData, [], undefined, [
{ refId: 'A', datasource: { uid: 'prometheus', type: 'prometheus' } },
]);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Card should NOT have disabled background styling - check it's clickable
expect(sqlCard).toBeInTheDocument();
// The card should not show the disabled info icon button
expect(getInfoIconButton(sqlCard)).toBeNull();
});
it('should disable SQL card when datasource is frontend-only', async () => {
getDataSourceSrvMock.mockReturnValue(
createMockDataSourceSrv(() => ({
uid: 'googlesheets',
name: 'Google Sheets',
meta: { backend: false, isBackend: false },
}))
);
const modelMock = createModelMock(mockData, [], undefined, [
{ refId: 'A', datasource: { uid: 'googlesheets', type: 'grafana-googlesheets-datasource' } },
]);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Card should show the disabled info icon button
expect(getInfoIconButton(sqlCard)).toBeInTheDocument();
});
it('should enable SQL card when datasource settings cannot be found', async () => {
// Return undefined for getInstanceSettings - simulating unknown datasource
getDataSourceSrvMock.mockReturnValue(createMockDataSourceSrv(() => undefined));
const modelMock = createModelMock(mockData, [], undefined, [
{ refId: 'A', datasource: { uid: 'unknown-ds', type: 'unknown' } },
]);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Card should NOT be disabled when we can't determine datasource type
expect(getInfoIconButton(sqlCard)).toBeNull();
});
it('should skip expression queries when checking SQL applicability', async () => {
getDataSourceSrvMock.mockReturnValue(
createMockDataSourceSrv((ref) => {
if (ref?.uid === ExpressionDatasourceUID) {
// Expression datasource - this should be skipped
return { uid: ExpressionDatasourceUID, name: 'Expression', meta: { backend: false } };
}
// Backend datasource
return { uid: 'prometheus', name: 'Prometheus', meta: { backend: true } };
})
);
const modelMock = createModelMock(mockData, [], undefined, [
{ refId: 'A', datasource: { uid: 'prometheus', type: 'prometheus' } },
{ refId: 'B', datasource: { uid: ExpressionDatasourceUID, type: '__expr__' } }, // Expression query
]);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Should still be enabled because expression queries are skipped
expect(getInfoIconButton(sqlCard)).toBeNull();
});
it('should disable SQL card if any non-expression query uses frontend-only datasource', async () => {
getDataSourceSrvMock.mockReturnValue(
createMockDataSourceSrv((ref) => {
if (ref?.uid === 'googlesheets') {
return { uid: 'googlesheets', name: 'Google Sheets', meta: { backend: false, isBackend: false } };
}
return { uid: 'prometheus', name: 'Prometheus', meta: { backend: true } };
})
);
const modelMock = createModelMock(mockData, [], undefined, [
{ refId: 'A', datasource: { uid: 'prometheus', type: 'prometheus' } },
{ refId: 'B', datasource: { uid: 'googlesheets', type: 'grafana-googlesheets-datasource' } },
]);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Card should be disabled because one datasource is frontend-only
expect(getInfoIconButton(sqlCard)).toBeInTheDocument();
});
it('should enable SQL card when there are no queries', async () => {
getDataSourceSrvMock.mockReturnValue(createMockDataSourceSrv(() => undefined));
const modelMock = createModelMock(mockData, [], undefined, []);
render(<PanelDataTransformationsTabRendered model={modelMock} />);
const sqlCard = await screen.findByTestId('go-to-queries-button');
// Card should be enabled when there are no queries
expect(getInfoIconButton(sqlCard)).toBeNull();
});
});
function setupTabScene(panelId: string) {
const scene = transformSaveModelToScene({ dashboard: testDashboard as unknown as DashboardDataDTO, meta: {} });
const panel = findVizPanelByKey(scene, panelId)!;

View File

@@ -5,6 +5,7 @@ import { useCallback, useMemo, useState } from 'react';
import { DataTransformerConfig, GrafanaTheme2, PanelData } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import { Trans, t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import {
SceneObjectBase,
SceneComponentProps,
@@ -18,6 +19,7 @@ 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 { ExpressionDatasourceUID } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
@@ -83,6 +85,41 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
: [];
}, [transformsWrongType]);
// Check if SQL expressions are applicable (all datasources must be backend datasources)
const queryRunner = model.getQueryRunner();
const { queries } = queryRunner.useState();
const isSqlApplicable = useMemo(() => {
if (!queries || queries.length === 0) {
return true; // If no queries, SQL is theoretically applicable
}
// Check each query's datasource
for (const query of queries) {
const datasourceRef = query.datasource;
// Skip expression queries
if (datasourceRef && 'uid' in datasourceRef && datasourceRef.uid === ExpressionDatasourceUID) {
continue;
}
const dsSettings = getDataSourceSrv().getInstanceSettings(datasourceRef);
// If we can't get datasource settings, default to allowing SQL expressions
// (we'd rather let the user try than incorrectly block them)
if (!dsSettings) {
continue;
}
// Only disable for datasources we KNOW are frontend-only
// This is a conservative deny-list approach - we may expand this in future
if (!dsSettings.meta.backend && !dsSettings.meta.isBackend) {
return false;
}
}
return true;
}, [queries]);
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
@@ -154,6 +191,7 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
onShowPicker={openDrawer}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
isSqlApplicable={isSqlApplicable}
/>
{transformationsDrawer}
</>

View File

@@ -1,7 +1,7 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { GrafanaTheme2 } from '@grafana/data';
import { Card, useStyles2 } from '@grafana/ui';
import { Card, IconButton, useStyles2 } from '@grafana/ui';
export interface SqlExpressionCardProps {
name: string;
@@ -9,13 +9,28 @@ export interface SqlExpressionCardProps {
imageUrl?: string;
onClick: () => void;
testId?: string;
isDisabled?: boolean;
disabledTooltip?: string;
}
export function SqlExpressionCard({ name, description, imageUrl, onClick, testId }: SqlExpressionCardProps) {
export function SqlExpressionCard({
name,
description,
imageUrl,
onClick,
testId,
isDisabled,
disabledTooltip,
}: SqlExpressionCardProps) {
const styles = useStyles2(getSqlExpressionCardStyles);
return (
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
<Card
className={cx(styles.card, { [styles.cardDisabled]: isDisabled })}
data-testid={testId}
onClick={onClick}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{name}</span>
@@ -28,6 +43,9 @@ export function SqlExpressionCard({ name, description, imageUrl, onClick, testId
<img className={styles.image} src={imageUrl} alt={name} />
</span>
)}
{isDisabled && disabledTooltip && (
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={disabledTooltip} />
)}
</Card.Description>
</Card>
);
@@ -67,5 +85,17 @@ function getSqlExpressionCardStyles(theme: GrafanaTheme2) {
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),
}),
};
}

View File

@@ -5942,6 +5942,7 @@
"add-transformation": "Add transformation",
"show-more": "Show more",
"sql-name": "Transform with SQL",
"sql-not-applicable": "SQL expressions require backend data sources",
"sql-transformation-description": "Manipulate your data using MySQL-like syntax"
},
"general-settings-edit-view": {