mirror of
https://github.com/grafana/grafana.git
synced 2025-12-21 20:24:41 +08:00
Compare commits
7 Commits
docs/add-t
...
add-transf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fa0092ad74 | ||
|
|
4be0055b97 | ||
|
|
af9a0d3598 | ||
|
|
59cbd4c7d8 | ||
|
|
6bdf2dd569 | ||
|
|
3fb47efc7f | ||
|
|
157fc6315b |
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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)!;
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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),
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
Reference in New Issue
Block a user