SQL Expressions: Reconfigure add expression button for improved UX (#106797)

* feat: reconfigure expression button for improved UX

* chore: fix broken test

* chore: refactor to use improved UX + combine another UI PR.

* chore: i18n

* chore: memoize options + add data test ids for tracking

* chore: common component for expression dropdown

* chore: streamline common component

* chore: add event tracking

* chore: put event tracking in its own PR
pull/107810/head
Alex Spencer 2 weeks ago committed by GitHub
parent 79ebe2dc10
commit 869094bb37
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 27
      public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.tsx
  2. 66
      public/app/features/expressions/ExpressionQueryEditor.tsx
  3. 100
      public/app/features/expressions/components/ExpressionTypeDropdown.tsx
  4. 10
      public/app/features/expressions/components/SqlExpr.tsx
  5. 1
      public/locales/en-US/grafana.json

@ -18,6 +18,9 @@ import { addQuery } from 'app/core/utils/query';
import { getLastUsedDatasourceFromStorage } from 'app/features/dashboard/utils/dashboard';
import { storeLastUsedDataSourceInLocalStorage } from 'app/features/datasources/components/picker/utils';
import { dataSource as expressionDatasource } from 'app/features/expressions/ExpressionDatasource';
import { ExpressionTypeDropdown } from 'app/features/expressions/components/ExpressionTypeDropdown';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { getDefaults } from 'app/features/expressions/utils/expressionTypes';
import { GroupActionComponents } from 'app/features/query/components/QueryActionComponent';
import { QueryEditorRows } from 'app/features/query/components/QueryEditorRows';
import { QueryGroupTopSection } from 'app/features/query/components/QueryGroup';
@ -286,9 +289,15 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
return (dsSettings.meta.backend || dsSettings.meta.alerting || dsSettings.meta.mixed) === true;
}
public onAddExpressionClick = () => {
public onAddExpressionOfType = (type: ExpressionQueryType) => {
const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, expressionDatasource.newQuery()));
// Create base expression query with the specified type
const baseQuery = expressionDatasource.newQuery();
const queryWithType = { ...baseQuery, type };
// Apply defaults specific to the expression type
const queryWithDefaults = getDefaults(queryWithType);
this.onQueriesChange(addQuery(queries, queryWithDefaults));
};
public renderExtraActions() {
@ -316,6 +325,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
if (!datasource || !dsSettings || !data) {
return null;
}
const showAddButton = !isSharedDashboardQuery(dsSettings.name);
const onSelectQueryFromLibrary = async (query: DataQuery) => {
// ensure all queries explicitly define a datasource
@ -394,16 +404,11 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
</>
)}
{config.expressionsEnabled && model.isExpressionsSupported(dsSettings) && (
<Button
icon="plus"
onClick={model.onAddExpressionClick}
variant="secondary"
data-testid={selectors.components.QueryTab.addExpression}
>
<span>
<ExpressionTypeDropdown handleOnSelect={model.onAddExpressionOfType}>
<Button icon="plus" variant="secondary" data-testid={selectors.components.QueryTab.addExpression}>
<Trans i18nKey="dashboard-scene.panel-data-queries-tab-rendered.expression">Expression&nbsp;</Trans>
</span>
</Button>
</Button>
</ExpressionTypeDropdown>
)}
{model.renderExtraActions()}
</Stack>

@ -1,10 +1,12 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useRef } from 'react';
import { DataSourceApi, QueryEditorProps, SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { InlineField, Select } from '@grafana/ui';
import { DataSourceApi, GrafanaTheme2, QueryEditorProps } from '@grafana/data';
import { t, Trans } from '@grafana/i18n';
import { Button, IconButton, InlineField, PopoverContent, useStyles2 } from '@grafana/ui';
import { ClassicConditions } from './components/ClassicConditions';
import { ExpressionTypeDropdown } from './components/ExpressionTypeDropdown';
import { Math } from './components/Math';
import { Reduce } from './components/Reduce';
import { Resample } from './components/Resample';
@ -20,6 +22,24 @@ const labelWidth = 15;
type NonClassicExpressionType = Exclude<ExpressionQueryType, ExpressionQueryType.classic>;
type ExpressionTypeConfigStorage = Partial<Record<NonClassicExpressionType, string>>;
// Help text for each expression type - can be expanded with more detailed content
const getExpressionHelpText = (type: ExpressionQueryType): PopoverContent | string => {
const description = expressionTypes.find(({ value }) => value === type)?.description;
switch (type) {
case ExpressionQueryType.sql:
return (
<Trans i18nKey="expressions.expression-query-editor.helper-text-sql">
Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie "A", "B")
are available as tables and referenced by query-name. Fields are available as columns, as returned from the
data source.
</Trans>
);
default:
return description ?? '';
}
};
function useExpressionsCache() {
const expressionCache = useRef<ExpressionTypeConfigStorage>({});
@ -62,14 +82,16 @@ export function ExpressionQueryEditor(props: Props) {
const { query, queries, onRunQuery, onChange, app } = props;
const { getCachedExpression, setCachedExpression } = useExpressionsCache();
const styles = useStyles2(getStyles);
useEffect(() => {
setCachedExpression(query.type, query.expression);
}, [query.expression, query.type, setCachedExpression]);
const onSelectExpressionType = useCallback(
(item: SelectableValue<ExpressionQueryType>) => {
const cachedExpression = getCachedExpression(item.value!);
const defaults = getDefaults({ ...query, type: item.value! });
(value: ExpressionQueryType) => {
const cachedExpression = getCachedExpression(value!);
const defaults = getDefaults({ ...query, type: value! });
onChange({ ...defaults, expression: cachedExpression ?? defaults.expression });
},
@ -100,17 +122,35 @@ export function ExpressionQueryEditor(props: Props) {
}
};
const selected = expressionTypes.find((o) => o.value === query.type);
const helperText = getExpressionHelpText(query.type);
return (
<div>
<InlineField
label={t('expressions.expression-query-editor.label-operation', 'Operation')}
labelWidth={labelWidth}
>
<Select options={expressionTypes} value={selected} onChange={onSelectExpressionType} width={25} />
</InlineField>
<div className={styles.operationRow}>
<InlineField
label={t('expressions.expression-query-editor.label-operation', 'Operation')}
labelWidth={labelWidth}
>
<ExpressionTypeDropdown handleOnSelect={onSelectExpressionType}>
<Button fill="outline" icon="angle-down" iconPlacement="right" variant="secondary">
{expressionTypes.find(({ value }) => value === query.type)?.label}
</Button>
</ExpressionTypeDropdown>
</InlineField>
{helperText && <IconButton className={styles.infoIcon} name="info-circle" tooltip={helperText} />}
</div>
{renderExpressionType()}
</div>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
operationRow: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
infoIcon: css({
marginBottom: theme.spacing(0.5), // Align with the select field
}),
});

@ -0,0 +1,100 @@
import { css } from '@emotion/css';
import { ReactElement, useCallback, useMemo, memo } from 'react';
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';
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>;
interface ExpressionTypeDropdownProps {
children: ReactElement;
handleOnSelect: (value: ExpressionQueryType) => void;
}
interface ExpressionMenuItemProps {
item: SelectableValue<ExpressionQueryType>;
onSelect: (value: ExpressionQueryType) => void;
}
const ExpressionMenuItem = memo<ExpressionMenuItemProps>(({ item, onSelect }) => {
const { value, label, description } = item;
const styles = useStyles2(getStyles);
const handleClick = useCallback(() => onSelect(value!), [value, onSelect]);
return (
<Menu.Item
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" />
{label}
{value === ExpressionQueryType.sql && <FeatureBadge featureState={FeatureState.new} />}
</div>
<Tooltip placement="right" content={description!}>
<Icon className={styles.infoIcon} name="info-circle" />
</Tooltip>
</div>
)}
key={value}
label=""
onClick={handleClick}
/>
);
});
ExpressionMenuItem.displayName = 'ExpressionMenuItem';
export const ExpressionTypeDropdown = memo<ExpressionTypeDropdownProps>(({ handleOnSelect, children }) => {
const menuItems = useMemo(
() => expressionTypes.map((item) => <ExpressionMenuItem key={item.value} item={item} onSelect={handleOnSelect} />),
[handleOnSelect]
);
const menuOverlay = useMemo(() => <Menu role="menu">{menuItems}</Menu>, [menuItems]);
return (
<Dropdown placement="bottom-start" overlay={menuOverlay}>
{children}
</Dropdown>
);
});
ExpressionTypeDropdown.displayName = 'ExpressionTypeDropdown';
const getStyles = (theme: GrafanaTheme2) => {
return {
expressionTypeItem: css({
width: '100%',
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
expressionTypeItemContent: css({
flexGrow: 1,
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
}),
icon: css({
color: theme.colors.text.secondary,
flexShrink: 0,
}),
infoIcon: css({
opacity: 0.7,
color: theme.colors.text.secondary,
flexShrink: 0,
}),
};
};

@ -30,12 +30,10 @@ interface Props {
export const SqlExpr = ({ onChange, refIds, query, alerting = false }: Props) => {
const vars = useMemo(() => refIds.map((v) => v.value!), [refIds]);
const initialQuery = `-- Run MySQL-dialect SQL against the tables returned from your data sources.
-- Data source queries (ie "${vars[0]}") are available as tables and referenced by query-name
-- Fields are available as columns, as returned from the data source.
SELECT *
FROM ${vars[0]}
LIMIT 10`;
const initialQuery = `SELECT *
FROM ${vars[0]}
LIMIT 10`;
const styles = useStyles2(getStyles);
const containerRef = useRef<HTMLDivElement>(null);
const [dimensions, setDimensions] = useState({ height: 0 });

@ -7209,6 +7209,7 @@
"when": "WHEN"
},
"expression-query-editor": {
"helper-text-sql": "Run MySQL-dialect SQL against the tables returned from your data sources. Data source queries (ie \"A\", \"B\") are available as tables and referenced by query-name. Fields are available as columns, as returned from the data source.",
"label-operation": "Operation"
},
"math": {

Loading…
Cancel
Save