add save query library functionality stub

This commit is contained in:
Develer
2025-12-03 17:57:09 +01:00
parent b085f3ccfd
commit eacd341de7
5 changed files with 464 additions and 60 deletions
@@ -8,72 +8,80 @@ import { ExpressionQueryType } from 'app/features/expressions/types';
interface AddDataItemMenuProps {
onAddQuery: () => void;
onAddFromSavedQueries: () => void;
onAddTransform: () => void;
onAddExpression: (type: ExpressionQueryType) => void;
}
export const AddDataItemMenu = memo(({ onAddQuery, onAddTransform, onAddExpression }: AddDataItemMenuProps) => {
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') },
{
type: ExpressionQueryType.resample,
label: t('dashboard-scene.add-data-item-menu.expression-resample', 'Resample'),
},
{
type: ExpressionQueryType.classic,
label: t('dashboard-scene.add-data-item-menu.expression-classic', 'Classic condition'),
},
{
type: ExpressionQueryType.threshold,
label: t('dashboard-scene.add-data-item-menu.expression-threshold', 'Threshold'),
},
];
export const AddDataItemMenu = memo(
({ onAddQuery, onAddFromSavedQueries, onAddTransform, onAddExpression }: AddDataItemMenuProps) => {
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') },
{
type: ExpressionQueryType.resample,
label: t('dashboard-scene.add-data-item-menu.expression-resample', 'Resample'),
},
{
type: ExpressionQueryType.classic,
label: t('dashboard-scene.add-data-item-menu.expression-classic', 'Classic condition'),
},
{
type: ExpressionQueryType.threshold,
label: t('dashboard-scene.add-data-item-menu.expression-threshold', 'Threshold'),
},
];
// Add SQL if feature flag is enabled
if (config.featureToggles.sqlExpressions) {
expressionTypes.push({
type: ExpressionQueryType.sql,
label: t('dashboard-scene.add-data-item-menu.expression-sql', 'SQL'),
});
}
// Add SQL if feature flag is enabled
if (config.featureToggles.sqlExpressions) {
expressionTypes.push({
type: ExpressionQueryType.sql,
label: t('dashboard-scene.add-data-item-menu.expression-sql', 'SQL'),
});
}
const expressionSubItems = expressionTypes.map(({ type, label }) => (
<Menu.Item key={type} label={label} icon={EXPRESSION_ICON_MAP[type]} onClick={() => onAddExpression(type)} />
));
const expressionSubItems = expressionTypes.map(({ type, label }) => (
<Menu.Item key={type} label={label} icon={EXPRESSION_ICON_MAP[type]} onClick={() => onAddExpression(type)} />
));
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-transformation', 'Transformation')}
icon="process"
onClick={onAddTransform}
/>
<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')}
const menu = (
<Menu>
<Menu.Item
label={t('dashboard-scene.add-data-item-menu.add-query', 'Query')}
icon="database"
onClick={onAddQuery}
/>
</Dropdown>
</Stack>
);
});
<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}
/>
</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>
);
}
);
AddDataItemMenu.displayName = 'AddDataItemMenu';
@@ -28,6 +28,7 @@ import { DataSourcePicker } from 'app/features/datasources/components/picker/Dat
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryTransformItem } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
interface DetailViewHeaderProps {
selectedItem: QueryTransformItem;
@@ -63,6 +64,7 @@ export const DetailViewHeader = ({
const [isEditing, setIsEditing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
const [isSavedQueriesDrawerOpen, setIsSavedQueriesDrawerOpen] = useState(false);
// Helper to update queries with consistent pattern
const updateQueries = useCallback(
@@ -265,6 +267,23 @@ export const DetailViewHeader = ({
onToggleTransformVisibility?.(selectedItem.index);
}, [selectedItem, onToggleTransformVisibility]);
// Handler for selecting a query from the library (replaces current query)
const onSelectQueryFromLibrary = useCallback(
(newQuery: DataQuery) => {
if (selectedItem.type !== 'query' || selectedItem.index === undefined) {
return;
}
updateQueries(
(queries) =>
queries.map((q, idx) =>
idx === selectedItem.index ? { ...newQuery, refId: q.refId, datasource: q.datasource } : q
),
true
);
},
[selectedItem, updateQueries]
);
const refId = 'refId' in selectedItem.data ? selectedItem.data.refId : '';
const isHidden =
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
@@ -342,6 +361,19 @@ export const DetailViewHeader = ({
{/* Right side: Run Query + Actions Menu for queries/expressions */}
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && (
<Stack gap={0.5} alignItems="center">
{/* Save Query Button (only for queries, not expressions) */}
{selectedItem.type === 'query' && 'refId' in selectedItem.data && (
<Button
className={styles.monospace}
variant="primary"
fill="text"
size="sm"
icon="bookmark"
onClick={() => setIsSavedQueriesDrawerOpen(true)}
>
{t('dashboard-scene.detail-view-header.save-query', 'SAVE')}
</Button>
)}
<Button
className={styles.monospace}
variant="primary"
@@ -420,6 +452,25 @@ export const DetailViewHeader = ({
</Stack>
)}
</div>
{/* Saved Queries Drawer */}
{selectedItem.type === 'query' && 'refId' in selectedItem.data && (
<SavedQueriesDrawer
isOpen={isSavedQueriesDrawerOpen}
onClose={() => setIsSavedQueriesDrawerOpen(false)}
onSelectQuery={onSelectQueryFromLibrary}
currentQuery={
'datasource' in selectedItem.data
? {
refId: selectedItem.data.refId,
datasource: datasourceSettings
? { uid: datasourceSettings.uid, type: datasourceSettings.type }
: selectedItem.data.datasource,
}
: { refId: selectedItem.data.refId }
}
/>
)}
</div>
);
};
@@ -1,7 +1,7 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState, useEffect, useRef } from 'react';
import { DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { DataQuery, DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
SceneComponentProps,
@@ -30,6 +30,7 @@ import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformList, QueryTransformItem } from './QueryTransformList';
import { SavedQueriesDrawer } from './SavedQueriesDrawer';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
@@ -89,6 +90,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const styles = useStyles2(getStyles);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [transformDrawerOpen, setTransformDrawerOpen] = useState(false);
const [savedQueriesDrawerOpen, setSavedQueriesDrawerOpen] = useState(false);
const panel = panelRef.resolve();
@@ -205,6 +207,49 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
}, [tabs, panel]);
const handleAddFromSavedQueries = useCallback(() => {
setSavedQueriesDrawerOpen(true);
}, []);
// This is a stub for the saved queries drawer
const handleSelectSavedQuery = useCallback(
(query: DataQuery) => {
if (!queryRunner) {
return;
}
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 query with the selected refId
const newQuery: DataQuery = {
...query,
refId: nextRefId,
};
queryRunner.setState({ queries: [...queries, newQuery] });
// Select the new query after a short delay
setTimeout(() => {
setSelectedId(queryItemId(newQuery));
}, 100);
setSavedQueriesDrawerOpen(false);
},
[queryRunner]
);
const handleAddTransform = useCallback(() => {
setTransformDrawerOpen(true);
}, []);
@@ -480,6 +525,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
selectedId={effectiveSelectedId}
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={handleAddFromSavedQueries}
onAddTransform={handleAddTransform}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
@@ -516,6 +562,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
onTransformationAdd={handleTransformationAdd}
series={series}
/>
<SavedQueriesDrawer
isOpen={savedQueriesDrawerOpen}
onClose={() => setSavedQueriesDrawerOpen(false)}
onSelectQuery={handleSelectSavedQuery}
/>
</div>
);
}
@@ -26,6 +26,7 @@ interface QueryTransformListProps {
selectedId: string | null;
onSelect: (id: string) => void;
onAddQuery: () => void;
onAddFromSavedQueries: () => void;
onAddTransform: () => void;
onAddExpression: (type: ExpressionQueryType) => void;
onDuplicateQuery?: (index: number) => void;
@@ -48,6 +49,7 @@ export const QueryTransformList = memo(
selectedId,
onSelect,
onAddQuery,
onAddFromSavedQueries,
onAddTransform,
onAddExpression,
onDuplicateQuery,
@@ -294,6 +296,7 @@ export const QueryTransformList = memo(
<AddDataItemMenu
onAddQuery={onAddQuery}
onAddFromSavedQueries={onAddFromSavedQueries}
onAddTransform={onAddTransform}
onAddExpression={onAddExpression}
/>
@@ -0,0 +1,291 @@
import { css } from '@emotion/css';
import { useState } from 'react';
import { DataQuery, GrafanaTheme2 } from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { Button, Drawer, Icon, Input, Stack, useStyles2 } from '@grafana/ui';
interface SavedQueriesDrawerProps {
isOpen: boolean;
onClose: () => void;
onSelectQuery: (query: DataQuery) => void;
currentQuery?: DataQuery;
}
// Stub saved queries for demonstration
const STUB_SAVED_QUERIES: Array<{ id: string; name: string; description: string; query: DataQuery }> = [
{
id: '1',
name: 'CPU Usage Query',
description: 'Returns CPU usage metrics for all hosts',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '2',
name: 'Memory Usage Query',
description: 'Returns memory usage metrics',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '3',
name: 'Network Traffic Query',
description: 'Returns network traffic in/out bytes',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
{
id: '4',
name: 'Disk I/O Query',
description: 'Returns disk read/write operations',
query: {
refId: 'A',
datasource: { type: 'prometheus', uid: 'prometheus' },
},
},
];
export function SavedQueriesDrawer({ isOpen, onClose, onSelectQuery, currentQuery }: SavedQueriesDrawerProps) {
const styles = useStyles2(getStyles);
const [searchTerm, setSearchTerm] = useState('');
const [isSaveMode, setIsSaveMode] = useState(false);
const [saveName, setSaveName] = useState('');
const [saveDescription, setSaveDescription] = useState('');
const filteredQueries = STUB_SAVED_QUERIES.filter(
(q) =>
q.name.toLowerCase().includes(searchTerm.toLowerCase()) ||
q.description.toLowerCase().includes(searchTerm.toLowerCase())
);
const handleSelectQuery = (query: DataQuery) => {
onSelectQuery(query);
onClose();
};
const handleSaveQuery = () => {
// Stub: In real implementation, this would save to the query library
// eslint-disable-next-line no-console
console.log('Saving query:', { name: saveName, description: saveDescription, query: currentQuery });
alert(t('dashboard-scene.saved-queries-drawer.save-success', 'Query saved successfully! (stub)'));
onClose();
};
if (!isOpen) {
return null;
}
return (
<Drawer
title={
isSaveMode
? t('dashboard-scene.saved-queries-drawer.save-title', 'Save Query')
: t('dashboard-scene.saved-queries-drawer.title', 'Saved Queries')
}
onClose={onClose}
size="md"
>
<div className={styles.container}>
{/* Tab-like buttons to switch modes */}
<Stack gap={1} direction="row">
<Button
variant={!isSaveMode ? 'primary' : 'secondary'}
fill={!isSaveMode ? 'solid' : 'outline'}
size="sm"
onClick={() => setIsSaveMode(false)}
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.browse-tab">Browse</Trans>
</Button>
<Button
variant={isSaveMode ? 'primary' : 'secondary'}
fill={isSaveMode ? 'solid' : 'outline'}
size="sm"
onClick={() => setIsSaveMode(true)}
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.save-tab">Save Current</Trans>
</Button>
</Stack>
{isSaveMode ? (
/* Save Mode */
<div className={styles.saveForm}>
<Stack direction="column" gap={2}>
<div>
<label htmlFor="saved-query-name" className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.name-label">Name</Trans>
</label>
<Input
id="saved-query-name"
value={saveName}
onChange={(e) => setSaveName(e.currentTarget.value)}
placeholder={t('dashboard-scene.saved-queries-drawer.name-placeholder', 'Enter query name...')}
/>
</div>
<div>
<label htmlFor="saved-query-description" className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.description-label">Description</Trans>
</label>
<Input
id="saved-query-description"
value={saveDescription}
onChange={(e) => setSaveDescription(e.currentTarget.value)}
placeholder={t(
'dashboard-scene.saved-queries-drawer.description-placeholder',
'Enter description...'
)}
/>
</div>
<div className={styles.queryPreview}>
<span className={styles.label}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.query-preview">Query Preview</Trans>
</span>
<pre className={styles.queryCode}>{JSON.stringify(currentQuery, null, 2)}</pre>
</div>
<Button onClick={handleSaveQuery} disabled={!saveName.trim()}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.save-button">Save Query</Trans>
</Button>
</Stack>
</div>
) : (
/* Browse Mode */
<>
<div className={styles.search}>
<Input
prefix={<Icon name="search" />}
placeholder={t('dashboard-scene.saved-queries-drawer.search-placeholder', 'Search saved queries...')}
value={searchTerm}
onChange={(e) => setSearchTerm(e.currentTarget.value)}
/>
</div>
<div className={styles.queryList}>
{filteredQueries.length === 0 ? (
<div className={styles.emptyState}>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.no-results">No queries found</Trans>
</div>
) : (
filteredQueries.map((savedQuery) => (
<div key={savedQuery.id} className={styles.queryItem}>
<div className={styles.queryInfo}>
<div className={styles.queryName}>{savedQuery.name}</div>
<div className={styles.queryDescription}>{savedQuery.description}</div>
</div>
<Button
variant="secondary"
size="sm"
onClick={() => handleSelectQuery(savedQuery.query)}
icon="arrow-right"
>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.use-query">Use</Trans>
</Button>
</div>
))
)}
</div>
<div className={styles.stubNotice}>
<Icon name="info-circle" />
<span>
<Trans i18nKey="dashboard-scene.saved-queries-drawer.stub-notice">
This is a stub implementation. Enable the queryLibrary feature toggle for full functionality.
</Trans>
</span>
</div>
</>
)}
</div>
</Drawer>
);
}
const getStyles = (theme: GrafanaTheme2) => ({
container: css({
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(2),
height: '100%',
}),
search: css({
marginTop: theme.spacing(1),
}),
queryList: css({
flex: 1,
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
gap: theme.spacing(1),
}),
queryItem: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: theme.spacing(1.5),
background: theme.colors.background.secondary,
borderRadius: theme.shape.radius.default,
border: `1px solid ${theme.colors.border.weak}`,
'&:hover': {
background: theme.colors.action.hover,
borderColor: theme.colors.border.medium,
},
}),
queryInfo: css({
flex: 1,
minWidth: 0,
}),
queryName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
marginBottom: theme.spacing(0.5),
}),
queryDescription: css({
fontSize: theme.typography.bodySmall.fontSize,
color: theme.colors.text.secondary,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
}),
emptyState: css({
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: theme.spacing(4),
color: theme.colors.text.secondary,
}),
stubNotice: css({
display: 'flex',
alignItems: 'center',
gap: theme.spacing(1),
padding: theme.spacing(1),
background: theme.colors.info.transparent,
borderRadius: theme.shape.radius.default,
color: theme.colors.info.text,
fontSize: theme.typography.bodySmall.fontSize,
}),
saveForm: css({
marginTop: theme.spacing(2),
}),
label: css({
display: 'block',
marginBottom: theme.spacing(0.5),
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.text.primary,
}),
queryPreview: css({
marginTop: theme.spacing(1),
}),
queryCode: css({
background: theme.colors.background.secondary,
padding: theme.spacing(1),
borderRadius: theme.shape.radius.default,
fontSize: theme.typography.bodySmall.fontSize,
overflow: 'auto',
maxHeight: '200px',
}),
});