clean up state logic, add the inline add button

This commit is contained in:
Paul Marbach
2025-12-03 16:02:57 -05:00
parent eacd341de7
commit 296e17f321
6 changed files with 413 additions and 361 deletions
+9 -2
View File
@@ -17,7 +17,12 @@ export function queryIsEmpty(query: DataQuery): boolean {
return true;
}
export function addQuery(queries: DataQuery[], query?: Partial<DataQuery>, datasource?: DataSourceRef): DataQuery[] {
export function addQuery(
queries: DataQuery[],
query?: Partial<DataQuery>,
datasource?: DataSourceRef,
index = queries.length
): DataQuery[] {
const q: DataQuery = {
...query,
refId: getNextRefId(queries),
@@ -28,7 +33,9 @@ export function addQuery(queries: DataQuery[], query?: Partial<DataQuery>, datas
q.datasource = datasource;
}
return [...queries, q];
queries.splice(index, 0, q);
return [...queries];
}
export function isDataQuery(url: string): boolean {
@@ -1,20 +1,37 @@
import { memo } from 'react';
import { memo, useState } from 'react';
import { t } from '@grafana/i18n';
import { config } from '@grafana/runtime';
import { Dropdown, IconButton, Menu, Stack } from '@grafana/ui';
import { Dropdown, IconButton, Menu } from '@grafana/ui';
import { EXPRESSION_ICON_MAP } from 'app/features/expressions/consts';
import { ExpressionQueryType } from 'app/features/expressions/types';
interface AddDataItemMenuProps {
onAddQuery: () => void;
onAddFromSavedQueries: () => void;
onAddTransform: () => void;
onAddExpression: (type: ExpressionQueryType) => void;
onAddQuery: (index?: number) => void;
onAddFromSavedQueries: (index?: number) => void;
onAddTransform: (index?: number) => void;
onAddExpression: (type: ExpressionQueryType, index?: number) => void;
index?: number;
allowedTypes?: Array<'query' | 'transform' | 'expression'>;
show?: boolean;
}
export const AddDataItemMenu = memo(
({ onAddQuery, onAddFromSavedQueries, onAddTransform, onAddExpression }: AddDataItemMenuProps) => {
({
onAddQuery,
onAddTransform,
onAddExpression,
onAddFromSavedQueries,
index,
allowedTypes = ['query', 'expression', 'transform'],
show = true,
}: AddDataItemMenuProps) => {
const [menuShown, setMenuShown] = useState(false);
if (!show && !menuShown) {
return;
}
const expressionTypes = [
{ type: ExpressionQueryType.math, label: t('dashboard-scene.add-data-item-menu.expression-math', 'Math') },
{ type: ExpressionQueryType.reduce, label: t('dashboard-scene.add-data-item-menu.expression-reduce', 'Reduce') },
@@ -41,45 +58,52 @@ export const AddDataItemMenu = memo(
}
const expressionSubItems = expressionTypes.map(({ type, label }) => (
<Menu.Item key={type} label={label} icon={EXPRESSION_ICON_MAP[type]} onClick={() => onAddExpression(type)} />
<Menu.Item
key={type}
label={label}
icon={EXPRESSION_ICON_MAP[type]}
onClick={() => onAddExpression(type, index)}
/>
));
const menu = (
<Menu>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-query', 'Query')}
icon="database"
onClick={onAddQuery}
/>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-from-saved-queries', 'From saved queries')}
icon="bookmark"
onClick={onAddFromSavedQueries}
/>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-transformation', 'Transformation')}
icon="process"
onClick={onAddTransform}
/>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.expressions-group', 'Expression')}
icon="calculator-alt"
childItems={expressionSubItems}
/>
{allowedTypes.includes('query') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-query', 'Query')}
icon="database"
onClick={() => onAddQuery(index)}
/>
)}
{allowedTypes.includes('query') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-from-saved-queries', 'From saved queries')}
icon="bookmark"
onClick={() => onAddFromSavedQueries(index)}
/>
)}
{allowedTypes.includes('transform') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-transformation', 'Transformation')}
icon="process"
onClick={() => onAddTransform(index)}
/>
)}
{allowedTypes.includes('expression') && (
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.expressions-group', 'Expression')}
icon="calculator-alt"
childItems={expressionSubItems}
/>
)}
</Menu>
);
return (
<Stack direction="row" justifyContent="center" alignItems="center">
<Dropdown overlay={menu} placement="top-start">
<IconButton
name="plus-circle"
size="xxl"
variant="secondary"
tooltip={t('dashboard-scene.add-data-item-menu.add-button', 'Add')}
/>
</Dropdown>
</Stack>
<Dropdown overlay={menu} placement="top-end" onVisibleChange={(shown) => setMenuShown(shown)}>
<IconButton name="plus" size="xs" variant="primary" tooltip={t('dashboard-scene.add-data-item-menu.add-button', 'Add')} />
</Dropdown>
);
}
);
@@ -1,5 +1,5 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { DataQuery, DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
@@ -11,7 +11,6 @@ import {
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
VizPanel,
SceneDataTransformer,
SceneDataQuery,
} from '@grafana/scenes';
import { useStyles2, useSplitter } from '@grafana/ui';
@@ -21,8 +20,7 @@ import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { isExpressionQuery } from '../../../expressions/guards';
import { ExpressionDatasourceUID, ExpressionQuery, ExpressionQueryType } from '../../../expressions/types';
import { getDefaults } from '../../../expressions/utils/expressionTypes';
import { ExpressionQueryType } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { DetailView } from './DetailView';
@@ -85,32 +83,45 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
}
}
interface DrawerState {
open: boolean;
index: number | null;
}
function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const { tabs, panelRef } = model.useState();
const styles = useStyles2(getStyles);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [transformDrawerOpen, setTransformDrawerOpen] = useState(false);
const [savedQueriesDrawerOpen, setSavedQueriesDrawerOpen] = useState(false);
const [savedQueriesDrawerState, setSavedQueriesDrawerState] = useState<DrawerState>({
open: false,
index: null,
});
const [transformDrawerState, setTransformDrawerState] = useState<DrawerState>({
open: false,
index: null,
});
const panel = panelRef.resolve();
// Subscribe to query runner state changes
// Subscribe to query runner and tab state changes
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const queriesTab = tabs.find((t): t is PanelDataQueriesTab => t.tabId === TabId.Queries);
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
const transformer = transformsTab?.getDataTransformer();
const transformerState = transformer?.useState();
const queries = queryRunnerState?.queries;
const transformations = transformerState?.transformations;
// the selectedId is based on the refId of the query. refId is a user-editable property, so it can change,
// which will break the selectId and result in the UI going into a deselected state. to avoid this,
// we can subscribe to changes and detect if a single refId just changed, and then assume that change
// is a rename of the currently selected query.
const orderedRefIds = useRef<string[]>(queryRunnerState?.queries.map(({ refId }) => refId) ?? []);
useEffect(() => {
queryRunner?.subscribeToState((newState) => {
queryRunner?.subscribeToState((newState, prevState) => {
// loop over the new queries and confirm that the refIds are the same. if not, then a mutation
// occurred, but we need to figure out if it was a rename or a reorder
const oldOrderedRefIds = orderedRefIds.current;
orderedRefIds.current = newState.queries.map(({ refId }) => refId);
const oldOrderedRefIds = prevState.queries.map(({ refId }) => refId);
if (newState.queries.length !== oldOrderedRefIds.length) {
return; // add, remove, something else.
}
@@ -135,17 +146,12 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
});
}, [queryRunner]);
// Subscribe to data transformer state changes
const dataTransformer = panel.state.$data instanceof SceneDataTransformer ? panel.state.$data : null;
const transformerState = dataTransformer?.useState();
// Build separate lists for queries/expressions and transformations
const { dataSourceItems, transformItems, allItems } = useMemo(() => {
const dataSourceItems: QueryTransformItem[] = [];
const transformItems: QueryTransformItem[] = [];
// Add queries and expressions
const queries = queryRunnerState?.queries;
for (let i = 0; i < (queries?.length ?? 0); i++) {
const query = queries![i];
dataSourceItems.push({
@@ -157,7 +163,6 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
// Add transformations
const transformations = transformerState?.transformations;
for (let i = 0; i < (transformations?.length ?? 0); i++) {
const transform = transformations![i];
if (isDataTransformerConfig(transform)) {
@@ -175,7 +180,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
transformItems,
allItems: [...dataSourceItems, ...transformItems],
};
}, [queryRunnerState?.queries, transformerState?.transformations]);
}, [queries, transformations]);
// Auto-select first item if nothing is selected
const effectiveSelectedId = useMemo(() => {
@@ -185,48 +190,120 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
return selectedId;
}, [selectedId, allItems]);
const selectedItem = useMemo(() => {
return allItems.find((item) => item.id === effectiveSelectedId);
}, [allItems, effectiveSelectedId]);
const selectedItem = useMemo(
() => allItems.find((item) => item.id === effectiveSelectedId),
[allItems, effectiveSelectedId]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
}, []);
const handleAddQuery = useCallback(() => {
const queriesTab = tabs.find((t) => t.tabId === TabId.Queries);
if (queriesTab instanceof PanelDataQueriesTab) {
queriesTab.addQueryClick();
// Select the new query after a short delay
setTimeout(() => {
const newQueries = getQueryRunnerFor(panel)?.state.queries || [];
if (newQueries.length > 0) {
setSelectedId(queryItemId(newQueries[newQueries.length - 1]));
}
}, 100);
}
}, [tabs, panel]);
const updateQuerySelectionOnStateChange = useCallback(
(index: number) => {
if (queryRunner) {
const unsub = queryRunner.subscribeToState((newState) => {
const newQueries = newState.queries;
if (newQueries.length > 0) {
const selected = newQueries[index] ?? newQueries[0];
setSelectedId(queryItemId(selected));
}
unsub.unsubscribe();
});
}
},
[queryRunner]
);
const handleAddFromSavedQueries = useCallback(() => {
setSavedQueriesDrawerOpen(true);
}, []);
/** QUERIES AND EXPRESSIONS **/
const handleAddQuery = useCallback(
(index?: number) => {
if (queriesTab) {
updateQuerySelectionOnStateChange(index ?? queries?.length ?? 0);
queriesTab.addQueryClick(index);
}
},
[queries, queriesTab, updateQuerySelectionOnStateChange]
);
const handleAddExpression = useCallback(
(type: ExpressionQueryType, index?: number) => {
if (queriesTab) {
updateQuerySelectionOnStateChange(index ?? queries?.length ?? 0);
queriesTab.onAddExpressionOfType(type, index);
}
},
[queriesTab, updateQuerySelectionOnStateChange, queries]
);
const handleDuplicateQuery = useCallback(
(index: number) => {
if (queryRunner && queriesTab) {
const queryToDuplicate = queries?.[index];
if (queryToDuplicate) {
// Create a copy with a new refId
let newRefId = queryToDuplicate.refId;
let counter = 1;
while (queries.some((q) => q.refId === newRefId)) {
newRefId = `${queryToDuplicate.refId}_${counter}`;
counter++;
}
const duplicatedQuery = {
...queryToDuplicate,
refId: newRefId,
};
updateQuerySelectionOnStateChange(index + 1);
queriesTab.onAddQuery(duplicatedQuery, index + 1);
}
}
},
[queryRunner, queriesTab, queries, updateQuerySelectionOnStateChange]
);
const handleRemoveQuery = useCallback(
(index: number) => {
if (queryRunner) {
const deletedQuery = queries?.[index];
const newQueries = queries?.filter((_, i) => i !== index);
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
// Clear selection if removing the selected query
if (deletedQuery && selectedId === queryItemId(deletedQuery)) {
setSelectedId(null);
}
}
},
[queryRunner, selectedId, queries]
);
const handleToggleQueryVisibility = useCallback(
(index: number) => {
if (queryRunner) {
const newQueries = queries?.map((q, i) => (i === index ? { ...q, hide: !q.hide } : q));
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
}
},
[queryRunner, queries]
);
// This is a stub for the saved queries drawer
const handleSelectSavedQuery = useCallback(
(query: DataQuery) => {
if (!queryRunner) {
if (!queryRunner || !queriesTab) {
return;
}
const queries = queryRunner.state.queries || [];
const selectedIndex = savedQueriesDrawerState.index ?? queries?.length ?? 0;
// Get next available refId
const existingRefIds = queries.map((q) => q.refId);
let nextRefId = 'A';
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < alphabet.length; i++) {
if (!existingRefIds.includes(alphabet[i])) {
if (!queries?.some(q => q.refId === alphabet[i])) {
nextRefId = alphabet[i];
break;
}
@@ -238,211 +315,47 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
refId: nextRefId,
};
queryRunner.setState({ queries: [...queries, newQuery] });
updateQuerySelectionOnStateChange(selectedIndex);
queriesTab.onAddQuery(newQuery);
// Select the new query after a short delay
setTimeout(() => {
setSelectedId(queryItemId(newQuery));
}, 100);
setSavedQueriesDrawerOpen(false);
setSavedQueriesDrawerState({ open: false, index: null });
},
[queryRunner]
[queryRunner, savedQueriesDrawerState.index, queries, updateQuerySelectionOnStateChange, queriesTab]
);
const handleAddTransform = useCallback(() => {
setTransformDrawerOpen(true);
}, []);
const handleAddExpression = useCallback(
(type: ExpressionQueryType) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
// Get next available refId
const existingRefIds = queries.map((q) => q.refId);
let nextRefId = 'A';
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ';
for (let i = 0; i < alphabet.length; i++) {
if (!existingRefIds.includes(alphabet[i])) {
nextRefId = alphabet[i];
break;
}
}
// Create new expression with defaults
const newExpression: ExpressionQuery = getDefaults({
refId: nextRefId,
type,
datasource: { uid: ExpressionDatasourceUID, type: '__expr__' },
});
// Add to queries
const newQueries = [...queries, newExpression];
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
// Select the new expression
setSelectedId(queryItemId(newExpression));
}
},
[queryRunner]
);
const handleTransformationAdd = useCallback(
/** TRANSFORMS **/
const handleAddTransform = useCallback(
(selected: SelectableValue<string>) => {
if (!selected.value) {
return;
}
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
if (transformsTab instanceof PanelDataTransformationsTab) {
const transformations = (transformsTab.getDataTransformer().state.transformations || []).filter(
isDataTransformerConfig
);
if (transformsTab && transformer) {
const selectedIndex = transformDrawerState.index ?? transformations?.length ?? 0;
const newTransformation: DataTransformerConfig = {
id: selected.value,
options: {},
};
transformsTab.onChangeTransformations([...transformations, newTransformation]);
setTransformDrawerOpen(false);
const unsub = transformer.subscribeToState((newState) => {
const newTransform = newState.transformations[selectedIndex];
// Select the newly added transformation
setTimeout(() => {
setSelectedId(transformItemId(transformations.length));
}, 100);
setSelectedId(!!newTransform ? transformItemId(selectedIndex) : null);
setTransformDrawerState({ open: false, index: null });
unsub.unsubscribe();
});
const newTransformations = [...(transformations?.filter(isDataTransformerConfig) ?? [])];
newTransformations.splice(selectedIndex, 0, newTransformation);
transformsTab.onChangeTransformations(newTransformations);
}
},
[tabs]
);
const handleDuplicateQuery = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const queryToDuplicate = queries[index];
if (queryToDuplicate) {
// Create a copy with a new refId
const existingRefIds = queries.map((q) => q.refId);
let newRefId = queryToDuplicate.refId;
let counter = 1;
while (existingRefIds.includes(newRefId)) {
newRefId = `${queryToDuplicate.refId}_${counter}`;
counter++;
}
const duplicatedQuery = {
...queryToDuplicate,
refId: newRefId,
};
const newQueries = [...queries, duplicatedQuery];
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
// Select the new query
setSelectedId(queryItemId(duplicatedQuery));
}
}
},
[queryRunner]
);
const handleRemoveQuery = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = queries.filter((_, i) => i !== index);
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
// Clear selection if removing the selected query
if (selectedId === queryItemId(queries[index])) {
setSelectedId(null);
}
}
},
[queryRunner, selectedId]
);
const handleToggleQueryVisibility = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = queries.map((q, i) => (i === index ? { ...q, hide: !q.hide } : q));
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
}
},
[queryRunner]
);
// Expression handlers (use actual index in queries array)
const handleDuplicateExpression = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const expressionToDuplicate = queries[index];
if (expressionToDuplicate && isExpressionQuery(expressionToDuplicate)) {
const existingRefIds = queries.map((q) => q.refId);
let newRefId = expressionToDuplicate.refId;
let counter = 1;
while (existingRefIds.includes(newRefId)) {
newRefId = `${expressionToDuplicate.refId}_${counter}`;
counter++;
}
const duplicatedExpression = {
...expressionToDuplicate,
refId: newRefId,
};
const newQueries = [...queries, duplicatedExpression];
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
setSelectedId(queryItemId(duplicatedExpression));
}
}
},
[queryRunner]
);
const handleRemoveExpression = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = queries.filter((_, i) => i !== index);
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
const expressionToRemove = queries[index];
if (expressionToRemove && selectedId === queryItemId(expressionToRemove)) {
setSelectedId(null);
}
}
},
[queryRunner, selectedId]
);
const handleToggleExpressionVisibility = useCallback(
(index: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = queries.map((q, i) => (i === index ? { ...q, hide: !q.hide } : q));
queryRunner.setState({ queries: newQueries });
queryRunner.runQueries();
}
},
[queryRunner]
[transformsTab, transformer, transformations, transformDrawerState.index]
);
const handleRemoveTransform = useCallback(
(index: number) => {
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
if (transformsTab) {
const transformations = (transformsTab.getDataTransformer().state.transformations || []).filter(
isDataTransformerConfig
@@ -456,12 +369,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
}
},
[tabs, selectedId]
[transformsTab, selectedId]
);
const handleToggleTransformVisibility = useCallback(
(index: number) => {
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
if (transformsTab) {
const transformations = (transformsTab.getDataTransformer().state.transformations || []).filter(
isDataTransformerConfig
@@ -472,7 +384,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
transformsTab.onChangeTransformations(newTransformations);
}
},
[tabs]
[transformsTab]
);
const handleReorderDataSources = useCallback(
@@ -490,7 +402,6 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const handleReorderTransforms = useCallback(
(startIndex: number, endIndex: number) => {
const transformsTab = tabs.find((t): t is PanelDataTransformationsTab => t.tabId === TabId.Transformations);
if (transformsTab) {
const transformations = (transformsTab.getDataTransformer().state.transformations || []).filter(
isDataTransformerConfig
@@ -501,7 +412,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
transformsTab.onChangeTransformations(newTransformations);
}
},
[tabs]
[transformsTab]
);
// Get data for transformations drawer
@@ -525,16 +436,12 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
selectedId={effectiveSelectedId}
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={handleAddFromSavedQueries}
onAddTransform={handleAddTransform}
onAddFromSavedQueries={index => setSavedQueriesDrawerState({ open: true, index: index ?? null })}
onAddTransform={(index) => setTransformDrawerState({ open: true, index: index ?? null })}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
// TODO: can all the expression stuff just be handled with the query handlers since expressions are queries?
onDuplicateExpression={handleDuplicateExpression}
onRemoveExpression={handleRemoveExpression}
onToggleExpressionVisibility={handleToggleExpressionVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
@@ -557,14 +464,14 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
</div>
</div>
<TransformationsDrawer
isOpen={transformDrawerOpen}
onClose={() => setTransformDrawerOpen(false)}
onTransformationAdd={handleTransformationAdd}
isOpen={transformDrawerState.open}
onClose={() => setTransformDrawerState({ open: false, index: null })}
onTransformationAdd={handleAddTransform}
series={series}
/>
<SavedQueriesDrawer
isOpen={savedQueriesDrawerOpen}
onClose={() => setSavedQueriesDrawerOpen(false)}
isOpen={savedQueriesDrawerState.open}
onClose={() => setSavedQueriesDrawerState({ open: false, index: null })}
onSelectQuery={handleSelectSavedQuery}
/>
</div>
@@ -273,17 +273,25 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
};
}
public addQueryClick = () => {
public addQueryClick = (index?: number) => {
const queries = this.getQueries();
this.onQueriesChange(addQuery(queries, this.newQuery()));
const dsSettings = this.state.dsSettings;
this.onQueriesChange(
addQuery(
queries,
this.newQuery(),
dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined },
index
)
);
};
public onAddQuery = (query: Partial<DataQuery>) => {
public onAddQuery = (query: Partial<DataQuery>, index?: number) => {
const queries = this.getQueries();
const dsSettings = this.state.dsSettings;
this.onQueriesChange(
addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined })
addQuery(queries, query, dsSettings ? getDataSourceRef(dsSettings) : { type: undefined, uid: undefined }, index)
);
};
@@ -291,7 +299,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
return (dsSettings.meta.backend || dsSettings.meta.alerting || dsSettings.meta.mixed) === true;
}
public onAddExpressionOfType = (type: ExpressionQueryType) => {
public onAddExpressionOfType = (type: ExpressionQueryType, index?: number) => {
const queries = this.getQueries();
// Create base expression query with the specified type
const baseQuery = expressionDatasource.newQuery();
@@ -299,7 +307,7 @@ export class PanelDataQueriesTab extends SceneObjectBase<PanelDataQueriesTabStat
// Apply defaults specific to the expression type
const queryWithDefaults = getDefaults(queryWithType);
this.onQueriesChange(addQuery(queries, queryWithDefaults));
this.onQueriesChange(addQuery(queries, queryWithDefaults, undefined, index));
};
public renderExtraActions() {
@@ -411,7 +419,7 @@ export function PanelDataQueriesTabRendered({ model }: SceneComponentProps<Panel
<>
<Button
icon="plus"
onClick={model.addQueryClick}
onClick={(ev) => model.addQueryClick()}
variant="secondary"
data-testid={selectors.components.QueryTab.addQuery}
>
@@ -7,6 +7,7 @@ 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 { usePanelDataPaneColors } from './theme';
import { queryItemId, transformItemId } from './utils';
@@ -20,10 +21,25 @@ interface QueryTransformCardProps {
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, isSelected, onClick, onDuplicate, onRemove, onToggleVisibility }: QueryTransformCardProps) => {
({
item,
type,
index,
isSelected,
onClick,
onDuplicate,
onRemove,
onToggleVisibility,
onAddQuery,
onAddTransform,
onAddExpression,
}: QueryTransformCardProps) => {
const colors = usePanelDataPaneColors();
const styles = useStyles2(getStyles, colors);
@@ -1,6 +1,6 @@
import { css } from '@emotion/css';
import { css, cx } from '@emotion/css';
import { DragDropContext, Draggable, Droppable, DropResult } from '@hello-pangea/dnd';
import { memo, useMemo, useState } from 'react';
import { memo, useEffect, useMemo, useState } from 'react';
import { DataTransformerConfig, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
@@ -19,22 +19,21 @@ export interface QueryTransformItem {
index: number;
}
const CARD_HEIGHT = 70;
interface QueryTransformListProps {
allItems: QueryTransformItem[];
dataSourceItems: QueryTransformItem[];
transformItems: QueryTransformItem[];
selectedId: string | null;
onSelect: (id: string) => void;
onAddQuery: () => void;
onAddFromSavedQueries: () => void;
onAddTransform: () => void;
onAddExpression: (type: ExpressionQueryType) => void;
onAddQuery: (index?: number) => void;
onAddFromSavedQueries: (index?: number) => void;
onAddTransform: (index?: number) => void;
onAddExpression: (type: ExpressionQueryType, index?: number) => void;
onDuplicateQuery?: (index: number) => void;
onRemoveQuery?: (index: number) => void;
onToggleQueryVisibility?: (index: number) => void;
onDuplicateExpression?: (index: number) => void;
onRemoveExpression?: (index: number) => void;
onToggleExpressionVisibility?: (index: number) => void;
onRemoveTransform?: (index: number) => void;
onToggleTransformVisibility?: (index: number) => void;
onReorderDataSources?: (startIndex: number, endIndex: number) => void;
@@ -55,9 +54,6 @@ export const QueryTransformList = memo(
onDuplicateQuery,
onRemoveQuery,
onToggleQueryVisibility,
onDuplicateExpression,
onRemoveExpression,
onToggleExpressionVisibility,
onRemoveTransform,
onToggleTransformVisibility,
onReorderDataSources,
@@ -65,6 +61,11 @@ export const QueryTransformList = memo(
}: QueryTransformListProps) => {
const styles = useStyles2(getStyles);
const [isDragging, setIsDragging] = useState(false);
const [hovered, setHovered] = useState<string | null>(null);
useEffect(() => {
console.log('hovered changed:', hovered);
}, [hovered]);
const onDragStart = () => {
setIsDragging(true);
@@ -143,20 +144,13 @@ export const QueryTransformList = memo(
const getHandlers = (item: QueryTransformItem) => {
switch (item.type) {
case 'expression':
case 'query':
return {
onDuplicate: onDuplicateQuery ? () => onDuplicateQuery(item.index) : undefined,
onRemove: onRemoveQuery ? () => onRemoveQuery(item.index) : undefined,
onToggleVisibility: onToggleQueryVisibility ? () => onToggleQueryVisibility(item.index) : undefined,
};
case 'expression':
return {
onDuplicate: onDuplicateExpression ? () => onDuplicateExpression(item.index) : undefined,
onRemove: onRemoveExpression ? () => onRemoveExpression(item.index) : undefined,
onToggleVisibility: onToggleExpressionVisibility
? () => onToggleExpressionVisibility(item.index)
: undefined,
};
case 'transform':
return {
onDuplicate: undefined,
@@ -181,7 +175,7 @@ export const QueryTransformList = memo(
}, [allItems, dataSourceItems, transformItems]);
return (
<div className={styles.container}>
<div className={styles.container} onMouseLeave={() => setHovered(null)}>
<div className={styles.header}>
<Stack justifyContent="space-between" alignItems="center" gap={2}>
<span className={styles.headerTitle}>
@@ -212,36 +206,80 @@ export const QueryTransformList = memo(
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={isDraggingFromOtherSection ? styles.droppableInvalid : undefined}
onMouseMove={ev => {
const rect = ev.currentTarget.getBoundingClientRect();
const y = ev.clientY - rect.top;
let hoveredIdx = Math.floor(y / CARD_HEIGHT);
if (hoveredIdx < 0) {
hoveredIdx = 0;
}
if (hoveredIdx > dataSourceItems.length) {
hoveredIdx = dataSourceItems.length;
}
const hoveredId = hoveredIdx === dataSourceItems.length ? 'queries-last' : dataSourceItems[hoveredIdx].id;
setHovered(hoveredId);
}}
className={cx(styles.cardList, isDraggingFromOtherSection ? styles.droppableInvalid : undefined)}
>
<Stack direction="column" gap={2}>
{dataSourceItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
<div key={item.id} className={styles.cardContainer}>
<Draggable draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
onAddExpression={onAddExpression}
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
<div className={styles.floatingButton}>
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
onAddFromSavedQueries={onAddFromSavedQueries}
index={index}
allowedTypes={['query', 'expression']}
show={hovered === item.id}
/>
</div>
</div>
))}
{provided.placeholder}
</Stack>
<div className={cx(styles.cardContainer, styles.cardContainerLast)}>
<div className={styles.floatingButton}>
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddFromSavedQueries={onAddFromSavedQueries}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
allowedTypes={['query', 'expression']}
index={transformItems.length}
show={hovered === 'queries-last'}
/>
</div>
</div>
</div>
);
}}
</Droppable>
</Stack>
)}
@@ -261,45 +299,80 @@ export const QueryTransformList = memo(
<div
ref={provided.innerRef}
{...provided.droppableProps}
className={isDraggingFromOtherSection ? styles.droppableInvalid : undefined}
className={cx(styles.cardList, isDraggingFromOtherSection ? styles.droppableInvalid : undefined)}
onMouseMove={ev => {
const rect = ev.currentTarget.getBoundingClientRect();
const y = ev.clientY - rect.top;
let hoveredIdx = Math.floor(((y - 16 + (CARD_HEIGHT / 2)) / CARD_HEIGHT));
if (hoveredIdx < 0) {
hoveredIdx = 0;
}
if (hoveredIdx > transformItems.length) {
hoveredIdx = transformItems.length;
}
const hoveredId = hoveredIdx === transformItems.length ? 'transformations-last' : transformItems[hoveredIdx].id;
setHovered(hoveredId);
}}
>
<Stack direction="column" gap={2}>
{transformItems.map((item, index) => (
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
<div key={item.id} className={styles.cardContainer}>
<Draggable key={item.id} draggableId={item.id} index={index}>
{(provided, snapshot) => (
<div
ref={provided.innerRef}
{...provided.draggableProps}
{...provided.dragHandleProps}
className={snapshot.isDragging ? styles.dragging : undefined}
>
<QueryTransformCard
item={item.data}
type={item.type}
index={item.index}
isSelected={selectedId === item.id}
onClick={() => onSelect(item.id)}
onAddExpression={onAddExpression}
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
{...getHandlers(item)}
/>
</div>
)}
</Draggable>
<div className={styles.floatingButton}>
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
onAddFromSavedQueries={onAddFromSavedQueries}
index={index}
allowedTypes={['transform']}
show={hovered === item.id}
/>
</div>
</div>
))}
{provided.placeholder}
</Stack>
<div className={cx(styles.cardContainer, styles.cardContainerLast)}>
<div className={styles.floatingButton}>
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddFromSavedQueries={onAddFromSavedQueries}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
allowedTypes={['transform']}
index={transformItems.length}
show={hovered === 'transformations-last'}
/>
</div>
</div>
</div>
);
}}
</Droppable>
</Stack>
)}
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddFromSavedQueries={onAddFromSavedQueries}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
/>
</Stack>
</div>
</DragDropContext>
@@ -384,7 +457,7 @@ const getStyles = (theme: GrafanaTheme2) => {
minHeight: '100%',
}),
content: css({
padding: `${theme.spacing(2)} ${theme.spacing(8)} ${theme.spacing(2)} ${theme.spacing(2)}`,
padding: theme.spacing(2, 8, 2, 2),
position: 'relative',
}),
dragging: css({
@@ -434,5 +507,22 @@ const getStyles = (theme: GrafanaTheme2) => {
alignItems: 'center',
gap: theme.spacing(0.5),
}),
cardList: css({
paddingLeft: theme.spacing(4),
marginLeft: theme.spacing(-2),
position: 'relative',
}),
cardContainer: css({
position: 'relative',
overflowX: 'visible',
}),
cardContainerLast: css({
marginTop: theme.spacing(2),
}),
floatingButton: css({
position: 'absolute',
top: theme.spacing(-2),
left: theme.spacing(-2.5),
}),
};
};