Compare commits

..

1 Commits

Author SHA1 Message Date
Evan
bc30677690 fix(dashboard): apply auto-refresh interval in standalone mode
The dashboard auto-refresh timer is driven by the useHeaderAutoRefresh
hook, which only mounts inside DashboardHeader. When the header is hidden
(standalone HideNavAndTitle mode, ?standalone=1 reports, or hideTitle in
embedded dashboards) the header never renders, so the refresh interval
never starts and charts stop auto-refreshing.

Add a headless HeadlessAutoRefresh component that sources the same redux
state and runs the auto-refresh hook when the header is hidden, so the
configured refresh interval keeps working regardless of header visibility.
Exactly one instance of the timer runs: the header when visible, the
headless component otherwise.

Fixes #25970

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 02:04:49 -07:00
11 changed files with 203 additions and 178 deletions

View File

@@ -44,8 +44,3 @@ export const FILTER_CONDITION_BODY_INDEX = {
} as const;
export const ROW_NUMBER_COL_ID = '__row_number__';
// Non-enumerable key used to attach a row's basic (increase/decrease) color
// formatter to the row data object so it travels with the row through AG Grid
// client-side sorting (#105973).
export const BASIC_COLOR_FORMATTERS_ROW_KEY = '__basicColorFormatters__';

View File

@@ -24,7 +24,6 @@ import {
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
import { useIsDark } from '../utils/useTableTheme';
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
const StyledTotalCell = styled.div`
${() => `
@@ -164,13 +163,13 @@ export const NumericCellRenderer = (
let arrow = '';
let arrowColor = '';
if (hasBasicColorFormatters && col?.metricName) {
const rowFormatter = getRowBasicColorFormatter(
node,
node?.rowIndex,
basicColorFormatters,
)?.[col.metricName];
arrow = rowFormatter?.mainArrow;
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
arrow =
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
?.mainArrow;
arrowColor =
basicColorFormatters?.[node?.rowIndex as number]?.[
col.metricName
]?.arrowColor?.toLowerCase();
}
const alignment =

View File

@@ -46,7 +46,6 @@ import {
} from '@superset-ui/chart-controls';
import isEqualColumns from './utils/isEqualColumns';
import DateWithFormatter from './utils/DateWithFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
import {
DataColumnMeta,
TableChartProps,
@@ -704,23 +703,6 @@ const transformProps = (
const basicColorFormatters =
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
// Attach each row's basic (increase/decrease) color formatter to the row data
// object so it travels with the row through AG Grid client-side sorting.
// basicColorFormatters is built in the original query order and was previously
// read positionally by the displayed rowIndex, which applied colors to the
// wrong rows once the table was sorted (#105973). The property is
// non-enumerable so it never leaks into exports, cross-filters or spreads.
if (basicColorFormatters) {
passedData.forEach((row, index) => {
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: basicColorFormatters[index],
enumerable: false,
configurable: true,
writable: true,
});
});
}
const columnColorFormatters =
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];

View File

@@ -24,7 +24,6 @@ import {
} from '@superset-ui/chart-controls';
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
import { BasicColorFormatterType, InputColumn } from '../types';
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
type CellStyleParams = CellClassParams & {
hasColumnColorFormatters: boolean | undefined;
@@ -85,11 +84,8 @@ const getCellStyle = (params: CellStyleParams) => {
col?.metricName &&
node?.rowPinned !== 'bottom'
) {
backgroundColor = getRowBasicColorFormatter(
node,
rowIndex,
basicColorFormatters,
)?.[col.metricName]?.backgroundColor;
backgroundColor =
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
}
const textAlign =

View File

@@ -1,51 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
import { BasicColorFormatterType } from '../types';
type RowFormatters = { [key: string]: BasicColorFormatterType };
/**
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
* row node.
*
* The formatter is attached to the row data object itself (see transformProps),
* so it follows the row through client-side sorting. Looking it up positionally
* by the displayed `rowIndex` was wrong once the user sorted the table, because
* the displayed index no longer matched the original data order (#105973).
*
* Falls back to the positional array for safety when no attached formatter is
* present.
*/
export default function getRowBasicColorFormatter(
node: { data?: Record<string, unknown> } | undefined,
rowIndex: number | null | undefined,
basicColorFormatters: RowFormatters[] | undefined,
): RowFormatters | undefined {
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
| RowFormatters
| undefined;
if (attached) {
return attached;
}
if (rowIndex == null) {
return undefined;
}
return basicColorFormatters?.[rowIndex];
}

View File

@@ -1,65 +0,0 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import getRowBasicColorFormatter from '../../src/utils/getRowBasicColorFormatter';
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
const red = { sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' } };
const green = {
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
};
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
const positional = [green, red] as any;
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
// After sorting, the row whose original formatter is `red` is displayed first
// (rowIndex 0). The positional lookup would wrongly return `green`.
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: red,
enumerable: false,
});
const node = { data: rowData };
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
expect(
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
).toBe('red');
});
test('falls back to positional lookup when no formatter is attached', () => {
const node = { data: { sales: 5 } };
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
});
test('returns undefined when nothing matches', () => {
expect(getRowBasicColorFormatter(undefined, null, positional)).toBeUndefined();
expect(
getRowBasicColorFormatter({ data: {} }, null, positional),
).toBeUndefined();
});
test('attached formatter is non-enumerable so it does not leak into the row', () => {
const rowData: Record<string, unknown> = { sales: 5 };
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
value: green,
enumerable: false,
});
expect(Object.keys(rowData)).toEqual(['sales']);
});

View File

@@ -282,11 +282,6 @@ export default function transformProps(
? formatTime
: numberFormatter;
const lineWidth = 2;
// Pad the grid by half the stroke width so the trendline isn't clipped at
// the edges of the chart area (the stroke extends beyond the data point).
const strokePad = lineWidth / 2;
const echartOptions: EChartsCoreOption = trendLineData
? {
series: [
@@ -298,9 +293,6 @@ export default function transformProps(
symbolSize: 10,
showSymbol: false,
color: mainColor ?? BRAND_COLOR,
lineStyle: {
width: lineWidth,
},
areaStyle: {
color: new graphic.LinearGradient(0, 0, 0, 1, [
{
@@ -354,10 +346,10 @@ export default function transformProps(
top: TIMESERIES_CONSTANTS.gridOffsetTop,
}
: {
bottom: strokePad,
left: strokePad,
right: strokePad,
top: strokePad,
bottom: 0,
left: 0,
right: 0,
top: 0,
},
tooltip: {
...getDefaultTooltip(refs),

View File

@@ -17,7 +17,6 @@
* under the License.
*/
import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core';
import type { LineSeriesOption } from 'echarts';
import { supersetTheme } from '@apache-superset/core/theme';
import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
import {
@@ -270,18 +269,11 @@ describe('BigNumberWithTrendline', () => {
},
});
const series = (
transformed.echartOptions?.series as LineSeriesOption[]
)?.[0];
const lineWidth = series?.lineStyle?.width;
expect(lineWidth).toBe(2);
const expectedPad = (lineWidth as number) / 2;
expect(transformed.echartOptions?.grid).toEqual({
bottom: expectedPad,
left: expectedPad,
right: expectedPad,
top: expectedPad,
bottom: 0,
left: 0,
right: 0,
top: 0,
});
});

View File

@@ -27,6 +27,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
import DashboardHeader from 'src/dashboard/components/Header';
import HeadlessAutoRefresh from 'src/dashboard/components/Header/HeadlessAutoRefresh';
import { Icons } from '@superset-ui/core/components/Icons';
import IconButton from 'src/dashboard/components/IconButton';
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
@@ -515,7 +516,7 @@ const DashboardBuilder = () => {
const headerContent = useMemo(
() => (
<>
{!hideDashboardHeader && <DashboardHeader />}
{hideDashboardHeader ? <HeadlessAutoRefresh /> : <DashboardHeader />}
{showFilterBar &&
filterBarOrientation === FilterBarOrientation.Horizontal && (
<FilterBar

View File

@@ -0,0 +1,97 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { render } from 'spec/helpers/testing-library';
import { AutoRefreshProvider } from 'src/dashboard/contexts/AutoRefreshContext';
import HeadlessAutoRefresh from './HeadlessAutoRefresh';
const mockUseHeaderAutoRefresh = jest.fn();
jest.mock('./useHeaderAutoRefresh', () => ({
useHeaderAutoRefresh: (props: unknown) => {
mockUseHeaderAutoRefresh(props);
return {
forceRefresh: jest.fn(),
handlePauseToggle: jest.fn(),
autoRefreshPauseOnInactiveTab: false,
setPauseOnInactiveTab: jest.fn(),
};
},
}));
const initialState = {
dashboardInfo: {
id: 42,
metadata: { timed_refresh_immune_slices: [7] },
common: { conf: { DASHBOARD_AUTO_REFRESH_MODE: 'fetch' } },
},
dashboardState: {
refreshFrequency: 30,
sliceIds: [1, 2, 3],
},
charts: {
1: { id: 1, chartUpdateStartTime: 0, chartUpdateEndTime: 0 },
2: { id: 2, chartUpdateStartTime: 0, chartUpdateEndTime: 0 },
},
};
beforeEach(() => {
mockUseHeaderAutoRefresh.mockClear();
});
test('drives the auto-refresh hook from redux state without a header', () => {
const { container } = render(
<AutoRefreshProvider>
<HeadlessAutoRefresh />
</AutoRefreshProvider>,
{ useRedux: true, initialState },
);
// The component renders nothing but must still start the refresh timer.
expect(container).toBeEmptyDOMElement();
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledTimes(1);
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledWith(
expect.objectContaining({
chartIds: [1, 2, 3],
dashboardId: 42,
refreshFrequency: 30,
timedRefreshImmuneSlices: [7],
autoRefreshMode: 'fetch',
isLoading: false,
}),
);
});
test('passes the redux refresh frequency through to the hook', () => {
render(
<AutoRefreshProvider>
<HeadlessAutoRefresh />
</AutoRefreshProvider>,
{
useRedux: true,
initialState: {
...initialState,
dashboardState: { ...initialState.dashboardState, refreshFrequency: 0 },
},
},
);
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledWith(
expect.objectContaining({ refreshFrequency: 0 }),
);
});

View File

@@ -0,0 +1,87 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import { useMemo } from 'react';
import { bindActionCreators } from 'redux';
import { useDispatch, useSelector } from 'react-redux';
import { RootState } from 'src/dashboard/types';
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
import { onRefresh, setRefreshFrequency } from '../../actions/dashboardState';
import { logEvent } from '../../../logger/actions';
import { useHeaderAutoRefresh } from './useHeaderAutoRefresh';
/**
* Headless component that drives the dashboard auto-refresh timer when the
* dashboard header is not rendered (e.g. standalone mode or `hideTitle: true`
* in embedded dashboards). The auto-refresh logic lives in the header, so
* hiding the header would otherwise stop the refresh interval from ever
* starting. Rendering this component keeps the timer running independently of
* header visibility. It renders nothing.
*/
const HeadlessAutoRefresh = (): null => {
const dispatch = useDispatch();
const chartIds = useChartIds();
const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id);
const refreshFrequency = useSelector(
(state: RootState) => state.dashboardState.refreshFrequency ?? 0,
);
const timedRefreshImmuneSlices = useSelector(
(state: RootState) =>
state.dashboardInfo.metadata?.timed_refresh_immune_slices || [],
);
const autoRefreshMode = useSelector(
(state: RootState) =>
state.dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_MODE,
);
const isLoading = useSelector((state: RootState) =>
Object.values(state.charts).some(chart => {
const start = chart.chartUpdateStartTime ?? 0;
const end = chart.chartUpdateEndTime ?? 0;
return start > end;
}),
);
const boundActionCreators = useMemo(
() =>
bindActionCreators(
{
onRefresh,
setRefreshFrequency,
logEvent,
},
dispatch,
),
[dispatch],
);
useHeaderAutoRefresh({
chartIds,
dashboardId,
refreshFrequency,
timedRefreshImmuneSlices,
autoRefreshMode,
isLoading,
onRefresh: boundActionCreators.onRefresh,
setRefreshFrequency: boundActionCreators.setRefreshFrequency,
logEvent: boundActionCreators.logEvent,
});
return null;
};
export default HeadlessAutoRefresh;