expression icons in cards

This commit is contained in:
Paul Marbach
2025-12-04 09:31:01 -05:00
parent 1610daeafb
commit e9f4d90337
10 changed files with 112 additions and 96 deletions
@@ -5,8 +5,7 @@ import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Button, Dropdown, IconButton, Menu, useStyles2 } from '@grafana/ui';
import { EXPRESSION_ICON_MAP } from 'app/features/expressions/consts';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { ExpressionQueryType, getExpressionIcon } from 'app/features/expressions/types';
interface AddDataItemMenuProps {
onAddQuery: (index?: number) => void;
@@ -83,12 +82,7 @@ export const AddDataItemMenu = memo(
}
const expressionSubItems = expressionTypes.map(({ type, label }) => (
<Menu.Item
key={type}
label={label}
icon={EXPRESSION_ICON_MAP[type]}
onClick={() => onAddExpression(type, index)}
/>
<Menu.Item key={type} label={label} icon={getExpressionIcon(type)} onClick={() => onAddExpression(type, index)} />
));
const menu = (
@@ -11,8 +11,7 @@ import { DetailViewHeader } from './DetailViewHeader';
import { ExpressionDetailView } from './ExpressionDetailView';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
import { QueryDetailView } from './QueryDetailView';
import { QueryTransformItem } from './QueryTransformList';
import { TabId } from './types';
import { TabId, QueryTransformItem } from './types';
interface DetailViewProps {
selectedItem: QueryTransformItem | undefined;
@@ -27,8 +27,8 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryTransformItem } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
import { QueryTransformItem } from './types';
interface DetailViewHeaderProps {
selectedItem: QueryTransformItem;
@@ -27,10 +27,10 @@ import { DetailView } from './DetailView';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformList, QueryTransformItem } from './QueryTransformList';
import { QueryTransformList } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId } from './types';
import { PanelDataPaneTab, TabId, QueryItem, TransformItem } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export interface PanelDataPaneState extends SceneObjectState {
@@ -147,14 +147,14 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}, [queryRunner]);
// Build separate lists for queries/expressions and transformations
const { dataSourceItems, transformItems, allItems } = useMemo(() => {
const dataSourceItems: QueryTransformItem[] = [];
const transformItems: QueryTransformItem[] = [];
const { queryExpressionItems, transformItems, allItems } = useMemo(() => {
const queryExpressionItems: QueryItem[] = [];
const transformItems: TransformItem[] = [];
// Add queries and expressions
for (let i = 0; i < (queries?.length ?? 0); i++) {
const query = queries![i];
dataSourceItems.push({
queryExpressionItems.push({
id: queryItemId(query),
type: isExpressionQuery(query) ? 'expression' : 'query',
data: query,
@@ -176,9 +176,9 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
return {
dataSourceItems,
queryExpressionItems,
transformItems,
allItems: [...dataSourceItems, ...transformItems],
allItems: [...queryExpressionItems, ...transformItems],
};
}, [queries, transformations]);
@@ -423,7 +423,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
<div {...primaryProps} className={cx(primaryProps.className, styles.leftPane)}>
<QueryTransformList
allItems={allItems}
dataSourceItems={dataSourceItems}
dataSourceItems={queryExpressionItems}
transformItems={transformItems}
selectedId={effectiveSelectedId}
onSelect={handleSelect}
@@ -2,74 +2,94 @@ import { css } from '@emotion/css';
import clsx from 'clsx';
import { memo, useMemo } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2, IconName } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneDataQuery } from '@grafana/scenes';
import { Icon, IconButton, Stack, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { getExpressionIcon } from 'app/features/expressions/types';
import { usePanelDataPaneColors } from './theme';
import { queryItemId, transformItemId } from './utils';
import { QueryTransformItem } from './types';
interface QueryTransformCardProps {
item: SceneDataQuery | DataTransformerConfig;
type: 'query' | 'transform' | 'expression';
index: number;
item: QueryTransformItem;
isSelected: boolean;
onClick: () => void;
onDuplicate?: () => void;
onRemove?: () => void;
onToggleVisibility?: () => void;
onAddQuery: (index: number) => void;
onAddTransform: (index: number) => void;
onAddExpression: (type: ExpressionQueryType, index: number) => void;
}
export const QueryTransformCard = memo(
({
item,
type,
index,
item: { data, type, id: itemId, index },
isSelected,
onClick,
onDuplicate,
onRemove,
onToggleVisibility,
onAddQuery,
onAddTransform,
onAddExpression,
}: QueryTransformCardProps) => {
const colors = usePanelDataPaneColors();
const styles = useStyles2(getStyles, colors);
const getName = (): string => {
if ((type === 'query' || type === 'expression') && 'refId' in item) {
return item.refId || `${type === 'expression' ? 'Expression' : 'Query'} ${index + 1}`;
} else if ('id' in item) {
return item.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
}
return '';
};
const datasourceIcon = useMemo(() => {
if (type === 'query' && 'datasource' in item && item.datasource) {
if (type === 'query' && 'datasource' in data && data.datasource) {
try {
const dsSettings = getDataSourceSrv().getInstanceSettings(item.datasource);
const dsSettings = getDataSourceSrv().getInstanceSettings(data.datasource);
return dsSettings?.meta.info.logos.small;
} catch {
return undefined;
}
}
return undefined;
}, [type, item]);
}, [type, data]);
const isHidden =
((type === 'query' || type === 'expression') && 'hide' in item && item.hide) ||
(type === 'transform' && 'disabled' in item && item.disabled);
const icon = type === 'query' ? 'database' : type === 'expression' ? 'code' : 'pivot';
const typeLabel = type === 'query' ? 'Query' : type === 'expression' ? 'Expression' : 'Transformation';
const name = getName();
((type === 'query' || type === 'expression') && 'hide' in data && data.hide) ||
(type === 'transform' && 'disabled' in data && data.disabled);
const typeLabel = useMemo(() => {
switch (type) {
case 'query':
return t('dashboard-scene.query-transform-card.query.label', 'Query');
case 'expression':
return t('dashboard-scene.query-transform-card.expression.label', 'Expression');
case 'transform':
return t('dashboard-scene.query-transform-card.transform.label', 'Transform');
default:
throw new Error('unreachable');
}
}, [type]);
const name = useMemo(() => {
switch (type) {
case 'query':
case 'expression': {
// FIXME untranslated string
return data.refId || `${type === 'expression' ? 'Expression' : 'Query'} ${index + 1}`;
}
case 'transform':
return data.id.replace(/-/g, ' ').replace(/\b\w/g, (l: string) => l.toUpperCase());
default:
throw new Error('unreachable');
}
}, [type, data, index]);
const icon = useMemo((): IconName => {
switch (type) {
case 'query':
return 'database';
case 'expression': {
const type = isExpressionQuery(data) ? data.type : undefined;
return getExpressionIcon(type);
}
case 'transform':
return 'pivot';
default:
throw new Error('unreachable');
}
}, [data, type]);
const handleAction = (e: React.MouseEvent, action: () => void) => {
e.stopPropagation();
@@ -91,7 +111,7 @@ export const QueryTransformCard = memo(
onClick={onClick}
onKeyDown={handleKeyDown}
data-testid={`${type}-card-${index}`}
data-card-id={'refId' in item ? queryItemId(item) : transformItemId(index)}
data-card-id={itemId}
>
{/* Header with type and action icons */}
<div
@@ -2,9 +2,8 @@ import { css, cx } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { HTMLAttributes, memo, useCallback, useMemo, useState } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
import { GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneDataQuery } from '@grafana/scenes';
import { Button, Icon, ScrollContainer, Stack, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType } from 'app/features/expressions/types';
@@ -12,20 +11,14 @@ import { AddDataItemMenu } from './AddDataItemMenu';
import { AiModeCard } from './AiModeCard';
import { ConnectionLines } from './ConnectionLines';
import { QueryTransformCard } from './QueryTransformCard';
export interface QueryTransformItem {
id: string;
type: 'query' | 'transform' | 'expression';
data: SceneDataQuery | DataTransformerConfig;
index: number;
}
import { QueryItem, QueryTransformItem, TransformItem } from './types';
const CARD_HEIGHT = 70;
interface QueryTransformListProps {
allItems: QueryTransformItem[];
dataSourceItems: QueryTransformItem[];
transformItems: QueryTransformItem[];
dataSourceItems: QueryItem[];
transformItems: TransformItem[];
selectedId: string | null;
onSelect: (id: string) => void;
onAddQuery: (index?: number) => void;
@@ -104,7 +97,7 @@ export const QueryTransformList = memo(
const conns: Array<{ from: string; to: string }> = [];
allItems.forEach((item) => {
if (item.type === 'expression' && 'expression' in item.data && 'refId' in item.data) {
if (item.type === 'expression') {
const expr = item.data;
if ('expression' in expr && typeof expr.expression === 'string' && 'type' in expr) {
@@ -135,7 +128,7 @@ export const QueryTransformList = memo(
// Filter connections to only show for selected card
const visibleConnections = useMemo(() => {
if (!activeItem || !('refId' in activeItem.data)) {
if (activeItem?.type !== 'query' && activeItem?.type !== 'expression') {
return [];
}
@@ -249,9 +242,7 @@ export const QueryTransformList = memo(
visibleConnectionsRefIds.add(conn.from);
visibleConnectionsRefIds.add(conn.to);
}
return dataSourceItems.filter((item) => {
return 'refId' in item.data && visibleConnectionsRefIds.has(item.data.refId);
});
return dataSourceItems.filter((item) => visibleConnectionsRefIds.has(item.data.refId));
}, [dataSourceItems, viewingConnections, visibleConnections]);
const canAdd = !isDragging && !viewingConnections;
@@ -382,18 +373,13 @@ export const QueryTransformList = memo(
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
item={item}
isSelected={
isAiMode
? selectedContextIds.includes(item.id)
: selectedId === item.id
}
onClick={() => handleCardClick(item.id)}
onAddExpression={onAddExpression}
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
{...getHandlers(item)}
/>
</div>
@@ -484,18 +470,13 @@ export const QueryTransformList = memo(
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
item={item}
isSelected={
isAiMode
? selectedContextIds.includes(item.id)
: selectedId === item.id
}
onClick={() => handleCardClick(item.id)}
onAddExpression={onAddExpression}
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
{...getHandlers(item)}
/>
</div>
@@ -1,4 +1,5 @@
import { SceneObject } from '@grafana/scenes';
import { DataTransformerConfig } from '@grafana/data';
import { SceneDataQuery, SceneObject } from '@grafana/scenes';
export enum TabId {
Queries = 'queries',
@@ -16,3 +17,20 @@ export interface PanelDataPaneTab extends SceneObject {
getTabLabel(): string;
tabId: TabId;
}
interface QueryTransformItemBase {
id: string;
index: number;
}
export interface QueryItem extends QueryTransformItemBase {
type: 'query' | 'expression';
data: SceneDataQuery;
}
export interface TransformItem extends QueryTransformItemBase {
type: 'transform';
data: DataTransformerConfig;
}
export type QueryTransformItem = QueryItem | TransformItem;
@@ -5,7 +5,7 @@ import { FeatureState, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { Dropdown, FeatureBadge, Icon, Menu, Tooltip, useStyles2 } from '@grafana/ui';
import { ExpressionQueryType, expressionTypes } from 'app/features/expressions/types';
import { EXPRESSION_ICON_MAP } from '../consts';
import { getExpressionIcon } from '../types';
interface ExpressionTypeDropdownProps {
children: ReactElement;
@@ -28,7 +28,7 @@ const ExpressionMenuItem = memo<ExpressionMenuItemProps>(({ item, onSelect }) =>
component={() => (
<div className={styles.expressionTypeItem} role="menuitem">
<div className={styles.expressionTypeItemContent} data-testid={`expression-type-${value}`}>
<Icon className={styles.icon} name={EXPRESSION_ICON_MAP[value!]} aria-hidden="true" />
<Icon className={styles.icon} name={getExpressionIcon(value!)} aria-hidden="true" />
{label}
{value === ExpressionQueryType.sql && <FeatureBadge featureState={FeatureState.preview} />}
</div>
-10
View File
@@ -1,10 +0,0 @@
import { ExpressionQueryType } from './types';
export const EXPRESSION_ICON_MAP = {
[ExpressionQueryType.math]: 'calculator-alt',
[ExpressionQueryType.reduce]: 'compress-arrows',
[ExpressionQueryType.resample]: 'sync',
[ExpressionQueryType.classic]: 'cog',
[ExpressionQueryType.threshold]: 'sliders-v-alt',
[ExpressionQueryType.sql]: 'database',
} as const satisfies Record<ExpressionQueryType, string>;
+15 -1
View File
@@ -1,4 +1,4 @@
import { DataQuery, ReducerID, SelectableValue } from '@grafana/data';
import { DataQuery, IconName, ReducerID, SelectableValue } from '@grafana/data';
import { config } from 'app/core/config';
import { EvalFunction } from '../alerting/state/alertDef';
@@ -17,6 +17,7 @@ export enum ExpressionQueryType {
sql = 'sql',
}
// FIXME: should be translated
export const getExpressionLabel = (type: ExpressionQueryType) => {
switch (type) {
case ExpressionQueryType.math:
@@ -34,6 +35,19 @@ export const getExpressionLabel = (type: ExpressionQueryType) => {
}
};
export const EXPRESSION_ICON_MAP = {
[ExpressionQueryType.math]: 'calculator-alt',
[ExpressionQueryType.reduce]: 'compress-arrows',
[ExpressionQueryType.resample]: 'sync',
[ExpressionQueryType.classic]: 'cog',
[ExpressionQueryType.threshold]: 'sliders-v-alt',
[ExpressionQueryType.sql]: 'database',
} as const satisfies Record<ExpressionQueryType, string>;
export const getExpressionIcon = (type?: ExpressionQueryType): IconName => {
return type && type in EXPRESSION_ICON_MAP ? EXPRESSION_ICON_MAP[type] : 'calculator-alt';
};
export const expressionTypes: Array<SelectableValue<ExpressionQueryType>> = [
{
value: ExpressionQueryType.math,