diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx index fd44eda74a5..a79b88bab01 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/ConnectionLines.tsx @@ -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) => { diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx index 1bcbc5b117d..74e83c7055b 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/DetailView.tsx @@ -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', diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx index 507b8374540..59f4b29a661 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataPane.tsx @@ -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; + transformPickerIndex?: number | null; + queryLibraryMode: QueryLibraryMode & { index: number | null }; } export class PanelDataPane extends SceneObjectBase { 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 { 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 { } } -interface DrawerState { - open: boolean; - index: number | null; -} function PanelDataPaneRendered({ model }: SceneComponentProps) { - const { tabs, panelRef } = model.useState(); + const { tabs, selectedQueryTransform, panelRef, transformPickerIndex, queryLibraryMode } = model.useState(); const styles = useStyles2(getStyles); - const [selectedId, setSelectedId] = useState(null); - const [queryLibraryMode, setQueryLibraryMode] = useState({ - active: false, - mode: 'browse', - index: null, - }); - const [transformDrawerState, setTransformDrawerState] = useState({ - 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) { 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) { 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) { } // Create new query with the selected refId - const newQuery: DataQuery = { + const newQuery: SceneDataQuery = { ...query, refId: nextRefId, }; @@ -331,45 +192,54 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { 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, customOptions?: Record) => { @@ -378,7 +248,7 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { } 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) { 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) { transformsTab.onChangeTransformations(newTransformations); } }, - [transformsTab, transformer, transformations, transformDrawerState.index] + [transformsTab, transformer, transformPickerIndex, transformations, model] ); const handleRemoveTransform = useCallback( @@ -411,11 +280,11 @@ function PanelDataPaneRendered({ model }: SceneComponentProps) { // 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) { [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 (
-
-
- 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, - }, - ], - } - ) - } +
+ model.onTransformPicker(null)} + transformationData={series} + onGoToQueries={handleGoToQueries} + queryLibraryMode={queryLibraryMode} + onQueryLibrarySelect={handleQueryLibrarySelect} + onQueryLibrarySave={handleQueryLibrarySave} + onQueryLibraryClose={handleQueryLibraryClose} + onOpenQueryLibrary={handleOpenQueryLibrary} /> -
-
-
- setIsAddingTransform(false)} - transformationData={series} - onGoToQueries={handleGoToQueries} - queryLibraryMode={queryLibraryMode} - onQueryLibrarySelect={handleQueryLibrarySelect} - onQueryLibrarySave={handleQueryLibrarySave} - onQueryLibraryClose={handleQueryLibraryClose} - onOpenQueryLibrary={handleOpenQueryLibrary} - /> -
- setTransformDrawerState({ open: false, index: null })} - onTransformationAdd={handleAddTransform} - series={series} - />
); } @@ -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', - }), }; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx index 40da4a158a8..96c3774f297 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataQueriesTab.test.tsx @@ -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(); diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataSidebar.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataSidebar.tsx new file mode 100644 index 00000000000..5ca48d47623 --- /dev/null +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataSidebar.tsx @@ -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) { + 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, customOptions?: Record) => { + 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 ( +
+
+ ); + } + + return ( +
+ 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, + }, + ], + } + ) + } + /> + 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, + }, + ], + } + ) + } + /> +
+ ); +} + +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, + }), + }; +} diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx index c6218be2123..a04d735ea33 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/PanelDataTransformationsTab.tsx @@ -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; } diff --git a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx index 54acee21fc5..802bd437c4e 100644 --- a/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx +++ b/public/app/features/dashboard-scene/panel-edit/PanelDataPane/QueryTransformList.tsx @@ -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(
setHovered(null)}>
- - {t('dashboard-scene.query-transform-list.header', 'Pipeline flow')} - + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-static-element-interactions */} +
+ + + {t('dashboard-scene.query-transform-list.header', 'Pipeline flow')} + +
)} + {dataPane && ( +
+ +
+ )}
@@ -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',