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
This commit is contained in:
+16
-11
@@ -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 </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": {
|
||||
|
||||
Reference in New Issue
Block a user