chore: build out header a bit

This commit is contained in:
Alex Spencer
2025-12-01 20:41:05 -08:00
parent 91cb43d124
commit 8ae9754dfc
3 changed files with 431 additions and 88 deletions
@@ -7,6 +7,7 @@ import { VizPanel } from '@grafana/scenes';
import { Container, ScrollContainer, useStyles2 } from '@grafana/ui';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { DetailViewHeader } from './DetailViewHeader';
import { ExpressionDetailView } from './ExpressionDetailView';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
import { QueryDetailView } from './QueryDetailView';
@@ -38,28 +39,37 @@ export const DetailView = memo(({ selectedItem, panel, tabs }: DetailViewProps)
if (selectedItem.type === 'query' && 'refId' in selectedItem.data) {
const query = selectedItem.data;
return (
<ScrollContainer>
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
<>
<DetailViewHeader selectedItem={selectedItem} panel={panel} />
<ScrollContainer>
<QueryDetailView panel={panel} query={query} queryIndex={selectedItem.index} />
</ScrollContainer>
</>
);
} else if (selectedItem.type === 'expression' && 'refId' in selectedItem.data) {
const data = selectedItem.data;
if (isExpressionQuery(data)) {
return (
<ScrollContainer>
<ExpressionDetailView panel={panel} expression={data} expressionIndex={selectedItem.index} />
</ScrollContainer>
<>
<DetailViewHeader selectedItem={selectedItem} panel={panel} />
<ScrollContainer>
<ExpressionDetailView panel={panel} expression={data} expressionIndex={selectedItem.index} />
</ScrollContainer>
</>
);
}
} else {
const transformsTab = tabs.find((t) => t.tabId === TabId.Transformations);
if (transformsTab instanceof PanelDataTransformationsTab && 'id' in selectedItem.data) {
return (
<ScrollContainer>
<Container>
<PanelDataTransformationsTabRendered model={transformsTab} />
</Container>
</ScrollContainer>
<>
<DetailViewHeader selectedItem={selectedItem} panel={panel} />
<ScrollContainer>
<Container>
<PanelDataTransformationsTabRendered model={transformsTab} />
</Container>
</ScrollContainer>
</>
);
}
}
@@ -0,0 +1,403 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState } from 'react';
import { CoreApp, DataQuery, DataSourceInstanceSettings, GrafanaTheme2 } from '@grafana/data';
import { t } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { VizPanel } from '@grafana/scenes';
import {
Button,
Dropdown,
FieldValidationMessage,
Icon,
IconButton,
Input,
Menu,
Stack,
useStyles2,
useTheme2,
} from '@grafana/ui';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryTransformItem } from './QueryTransformList';
interface DetailViewHeaderProps {
selectedItem: QueryTransformItem;
panel: VizPanel;
}
const ITEM_CONFIG = (theme: GrafanaTheme2) => ({
query: {
color: theme.colors.primary.main,
icon: 'database' as const,
},
expression: {
color: theme.visualization.getColorByName('purple'),
icon: 'calculator-alt' as const,
},
transform: {
color: theme.visualization.getColorByName('orange'),
icon: 'process' as const,
},
});
export const DetailViewHeader = ({ selectedItem, panel }: DetailViewHeaderProps) => {
const theme = useTheme2();
const config = useMemo(() => ITEM_CONFIG(theme)[selectedItem.type], [theme, selectedItem.type]);
const styles = useStyles2(getStyles, config);
const [isEditing, setIsEditing] = useState(false);
const [validationError, setValidationError] = useState<string | null>(null);
// Helper to update queries with consistent pattern
const updateQueries = useCallback(
(updater: (queries: DataQuery[]) => DataQuery[], runAfter = false) => {
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
const queries = queryRunner.state.queries || [];
const newQueries = updater(queries);
queryRunner.setState({ queries: newQueries });
if (runAfter) {
queryRunner.runQueries();
}
},
[panel]
);
// Get datasource settings for queries
const datasourceSettings = useMemo(() => {
if (selectedItem.type === 'query') {
try {
// If the query has a datasource, use it; otherwise get the default datasource
const datasource = 'datasource' in selectedItem.data ? selectedItem.data.datasource : null;
return getDataSourceSrv().getInstanceSettings(datasource);
} catch {
return getDataSourceSrv().getInstanceSettings(null);
}
}
return undefined;
}, [selectedItem]);
// Get all queries for validation
const allQueries = useMemo(() => {
const queryRunner = getQueryRunnerFor(panel);
return queryRunner?.state.queries || [];
}, [panel]);
// Handle datasource change for queries
const handleDataSourceChange = useCallback(
async (newDsSettings: DataSourceInstanceSettings) => {
if (selectedItem.type !== 'query' || selectedItem.index === undefined) {
return;
}
try {
// Load the new datasource to get its default query
const newDatasource = await getDataSourceSrv().get(newDsSettings.uid);
const defaultQuery = newDatasource.getDefaultQuery?.(CoreApp.PanelEditor) || {};
const queryRunner = getQueryRunnerFor(panel);
if (!queryRunner) {
return;
}
const queries = queryRunner.state.queries || [];
const newQueries = queries.map((q, idx) => {
if (idx === selectedItem.index) {
// Merge default query with existing query to preserve properties
return {
...defaultQuery,
...q,
datasource: { uid: newDsSettings.uid, type: newDsSettings.type },
refId: q.refId,
};
}
return q;
});
// Update queries, datasource, and clear cached data
queryRunner.setState({
datasource: { uid: newDsSettings.uid, type: newDsSettings.type },
queries: newQueries,
data: undefined,
});
// Run the query with the new datasource
queryRunner.runQueries();
} catch (error) {
console.error('Error changing datasource:', error);
}
},
[selectedItem, panel]
);
// Handle query name editing
const onEditQueryName = useCallback(() => {
setIsEditing(true);
}, []);
const onEndEditName = useCallback(
(newName: string) => {
setIsEditing(false);
// Ignore change if invalid
if (validationError) {
setValidationError(null);
return;
}
if (!('refId' in selectedItem.data) || selectedItem.data.refId === newName || selectedItem.index === undefined) {
return;
}
// Update the query with the new refId - just update state, don't run queries
updateQueries((queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, refId: newName } : q)));
},
[selectedItem, validationError, updateQueries]
);
const onInputChange = useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
const newName = event.currentTarget.value.trim();
if (newName.length === 0) {
setValidationError('An empty query name is not allowed');
return;
}
for (const otherQuery of allQueries) {
if (otherQuery !== selectedItem.data && newName === otherQuery.refId) {
setValidationError('Query name already exists');
return;
}
}
if (validationError) {
setValidationError(null);
}
},
[allQueries, selectedItem.data, validationError]
);
const onEditQueryBlur = useCallback(
(event: React.SyntheticEvent<HTMLInputElement>) => {
onEndEditName(event.currentTarget.value.trim());
},
[onEndEditName]
);
const onKeyDown = useCallback(
(event: React.KeyboardEvent<HTMLInputElement>) => {
if (event.key === 'Enter') {
onEndEditName(event.currentTarget.value);
}
},
[onEndEditName]
);
const onFocus = useCallback((event: React.FocusEvent<HTMLInputElement>) => {
event.target.select();
}, []);
// Action handlers
const onCopyQuery = useCallback(() => {
if (selectedItem.index === undefined) {
return;
}
updateQueries((queries) => {
const queryToCopy = queries[selectedItem.index];
return queryToCopy ? [...queries, { ...queryToCopy }] : queries;
});
}, [selectedItem, updateQueries]);
const onRemoveQuery = useCallback(() => {
if (selectedItem.index === undefined) {
return;
}
updateQueries((queries) => queries.filter((_, idx) => idx !== selectedItem.index));
}, [selectedItem, updateQueries]);
const onToggleHideQuery = useCallback(() => {
if ((selectedItem.type !== 'query' && selectedItem.type !== 'expression') || selectedItem.index === undefined) {
return;
}
updateQueries(
(queries) => queries.map((q, idx) => (idx === selectedItem.index ? { ...q, hide: !q.hide } : q)),
true // Run queries after update
);
}, [selectedItem, updateQueries]);
const onRunQuery = useCallback(() => {
const queryRunner = getQueryRunnerFor(panel);
queryRunner?.runQueries();
}, [panel]);
const refId = 'refId' in selectedItem.data ? selectedItem.data.refId : '';
const isHidden =
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
'hide' in selectedItem.data &&
selectedItem.data.hide;
return (
<div className={styles.header}>
<Stack justifyContent="space-between" alignItems="center" gap={2}>
{/* Left side: Icon, Datasource, Name */}
<Stack gap={1} alignItems="center" grow={1} minWidth={0}>
<Icon name={config.icon} className={styles.icon} />
{/* Datasource picker for queries */}
{selectedItem.type === 'query' && datasourceSettings && (
<DataSourcePicker
dashboard={true}
variables={true}
current={datasourceSettings.name}
onChange={handleDataSourceChange}
/>
)}
{/* Editable query/expression name */}
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && refId && (
<>
{!isEditing ? (
<button
className={styles.queryNameWrapper}
title={t('dashboard-scene.detail-view-header.edit-query-name', 'Edit query name')}
onClick={onEditQueryName}
type="button"
>
<span className={styles.queryName}>{refId}</span>
<Icon name="pen" className={styles.queryEditIcon} size="sm" />
</button>
) : (
<>
<Input
type="text"
defaultValue={refId}
onBlur={onEditQueryBlur}
autoFocus
onKeyDown={onKeyDown}
onFocus={onFocus}
invalid={validationError !== null}
onChange={onInputChange}
className={styles.queryNameInput}
/>
{validationError && <FieldValidationMessage horizontal>{validationError}</FieldValidationMessage>}
</>
)}
</>
)}
</Stack>
{/* Right side: Run Query + Actions Menu */}
{(selectedItem.type === 'query' || selectedItem.type === 'expression') && (
<Stack gap={0.5} alignItems="center">
<Button variant="primary" fill="text" size="sm" onClick={onRunQuery} icon="play">
{t('dashboard-scene.detail-view-header.run-query', 'RUN QUERY')}
</Button>
<Dropdown
overlay={
<Menu>
<Menu.Item
label={t('dashboard-scene.detail-view-header.duplicate-query', 'Duplicate')}
icon="copy"
onClick={onCopyQuery}
/>
<Menu.Item
label={
isHidden
? t('dashboard-scene.detail-view-header.show-response', 'Show response')
: t('dashboard-scene.detail-view-header.hide-response', 'Hide response')
}
icon={isHidden ? 'eye-slash' : 'eye'}
onClick={onToggleHideQuery}
/>
<Menu.Divider />
<Menu.Item
label={t('dashboard-scene.detail-view-header.remove-query', 'Remove')}
icon="trash-alt"
onClick={onRemoveQuery}
/>
</Menu>
}
>
<IconButton
name="ellipsis-v"
variant="secondary"
tooltip={t('dashboard-scene.detail-view-header.actions', 'Actions')}
/>
</Dropdown>
</Stack>
)}
</Stack>
</div>
);
};
const getStyles = (theme: GrafanaTheme2, config: { color: string }) => {
return {
header: css({
padding: theme.spacing(1, 2),
borderLeft: `4px solid ${config.color}`,
borderBottom: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.secondary,
minHeight: theme.spacing(6),
}),
icon: css({
color: theme.colors.text.secondary,
fontSize: theme.typography.h5.fontSize,
}),
queryNameWrapper: css({
display: 'flex',
cursor: 'pointer',
border: '1px solid transparent',
borderRadius: theme.shape.radius.default,
alignItems: 'center',
padding: theme.spacing(0.5, 1),
margin: 0,
background: 'transparent',
overflow: 'hidden',
'&:hover': {
background: theme.colors.action.hover,
border: `1px dashed ${theme.colors.border.strong}`,
},
'&:focus': {
border: `2px solid ${theme.colors.primary.border}`,
},
'&:hover, &:focus': {
'.query-name-edit-icon': {
visibility: 'visible',
},
},
}),
queryName: css({
fontWeight: theme.typography.fontWeightMedium,
color: theme.colors.primary.text,
cursor: 'pointer',
overflow: 'hidden',
marginLeft: theme.spacing(0.5),
}),
queryEditIcon: cx(
css({
marginLeft: theme.spacing(1),
visibility: 'hidden',
}),
'query-name-edit-icon'
),
queryNameInput: css({
maxWidth: '300px',
margin: '-4px 0',
}),
};
};
@@ -1,21 +1,13 @@
import { css } from '@emotion/css';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useMemo } from 'react';
import { useAsync } from 'react-use';
import {
CoreApp,
DataQuery,
DataSourceInstanceSettings,
DataSourcePluginContextProvider,
GrafanaTheme2,
TimeRange,
} from '@grafana/data';
import { Trans, t } from '@grafana/i18n';
import { CoreApp, DataQuery, DataSourcePluginContextProvider, GrafanaTheme2, TimeRange } from '@grafana/data';
import { Trans } from '@grafana/i18n';
import { getDataSourceSrv } from '@grafana/runtime';
import { SceneDataQuery, VizPanel } from '@grafana/scenes';
import { ErrorBoundaryAlert, useStyles2 } from '@grafana/ui';
import { QueryOperationRow } from 'app/core/components/QueryOperationRow/QueryOperationRow';
import { DataSourcePicker } from 'app/features/datasources/components/picker/DataSourcePicker';
import { QueryErrorAlert } from 'app/features/query/components/QueryErrorAlert';
import { getQueryRunnerFor } from '../../utils/utils';
@@ -28,20 +20,14 @@ interface QueryDetailViewProps {
export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewProps) {
const styles = useStyles2(getStyles);
const [datasourceRef, setDatasourceRef] = useState(query.datasource);
let initialDsSettings: DataSourceInstanceSettings | undefined = useMemo(() => {
const dsSettings = useMemo(() => {
try {
return getDataSourceSrv().getInstanceSettings(query.datasource);
} catch {
return getDataSourceSrv().getInstanceSettings(null);
}
// we want to run this on mount and then never again
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const [dsSettings, setDsSettings] = useState<DataSourceInstanceSettings | undefined>(initialDsSettings);
const [needsQueryRun, setNeedsQueryRun] = useState(false);
}, [query.datasource]);
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
@@ -51,11 +37,11 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
// FIXME: handle loading and error cases
const { value: datasource } = useAsync(async () => {
try {
return await getDataSourceSrv().get(datasourceRef);
return await getDataSourceSrv().get(query.datasource);
} catch {
return await getDataSourceSrv().get();
}
}, [datasourceRef]);
}, [query.datasource]);
// Subscribe to panel data
const data = useMemo(() => {
@@ -72,51 +58,6 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
};
}, [queryRunnerState?.data, query.refId]);
useEffect(() => {
// Update the query with the new datasource
if (queryRunner && datasource) {
const queries = queryRunner.state.queries || [];
const newQueries = queries.map((q, idx) => {
if (idx === queryIndex) {
// Get default query for new datasource
const defaultQuery = datasource.getDefaultQuery?.(CoreApp.PanelEditor) || {};
return {
...defaultQuery,
...q,
datasource: { uid: datasource.uid, type: datasource.type },
refId: q.refId,
};
}
return q;
});
queryRunner.setState({
datasource: { uid: datasource.uid, type: datasource.type },
queries: newQueries,
});
if (needsQueryRun) {
queryRunner.runQueries();
setNeedsQueryRun(false);
}
}
}, [datasource, queryIndex, queryRunner, needsQueryRun]);
const handleDataSourceChange = useCallback(
async (newDsSettings: DataSourceInstanceSettings) => {
setDatasourceRef({
// FIXME: apiVersion isn't on the datasources we looked at.
uid: newDsSettings.uid,
type: newDsSettings.type,
});
setDsSettings(newDsSettings);
if (newDsSettings.uid !== dsSettings?.uid) {
setNeedsQueryRun(true);
}
},
[dsSettings]
);
const handleQueryChange = useCallback(
(updatedQuery: DataQuery) => {
if (queryRunner) {
@@ -175,14 +116,6 @@ export function QueryDetailView({ panel, query, queryIndex }: QueryDetailViewPro
return (
<div className={styles.container}>
<div className={styles.datasourceSection}>
<DataSourcePicker
current={dsSettings?.uid || query.datasource}
onChange={handleDataSourceChange}
placeholder={t('dashboard-scene.query-detail-view.select-datasource', 'Select data source')}
/>
</div>
<QueryOperationRow
id={`query-${query.refId}`}
index={queryIndex}
@@ -209,9 +142,6 @@ const getStyles = (theme: GrafanaTheme2) => {
padding: theme.spacing(2),
width: '100%',
}),
datasourceSection: css({
paddingBottom: theme.spacing(2),
}),
queryContent: css({
display: 'flex',
flexDirection: 'column',