fix up long sidebar

This commit is contained in:
Paul Marbach
2025-12-04 15:54:09 -05:00
parent 8914c2b054
commit cee3b34cf7
9 changed files with 612 additions and 354 deletions
@@ -108,7 +108,7 @@ export function ConnectionLines({ connections, isDragging = false, selected, onC
refIds.add(to);
});
const swimlaneX = containerRect.width - 40; // Adjusted for increased right padding (64px)
const swimlaneX = containerRect.width - 24; // Adjusted for increased right padding (48px)
const cardPositions: number[] = [];
refIds.forEach((refId) => {
@@ -195,6 +195,9 @@ const getStyles = (theme: GrafanaTheme2) => {
width: '100%',
background: theme.colors.background.primary,
border: `1px solid ${theme.colors.border.weak}`,
borderTopLeftRadius: theme.shape.radius.md,
borderTopRightRadius: theme.shape.radius.md,
overflow: 'hidden',
}),
emptyState: css({
display: 'flex',
@@ -1,62 +1,76 @@
import { css, cx } from '@emotion/css';
import { useCallback, useMemo, useState, useEffect } from 'react';
import { useCallback, useMemo, useState } from 'react';
import { DataQuery, DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { selectors } from '@grafana/e2e-selectors';
import {
SceneComponentProps,
SceneDataQuery,
SceneObjectBase,
SceneObjectRef,
SceneObjectState,
SceneObjectUrlSyncConfig,
SceneObjectUrlValues,
VizPanel,
SceneDataQuery,
} from '@grafana/scenes';
import { useStyles2, useSplitter } from '@grafana/ui';
import { useStyles2 } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { isExpressionQuery } from '../../../expressions/guards';
import { ExpressionQueryType } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { DetailView, QueryLibraryMode } from './DetailView';
import { PanelDataAlertingTab } from './PanelDataAlertingTab';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataSidebarRendered } from './PanelDataSidebar';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformList } from './QueryTransformList';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId, QueryItem, TransformItem } from './types';
import { useQueryTransformItems } from './hooks';
import { PanelDataPaneTab, TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export interface PanelDataPaneState extends SceneObjectState {
tabs: PanelDataPaneTab[];
tab: TabId;
sidebarCollapsed: boolean;
selectedQueryTransform: string | null;
panelRef: SceneObjectRef<VizPanel>;
transformPickerIndex?: number | null;
queryLibraryMode: QueryLibraryMode & { index: number | null };
}
export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
static Component = PanelDataPaneRendered;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab'] });
static FooterComponent = PanelDataSidebarRendered;
protected _urlSync = new SceneObjectUrlSyncConfig(this, { keys: ['tab', 'selectedQueryTransform'] });
public static createFor(panel: VizPanel) {
const panelRef = panel.getRef();
const tabs: PanelDataPaneTab[] = [
new PanelDataQueriesTab({ panelRef }),
new PanelDataTransformationsTab({ panelRef }),
new PanelDataQueriesTab({ panelRef: panel.getRef() }),
new PanelDataTransformationsTab({ panelRef: panel.getRef() }),
];
if (shouldShowAlertingTab(panel.state.pluginId)) {
tabs.push(new PanelDataAlertingTab({ panelRef }));
tabs.push(new PanelDataAlertingTab({ panelRef: panel.getRef() }));
}
const tab = tabs[0]?.tabId ?? TabId.Queries;
return new PanelDataPane({
selectedQueryTransform: null,
panelRef,
tabs,
tab: TabId.Queries,
tab,
sidebarCollapsed: false,
queryLibraryMode: {
active: false,
mode: 'browse',
index: null,
},
});
}
@@ -64,6 +78,22 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
this.setState({ tab: tab.tabId });
};
public onChangeSelected = (selectedId: string | null) => {
this.setState({ selectedQueryTransform: selectedId });
};
public onCollapseSidebar = (newState: boolean) => {
this.setState({ sidebarCollapsed: newState });
};
public onTransformPicker = (index?: number | null) => {
this.setState({ transformPickerIndex: index });
};
public setQueryLibraryMode = (mode: QueryLibraryMode & { index: number | null }) => {
this.setState({ queryLibraryMode: mode });
}
public getUrlState() {
return { tab: this.state.tab };
}
@@ -82,29 +112,13 @@ export class PanelDataPane extends SceneObjectBase<PanelDataPaneState> {
}
}
interface DrawerState {
open: boolean;
index: number | null;
}
function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const { tabs, panelRef } = model.useState();
const { tabs, selectedQueryTransform, panelRef, transformPickerIndex, queryLibraryMode } = model.useState();
const styles = useStyles2(getStyles);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [queryLibraryMode, setQueryLibraryMode] = useState<QueryLibraryMode & { index: number | null }>({
active: false,
mode: 'browse',
index: null,
});
const [transformDrawerState, setTransformDrawerState] = useState<DrawerState>({
open: false,
index: null,
});
const [isAddingTransform, setIsAddingTransform] = useState(false);
const panel = panelRef.resolve();
// Subscribe to query runner and tab state changes
const panel = panelRef.resolve();
const queryRunner = getQueryRunnerFor(panel);
const queryRunnerState = queryRunner?.useState();
const queriesTab = tabs.find((t): t is PanelDataQueriesTab => t.tabId === TabId.Queries);
@@ -114,92 +128,24 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const queries = queryRunnerState?.queries;
const transformations = transformerState?.transformations?.filter(isDataTransformerConfig);
// 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.
useEffect(() => {
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 = prevState.queries.map(({ refId }) => refId);
if (newState.queries.length !== oldOrderedRefIds.length) {
return; // add, remove, something else.
}
let refIdChanges = 0;
let updatedQuery: SceneDataQuery | undefined = undefined;
for (let i = 0; i < newState.queries.length; i++) {
const newQuery = newState.queries[i];
const oldRefId = oldOrderedRefIds[i];
if (newQuery.refId !== oldRefId) {
if (++refIdChanges < 2) {
updatedQuery = newQuery;
} else {
return; // more than 2 refId changes, so it's a reorder or something else.
}
}
}
if (updatedQuery) {
setSelectedId(queryItemId(updatedQuery));
}
});
}, [queryRunner]);
// Build separate lists for queries/expressions and transformations
const { queryExpressionItems, transformItems, allItems } = useMemo(() => {
const queryExpressionItems: QueryItem[] = [];
const transformItems: TransformItem[] = [];
// Add queries and expressions
for (let i = 0; i < (queries?.length ?? 0); i++) {
const query = queries![i];
queryExpressionItems.push({
id: queryItemId(query),
type: isExpressionQuery(query) ? 'expression' : 'query',
data: query,
index: i, // Store actual index in queries array
});
}
// Add transformations
for (let i = 0; i < (transformations?.length ?? 0); i++) {
const transform = transformations![i];
if (isDataTransformerConfig(transform)) {
transformItems.push({
id: transformItemId(i),
type: 'transform',
data: transform,
index: i,
});
}
}
return {
queryExpressionItems,
transformItems,
allItems: [...queryExpressionItems, ...transformItems],
};
}, [queries, transformations]);
// Auto-select first item if nothing is selected
const effectiveSelectedId = useMemo(() => {
if (selectedId === null && allItems.length > 0) {
return allItems[0].id;
}
return selectedId;
}, [selectedId, allItems]);
const selectedItem = useMemo(
() => allItems.find((item) => item.id === effectiveSelectedId),
[allItems, effectiveSelectedId]
const { allItems } = useQueryTransformItems(queries, transformations);
const handleSelect = useCallback(
(id: string | null) => {
model.setState({ selectedQueryTransform: id });
model.onTransformPicker(null);
},
[model]
);
const handleSelect = useCallback((id: string) => {
setSelectedId(id);
setIsAddingTransform(false);
}, []);
// Auto-select first item if nothing is selected
const selectedId = useMemo(() => {
if (selectedQueryTransform === null && allItems.length > 0) {
return allItems[0].id;
}
return selectedQueryTransform;
}, [selectedQueryTransform, allItems]);
const selectedItem = useMemo(() => allItems.find((item) => item.id === selectedId), [allItems, selectedId]);
const updateQuerySelectionOnStateChange = useCallback(
(index: number) => {
@@ -208,104 +154,19 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const newQueries = newState.queries;
if (newQueries.length > 0) {
const selected = newQueries[index] ?? newQueries[0];
setSelectedId(queryItemId(selected));
handleSelect(queryItemId(selected));
}
unsub.unsubscribe();
});
}
},
[queryRunner]
[queryRunner, handleSelect]
);
/** 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 handleGoToQueries = useCallback(() => {
// Close the transformation picker
setIsAddingTransform(false);
// Add a SQL expression
if (queriesTab) {
updateQuerySelectionOnStateChange(queries?.length ?? 0);
queriesTab.onAddExpressionOfType(ExpressionQueryType.sql);
}
}, [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)) {
const prevQuery = newQueries?.[index - 1];
setSelectedId(prevQuery ? queryItemId(prevQuery) : 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]
);
// Handler for selecting a query from the query library
const handleQueryLibrarySelect = useCallback(
(query: DataQuery) => {
(query: SceneDataQuery) => {
if (!queryRunner || !queriesTab) {
return;
}
@@ -323,7 +184,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
// Create new query with the selected refId
const newQuery: DataQuery = {
const newQuery: SceneDataQuery = {
...query,
refId: nextRefId,
};
@@ -331,45 +192,54 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
updateQuerySelectionOnStateChange(selectedIndex);
queriesTab.onAddQuery(newQuery);
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
},
[queryRunner, queryLibraryMode.index, queries, updateQuerySelectionOnStateChange, queriesTab]
[queryRunner, queryLibraryMode.index, queries, updateQuerySelectionOnStateChange, queriesTab, model]
);
// Handler for saving a query to the query library (stub)
const handleQueryLibrarySave = useCallback((_name: string, _description: string) => {
// Stub: In real implementation, this would save to the query library
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, []);
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, [model]);
// Handler to close the query library view
const handleQueryLibraryClose = useCallback(() => {
setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, []);
model.setQueryLibraryMode({ active: false, mode: 'browse', index: null });
}, [model]);
// Handler to open query library in a specific mode
const handleOpenQueryLibrary = useCallback(
(mode: 'browse' | 'save', index?: number) => {
let currentQuery: DataQuery | undefined;
(mode: QueryLibraryMode["mode"], index?: number) => {
let currentQuery: SceneDataQuery | undefined;
if (
mode === 'save' &&
selectedItem &&
(selectedItem.type === 'query' || selectedItem.type === 'expression') &&
'refId' in selectedItem.data
(selectedItem?.type === 'query' || selectedItem?.type === 'expression')
) {
currentQuery = selectedItem.data;
}
setQueryLibraryMode({
model.setQueryLibraryMode({
active: true,
mode,
currentQuery,
index: index ?? null,
});
},
[selectedItem]
[selectedItem, model]
);
const handleGoToQueries = useCallback(() => {
// Close the transformation picker
model.onTransformPicker(null);
// Add a SQL expression
if (queriesTab) {
updateQuerySelectionOnStateChange(queries?.length ?? 0);
queriesTab.onAddExpressionOfType(ExpressionQueryType.sql);
}
}, [queriesTab, updateQuerySelectionOnStateChange, queries, model]);
/** TRANSFORMS **/
const handleAddTransform = useCallback(
(selected: SelectableValue<string>, customOptions?: Record<string, unknown>) => {
@@ -378,7 +248,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
}
if (transformsTab && transformer) {
const selectedIndex = transformDrawerState.index ?? transformations?.length ?? 0;
const selectedIndex = transformPickerIndex ?? transformations?.length ?? 0;
const newTransformation: DataTransformerConfig = {
id: selected.value,
options: customOptions ?? {},
@@ -387,9 +257,8 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
const unsub = transformer.subscribeToState((newState) => {
const newTransform = newState.transformations[selectedIndex];
setSelectedId(!!newTransform ? transformItemId(selectedIndex) : null);
setTransformDrawerState({ open: false, index: null });
setIsAddingTransform(false);
model.onChangeSelected(!!newTransform ? transformItemId(selectedIndex) : null);
model.onTransformPicker(null);
unsub.unsubscribe();
});
@@ -399,7 +268,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformsTab, transformer, transformations, transformDrawerState.index]
[transformsTab, transformer, transformPickerIndex, transformations, model]
);
const handleRemoveTransform = useCallback(
@@ -411,11 +280,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
// Clear selection if removing the selected transformation
if (selectedId === transformItemId(index)) {
const prevTransform = newTransformations[index - 1];
setSelectedId(prevTransform ? transformItemId(index - 1) : null);
handleSelect(prevTransform ? transformItemId(index - 1) : null);
}
}
},
[transformations, transformsTab, selectedId]
[transformations, transformsTab, selectedId, handleSelect]
);
const handleToggleTransformVisibility = useCallback(
@@ -429,116 +298,31 @@ function PanelDataPaneRendered({ model }: SceneComponentProps<PanelDataPane>) {
[transformations, transformsTab]
);
const handleReorderDataSources = useCallback(
(startIndex: number, endIndex: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = Array.from(queries);
const [removed] = newQueries.splice(startIndex, 1);
newQueries.splice(endIndex, 0, removed);
queryRunner.setState({ queries: newQueries });
}
},
[queryRunner]
);
const handleReorderTransforms = useCallback(
(startIndex: number, endIndex: number) => {
if (transformsTab) {
const newTransformations = [...(transformations ?? [])];
const [removed] = newTransformations.splice(startIndex, 1);
newTransformations.splice(endIndex, 0, removed);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
// Get data for transformations drawer
const sourceData = queryRunner?.useState();
const series = sourceData?.data?.series || [];
const { containerProps, primaryProps, secondaryProps, splitterProps } = useSplitter({
direction: 'row',
initialSize: 0.01,
handleSize: 'xs',
});
return (
<div className={styles.dataPane} data-testid={selectors.components.PanelEditor.DataPane.content}>
<div {...containerProps} className={cx(containerProps.className, styles.unifiedLayout)}>
<div {...primaryProps} className={cx(primaryProps.className, styles.leftPane)}>
<QueryTransformList
allItems={allItems}
dataSourceItems={queryExpressionItems}
transformItems={transformItems}
selectedId={effectiveSelectedId}
onSelect={handleSelect}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => handleOpenQueryLibrary('browse', index)}
onAddTransform={(index) => {
setIsAddingTransform(true);
setSelectedId(null);
}}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
onReorderTransforms={handleReorderTransforms}
onAddOrganizeFieldsTransform={() =>
handleAddTransform(
{ value: 'organize' },
{
excludeByName: {},
indexByName: {},
renameByName: {},
includeByName: {},
orderByMode: 'auto',
orderBy: [
{
type: 'name',
desc: false,
},
],
}
)
}
<div className={cx(styles.unifiedLayout)}>
<DetailView
selectedItem={selectedItem}
panel={panel}
tabs={tabs}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
isAddingTransform={transformPickerIndex != null}
onAddTransformation={handleAddTransform}
onCancelAddTransform={() => model.onTransformPicker(null)}
transformationData={series}
onGoToQueries={handleGoToQueries}
queryLibraryMode={queryLibraryMode}
onQueryLibrarySelect={handleQueryLibrarySelect}
onQueryLibrarySave={handleQueryLibrarySave}
onQueryLibraryClose={handleQueryLibraryClose}
onOpenQueryLibrary={handleOpenQueryLibrary}
/>
</div>
<div
{...splitterProps}
className={cx(splitterProps.className, styles.splitter)}
style={{ ...splitterProps.style, width: '16px' }}
/>
<div {...secondaryProps}>
<DetailView
selectedItem={selectedItem}
panel={panel}
tabs={tabs}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
isAddingTransform={isAddingTransform}
onAddTransformation={handleAddTransform}
onCancelAddTransform={() => setIsAddingTransform(false)}
transformationData={series}
onGoToQueries={handleGoToQueries}
queryLibraryMode={queryLibraryMode}
onQueryLibrarySelect={handleQueryLibrarySelect}
onQueryLibrarySave={handleQueryLibrarySave}
onQueryLibraryClose={handleQueryLibraryClose}
onOpenQueryLibrary={handleOpenQueryLibrary}
/>
</div>
</div>
<TransformationsDrawer
isOpen={transformDrawerState.open}
onClose={() => setTransformDrawerState({ open: false, index: null })}
onTransformationAdd={handleAddTransform}
series={series}
/>
</div>
);
}
@@ -557,10 +341,6 @@ export function shouldShowAlertingTab(pluginId: string) {
return isGraph || isTimeseries;
}
// Left pane sizing: cards grow from 180px-300px, plus content padding (48px + 64px = 112px)
const LEFT_PANE_MIN = 180 + 112; // 292px (180px card min + 112px padding)
const LEFT_PANE_MAX = 300 + 112; // 412px (300px card max + 112px padding)
function getStyles(theme: GrafanaTheme2) {
return {
dataPane: css({
@@ -570,6 +350,7 @@ function getStyles(theme: GrafanaTheme2) {
minHeight: 0,
height: '100%',
width: '100%',
paddingLeft: theme.spacing(2),
}),
unifiedLayout: css({
flex: 1,
@@ -577,16 +358,5 @@ function getStyles(theme: GrafanaTheme2) {
background: theme.colors.background.primary,
overflow: 'hidden',
}),
splitter: css({
position: 'relative',
background: theme.colors.background.canvas,
cursor: 'col-resize',
}),
leftPane: css({
// !important on minWidth to override useSplitter's inline minWidth: 'min-content'
minWidth: `${LEFT_PANE_MIN}px !important`,
maxWidth: `${LEFT_PANE_MAX}px`,
overflow: 'hidden',
}),
};
}
@@ -837,7 +837,7 @@ async function setupScene(panelId: string) {
deactivators.push(dashboard.activate());
deactivators.push(panelEditor.activate());
const queriesTab = panelEditor.state.dataPane!.state.tabs[0] as PanelDataQueriesTab;
const queriesTab = panelEditor.state.dataPane!.state.editorRef.resolve().state.dataPaneTabs[0];
deactivators.push(queriesTab.activate());
await Promise.resolve();
@@ -0,0 +1,414 @@
import { css } from '@emotion/css';
import { useCallback, useState, useEffect, useMemo } from 'react';
import { DataQuery, DataTransformerConfig, GrafanaTheme2, SelectableValue } from '@grafana/data';
import { t } from '@grafana/i18n';
import { SceneComponentProps, SceneDataQuery } from '@grafana/scenes';
import { Button, useStyles2 } from '@grafana/ui';
import { getConfig } from 'app/core/config';
import { contextSrv } from 'app/core/services/context_srv';
import { getRulesPermissions } from 'app/features/alerting/unified/utils/access-control';
import { GRAFANA_RULES_SOURCE_NAME } from 'app/features/alerting/unified/utils/datasource';
import { ExpressionQueryType } from '../../../expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { QueryLibraryMode } from './DetailView';
import { PanelDataPane } from './PanelDataPane';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { PanelDataTransformationsTab } from './PanelDataTransformationsTab';
import { QueryTransformList } from './QueryTransformList';
import { useQueryTransformItems } from './hooks';
import { TabId } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export function PanelDataSidebarRendered({ model }: SceneComponentProps<PanelDataPane>) {
const { panelRef, tabs, selectedQueryTransform, sidebarCollapsed, transformPickerIndex } = model.useState();
const styles = useStyles2(getStyles, sidebarCollapsed);
const panel = panelRef.resolve();
// 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?.filter(isDataTransformerConfig);
// 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.
useEffect(() => {
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 = prevState.queries.map(({ refId }) => refId);
if (newState.queries.length !== oldOrderedRefIds.length) {
return; // add, remove, something else.
}
let refIdChanges = 0;
let updatedQuery: SceneDataQuery | undefined = undefined;
for (let i = 0; i < newState.queries.length; i++) {
const newQuery = newState.queries[i];
const oldRefId = oldOrderedRefIds[i];
if (newQuery.refId !== oldRefId) {
if (++refIdChanges < 2) {
updatedQuery = newQuery;
} else {
return; // more than 2 refId changes, so it's a reorder or something else.
}
}
}
if (updatedQuery) {
model.onChangeSelected(queryItemId(updatedQuery));
}
});
}, [queryRunner, model]);
// Build separate lists for queries/expressions and transformations
const { queryExpressionItems, transformItems, allItems } = useQueryTransformItems(queries, transformations);
// Auto-select first item if nothing is selected
const selectedId = useMemo(() => {
if (selectedQueryTransform === null && allItems.length > 0) {
return allItems[0].id;
}
return selectedQueryTransform;
}, [selectedQueryTransform, allItems]);
const selectedItem = useMemo(() => allItems.find((item) => item.id === selectedId), [allItems, selectedId]);
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];
model.onChangeSelected(queryItemId(selected));
}
unsub.unsubscribe();
});
}
},
[queryRunner, model]
);
/** 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)) {
const prevQuery = newQueries?.[index - 1];
model.onChangeSelected(prevQuery ? queryItemId(prevQuery) : null);
}
}
},
[queryRunner, selectedId, queries, model]
);
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]
);
const handleOpenQueryLibrary = useCallback(
(mode: QueryLibraryMode["mode"], index?: number) => {
let currentQuery: SceneDataQuery | undefined;
if (
mode === 'save' &&
(selectedItem?.type === 'query' || selectedItem?.type === 'expression')
) {
currentQuery = selectedItem.data;
}
model.setQueryLibraryMode({
active: true,
mode,
currentQuery,
index: index ?? null,
});
},
[selectedItem, model]
);
/** TRANSFORMS **/
const handleAddTransform = useCallback(
(selected: SelectableValue<string>, customOptions?: Record<string, unknown>) => {
if (!selected.value) {
return;
}
if (transformsTab && transformer) {
const selectedIndex = transformPickerIndex ?? transformations?.length ?? 0;
const newTransformation: DataTransformerConfig = {
id: selected.value,
options: customOptions ?? {},
};
const unsub = transformer.subscribeToState((newState) => {
const newTransform = newState.transformations[selectedIndex];
model.onChangeSelected(!!newTransform ? transformItemId(selectedIndex) : null);
model.onTransformPicker(null);
unsub.unsubscribe();
});
const newTransformations = [...(transformations ?? [])];
newTransformations.splice(selectedIndex, 0, newTransformation);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformsTab, transformer, transformPickerIndex, transformations, model]
);
const handleRemoveTransform = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations = transformations?.filter((_, i) => i !== index) ?? [];
transformsTab.onChangeTransformations(newTransformations);
// Clear selection if removing the selected transformation
if (selectedId === transformItemId(index)) {
const prevTransform = newTransformations[index - 1];
model.onChangeSelected(prevTransform ? transformItemId(index - 1) : null);
}
}
},
[transformations, transformsTab, selectedId, model]
);
const handleToggleTransformVisibility = useCallback(
(index: number) => {
if (transformsTab) {
const newTransformations =
transformations?.map((t, i) => (i === index ? { ...t, disabled: t.disabled ? undefined : true } : t)) ?? [];
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
const handleReorderDataSources = useCallback(
(startIndex: number, endIndex: number) => {
if (queryRunner) {
const queries = queryRunner.state.queries || [];
const newQueries = Array.from(queries);
const [removed] = newQueries.splice(startIndex, 1);
newQueries.splice(endIndex, 0, removed);
queryRunner.setState({ queries: newQueries });
}
},
[queryRunner]
);
const handleReorderTransforms = useCallback(
(startIndex: number, endIndex: number) => {
if (transformsTab) {
const newTransformations = [...(transformations ?? [])];
const [removed] = newTransformations.splice(startIndex, 1);
newTransformations.splice(endIndex, 0, removed);
transformsTab.onChangeTransformations(newTransformations);
}
},
[transformations, transformsTab]
);
// Get data for transformations drawer
const sourceData = queryRunner?.useState();
const series = sourceData?.data?.series || [];
if (sidebarCollapsed) {
return (
<div className={styles.sidebarPane}>
<Button
icon="angle-right"
variant="secondary"
onClick={() => model.onCollapseSidebar(false)}
tooltip={t('app.features.dashboardScene.panelEdit.panelDataPane.expandSidebar', 'Expand sidebar')}
/>
</div>
);
}
return (
<div className={styles.sidebarPane}>
<QueryTransformList
allItems={allItems}
dataSourceItems={queryExpressionItems}
transformItems={transformItems}
selectedId={selectedId}
onCollapseSidebar={() => model.onCollapseSidebar(true)}
onSelect={(id) => {
model.onChangeSelected(id)
}}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => handleOpenQueryLibrary('browse', index)}
onAddTransform={(index) => {
model.onChangeSelected(null);
model.onTransformPicker(index);
}}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
onReorderTransforms={handleReorderTransforms}
onAddOrganizeFieldsTransform={() =>
handleAddTransform(
{ value: 'organize' },
{
excludeByName: {},
indexByName: {},
renameByName: {},
includeByName: {},
orderByMode: 'auto',
orderBy: [
{
type: 'name',
desc: false,
},
],
}
)
}
/>
<QueryTransformList
allItems={allItems}
dataSourceItems={queryExpressionItems}
transformItems={transformItems}
selectedId={selectedId}
onCollapseSidebar={() => model.onCollapseSidebar(true)}
onSelect={(newSelectedId) => model.onChangeSelected(newSelectedId)}
onAddQuery={handleAddQuery}
onAddFromSavedQueries={(index) => handleOpenQueryLibrary('browse', index)}
onAddTransform={(index) => {
model.onTransformPicker(index);
model.onChangeSelected(null);
}}
onAddExpression={handleAddExpression}
onDuplicateQuery={handleDuplicateQuery}
onRemoveQuery={handleRemoveQuery}
onToggleQueryVisibility={handleToggleQueryVisibility}
onRemoveTransform={handleRemoveTransform}
onToggleTransformVisibility={handleToggleTransformVisibility}
onReorderDataSources={handleReorderDataSources}
onReorderTransforms={handleReorderTransforms}
onAddOrganizeFieldsTransform={() =>
handleAddTransform(
{ value: 'organize' },
{
excludeByName: {},
indexByName: {},
renameByName: {},
includeByName: {},
orderByMode: 'auto',
orderBy: [
{
type: 'name',
desc: false,
},
],
}
)
}
/>
</div>
);
}
export function shouldShowAlertingTab(pluginId: string) {
const { unifiedAlertingEnabled = false } = getConfig();
const hasRuleReadPermissions = contextSrv.hasPermission(getRulesPermissions(GRAFANA_RULES_SOURCE_NAME).read);
const isAlertingAvailable = unifiedAlertingEnabled && hasRuleReadPermissions;
if (!isAlertingAvailable) {
return false;
}
const isGraph = pluginId === 'graph';
const isTimeseries = pluginId === 'timeseries';
return isGraph || isTimeseries;
}
function getStyles(theme: GrafanaTheme2, collapsed: boolean) {
return {
sidebarPane: css({
overflow: 'hidden',
height: '100%',
width: collapsed ? 49 : 300,
padding: collapsed ? theme.spacing(1) : 'unset',
borderTopRightRadius: theme.shape.radius.md,
borderTop: `1px solid ${theme.colors.border.weak}`,
borderRight: `1px solid ${theme.colors.border.weak}`,
background: theme.colors.background.primary,
}),
};
}
@@ -90,7 +90,7 @@ export function PanelDataTransformationsTabRendered({
return;
}
const queriesTab = parent.state.tabs.find((tab) => tab.tabId === TabId.Queries);
const queriesTab = parent.state.editorRef.resolve().state.dataPaneTabs.find((tab) => tab.tabId === TabId.Queries);
if (!(queriesTab instanceof PanelDataQueriesTab)) {
return;
}
@@ -33,6 +33,7 @@ interface QueryTransformListProps {
onReorderDataSources?: (startIndex: number, endIndex: number) => void;
onReorderTransforms?: (startIndex: number, endIndex: number) => void;
onAddOrganizeFieldsTransform?: () => void;
onCollapseSidebar: () => void;
}
export const QueryTransformList = memo(
@@ -54,6 +55,7 @@ export const QueryTransformList = memo(
onReorderDataSources,
onReorderTransforms,
onAddOrganizeFieldsTransform,
onCollapseSidebar,
}: QueryTransformListProps) => {
const styles = useStyles2(getStyles);
const [isDragging, setIsDragging] = useState(false);
@@ -269,9 +271,13 @@ export const QueryTransformList = memo(
<div className={styles.container} onMouseLeave={() => setHovered(null)}>
<div className={styles.header}>
<Stack justifyContent="space-between" alignItems="center" gap={2}>
<span className={styles.headerTitle}>
{t('dashboard-scene.query-transform-list.header', 'Pipeline flow')}
</span>
{/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */}
<div className={styles.headerTitle} onClick={onCollapseSidebar}>
<Stack direction="row" alignItems="center" gap={1}>
<Icon name="angle-down" />
<span>{t('dashboard-scene.query-transform-list.header', 'Pipeline flow')}</span>
</Stack>
</div>
<Button size="sm" onClick={handleToggleAiMode} className={styles.aiModeButton}>
<Stack direction="row" gap={0.5} alignItems="center">
<Icon name={isAiMode ? 'times' : 'ai'} size="sm" />
@@ -572,8 +578,9 @@ const getStyles = (theme: GrafanaTheme2) => {
height: '100%',
width: '100%',
maxWidth: '100%',
overflow: 'hidden',
overflow: 'auto',
border: `1px solid ${theme.colors.border.weak}`,
borderLeft: 'none',
}),
header: css({
...barBase,
@@ -588,6 +595,7 @@ const getStyles = (theme: GrafanaTheme2) => {
fontFamily: theme.typography.fontFamilyMonospace,
textTransform: 'uppercase',
color: theme.colors.text.primary,
cursor: 'pointer',
}),
sectionLabel: css({
cursor: 'pointer',
@@ -647,7 +655,7 @@ const getStyles = (theme: GrafanaTheme2) => {
},
}),
content: css({
padding: theme.spacing(2, 8, 2, 2),
padding: theme.spacing(2, 6, 2, 2),
position: 'relative',
}),
contentGradientBorder: css({
@@ -0,0 +1,47 @@
import { useMemo } from 'react';
import { DataTransformerConfig } from '@grafana/data';
import { SceneDataQuery } from '@grafana/scenes';
import { isExpressionQuery } from 'app/features/expressions/guards';
import { QueryItem, TransformItem } from './types';
import { isDataTransformerConfig, queryItemId, transformItemId } from './utils';
export function useQueryTransformItems(queries?: SceneDataQuery[], transformations?: DataTransformerConfig[]) {
const result = useMemo(() => {
const queryExpressionItems: QueryItem[] = [];
const transformItems: TransformItem[] = [];
// Add queries and expressions
for (let i = 0; i < (queries?.length ?? 0); i++) {
const query = queries![i];
queryExpressionItems.push({
id: queryItemId(query),
type: isExpressionQuery(query) ? 'expression' : 'query',
data: query,
index: i, // Store actual index in queries array
});
}
// Add transformations
for (let i = 0; i < (transformations?.length ?? 0); i++) {
const transform = transformations![i];
if (isDataTransformerConfig(transform)) {
transformItems.push({
id: transformItemId(i),
type: 'transform',
data: transform,
index: i,
});
}
}
return {
queryExpressionItems,
transformItems,
allItems: [...queryExpressionItems, ...transformItems],
};
}, [queries, transformations]);
return result;
}
@@ -13,6 +13,7 @@ import { NavToolbarActions } from '../scene/NavToolbarActions';
import { UnlinkModal } from '../scene/UnlinkModal';
import { getDashboardSceneFor, getLibraryPanelBehavior } from '../utils/utils';
import { PanelDataSidebarRendered } from './PanelDataPane/PanelDataSidebar';
import { PanelEditor } from './PanelEditor';
import { SaveLibraryVizPanelModal } from './SaveLibraryVizPanelModal';
import { useSnappingSplitter } from './splitter/useSnappingSplitter';
@@ -111,6 +112,11 @@ function VizAndDataPane({ model }: SceneComponentProps<PanelEditor>) {
<controls.Component model={controls} />
</div>
)}
{dataPane && (
<div className={styles.sidebar}>
<PanelDataSidebarRendered model={dataPane} />
</div>
)}
<div {...containerProps}>
<div {...primaryProps} className={cx(primaryProps.className, isScrollingLayout && styles.fixedSizeViz)}>
<VizWrapper panel={panel} tableView={tableView} />
@@ -181,8 +187,8 @@ function getStyles(theme: GrafanaTheme2) {
pageContainer: css({
display: 'grid',
gridTemplateAreas: `
"panels"`,
gridTemplateColumns: `1fr`,
"sidebar panels"`,
gridTemplateColumns: `auto 1fr`,
gridTemplateRows: '1fr',
height: '100%',
[scrollReflowMediaQuery]: {
@@ -191,14 +197,24 @@ function getStyles(theme: GrafanaTheme2) {
}),
pageContainerWithControls: css({
gridTemplateAreas: `
"controls"
"panels"`,
"controls controls"
"sidebar panels"`,
gridTemplateRows: 'auto 1fr',
}),
container: css({
gridArea: 'panels',
height: '100%',
}),
sidebar: css({
gridArea: 'sidebar',
height: '100%',
maxHeight: '100%',
overflow: 'auto',
}),
expandDataSidebar: css({
width: theme.spacing(6),
padding: theme.spacing(1),
}),
canvasContent: css({
label: 'canvas-content',
display: 'flex',