Compare commits

...

3 Commits

Author SHA1 Message Date
Alex Spencer
fd16965d31 fix: tests 2025-11-26 09:22:13 -08:00
Alex Spencer
9d6f980320 fix: type optionality 2025-11-25 12:10:33 -08:00
Alex Spencer
f9b4fc71e5 chore: PoC 2025-11-25 12:08:09 -08:00
5 changed files with 113 additions and 12 deletions

View File

@@ -24,6 +24,16 @@ import { testDashboard } from '../testfiles/testDashboard';
import { PanelDataTransformationsTab, PanelDataTransformationsTabRendered } from './PanelDataTransformationsTab';
// Mock getDashboardSceneFor to return a mock dashboard
jest.mock('../../utils/utils', () => ({
...jest.requireActual('../../utils/utils'),
getDashboardSceneFor: jest.fn(() => ({
state: {
uid: 'test-dashboard-uid',
},
})),
}));
function createModelMock(
panelData: PanelData,
transformations?: DataTransformerConfig[],
@@ -33,6 +43,15 @@ function createModelMock(
getDataTransformer: () => new SceneDataTransformer({ data: panelData, transformations: transformations || [] }),
getQueryRunner: () => new SceneQueryRunner({ queries: [], data: panelData }),
onChangeTransformations: onChangeTransformationsMock,
state: {
panelRef: {
resolve: () => ({
state: {
key: 'test-panel-key',
},
}),
},
},
} as unknown as PanelDataTransformationsTab;
}

View File

@@ -18,13 +18,14 @@ import { Button, ButtonGroup, ConfirmModal, Tab, useStyles2 } from '@grafana/ui'
import { TransformationOperationRows } from 'app/features/dashboard/components/TransformationsEditor/TransformationOperationRows';
import { ExpressionQueryType } from 'app/features/expressions/types';
import { getQueryRunnerFor } from '../../utils/utils';
import { getDashboardSceneFor, getQueryRunnerFor } from '../../utils/utils';
import { EmptyTransformationsMessage } from './EmptyTransformationsMessage';
import { PanelDataPane } from './PanelDataPane';
import { PanelDataQueriesTab } from './PanelDataQueriesTab';
import { TransformationsDrawer } from './TransformationsDrawer';
import { PanelDataPaneTab, TabId, PanelDataTabHeaderProps } from './types';
import { usePersistedTransformationState } from './usePersistedTransformationState';
import { findSqlExpression, scrollToQueryRow } from './utils';
const SET_TIMEOUT = 750;
@@ -73,6 +74,11 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
const sourceData = model.getQueryRunner().useState();
const { data, transformations: transformsWrongType } = model.getDataTransformer().useState();
// Get dashboard and panel IDs for scoping session storage
const dashboard = getDashboardSceneFor(model);
const panel = model.state.panelRef.resolve();
const storageKey = `${dashboard.state.uid}-${panel.state.key || 'unknown'}`;
// Type guard to ensure transformations are DataTransformerConfig[]
const transformations = useMemo<DataTransformerConfig[]>(() => {
return Array.isArray(transformsWrongType)
@@ -83,11 +89,13 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
: [];
}, [transformsWrongType]);
const [drawerOpen, setDrawerOpen] = useState<boolean>(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
const { isOpen: getRowCollapseState, setIsOpen: setRowCollapseState } = usePersistedTransformationState(storageKey);
const openDrawer = () => setDrawerOpen(true);
const closeDrawer = () => setDrawerOpen(false);
const [confirmModalOpen, setConfirmModalOpen] = useState<boolean>(false);
const [pickerDrawerOpen, setPickerDrawerOpen] = useState<boolean>(false);
const openPickerDrawer = () => setPickerDrawerOpen(true);
const closePickerDrawer = () => setPickerDrawerOpen(false);
const onGoToQueries = useCallback(() => {
const parent = model.parent;
@@ -134,15 +142,15 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
const transformationsDrawer = (
<TransformationsDrawer
onClose={closeDrawer}
onClose={closePickerDrawer}
onTransformationAdd={(selected) => {
if (selected.value === undefined) {
return;
}
model.onChangeTransformations([...transformations, { id: selected.value, options: {} }]);
closeDrawer();
closePickerDrawer();
}}
isOpen={drawerOpen}
isOpen={pickerDrawerOpen}
series={data.series}
/>
);
@@ -151,7 +159,7 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
return (
<>
<EmptyTransformationsMessage
onShowPicker={openDrawer}
onShowPicker={openPickerDrawer}
onGoToQueries={onGoToQueries}
onAddTransformation={onAddTransformation}
/>
@@ -162,12 +170,18 @@ export function PanelDataTransformationsTabRendered({ model }: SceneComponentPro
return (
<>
<TransformationsEditor data={sourceData.data} transformations={transformations} model={model} />
<TransformationsEditor
data={sourceData.data}
transformations={transformations}
model={model}
getIsOpen={getRowCollapseState}
setIsOpen={setRowCollapseState}
/>
<ButtonGroup>
<Button
icon="plus"
variant="secondary"
onClick={openDrawer}
onClick={openPickerDrawer}
data-testid={selectors.components.Transforms.addTransformationButton}
>
<Trans i18nKey="dashboard-scene.panel-data-transformations-tab-rendered.add-another-transformation">
@@ -212,9 +226,11 @@ interface TransformationEditorProps {
transformations: DataTransformerConfig[];
model: PanelDataTransformationsTab;
data: PanelData;
getIsOpen: (id: string) => boolean | undefined;
setIsOpen: (id: string, isOpen: boolean) => void;
}
function TransformationsEditor({ transformations, model, data }: TransformationEditorProps) {
function TransformationsEditor({ transformations, model, data, getIsOpen, setIsOpen }: TransformationEditorProps) {
const transformationEditorRows = transformations.map((t, i) => ({ id: `${i} - ${t.id}`, transformation: t }));
const onDragEnd = (result: DropResult) => {
@@ -252,6 +268,8 @@ function TransformationsEditor({ transformations, model, data }: TransformationE
}}
configs={transformationEditorRows}
data={data}
getIsOpen={getIsOpen}
setIsOpen={setIsOpen}
></TransformationOperationRows>
{provided.placeholder}
</div>

View File

@@ -0,0 +1,51 @@
import { useCallback } from 'react';
/**
* Session storage key prefix for persisting transformation row collapse states.
* Full key format: grafana.panelEditor.transformations.${dashboardUID}-${panelKey}
*/
export const TRANSFORMATION_ROWS_STATE_KEY = 'grafana.panelEditor.transformations';
/**
* Persists transformation row collapse states in sessionStorage.
* Scoped by dashboard UID and panel ID for uniqueness across dashboards.
* Reads/writes directly from sessionStorage.
*/
export function usePersistedTransformationState(storageKey: string) {
const fullStorageKey = `${TRANSFORMATION_ROWS_STATE_KEY}.${storageKey}`;
const isOpen = useCallback(
(transformationId: string): boolean | undefined => {
try {
const stored = sessionStorage.getItem(fullStorageKey);
if (!stored) {
return undefined;
}
const rowStates = JSON.parse(stored);
return rowStates[transformationId];
} catch {
return undefined;
}
},
[fullStorageKey]
);
const setIsOpen = useCallback(
(transformationId: string, open: boolean) => {
try {
const stored = sessionStorage.getItem(fullStorageKey);
const rowStates = stored ? JSON.parse(stored) : {};
rowStates[transformationId] = open;
sessionStorage.setItem(fullStorageKey, JSON.stringify(rowStates));
} catch (error) {
if (process.env.NODE_ENV === 'development') {
console.warn(`Failed to persist transformation state for ${transformationId}:`, error);
}
// Silently fail in production if storage unavailable or quota exceeded
}
},
[fullStorageKey]
);
return { isOpen, setIsOpen };
}

View File

@@ -36,6 +36,8 @@ interface TransformationOperationRowProps {
configs: TransformationsEditorTransformation[];
onRemove: (index: number) => void;
onChange: (index: number, config: DataTransformerConfig) => void;
isOpen?: boolean;
onOpenChanged: (isOpen: boolean) => void;
}
export const TransformationOperationRow = ({
@@ -46,6 +48,8 @@ export const TransformationOperationRow = ({
configs,
uiConfig,
onChange,
isOpen,
onOpenChanged,
}: TransformationOperationRowProps) => {
const [showDeleteModal, setShowDeleteModal] = useToggle(false);
const [showDebug, toggleShowDebug] = useToggle(false);
@@ -227,6 +231,9 @@ export const TransformationOperationRow = ({
draggable
actions={renderActions}
disabled={disabled}
isOpen={isOpen}
onOpen={() => onOpenChanged(true)}
onClose={() => onOpenChanged(false)}
expanderMessages={{
close: 'Collapse transformation row',
open: 'Expand transformation row',

View File

@@ -9,6 +9,8 @@ interface TransformationOperationRowsProps {
configs: TransformationsEditorTransformation[];
onRemove: (index: number) => void;
onChange: (index: number, config: DataTransformerConfig) => void;
getIsOpen?: (id: string) => boolean | undefined;
setIsOpen?: (id: string, isOpen: boolean) => void;
}
export const TransformationOperationRows = ({
@@ -16,6 +18,8 @@ export const TransformationOperationRows = ({
onChange,
onRemove,
configs,
getIsOpen,
setIsOpen,
}: TransformationOperationRowsProps) => {
return (
<>
@@ -36,6 +40,8 @@ export const TransformationOperationRows = ({
uiConfig={uiConfig}
onRemove={onRemove}
onChange={onChange}
isOpen={getIsOpen?.(t.id)}
onOpenChanged={(isOpen) => setIsOpen?.(t.id, isOpen)}
/>
);
})}