transformations and sql expressions discovery poc (#113664)

* transformations and sql expressions discovery poc

* add translations

* extract transformation card component

* scroll to sql expression

* put changes under ff

* fix translation

* add event tracker

* add tests

* naming, refactoring

* button amends

* add icons for sql expression tile

* TransformationPickerNg refactoring

* PanelDataTransformationsTab refactoring

* refactor empty message component

* refactor empty message component

* cleaning

* update feature toggle

* cover event tracking

* update interation events

---------

Co-authored-by: Develer <yeryomin.igor@gmail.com>
This commit is contained in:
Natalia Bernarte Oses
2025-11-17 14:57:05 +01:00
committed by GitHub
co-authored by Develer
parent 100fb26d27
commit c4f2f3f6f4
19 changed files with 817 additions and 194 deletions
@@ -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
+4
View File
@@ -1200,4 +1200,8 @@ export interface FeatureToggles {
* @default false
*/
awsDatasourcesHttpProxy?: boolean;
/**
* Show transformation quick-start cards in empty transformations state
*/
transformationsEmptyPlaceholder?: boolean;
}
@@ -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',
},
+7
View File
@@ -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,
},
}
)
+1
View File
@@ -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
1 Name Stage Owner requiresDevMode RequiresRestart FrontendOnly
267 dashboardTemplates experimental @grafana/sharing-squad false false false
268 kubernetesAnnotations experimental @grafana/grafana-backend-services-squad false false false
269 awsDatasourcesHttpProxy experimental @grafana/aws-datasources false false false
270 transformationsEmptyPlaceholder preview @grafana/datapro false false true
+4
View File
@@ -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"
)
+16
View File
@@ -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",
@@ -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(<LegacyEmptyTransformationsMessage onShowPicker={onShowPicker} />);
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(<LegacyEmptyTransformationsMessage onShowPicker={onShowPicker} />);
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(
<NewEmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
);
// 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(
<NewEmptyTransformationsMessage
onShowPicker={onShowPicker}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
);
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(<NewEmptyTransformationsMessage onShowPicker={onShowPicker} onAddTransformation={onAddTransformation} />);
expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument();
});
it('should not show transformation cards grid when neither onGoToQueries nor onAddTransformation are provided', () => {
render(<NewEmptyTransformationsMessage onShowPicker={onShowPicker} />);
expect(screen.queryByText('SQL Expressions')).not.toBeInTheDocument();
// But should still show the "Show more" button
expect(screen.getByTestId(selectors.components.Transforms.addTransformationButton)).toBeInTheDocument();
});
});
});
@@ -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 (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={2}>
@@ -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}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.add-transformation">Add transformation</Trans>
@@ -33,3 +53,92 @@ export function EmptyTransformationsMessage(props: EmptyTransformationsProps) {
</Box>
);
}
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 (
<Box alignItems="center" padding={4}>
<Stack direction="column" alignItems="center" gap={4}>
{(hasAddTransformation || hasGoToQueries) && (
<Grid columns={5} gap={1}>
{hasGoToQueries && (
<SqlExpressionCard
name={t('dashboard-scene.empty-transformations-message.sql-name', 'SQL Expressions')}
description={t(
'dashboard-scene.empty-transformations-message.sql-transformation-description',
'Manipulate your data using MySQL-like syntax'
)}
imageUrl={config.theme2.isDark ? sqlDarkImage : sqlLightImage}
onClick={handleSqlTransformationClick}
testId="go-to-queries-button"
/>
)}
{hasAddTransformation &&
transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
onClick={handleTransformationClick}
showIllustrations={true}
showPluginState={false}
showTags={false}
/>
))}
</Grid>
)}
<Stack direction="row" gap={2}>
<Button
icon="plus"
variant="primary"
size="md"
onClick={handleShowMoreClick}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.empty-transformations-message.show-more">Show more</Trans>
</Button>
</Stack>
</Stack>
</Box>
);
}
export function EmptyTransformationsMessage(props: EmptyTransformationsProps) {
if (config.featureToggles.transformationsEmptyPlaceholder) {
return <NewEmptyTransformationsMessage {...props} />;
}
return <LegacyEmptyTransformationsMessage onShowPicker={props.onShowPicker} />;
}
@@ -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(<PanelDataTransformationsTabRendered model={modelMock}></PanelDataTransformationsTabRendered>);
// 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 };
}
@@ -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<VizPanel>;
@@ -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<DataTransformerConfig[]>(() => {
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<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(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}
></TransformationsDrawer>
/>
);
if (transformations.length < 1) {
return (
<>
<EmptyTransformationsMessage onShowPicker={openDrawer}></EmptyTransformationsMessage>
<EmptyTransformationsMessage
onShowPicker={openDrawer}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
{transformationsDrawer}
</>
);
@@ -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' });
}
}
}
@@ -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 (
<Card className={styles.card} data-testid={testId} onClick={onClick} noMargin>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{name}</span>
</div>
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={name} />
</span>
)}
</Card.Description>
</Card>
);
}
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),
}),
};
}
@@ -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 (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
{showPluginState && (
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
)}
</div>
{showTags && transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
<Badge color="darkgrey" icon="tag-alt" key={tag} text={tag} />
))}
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{description}</span>
{showIllustrations && imageUrl && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton className={styles.cardApplicableInfo} name="info-circle" tooltip={applicabilityDescription} />
)}
</Card.Description>
</Card>
);
}
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),
}),
};
}
@@ -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(<TransformationCard transform={transform} onClick={onClick} />);
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(<TransformationCard transform={transform} onClick={onClick} />);
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(<TransformationCard transform={transform} onClick={onClick} showIllustrations />);
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(
<TransformationCard transform={transform} onClick={onClick} showIllustrations={false} />
);
expect(container.querySelector('img')).not.toBeInTheDocument();
});
it('hides plugin state when showPluginState is false', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(
<TransformationCard transform={transform} onClick={onClick} showPluginState={false} />
);
expect(container.querySelector('[class*="pluginStateInfoWrapper"]')).not.toBeInTheDocument();
});
it('hides tags when showTags is false', () => {
const transform = standardTransformersRegistry.get('organize');
const { container } = render(<TransformationCard transform={transform} onClick={onClick} showTags={false} />);
expect(container.querySelector('[class*="tagsWrapper"]')).not.toBeInTheDocument();
});
});
describe('SqlExpressionCard', () => {
const onClick = jest.fn();
beforeEach(() => {
jest.clearAllMocks();
});
it('renders SQL expression name and description', () => {
render(<SqlExpressionCard name="SQL Expressions" description="Manipulate data with SQL" onClick={onClick} />);
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(<SqlExpressionCard name="SQL Expressions" description="Test" onClick={onClick} />);
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(
<SqlExpressionCard name="SQL" description="Test" imageUrl="/test.svg" onClick={onClick} />
);
const img = container.querySelector('img');
expect(img).toBeInTheDocument();
expect(img?.src).toContain('/test.svg');
});
});
@@ -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 (
<Grid columns={3} gap={1}>
{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 (
<Card
className={cardClasses}
data-testid={selectors.components.TransformTab.newTransform(transform.name)}
onClick={() => onClick(transform.id)}
key={transform.id}
noMargin
>
<Card.Heading className={styles.heading}>
<div className={styles.titleRow}>
<span>{transform.name}</span>
<span className={styles.pluginStateInfoWrapper}>
<PluginStateInfo state={transform.state} />
</span>
</div>
{transform.tags && transform.tags.size > 0 && (
<div className={styles.tagsWrapper}>
{Array.from(transform.tags).map((tag) => (
<Badge color="darkgrey" icon="tag-alt" key={tag} text={tag} />
))}
</div>
)}
</Card.Heading>
<Card.Description className={styles.description}>
<span>{standardTransformersRegistry.getIfExists(transform.id)?.description}</span>
{showIllustrations && (
<span>
<img className={styles.image} src={imageUrl} alt={transform.name} />
</span>
)}
{!isApplicable && applicabilityDescription !== null && (
<IconButton
className={styles.cardApplicableInfo}
name="info-circle"
tooltip={applicabilityDescription}
/>
)}
</Card.Description>
</Card>
);
})}
{transformations.map((transform) => (
<TransformationCard
key={transform.id}
transform={transform}
showIllustrations={showIllustrations}
onClick={onClick}
data={data}
/>
))}
</Grid>
);
}
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),
}),
};
}
@@ -0,0 +1,49 @@
<svg width="120" height="48" viewBox="0 0 120 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Input data table -->
<path d="M0 1.04083V13H28V0H1.16951C0.859756 0 0.56261 0.109659 0.337073 0.304853C0.111537 0.500047 0 0.764787 0 1.04083Z" fill="url(#paint0_linear_sql_dark)"/>
<path d="M28 17H0V30H28V17Z" fill="#84AFF1"/>
<path d="M28 34H0V48H28V34Z" fill="#84AFF1"/>
<!-- a1 text -->
<path d="M13.7744 9.5C14.5264 9.5 15.0464 9.23767 15.3415 8.74147H15.3837V9.41783H17V6.1182C17 5.09102 15.9845 4.5 14.6107 4.5C13.1595 4.5 12.3373 5.1574 12.2003 6.04235L13.785 6.09292C13.8587 5.78319 14.1434 5.59355 14.5966 5.59355C15.0183 5.59355 15.2853 5.77686 15.2853 6.1024V6.1182C15.2853 6.4153 14.9269 6.47851 14.0063 6.5512C12.9136 6.63338 12 6.99684 12 8.07143C12 9.03224 12.7414 9.5 13.7744 9.5ZM14.305 8.48862C13.9079 8.48862 13.6268 8.31795 13.6268 7.99558C13.6268 7.68268 13.9009 7.49305 14.3893 7.42668C14.7091 7.38559 15.1026 7.32238 15.2959 7.23072V7.69216C15.2959 8.16625 14.8531 8.48862 14.305 8.48862Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M14.5 21H13.0716L11.5 21.9199V23.1738L12.9253 22.3535H12.9627V27H14.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M10.3373 44.5H14.75V43.3468H12.3341V43.3092L13.0472 42.6561C14.3396 41.5376 14.6772 40.9682 14.6772 40.289C14.6772 39.2225 13.8011 38.5 12.4476 38.5C11.129 38.5 10.2471 39.2543 10.25 40.4595H11.6151C11.6151 39.9249 11.947 39.6156 12.4418 39.6156C12.9279 39.6156 13.2801 39.9133 13.2801 40.4017C13.2801 40.8439 13.0007 41.1445 12.5116 41.5809L10.3373 43.4711V44.5Z" fill="#24292E"/>
<!-- SQL symbol in middle -->
<g transform="translate(32, 16)">
<!-- Database cylinder icon -->
<ellipse cx="14" cy="5" rx="10" ry="3.5" fill="#F2CC0C" fill-opacity="0.3" stroke="#FF9830" stroke-width="1.5"/>
<path d="M4 5 V10 C4 11.933 8.477 13.5 14 13.5 C19.523 13.5 24 11.933 24 10 V5" fill="#F2CC0C" fill-opacity="0.2" stroke="#FF9830" stroke-width="1.5"/>
<line x1="4" y1="8" x2="4" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<line x1="24" y1="8" x2="24" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<!-- SQL text -->
<text x="14" y="23" font-family="Arial, sans-serif" font-size="7" font-weight="600" fill="#CCCCDC" text-anchor="middle">SQL</text>
</g>
<!-- Output data table -->
<path d="M92 1.04083V13H120V0H93.1695C92.8598 0 92.5626 0.109659 92.3371 0.304853C92.1115 0.500047 92 0.764787 92 1.04083Z" fill="url(#paint1_linear_sql_dark)"/>
<path d="M120 17H92V30H120V17Z" fill="#84AFF1"/>
<path d="M120 34H92V48H120V34Z" fill="#84AFF1"/>
<!-- b1 text -->
<path d="M103 9.68623H104.566V8.96449H104.615C104.816 9.37609 105.262 9.75 106.013 9.75C107.113 9.75 108 8.97898 108 7.46304C108 5.89203 107.061 5.17609 106.023 5.17609C105.236 5.17609 104.806 5.58768 104.615 5.99638H104.583V3.75H103V9.68623ZM104.55 7.46015C104.55 6.73261 104.887 6.28333 105.466 6.28333C106.052 6.28333 106.375 6.7442 106.375 7.46015C106.375 8.17899 106.052 8.64565 105.466 8.64565C104.887 8.64565 104.55 8.18188 104.55 7.46015Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M106.5 21H105.072L103.5 21.9199V23.1738L104.925 22.3535H104.963V27H106.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M102.337 44.5H106.75V43.3468H104.334V43.3092L105.047 42.6561C106.34 41.5376 106.677 40.9682 106.677 40.289C106.677 39.2225 105.801 38.5 104.448 38.5C103.129 38.5 102.247 39.2543 102.25 40.4595H103.615C103.615 39.9249 103.947 39.6156 104.442 39.6156C104.928 39.6156 105.28 39.9133 105.28 40.4017C105.28 40.8439 105.001 41.1445 104.512 41.5809L102.337 43.4711V44.5Z" fill="#24292E"/>
<!-- Arrow indicating transformation -->
<path d="M71.9067 30C72.6011 30 77.9327 26 77.9327 24C77.9327 22 72.7357 18 71.9067 18C71.0778 18 70.4023 18.5 70.4023 19.4756C70.4023 20.4512 73.9067 22.9206 73.9067 22.9206C73.9067 22.9206 66.2539 22.25 66 22.9206C65.7461 23.5911 65.7461 24.4089 66 25.0794C66.2539 25.75 73.9067 25.0794 73.9067 25.0794C73.9067 25.0794 70.4023 27.75 70.4023 28.5301C70.4023 29.3103 71.2124 30 71.9067 30Z" fill="#CCCCDC"/>
<defs>
<linearGradient id="paint0_linear_sql_dark" x1="0" y1="6.5" x2="28" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
<linearGradient id="paint1_linear_sql_dark" x1="92" y1="6.5" x2="120" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

@@ -0,0 +1,49 @@
<svg width="120" height="48" viewBox="0 0 120 48" fill="none" xmlns="http://www.w3.org/2000/svg">
<!-- Input data table -->
<path d="M0 1.04083V13H28V0H1.16951C0.859756 0 0.56261 0.109659 0.337073 0.304853C0.111537 0.500047 0 0.764787 0 1.04083Z" fill="url(#paint0_linear_sql)"/>
<path d="M28 17H0V30H28V17Z" fill="#84AFF1"/>
<path d="M28 34H0V48H28V34Z" fill="#84AFF1"/>
<!-- a1 text -->
<path d="M13.7744 9.5C14.5264 9.5 15.0464 9.23767 15.3415 8.74147H15.3837V9.41783H17V6.1182C17 5.09102 15.9845 4.5 14.6107 4.5C13.1595 4.5 12.3373 5.1574 12.2003 6.04235L13.785 6.09292C13.8587 5.78319 14.1434 5.59355 14.5966 5.59355C15.0183 5.59355 15.2853 5.77686 15.2853 6.1024V6.1182C15.2853 6.4153 14.9269 6.47851 14.0063 6.5512C12.9136 6.63338 12 6.99684 12 8.07143C12 9.03224 12.7414 9.5 13.7744 9.5ZM14.305 8.48862C13.9079 8.48862 13.6268 8.31795 13.6268 7.99558C13.6268 7.68268 13.9009 7.49305 14.3893 7.42668C14.7091 7.38559 15.1026 7.32238 15.2959 7.23072V7.69216C15.2959 8.16625 14.8531 8.48862 14.305 8.48862Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M14.5 21H13.0716L11.5 21.9199V23.1738L12.9253 22.3535H12.9627V27H14.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M10.3373 44.5H14.75V43.3468H12.3341V43.3092L13.0472 42.6561C14.3396 41.5376 14.6772 40.9682 14.6772 40.289C14.6772 39.2225 13.8011 38.5 12.4476 38.5C11.129 38.5 10.2471 39.2543 10.25 40.4595H11.6151C11.6151 39.9249 11.947 39.6156 12.4418 39.6156C12.9279 39.6156 13.2801 39.9133 13.2801 40.4017C13.2801 40.8439 13.0007 41.1445 12.5116 41.5809L10.3373 43.4711V44.5Z" fill="#24292E"/>
<!-- SQL symbol in middle -->
<g transform="translate(32, 16)">
<!-- Database cylinder icon -->
<ellipse cx="14" cy="5" rx="10" ry="3.5" fill="#F2CC0C" fill-opacity="0.3" stroke="#FF9830" stroke-width="1.5"/>
<path d="M4 5 V10 C4 11.933 8.477 13.5 14 13.5 C19.523 13.5 24 11.933 24 10 V5" fill="#F2CC0C" fill-opacity="0.2" stroke="#FF9830" stroke-width="1.5"/>
<line x1="4" y1="8" x2="4" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<line x1="24" y1="8" x2="24" y2="10" stroke="#FF9830" stroke-width="1.5"/>
<!-- SQL text -->
<text x="14" y="23" font-family="Arial, sans-serif" font-size="7" font-weight="600" fill="#24292E" text-anchor="middle">SQL</text>
</g>
<!-- Output data table -->
<path d="M92 1.04083V13H120V0H93.1695C92.8598 0 92.5626 0.109659 92.3371 0.304853C92.1115 0.500047 92 0.764787 92 1.04083Z" fill="url(#paint1_linear_sql)"/>
<path d="M120 17H92V30H120V17Z" fill="#84AFF1"/>
<path d="M120 34H92V48H120V34Z" fill="#84AFF1"/>
<!-- b1 text -->
<path d="M103 9.68623H104.566V8.96449H104.615C104.816 9.37609 105.262 9.75 106.013 9.75C107.113 9.75 108 8.97898 108 7.46304C108 5.89203 107.061 5.17609 106.023 5.17609C105.236 5.17609 104.806 5.58768 104.615 5.99638H104.583V3.75H103V9.68623ZM104.55 7.46015C104.55 6.73261 104.887 6.28333 105.466 6.28333C106.052 6.28333 106.375 6.7442 106.375 7.46015C106.375 8.17899 106.052 8.64565 105.466 8.64565C104.887 8.64565 104.55 8.18188 104.55 7.46015Z" fill="#24292E"/>
<!-- 1 text -->
<path d="M106.5 21H105.072L103.5 21.9199V23.1738L104.925 22.3535H104.963V27H106.5V21Z" fill="#24292E"/>
<!-- 2 text -->
<path d="M102.337 44.5H106.75V43.3468H104.334V43.3092L105.047 42.6561C106.34 41.5376 106.677 40.9682 106.677 40.289C106.677 39.2225 105.801 38.5 104.448 38.5C103.129 38.5 102.247 39.2543 102.25 40.4595H103.615C103.615 39.9249 103.947 39.6156 104.442 39.6156C104.928 39.6156 105.28 39.9133 105.28 40.4017C105.28 40.8439 105.001 41.1445 104.512 41.5809L102.337 43.4711V44.5Z" fill="#24292E"/>
<!-- Arrow indicating transformation -->
<path d="M71.9067 30C72.6011 30 77.9327 26 77.9327 24C77.9327 22 72.7357 18 71.9067 18C71.0778 18 70.4023 18.5 70.4023 19.4756C70.4023 20.4512 73.9067 22.9206 73.9067 22.9206C73.9067 22.9206 66.2539 22.25 66 22.9206C65.7461 23.5911 65.7461 24.4089 66 25.0794C66.2539 25.75 73.9067 25.0794 73.9067 25.0794C73.9067 25.0794 70.4023 27.75 70.4023 28.5301C70.4023 29.3103 71.2124 30 71.9067 30Z" fill="#24292E"/>
<defs>
<linearGradient id="paint0_linear_sql" x1="0" y1="6.5" x2="28" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
<linearGradient id="paint1_linear_sql" x1="92" y1="6.5" x2="120" y2="6.5" gradientUnits="userSpaceOnUse">
<stop stop-color="#F2CC0C"/>
<stop offset="1" stop-color="#FF9830"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 4.3 KiB

+4 -1
View File
@@ -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": {