diff --git a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx index 0f9c0a33244a..b1914d3e2673 100644 --- a/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx +++ b/superset-frontend/packages/superset-ui-core/src/components/Icons/AntdEnhanced.tsx @@ -106,8 +106,11 @@ import { MoreOutlined, OrderedListOutlined, PartitionOutlined, - PieChartOutlined, + PauseCircleOutlined, + PauseOutlined, PicCenterOutlined, + PieChartOutlined, + PlayCircleOutlined, PlusCircleOutlined, PlusSquareOutlined, PlusOutlined, @@ -258,8 +261,11 @@ const AntdIcons = { MoreOutlined, OrderedListOutlined, PartitionOutlined, - PieChartOutlined, + PauseCircleOutlined, + PauseOutlined, PicCenterOutlined, + PieChartOutlined, + PlayCircleOutlined, PlusCircleOutlined, PlusSquareOutlined, PlusOutlined, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts index b610d5b35dec..fe07da4a03bc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberPeriodOverPeriod/transformProps.ts @@ -87,6 +87,7 @@ export default function transformProps(chartProps: ChartProps) { columnFormats = {}, currencyCodeColumn, }, + isRefreshing, } = chartProps; const { boldText, @@ -227,5 +228,6 @@ export default function transformProps(chartProps: ChartProps) { shift: timeComparison, dashboardTimeRange: formData?.extraFormData?.time_range, columnConfig, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts index ef9501fe9af7..1775d1529ffa 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberTotal/transformProps.ts @@ -48,6 +48,7 @@ export default function transformProps( currencyCodeColumn, }, theme, + isRefreshing, } = chartProps; const { metricNameFontSize, @@ -135,5 +136,6 @@ export default function transformProps( metricName: originalLabel, showMetricName, metricNameFontSize, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx index e66eda4e13d5..91ec0a7c151f 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberViz.tsx @@ -353,6 +353,7 @@ function BigNumberVis({ onContextMenu, formData, xValueFormatter, + isRefreshing, } = props; // if can't find any non-null values, no point rendering the trendline @@ -395,6 +396,7 @@ function BigNumberVis({ echartOptions={echartOptions} eventHandlers={eventHandlers} vizType={formData?.vizType} + isRefreshing={isRefreshing} /> ) ); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts index a1e11c63fea6..bf80cd811651 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/BigNumberWithTrendline/transformProps.ts @@ -88,6 +88,7 @@ export default function transformProps( columnFormats = {}, currencyCodeColumn, }, + isRefreshing, } = chartProps; const { colorPicker, @@ -406,5 +407,6 @@ export default function transformProps( onContextMenu, xValueFormatter: formatTime, refs, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts index 8f6bde4d2b8f..d466a1781c85 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BigNumber/types.ts @@ -97,6 +97,7 @@ export type BigNumberVizProps = { trendLineData?: TimeSeriesDatum[]; mainColor?: string; echartOptions?: EChartsCoreOption; + isRefreshing?: boolean; onContextMenu?: ( clientX: number, clientY: number, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx index 637e8236fa89..18e4a307ab3c 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/EchartsBoxPlot.tsx @@ -21,8 +21,15 @@ import { allEventHandlers } from '../utils/eventHandlers'; import { BoxPlotChartTransformedProps } from './types'; export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) { - const { height, width, echartOptions, selectedValues, refs, formData } = - props; + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + isRefreshing, + } = props; const eventHandlers = allEventHandlers(props); @@ -35,6 +42,7 @@ export default function EchartsBoxPlot(props: BoxPlotChartTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts index b0aa1e35826d..1690b0b5a9f4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/BoxPlot/transformProps.ts @@ -55,6 +55,7 @@ export default function transformProps( queriesData, inContextMenu, emitCrossFilters, + isRefreshing, } = chartProps; const { data = [] } = queriesData[0]; const { setDataMask = () => {}, onContextMenu } = hooks; @@ -322,5 +323,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx index adfa0acfe301..bfdf2b2c43e7 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/EchartsBubble.tsx @@ -20,7 +20,7 @@ import { BubbleChartTransformedProps } from './types'; import Echart from '../components/Echart'; export default function EchartsBubble(props: BubbleChartTransformedProps) { - const { height, width, echartOptions, refs, formData } = props; + const { height, width, echartOptions, refs, formData, isRefreshing } = props; return ( ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts index ec671b948936..d588188aabde 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Bubble/transformProps.ts @@ -95,8 +95,16 @@ export function formatTooltip( } export default function transformProps(chartProps: EchartsBubbleChartProps) { - const { height, width, hooks, queriesData, formData, inContextMenu, theme } = - chartProps; + const { + height, + width, + hooks, + queriesData, + formData, + inContextMenu, + theme, + isRefreshing, + } = chartProps; const { data = [] } = queriesData[0]; const { @@ -263,5 +271,6 @@ export default function transformProps(chartProps: EchartsBubbleChartProps) { onContextMenu, setDataMask, formData, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx index c3b2c81a12aa..bd89383d2252 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/EchartsFunnel.tsx @@ -21,8 +21,15 @@ import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; export default function EchartsFunnel(props: FunnelChartTransformedProps) { - const { height, width, echartOptions, selectedValues, refs, formData } = - props; + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + isRefreshing, + } = props; const eventHandlers = allEventHandlers(props); @@ -35,6 +42,7 @@ export default function EchartsFunnel(props: FunnelChartTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts index 58c0a6974027..df81b5d07575 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Funnel/transformProps.ts @@ -97,6 +97,7 @@ export default function transformProps( theme, emitCrossFilters, datasource, + isRefreshing, } = chartProps; const data: DataRecord[] = queriesData[0].data || []; const detectedCurrency = queriesData[0]?.detected_currency; @@ -320,5 +321,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx index f914b7f4ace8..722720feab5d 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/EchartsGantt.tsx @@ -35,6 +35,7 @@ export default function EchartsGantt(props: EchartsGanttChartTransformedProps) { formData, setControlValue, onLegendStateChanged, + isRefreshing, } = props; const extraControlRef = useRef(null); const [extraHeight, setExtraHeight] = useState(0); @@ -84,6 +85,7 @@ export default function EchartsGantt(props: EchartsGanttChartTransformedProps) { selectedValues={selectedValues} eventHandlers={eventHandlers} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts index 62893e1cbd98..65233d30d703 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gantt/transformProps.ts @@ -115,6 +115,7 @@ export default function transformProps(chartProps: EchartsGanttChartProps) { emitCrossFilters, datasource, legendState, + isRefreshing, } = chartProps; const { @@ -453,5 +454,6 @@ export default function transformProps(chartProps: EchartsGanttChartProps) { refs, setControlValue, onLegendStateChanged, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx index 3482977f83e6..fa7040080fe6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/EchartsGauge.tsx @@ -21,8 +21,15 @@ import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; export default function EchartsGauge(props: GaugeChartTransformedProps) { - const { height, width, echartOptions, selectedValues, refs, formData } = - props; + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + isRefreshing, + } = props; const eventHandlers = allEventHandlers(props); @@ -35,6 +42,7 @@ export default function EchartsGauge(props: GaugeChartTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts index c879557ba75a..f31ecae2ecb0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Gauge/transformProps.ts @@ -105,6 +105,7 @@ export default function transformProps( theme, emitCrossFilters, datasource, + isRefreshing, } = chartProps; const gaugeSeriesOptions = defaultGaugeSeriesOption(theme); @@ -379,5 +380,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx index b765bb6bc0e9..650c7e9a0d61 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/EchartsGraph.tsx @@ -52,6 +52,7 @@ export default function EchartsGraph({ emitCrossFilters, refs, coltypeMapping, + isRefreshing, }: GraphChartTransformedProps) { const getCrossFilterDataMask = (node: DataRow | undefined) => { if (!node?.name || !node?.col) { @@ -176,6 +177,7 @@ export default function EchartsGraph({ echartOptions={echartOptions} eventHandlers={eventHandlers} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/buildQuery.ts index c3cbe6c1438f..78e431ac7075 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/buildQuery.ts @@ -16,9 +16,20 @@ * specific language governing permissions and limitations * under the License. */ -import { buildQueryContext, QueryFormData } from '@superset-ui/core'; +import { buildQueryContext } from '@superset-ui/core'; +import { EchartsGraphFormData } from './types'; +import { buildColumnsOrderBy, applyOrderBy } from '../utils/orderby'; + +export default function buildQuery(formData: EchartsGraphFormData) { + const { source, target, source_category, target_category, row_limit } = + formData; + const orderby = buildColumnsOrderBy([ + source, + target, + source_category, + target_category, + ]); -export default function buildQuery(formData: QueryFormData) { return buildQueryContext(formData, { queryFields: { source: 'columns', @@ -26,5 +37,11 @@ export default function buildQuery(formData: QueryFormData) { source_category: 'columns', target_category: 'columns', }, + buildQuery: baseQueryObject => [ + { + ...baseQueryObject, + ...applyOrderBy(orderby, row_limit), + }, + ], }); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts index 8204351f30f8..a01a513339aa 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Graph/transformProps.ts @@ -166,6 +166,7 @@ export default function transformProps( filterState, emitCrossFilters, theme, + isRefreshing, } = chartProps; const data: DataRecord[] = queriesData[0].data || []; const coltypeMapping = getColtypesMapping(queriesData[0]); @@ -375,5 +376,6 @@ export default function transformProps( refs, emitCrossFilters, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx index 21ca3ae01528..306d5ce246c1 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/Heatmap.tsx @@ -20,7 +20,7 @@ import { HeatmapTransformedProps } from './types'; import Echart from '../components/Echart'; export default function Heatmap(props: HeatmapTransformedProps) { - const { height, width, echartOptions, refs, formData } = props; + const { height, width, echartOptions, refs, formData, isRefreshing } = props; return ( ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts index 60c6aceaf297..05f9a8d733f5 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Heatmap/transformProps.ts @@ -176,8 +176,15 @@ export default function transformProps( chartProps: HeatmapChartProps, ): HeatmapTransformedProps { const refs: Refs = {}; - const { width, height, formData, queriesData, datasource, theme } = - chartProps; + const { + width, + height, + formData, + queriesData, + datasource, + theme, + isRefreshing, + } = chartProps; const { bottomMargin, xAxis, @@ -440,5 +447,6 @@ export default function transformProps( width, height, formData, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx index 4f7b5c220d7c..ede0410f07bb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/Histogram.tsx @@ -29,6 +29,7 @@ export default function Histogram(props: HistogramTransformedProps) { onLegendStateChanged, refs, formData, + isRefreshing, } = props; const eventHandlers: EventHandlers = { @@ -57,6 +58,7 @@ export default function Histogram(props: HistogramTransformedProps) { echartOptions={echartOptions} eventHandlers={eventHandlers} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts index 28d2a93e6d11..b8283ad97beb 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Histogram/transformProps.ts @@ -45,6 +45,7 @@ export default function transformProps( formData, height, hooks, + isRefreshing, legendState = {}, queriesData, theme, @@ -202,6 +203,7 @@ export default function transformProps( width, height, echartOptions, + isRefreshing, onFocusedSeries, onLegendStateChanged, }; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx index 6dbe6d7e0358..5c0ece301d32 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/EchartsMixedTimeseries.tsx @@ -50,6 +50,7 @@ export default function EchartsMixedTimeseries({ xAxis, refs, coltypeMapping, + isRefreshing, }: EchartsMixedTimeseriesChartTransformedProps) { const isFirstQuery = useCallback( (seriesIndex: number) => seriesIndex < seriesBreakdown, @@ -215,6 +216,7 @@ export default function EchartsMixedTimeseries({ eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts index 907ed4803d4b..f4d16aeb24f9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/MixedTimeseries/transformProps.ts @@ -131,6 +131,7 @@ export default function transformProps( inContextMenu, emitCrossFilters, legendState, + isRefreshing, } = chartProps; let focusedSeries: string | null = null; @@ -825,5 +826,6 @@ export default function transformProps( }, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx index 3f4d4f277471..64f580e20d57 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/EchartsPie.tsx @@ -21,8 +21,15 @@ import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; export default function EchartsPie(props: PieChartTransformedProps) { - const { height, width, echartOptions, selectedValues, refs, formData } = - props; + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + isRefreshing, + } = props; const eventHandlers = allEventHandlers(props); @@ -35,6 +42,7 @@ export default function EchartsPie(props: PieChartTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts index d33110f6ab3f..9271e15c9e26 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Pie/transformProps.ts @@ -134,6 +134,7 @@ export default function transformProps( inContextMenu, emitCrossFilters, datasource, + isRefreshing, } = chartProps; const { columnFormats = {}, @@ -481,5 +482,6 @@ export default function transformProps( refs, emitCrossFilters, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx index e97b06000e6c..6f0fc5020f25 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/EchartsRadar.tsx @@ -21,8 +21,15 @@ import Echart from '../components/Echart'; import { allEventHandlers } from '../utils/eventHandlers'; export default function EchartsRadar(props: RadarChartTransformedProps) { - const { height, width, echartOptions, selectedValues, refs, formData } = - props; + const { + height, + width, + echartOptions, + selectedValues, + refs, + formData, + isRefreshing, + } = props; const eventHandlers = allEventHandlers(props); return ( @@ -34,6 +41,7 @@ export default function EchartsRadar(props: RadarChartTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts index 142bc51de450..00a8a70c1974 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Radar/transformProps.ts @@ -97,6 +97,7 @@ export default function transformProps( theme, inContextMenu, emitCrossFilters, + isRefreshing, } = chartProps; const refs: Refs = {}; const { data = [] } = queriesData[0]; @@ -397,5 +398,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx index 88c5b14f93d0..72b0b56df971 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/Sankey.tsx @@ -20,7 +20,7 @@ import { SankeyTransformedProps } from './types'; import Echart from '../components/Echart'; export default function Sankey(props: SankeyTransformedProps) { - const { height, width, echartOptions, refs, formData } = props; + const { height, width, echartOptions, refs, formData, isRefreshing } = props; return ( ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/buildQuery.ts index 0ec7ec85bf0f..5a573e29af89 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/buildQuery.ts @@ -16,17 +16,30 @@ * specific language governing permissions and limitations * under the License. */ -import { buildQueryContext } from '@superset-ui/core'; +import { buildQueryContext, QueryFormOrderBy } from '@superset-ui/core'; import { SankeyFormData } from './types'; export default function buildQuery(formData: SankeyFormData) { - const { metric, sort_by_metric, source, target } = formData; + const { metric, sort_by_metric, source, target, row_limit } = formData; const groupby = [source, target]; + const orderby: QueryFormOrderBy[] = []; + const shouldApplyOrderBy = + row_limit !== undefined && row_limit !== null && row_limit !== 0; + + if (sort_by_metric && metric) { + orderby.push([metric, false]); + } + [source, target].forEach(column => { + if (column) { + orderby.push([column, true]); + } + }); + return buildQueryContext(formData, baseQueryObject => [ { ...baseQueryObject, groupby, - ...(sort_by_metric && { orderby: [[metric, false]] }), + ...(shouldApplyOrderBy && orderby.length > 0 && { orderby }), }, ]); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts index 3581492466f7..58f52371a411 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sankey/transformProps.ts @@ -39,7 +39,8 @@ export default function transformProps( chartProps: SankeyChartProps, ): SankeyTransformedProps { const refs: Refs = {}; - const { formData, height, hooks, queriesData, width, theme } = chartProps; + const { formData, height, hooks, queriesData, width, theme, isRefreshing } = + chartProps; const { onLegendStateChanged } = hooks; const { colorScheme, metric, source, target, sliceId } = formData; const { data } = queriesData[0]; @@ -138,6 +139,7 @@ export default function transformProps( width, height, echartOptions, + isRefreshing, onLegendStateChanged, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx index 8ba09fd5d8f4..3e399ea02da9 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/EchartsSunburst.tsx @@ -45,6 +45,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { refs, emitCrossFilters, coltypeMapping, + isRefreshing, } = props; const { columns } = formData; @@ -161,6 +162,7 @@ export default function EchartsSunburst(props: SunburstTransformedProps) { eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts index 5deb443372fb..8f75051e0e52 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Sunburst/transformProps.ts @@ -169,6 +169,7 @@ export default function transformProps( inContextMenu, emitCrossFilters, datasource, + isRefreshing, } = chartProps; const { data = [], detected_currency: detectedCurrency } = queriesData[0]; const coltypeMapping = getColtypesMapping(queriesData[0]); @@ -408,5 +409,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx index 87778a6b8672..7d9858297543 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/EchartsTimeseries.tsx @@ -58,6 +58,7 @@ export default function EchartsTimeseries({ emitCrossFilters, coltypeMapping, onLegendScroll, + isRefreshing, }: TimeseriesChartTransformedProps) { const { stack } = formData; const echartRef = useRef(null); @@ -372,6 +373,7 @@ export default function EchartsTimeseries({ zrEventHandlers={zrEventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts index 7acd63132b98..580985ddbce8 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/transformProps.ts @@ -129,6 +129,7 @@ export default function transformProps( inContextMenu, emitCrossFilters, legendIndex, + isRefreshing, } = chartProps; let focusedSeries: string | null = null; @@ -845,6 +846,7 @@ export default function transformProps( formData, groupby: groupBy, height, + isRefreshing, labelMap, selectedValues, setDataMask, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts index 37c7d3275726..54775545fb69 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Timeseries/types.ts @@ -110,6 +110,7 @@ export type TimeseriesChartTransformedProps = ContextMenuTransformedProps & CrossFilterTransformedProps & { legendData?: OptionName[]; + isRefreshing?: boolean; xValueFormatter: TimeFormatter | StringConstructor; xAxis: { label: string; diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/EchartsTree.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/EchartsTree.tsx index cb1ef9b904c7..420661414a5e 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/EchartsTree.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/EchartsTree.tsx @@ -25,6 +25,7 @@ export default function EchartsTree({ refs, width, formData, + isRefreshing, }: TreeTransformedProps) { return ( ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/buildQuery.ts index ccc398b1b8b4..09d99b28dd92 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/buildQuery.ts @@ -16,14 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { buildQueryContext, QueryFormData } from '@superset-ui/core'; +import { buildQueryContext } from '@superset-ui/core'; +import { EchartsTreeFormData } from './types'; +import { buildColumnsOrderBy, applyOrderBy } from '../utils/orderby'; + +export default function buildQuery(formData: EchartsTreeFormData) { + const { id, parent, name, row_limit } = formData; + const orderby = buildColumnsOrderBy([parent, id, name]); -export default function buildQuery(formData: QueryFormData) { return buildQueryContext(formData, { queryFields: { id: 'columns', parent: 'columns', name: 'columns', }, + buildQuery: baseQueryObject => [ + { + ...baseQueryObject, + ...applyOrderBy(orderby, row_limit), + }, + ], }); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/transformProps.ts index a4336162f3c9..4e1b21baa5fc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Tree/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Tree/transformProps.ts @@ -56,7 +56,8 @@ export function formatTooltip({ export default function transformProps( chartProps: EchartsTreeChartProps, ): TreeTransformedProps { - const { width, height, formData, queriesData, theme } = chartProps; + const { width, height, formData, queriesData, theme, isRefreshing } = + chartProps; const refs: Refs = {}; const data: TreeDataRecord[] = queriesData[0].data || []; @@ -182,6 +183,11 @@ export default function transformProps( } }); } + // Disable animation during refresh to prevent expand/collapse layout animation + const seriesAnimation = isRefreshing + ? false + : DEFAULT_TREE_SERIES_OPTION.animation; + const series: TreeSeriesOption[] = [ { type: 'tree', @@ -192,7 +198,7 @@ export default function transformProps( color: theme.colorText, }, emphasis: { focus: emphasis }, - animation: DEFAULT_TREE_SERIES_OPTION.animation, + animation: seriesAnimation, layout, orient, symbol, @@ -230,5 +236,6 @@ export default function transformProps( height, echartOptions, refs, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx index 6fb4f57e3716..299de87586b0 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/EchartsTreemap.tsx @@ -44,6 +44,7 @@ export default function EchartsTreemap({ width, formData, coltypeMapping, + isRefreshing, }: TreemapTransformedProps) { const getCrossFilterDataMask = useCallback( (data, treePathInfo) => { @@ -106,7 +107,7 @@ export default function EchartsTreemap({ setDataMask(dataMask); } }, - [emitCrossFilters, getCrossFilterDataMask, setDataMask], + [emitCrossFilters, getCrossFilterDataMask, setDataMask, groupby.length], ); const eventHandlers: EventHandlers = { @@ -164,6 +165,7 @@ export default function EchartsTreemap({ eventHandlers={eventHandlers} selectedValues={selectedValues} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/buildQuery.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/buildQuery.ts index 43ab986d3035..e6e76ac7ad39 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/buildQuery.ts @@ -16,15 +16,25 @@ * specific language governing permissions and limitations * under the License. */ -import { buildQueryContext, QueryFormData } from '@superset-ui/core'; +import { + buildQueryContext, + QueryFormData, + QueryFormOrderBy, +} from '@superset-ui/core'; +import { buildColumnsOrderBy, applyOrderBy } from '../utils/orderby'; export default function buildQuery(formData: QueryFormData) { - const { metric, sort_by_metric } = formData; + const { metric, sort_by_metric, groupby = [], row_limit } = formData; + const orderby: QueryFormOrderBy[] = []; + if (sort_by_metric && metric) { + orderby.push([metric, false]); + } + orderby.push(...buildColumnsOrderBy(groupby)); return buildQueryContext(formData, baseQueryObject => [ { ...baseQueryObject, - ...(sort_by_metric && { orderby: [[metric, false]] }), + ...applyOrderBy(orderby, row_limit), }, ]); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts index 05d7b1b6a9c4..8f5c2b1487f4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Treemap/transformProps.ts @@ -118,6 +118,7 @@ export default function transformProps( inContextMenu, emitCrossFilters, datasource, + isRefreshing, } = chartProps; const { data = [], detected_currency: detectedCurrency } = queriesData[0]; const { @@ -319,5 +320,6 @@ export default function transformProps( onContextMenu, refs, coltypeMapping, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx index 58c86a878418..127a34f8b7be 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/EchartsWaterfall.tsx @@ -23,8 +23,15 @@ import { EventHandlers } from '../types'; export default function EchartsWaterfall( props: WaterfallChartTransformedProps, ) { - const { height, width, echartOptions, refs, onLegendStateChanged, formData } = - props; + const { + height, + width, + echartOptions, + refs, + onLegendStateChanged, + formData, + isRefreshing, + } = props; const eventHandlers: EventHandlers = { legendselectchanged: payload => { @@ -46,6 +53,7 @@ export default function EchartsWaterfall( echartOptions={echartOptions} eventHandlers={eventHandlers} vizType={formData.vizType} + isRefreshing={isRefreshing} /> ); } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts index c77a2783f472..963cfb33d203 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/Waterfall/transformProps.ts @@ -168,6 +168,7 @@ export default function transformProps( hooks, theme, inContextMenu, + isRefreshing, } = chartProps; const refs: Refs = {}; const { data = [] } = queriesData[0]; @@ -488,5 +489,6 @@ export default function transformProps( setDataMask, onContextMenu, onLegendStateChanged, + isRefreshing, }; } diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx index c62f5535a300..31507838e358 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx +++ b/superset-frontend/plugins/plugin-chart-echarts/src/components/Echart.tsx @@ -132,6 +132,7 @@ function Echart( selectedValues = {}, refs, vizType, + isRefreshing, }: EchartsProps, ref: Ref, ) { @@ -244,15 +245,30 @@ function Echart( ? theme.echartsOptionsOverridesByChartType?.[vizType] || {} : {}; + // Disable animations during auto-refresh to reduce visual noise + const animationOverride = isRefreshing + ? { + animation: false, + animationDuration: 0, + } + : {}; + const themedEchartOptions = mergeReplaceArrays( baseTheme, echartOptions, globalOverrides, chartOverrides, + animationOverride, ); - chartRef.current?.setOption(themedEchartOptions, true); + const notMerge = !isRefreshing; + chartRef.current?.setOption(themedEchartOptions, { + notMerge, + replaceMerge: notMerge ? undefined : ['series'], + lazyUpdate: isRefreshing, + }); } + // eslint-disable-next-line react-hooks/exhaustive-deps -- isRefreshing intentionally excluded to prevent extra setOption calls }, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]); useEffect(() => () => chartRef.current?.dispose(), []); diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts index 817996b3182a..a65589a02b48 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/types.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/types.ts @@ -56,6 +56,8 @@ export interface EchartsProps { forceClear?: boolean; refs: Refs; vizType?: string; + /** Whether the chart is refreshing (disables animations during auto-refresh) */ + isRefreshing?: boolean; } export interface EchartsHandler { @@ -128,6 +130,7 @@ export interface BaseTransformedProps { echartOptions: EChartsCoreOption; formData: F; height: number; + isRefreshing?: boolean; onContextMenu?: ( clientX: number, clientY: number, diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/orderby.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/orderby.ts new file mode 100644 index 000000000000..532d5256979d --- /dev/null +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/orderby.ts @@ -0,0 +1,47 @@ +/** + * 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 { QueryFormColumn, QueryFormOrderBy } from '@superset-ui/core'; + +/** + * Builds orderby clauses from a list of columns, filtering out any non-string + * or nullish values. This ensures deterministic row ordering so that chart + * elements maintain stable positions across auto-refreshes. + */ +export function buildColumnsOrderBy( + columns: (QueryFormColumn | string | undefined | null)[], + ascending: boolean = true, +): QueryFormOrderBy[] { + return columns + .filter((col): col is string => typeof col === 'string' && col !== '') + .map(col => [col, ascending]); +} + +/** + * Conditionally applies orderby to a query object spread. Returns the + * orderby field only when row_limit is set (non-zero, non-null) and + * there are orderby entries to apply. + */ +export function applyOrderBy( + orderby: QueryFormOrderBy[], + rowLimit: string | number | undefined | null, +): { orderby: QueryFormOrderBy[] } | Record { + const shouldApply = + rowLimit !== undefined && rowLimit !== null && rowLimit !== 0; + return shouldApply && orderby.length > 0 ? { orderby } : {}; +} diff --git a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts index 5befd6ab542b..c380b5ec2adc 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/src/utils/series.ts @@ -171,8 +171,8 @@ export function sortAndFilterSeries( return orderBy( sortedValues, - ['value'], - [sortSeriesAscending ? 'asc' : 'desc'], + ['value', 'name'], + [sortSeriesAscending ? 'asc' : 'desc', 'asc'], ).map(({ name }) => name); } @@ -452,7 +452,7 @@ export function getLegendProps( : 'vertical', show, type: effectiveType, - selected: legendState, + selected: legendState ?? {}, selector: ['all', 'inverse'], selectorLabel: { fontFamily: theme.fontFamily, diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/buildQuery.test.ts index 588f2f41991b..272acd99c9b6 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Graph/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Graph/buildQuery.test.ts @@ -17,9 +17,11 @@ * under the License. */ import buildQuery from '../../src/Graph/buildQuery'; +import { DEFAULT_FORM_DATA } from '../../src/Graph/types'; describe('Graph buildQuery', () => { const formData = { + ...DEFAULT_FORM_DATA, datasource: '5__table', granularity_sqla: 'ds', source: 'dummy_source', diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts index 562f3fd51903..e3d279c1ba18 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Timeseries/transformProps.test.ts @@ -697,8 +697,8 @@ describe('legend sorting', () => { ); expect((transformed.echartOptions.legend as any).data).toEqual([ - 'San Francisco', 'Boston', + 'San Francisco', 'New York', 'Milton', ]); diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/Tree/buildQuery.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/Tree/buildQuery.test.ts index 35b16b9c05f8..9dea35e82dc4 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/Tree/buildQuery.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/Tree/buildQuery.test.ts @@ -17,17 +17,35 @@ * under the License. */ import buildQuery from '../../src/Tree/buildQuery'; +import { EchartsTreeFormData } from '../../src/Tree/types'; + +const BASE_FORM_DATA: EchartsTreeFormData = { + datasource: '5__table', + granularity_sqla: 'ds', + viz_type: 'my_chart', + id: '', + parent: '', + name: '', + orient: 'LR', + symbol: 'emptyCircle', + symbolSize: 7, + layout: 'orthogonal', + roam: true, + nodeLabelPosition: 'left', + childLabelPosition: 'bottom', + emphasis: 'descendant', + initialTreeDepth: 2, + metrics: [], +}; describe('Tree buildQuery', () => { test('should build query', () => { - const formData = { - datasource: '5__table', - granularity_sqla: 'ds', + const formData: EchartsTreeFormData = { + ...BASE_FORM_DATA, id: 'id_col', parent: 'relation_col', name: 'name_col', metrics: ['foo', 'bar'], - viz_type: 'my_chart', }; const queryContext = buildQuery(formData); const [query] = queryContext.queries; @@ -35,13 +53,12 @@ describe('Tree buildQuery', () => { expect(query.metrics).toEqual(['foo', 'bar']); }); test('should build query without name column', () => { - const formData = { - datasource: '5__table', - granularity_sqla: 'ds', + const formData: EchartsTreeFormData = { + ...BASE_FORM_DATA, id: 'id_col', parent: 'relation_col', + name: '', metrics: ['foo', 'bar'], - viz_type: 'my_chart', }; const queryContext = buildQuery(formData); const [query] = queryContext.queries; diff --git a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts index 467c58f0fd8e..a2cda2facf91 100644 --- a/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts +++ b/superset-frontend/plugins/plugin-chart-echarts/test/utils/series.test.ts @@ -53,7 +53,7 @@ import { NULL_STRING } from '../../src/constants'; const expectedThemeProps = { selector: ['all', 'inverse'], - selected: undefined, + selected: {}, selectorLabel: { fontFamily: theme.fontFamily, fontSize: theme.fontSizeSM, diff --git a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/buildQuery.ts b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/buildQuery.ts index f37325539a20..faf816d5eca4 100644 --- a/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/buildQuery.ts +++ b/superset-frontend/plugins/plugin-chart-word-cloud/src/plugin/buildQuery.ts @@ -17,17 +17,26 @@ * under the License. */ -import { buildQueryContext } from '@superset-ui/core'; +import { buildQueryContext, QueryFormOrderBy } from '@superset-ui/core'; import { WordCloudFormData } from '../types'; export default function buildQuery(formData: WordCloudFormData) { - // Set the single QueryObject's groupby field with series in formData - const { metric, sort_by_metric } = formData; + const { metric, sort_by_metric, series, row_limit } = formData; + const orderby: QueryFormOrderBy[] = []; + const shouldApplyOrderBy = + row_limit !== undefined && row_limit !== null && row_limit !== 0; + + if (sort_by_metric && metric) { + orderby.push([metric, false]); + } + if (series) { + orderby.push([series, true]); + } return buildQueryContext(formData, baseQueryObject => [ { ...baseQueryObject, - ...(sort_by_metric && { orderby: [[metric, false]] }), + ...(shouldApplyOrderBy && orderby.length > 0 && { orderby }), }, ]); } diff --git a/superset-frontend/src/components/Chart/Chart.tsx b/superset-frontend/src/components/Chart/Chart.tsx index 9a05bb270fbf..6297a8eac91d 100644 --- a/superset-frontend/src/components/Chart/Chart.tsx +++ b/superset-frontend/src/components/Chart/Chart.tsx @@ -87,6 +87,8 @@ export interface ChartProps { isInView?: boolean; emitCrossFilters?: boolean; onChartStateChange?: (chartState: AgGridChartState) => void; + /** Whether to suppress the loading spinner (during auto-refresh) */ + suppressLoadingSpinner?: boolean; } export type Actions = { @@ -351,6 +353,8 @@ class Chart extends PureComponent { const databaseName = datasource?.database?.name as string | undefined; const isLoading = chartStatus === 'loading'; + // Suppress spinner during auto-refresh to avoid visual flicker + const showSpinner = isLoading && !this.props.suppressLoadingSpinner; if (chartStatus === 'failed') { return queriesResponse?.map(item => @@ -407,7 +411,7 @@ class Chart extends PureComponent { height={height} width={width} > - {isLoading + {showSpinner ? this.renderSpinner(databaseName) : this.renderChartContainer()} diff --git a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx index 31b204663892..c51212104e65 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.test.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.test.tsx @@ -40,9 +40,13 @@ jest.mock('@superset-ui/core', () => ({ ...jest.requireActual('@superset-ui/core'), SuperChart: ({ postTransformProps = (x: JsonObject) => x, + isRefreshing = false, ...props - }: MockSuperChartProps) => ( -
+ }: MockSuperChartProps & { isRefreshing?: boolean }) => ( +
{JSON.stringify(postTransformProps(props).formData)}
), @@ -373,3 +377,33 @@ test('should detect nested matrixify property changes', () => { JSON.stringify(updatedProps.formData), ); }); + +test('renders chart during loading when suppressLoadingSpinner has valid data', () => { + const props = { + ...requiredProps, + chartStatus: 'loading' as const, + chartAlert: undefined, + suppressLoadingSpinner: true, + queriesResponse: [{ data: [{ value: 1 }] }], + }; + + const { getByTestId } = render(); + expect(getByTestId('mock-super-chart')).toBeInTheDocument(); + expect(getByTestId('mock-super-chart')).toHaveAttribute( + 'data-is-refreshing', + 'true', + ); +}); + +test('does not render chart during loading when last data has errors', () => { + const props = { + ...requiredProps, + chartStatus: 'loading' as const, + chartAlert: undefined, + suppressLoadingSpinner: true, + queriesResponse: [{ error: 'bad' }], + }; + + const { queryByTestId } = render(); + expect(queryByTestId('mock-super-chart')).not.toBeInTheDocument(); +}); diff --git a/superset-frontend/src/components/Chart/ChartRenderer.tsx b/superset-frontend/src/components/Chart/ChartRenderer.tsx index 2b6016c1532f..995df527d64e 100644 --- a/superset-frontend/src/components/Chart/ChartRenderer.tsx +++ b/superset-frontend/src/components/Chart/ChartRenderer.tsx @@ -134,6 +134,7 @@ export interface ChartRendererProps { emitCrossFilters?: boolean; cacheBusterProp?: string; onChartStateChange?: (chartState: AgGridChartState) => void; + suppressLoadingSpinner?: boolean; } // State interface @@ -411,11 +412,20 @@ class ChartRenderer extends Component { render(): ReactNode { const { chartAlert, chartStatus, chartId, emitCrossFilters } = this.props; - // Skip chart rendering - if (chartStatus === 'loading' || !!chartAlert || chartStatus === null) { + const hasAnyErrors = this.props.queriesResponse?.some(item => item?.error); + const hasValidPreviousData = + (this.props.queriesResponse?.length ?? 0) > 0 && !hasAnyErrors; + + if (!!chartAlert || chartStatus === null) { return null; } + if (chartStatus === 'loading') { + if (!this.props.suppressLoadingSpinner || !hasValidPreviousData) { + return null; + } + } + this.renderStartTime = Logger.getTimestamp(); const { @@ -548,6 +558,7 @@ class ChartRenderer extends Component { legendState={this.state.legendState} enableNoResults={bypassNoResult} legendIndex={this.state.legendIndex} + isRefreshing={this.props.suppressLoadingSpinner} {...drillToDetailProps} />
diff --git a/superset-frontend/src/components/Chart/chartAction.ts b/superset-frontend/src/components/Chart/chartAction.ts index 63fa72b5ad20..2ed8bba83b77 100644 --- a/superset-frontend/src/components/Chart/chartAction.ts +++ b/superset-frontend/src/components/Chart/chartAction.ts @@ -129,6 +129,7 @@ export interface ChartUpdateSucceededAction { export interface ChartUpdateStoppedAction { type: typeof CHART_UPDATE_STOPPED; key: string | number; + queryController?: AbortController; } export interface ChartUpdateFailedAction { @@ -327,8 +328,9 @@ export function chartUpdateSucceeded( export function chartUpdateStopped( key: string | number, + queryController?: AbortController, ): ChartUpdateStoppedAction { - return { type: CHART_UPDATE_STOPPED, key }; + return { type: CHART_UPDATE_STOPPED, key, queryController }; } export function chartUpdateFailed( @@ -819,7 +821,9 @@ export function exploreJSON( response?.name === 'AbortError' || response?.statusText === 'abort'; if (isAbort) { // Abort is expected: filters changed, chart unmounted, etc. - return dispatch(chartUpdateStopped(key as string | number)); + return dispatch( + chartUpdateStopped(key as string | number, controller), + ); } if (isFeatureEnabled(FeatureFlag.GlobalAsyncQueries)) { @@ -945,9 +949,15 @@ export function refreshChart( chartKey: string | number, force: boolean, dashboardId?: number, -): ChartThunkAction { - return (dispatch: ChartThunkDispatch, getState: () => RootState): void => { +): ChartThunkAction> { + return ( + dispatch: ChartThunkDispatch, + getState: () => RootState, + ): Promise => { const chart = (getState().charts || {})[chartKey]; + if (!chart) { + return Promise.resolve(); + } const timeout = getState().dashboardInfo.common.conf.SUPERSET_WEBSERVER_TIMEOUT; @@ -955,9 +965,9 @@ export function refreshChart( !chart.latestQueryFormData || Object.keys(chart.latestQueryFormData).length === 0 ) { - return; + return Promise.resolve(); } - dispatch( + return dispatch( postChartFormData( chart.latestQueryFormData, force, @@ -966,7 +976,7 @@ export function refreshChart( dashboardId, getState().dataMask[chart.id]?.ownState, ), - ); + ) as unknown as Promise; }; } diff --git a/superset-frontend/src/components/Chart/chartReducer.ts b/superset-frontend/src/components/Chart/chartReducer.ts index b4f089feae5d..416e3843bf4b 100644 --- a/superset-frontend/src/components/Chart/chartReducer.ts +++ b/superset-frontend/src/components/Chart/chartReducer.ts @@ -78,11 +78,19 @@ export default function chartReducer( }; }, [actions.CHART_UPDATE_STOPPED](state) { + if ( + action.queryController && + state.queryController && + action.queryController !== state.queryController + ) { + return state; + } return { ...state, chartStatus: 'stopped', chartAlert: t('Updating chart was stopped'), chartUpdateEndTime: now(), + queryController: null, }; }, [actions.CHART_RENDERING_SUCCEEDED](state) { diff --git a/superset-frontend/src/components/Chart/chartReducers.test.ts b/superset-frontend/src/components/Chart/chartReducers.test.ts index 06aceebddbf9..ee5010358cb5 100644 --- a/superset-frontend/src/components/Chart/chartReducers.test.ts +++ b/superset-frontend/src/components/Chart/chartReducers.test.ts @@ -35,11 +35,39 @@ describe('chart reducers', () => { }); test('should update endtime on fail', () => { - const newState = chartReducer(charts, actions.chartUpdateStopped(chartKey)); + const controller = new AbortController(); + charts[chartKey] = { + ...charts[chartKey], + queryController: controller, + }; + const newState = chartReducer( + charts, + actions.chartUpdateStopped(chartKey, controller), + ); expect(newState[chartKey].chartUpdateEndTime).toBeGreaterThan(0); expect(newState[chartKey].chartStatus).toEqual('stopped'); }); + test('should ignore stopped updates from stale controllers', () => { + const controller = new AbortController(); + const staleController = new AbortController(); + charts[chartKey] = { + ...charts[chartKey], + chartStatus: 'loading', + queryController: controller, + }; + + const newState = chartReducer( + charts, + actions.chartUpdateStopped(chartKey, staleController), + ); + + expect(newState[chartKey].chartStatus).toEqual('loading'); + expect(newState[chartKey].chartUpdateEndTime).toEqual( + charts[chartKey].chartUpdateEndTime, + ); + }); + test('should update endtime on timeout', () => { const newState = chartReducer( charts, diff --git a/superset-frontend/src/dashboard/actions/autoRefresh.ts b/superset-frontend/src/dashboard/actions/autoRefresh.ts new file mode 100644 index 000000000000..474044eb4876 --- /dev/null +++ b/superset-frontend/src/dashboard/actions/autoRefresh.ts @@ -0,0 +1,124 @@ +/** + * 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 { AutoRefreshStatus } from '../types/autoRefresh'; + +export const SET_AUTO_REFRESH_STATUS = 'SET_AUTO_REFRESH_STATUS'; +export const SET_AUTO_REFRESH_PAUSED = 'SET_AUTO_REFRESH_PAUSED'; +export const SET_AUTO_REFRESH_PAUSED_BY_TAB = 'SET_AUTO_REFRESH_PAUSED_BY_TAB'; +export const RECORD_AUTO_REFRESH_SUCCESS = 'RECORD_AUTO_REFRESH_SUCCESS'; +export const RECORD_AUTO_REFRESH_ERROR = 'RECORD_AUTO_REFRESH_ERROR'; +export const SET_AUTO_REFRESH_FETCH_START_TIME = + 'SET_AUTO_REFRESH_FETCH_START_TIME'; +export const SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB = + 'SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB'; + +export interface SetAutoRefreshStatusAction { + type: typeof SET_AUTO_REFRESH_STATUS; + status: AutoRefreshStatus; + [key: string]: unknown; +} + +export interface SetAutoRefreshPausedAction { + type: typeof SET_AUTO_REFRESH_PAUSED; + isPaused: boolean; + [key: string]: unknown; +} + +export interface SetAutoRefreshPausedByTabAction { + type: typeof SET_AUTO_REFRESH_PAUSED_BY_TAB; + isPausedByTab: boolean; + [key: string]: unknown; +} + +export interface RecordAutoRefreshSuccessAction { + type: typeof RECORD_AUTO_REFRESH_SUCCESS; + timestamp: number; + [key: string]: unknown; +} + +export interface RecordAutoRefreshErrorAction { + type: typeof RECORD_AUTO_REFRESH_ERROR; + error: string | undefined; + timestamp: number; + [key: string]: unknown; +} + +export interface SetAutoRefreshFetchStartTimeAction { + type: typeof SET_AUTO_REFRESH_FETCH_START_TIME; + timestamp: number | null; + [key: string]: unknown; +} + +export interface SetAutoRefreshPauseOnInactiveTabAction { + type: typeof SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB; + pauseOnInactiveTab: boolean; + [key: string]: unknown; +} + +export type AutoRefreshAction = + | SetAutoRefreshStatusAction + | SetAutoRefreshPausedAction + | SetAutoRefreshPausedByTabAction + | RecordAutoRefreshSuccessAction + | RecordAutoRefreshErrorAction + | SetAutoRefreshFetchStartTimeAction + | SetAutoRefreshPauseOnInactiveTabAction; + +export function setAutoRefreshStatus( + status: AutoRefreshStatus, +): SetAutoRefreshStatusAction { + return { type: SET_AUTO_REFRESH_STATUS, status }; +} + +export function setAutoRefreshPaused( + isPaused: boolean, +): SetAutoRefreshPausedAction { + return { type: SET_AUTO_REFRESH_PAUSED, isPaused }; +} + +export function setAutoRefreshPausedByTab( + isPausedByTab: boolean, +): SetAutoRefreshPausedByTabAction { + return { type: SET_AUTO_REFRESH_PAUSED_BY_TAB, isPausedByTab }; +} + +export function recordAutoRefreshSuccess(): RecordAutoRefreshSuccessAction { + return { + type: RECORD_AUTO_REFRESH_SUCCESS, + timestamp: Date.now(), + }; +} + +export function recordAutoRefreshError( + error: string | undefined, +): RecordAutoRefreshErrorAction { + return { type: RECORD_AUTO_REFRESH_ERROR, error, timestamp: Date.now() }; +} + +export function setAutoRefreshFetchStartTime( + timestamp: number | null, +): SetAutoRefreshFetchStartTimeAction { + return { type: SET_AUTO_REFRESH_FETCH_START_TIME, timestamp }; +} + +export function setAutoRefreshPauseOnInactiveTab( + pauseOnInactiveTab: boolean, +): SetAutoRefreshPauseOnInactiveTabAction { + return { type: SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB, pauseOnInactiveTab }; +} diff --git a/superset-frontend/src/dashboard/actions/dashboardState.test.ts b/superset-frontend/src/dashboard/actions/dashboardState.test.ts index 7f8b3b53d4a3..9a96eb58f41c 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.test.ts +++ b/superset-frontend/src/dashboard/actions/dashboardState.test.ts @@ -23,7 +23,13 @@ import { SAVE_DASHBOARD_STARTED, saveDashboardRequest, SET_OVERRIDE_CONFIRM, + fetchCharts, + onRefresh, + ON_FILTERS_REFRESH, + ON_REFRESH, + ON_REFRESH_SUCCESS, } from 'src/dashboard/actions/dashboardState'; +import { refreshChart } from 'src/components/Chart/chartAction'; import { UPDATE_COMPONENTS_PARENTS_LIST } from 'src/dashboard/actions/dashboardLayout'; import { DASHBOARD_GRID_ID, @@ -44,6 +50,10 @@ jest.mock('@superset-ui/core', () => ({ isFeatureEnabled: jest.fn(), })); +jest.mock('src/components/Chart/chartAction', () => ({ + refreshChart: jest.fn(() => () => Promise.resolve()), +})); + jest.mock('src/utils/navigationUtils', () => ({ navigateTo: jest.fn(), navigateWithState: jest.fn(), @@ -236,4 +246,126 @@ describe('dashboardState actions', () => { ); }); }); + + test('fetchCharts returns a Promise that resolves after all refreshes', async () => { + (refreshChart as jest.Mock).mockClear(); + const { getState } = setup({ + dashboardInfo: { + metadata: {}, + common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, + }, + }); + const dispatch = (action: unknown): unknown => { + if (typeof action === 'function') { + return (action as Function)(dispatch, getState); + } + return action; + }; + const chartIds = [1, 2]; + const promise = fetchCharts(chartIds, false, 0, 10)(dispatch, getState); + await promise; + + expect(refreshChart).toHaveBeenCalledTimes(chartIds.length); + }); + + test('fetchCharts resolves for staggered refreshes', async () => { + jest.useFakeTimers(); + (refreshChart as jest.Mock).mockClear(); + const { getState } = setup({ + dashboardInfo: { + metadata: { stagger_time: 1000, stagger_refresh: true }, + common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, + }, + }); + const dispatch = (action: unknown): unknown => { + if (typeof action === 'function') { + return (action as Function)(dispatch, getState); + } + return action; + }; + const chartIds = [1, 2, 3]; + const promise = fetchCharts(chartIds, false, 1000, 10)(dispatch, getState); + + jest.runAllTimers(); + await promise; + jest.useRealTimers(); + + expect(refreshChart).toHaveBeenCalledTimes(chartIds.length); + }); + + test('onRefresh dispatches success and filters refresh by default', async () => { + const { getState } = setup({ + dashboardInfo: { + metadata: {}, + common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, + }, + }); + const dispatched: { type: string }[] = []; + const dispatch = (action: unknown): unknown => { + if (typeof action === 'function') { + return (action as Function)(dispatch, getState); + } + dispatched.push(action as { type: string }); + return action; + }; + + await onRefresh([1], true, 0, 10)(dispatch as never); + + expect(dispatched.map(action => action.type)).toEqual( + expect.arrayContaining([ + ON_REFRESH, + ON_REFRESH_SUCCESS, + ON_FILTERS_REFRESH, + ]), + ); + }); + + test('onRefresh skips filter refresh when requested', async () => { + const { getState } = setup({ + dashboardInfo: { + metadata: {}, + common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, + }, + }); + const dispatched: { type: string }[] = []; + const dispatch = (action: unknown): unknown => { + if (typeof action === 'function') { + return (action as Function)(dispatch, getState); + } + dispatched.push(action as { type: string }); + return action; + }; + + await onRefresh([1], true, 0, 10, true)(dispatch as never); + + const dispatchedTypes = dispatched.map(action => action.type); + expect(dispatchedTypes).toEqual( + expect.arrayContaining([ON_REFRESH, ON_REFRESH_SUCCESS]), + ); + expect(dispatchedTypes).not.toContain(ON_FILTERS_REFRESH); + }); + + test('onRefresh skips ON_REFRESH and filters refresh for lazy-loaded tabs', async () => { + const { getState } = setup({ + dashboardInfo: { + metadata: {}, + common: { conf: { SUPERSET_WEBSERVER_TIMEOUT: 60 } }, + }, + }); + const dispatched: { type: string }[] = []; + const dispatch = (action: unknown): unknown => { + if (typeof action === 'function') { + return (action as Function)(dispatch, getState); + } + dispatched.push(action as { type: string }); + return action; + }; + + await onRefresh([1], true, 0, 10, false, true)(dispatch as never); + + const dispatchedTypes = dispatched.map(action => action.type); + expect(dispatchedTypes).toContain(ON_REFRESH_SUCCESS); + expect(dispatchedTypes).not.toContain(ON_REFRESH); + expect(dispatchedTypes).not.toContain(ON_FILTERS_REFRESH); + }); }); diff --git a/superset-frontend/src/dashboard/actions/dashboardState.ts b/superset-frontend/src/dashboard/actions/dashboardState.ts index 4901f0a064fc..851ffdff0127 100644 --- a/superset-frontend/src/dashboard/actions/dashboardState.ts +++ b/superset-frontend/src/dashboard/actions/dashboardState.ts @@ -412,9 +412,9 @@ interface DashboardSaveData extends JsonObject { css?: string; dashboard_title?: string; owners?: { id: number }[] | number[]; - roles?: { id: number }[] | number[]; + roles?: JsonObject[]; slug?: string | null; - tags?: { id: number }[] | number[]; + tags?: JsonObject[]; metadata?: JsonObject; positions?: JsonObject; duplicate_slices?: boolean; @@ -740,13 +740,14 @@ export function fetchCharts( force = false, interval = 0, dashboardId?: number, -): (dispatch: AppDispatch, getState: GetState) => void { +): (dispatch: AppDispatch, getState: GetState) => Promise { return (dispatch: AppDispatch, getState: GetState) => { if (!interval) { - chartList.forEach(chartKey => - dispatch(refreshChart(chartKey, force, dashboardId)), - ); - return; + return Promise.all( + chartList.map(chartKey => + Promise.resolve(dispatch(refreshChart(chartKey, force, dashboardId))), + ), + ).then(() => undefined); } const { metadata } = getState().dashboardInfo; @@ -770,12 +771,20 @@ export function fetchCharts( staggerRefresh && chartList.length > 1 ? refreshTime / (chartList.length - 1) : 0; - chartList.forEach((chartKey: number, i: number) => { - setTimeout( - () => dispatch(refreshChart(chartKey, force, dashboardId)), - delay * i, - ); - }); + return Promise.all( + chartList.map( + (chartKey: number, i: number) => + new Promise(resolve => { + setTimeout(() => { + Promise.resolve( + dispatch(refreshChart(chartKey, force, dashboardId)), + ) + .then(() => resolve()) + .catch(() => resolve()); + }, delay * i); + }), + ), + ).then(() => undefined); }; } @@ -786,10 +795,7 @@ const refreshCharts = ( dashboardId: number | undefined, dispatch: AppDispatch, ): Promise => - new Promise(resolve => { - dispatch(fetchCharts(chartList, force, interval, dashboardId)); - resolve(); - }); + dispatch(fetchCharts(chartList, force, interval, dashboardId)); export const ON_FILTERS_REFRESH = 'ON_FILTERS_REFRESH'; @@ -828,8 +834,9 @@ export function onRefresh( force = false, interval = 0, dashboardId?: number, + skipFiltersRefresh = false, isLazyLoad = false, -): (dispatch: AppDispatch) => void { +): (dispatch: AppDispatch) => Promise { return (dispatch: AppDispatch) => { // Only dispatch ON_REFRESH for dashboard-level refreshes // Skip it for lazy-loaded tabs to prevent infinite loops @@ -837,14 +844,18 @@ export function onRefresh( dispatch({ type: ON_REFRESH }); } - refreshCharts(chartList, force, interval, dashboardId, dispatch).then( - () => { - dispatch(onRefreshSuccess()); - if (!isLazyLoad) { - dispatch(onFiltersRefresh()); - } - }, - ); + return refreshCharts( + chartList, + force, + interval, + dashboardId, + dispatch, + ).then(() => { + dispatch(onRefreshSuccess()); + if (!skipFiltersRefresh && !isLazyLoad) { + dispatch(onFiltersRefresh()); + } + }); }; } diff --git a/superset-frontend/src/dashboard/actions/hydrate.ts b/superset-frontend/src/dashboard/actions/hydrate.ts index 5bcb85744351..df51176c9b2b 100644 --- a/superset-frontend/src/dashboard/actions/hydrate.ts +++ b/superset-frontend/src/dashboard/actions/hydrate.ts @@ -60,6 +60,7 @@ import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import type { DashboardChartStates } from 'src/dashboard/types/chartState'; import extractUrlParams from '../util/extractUrlParams'; import updateComponentParentsList from '../util/updateComponentParentsList'; +import { AUTO_REFRESH_STATE_DEFAULTS } from '../types/autoRefresh'; import { DashboardLayout, FilterBarOrientation, @@ -359,6 +360,7 @@ export const hydrateDashboard = dashboardFilters, nativeFilters, dashboardState: { + ...AUTO_REFRESH_STATE_DEFAULTS, preselectNativeFilters: getUrlParam(URL_PARAMS.nativeFilters), sliceIds: Array.from(sliceIds), directPathToChild, diff --git a/superset-frontend/src/dashboard/components/AutoRefreshControls/AutoRefreshControls.test.tsx b/superset-frontend/src/dashboard/components/AutoRefreshControls/AutoRefreshControls.test.tsx new file mode 100644 index 000000000000..4875c392732c --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshControls/AutoRefreshControls.test.tsx @@ -0,0 +1,142 @@ +/** + * 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, screen, fireEvent } from 'spec/helpers/testing-library'; +import { AutoRefreshControls } from './index'; +import { AUTO_REFRESH_STATE_DEFAULTS } from '../../types/autoRefresh'; + +// Helper to create mock state +const createMockState = (overrides = {}) => ({ + dashboardState: { + ...AUTO_REFRESH_STATE_DEFAULTS, + refreshFrequency: 0, + ...overrides, + }, +}); + +test('does not render when refreshFrequency is 0', () => { + const onTogglePause = jest.fn(); + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 0 }), + }); + expect(screen.queryByTestId('auto-refresh-toggle')).not.toBeInTheDocument(); +}); + +test('renders pause button when not paused', () => { + const onTogglePause = jest.fn(); + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshPaused: false, + }), + }); + + const button = screen.getByTestId('auto-refresh-toggle'); + expect(button).toBeInTheDocument(); + expect(button).toHaveAttribute('aria-label', 'Pause auto-refresh'); +}); + +test('renders play button when paused', () => { + const onTogglePause = jest.fn(); + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshPaused: true, + }), + }); + + const button = screen.getByTestId('auto-refresh-toggle'); + expect(button).toHaveAttribute('aria-label', 'Resume auto-refresh'); +}); + +test('calls onTogglePause when clicked', () => { + const onTogglePause = jest.fn(); + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }); + + fireEvent.click(screen.getByTestId('auto-refresh-toggle')); + expect(onTogglePause).toHaveBeenCalledTimes(1); +}); + +test('is disabled when isLoading is true', () => { + const onTogglePause = jest.fn(); + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }); + + const button = screen.getByTestId('auto-refresh-toggle'); + expect(button).toBeDisabled(); +}); + +test('is not disabled when isLoading is false', () => { + const onTogglePause = jest.fn(); + render( + , + { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }, + ); + + const button = screen.getByTestId('auto-refresh-toggle'); + expect(button).not.toBeDisabled(); +}); + +test('renders refresh button', () => { + const onTogglePause = jest.fn(); + const onRefresh = jest.fn(); + render( + , + { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }, + ); + + const refreshButton = screen.getByTestId('auto-refresh-refresh-button'); + expect(refreshButton).toBeInTheDocument(); +}); + +test('calls onRefresh when refresh button clicked', () => { + const onTogglePause = jest.fn(); + const onRefresh = jest.fn(); + render( + , + { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }, + ); + + fireEvent.click(screen.getByTestId('auto-refresh-refresh-button')); + expect(onRefresh).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/src/dashboard/components/AutoRefreshControls/index.tsx b/superset-frontend/src/dashboard/components/AutoRefreshControls/index.tsx new file mode 100644 index 000000000000..99dfc9a512bb --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshControls/index.tsx @@ -0,0 +1,105 @@ +/** + * 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 { FC } from 'react'; +import { css, useTheme, t } from '@apache-superset/core/ui'; +import { Tooltip, Button } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { useRealTimeDashboard } from '../../hooks/useRealTimeDashboard'; + +export interface AutoRefreshControlsProps { + onTogglePause: () => void; + onRefresh?: () => void; + isLoading?: boolean; + showRefreshButton?: boolean; +} + +/** + * Pause/Resume and Refresh buttons for real-time dashboards. + * Only renders when auto-refresh is enabled. + */ +export const AutoRefreshControls: FC = ({ + onTogglePause, + onRefresh, + isLoading, + showRefreshButton = false, +}) => { + const theme = useTheme(); + const { isRealTimeDashboard, isPaused } = useRealTimeDashboard(); + + if (!isRealTimeDashboard) { + return null; + } + + const containerStyles = css` + display: flex; + align-items: center; + gap: ${theme.marginXS}px; + `; + + const buttonStyles = css` + padding: ${theme.paddingXXS}px ${theme.paddingXS}px; + display: flex; + align-items: center; + justify-content: center; + min-width: ${theme.controlHeight}px; + height: ${theme.controlHeight}px; + `; + + const pauseTooltipTitle = isPaused + ? t('Resume auto-refresh') + : t('Pause auto-refresh'); + + const PauseIcon = isPaused + ? Icons.PlayCircleOutlined + : Icons.PauseCircleOutlined; + + return ( +
+ + + + + {showRefreshButton && onRefresh && ( + + + + )} +
+ ); +}; + +export default AutoRefreshControls; diff --git a/superset-frontend/src/dashboard/components/AutoRefreshIndicator/index.tsx b/superset-frontend/src/dashboard/components/AutoRefreshIndicator/index.tsx new file mode 100644 index 000000000000..8e7ac3c8f9aa --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshIndicator/index.tsx @@ -0,0 +1,169 @@ +/** + * 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 { FC, useMemo } from 'react'; +import { css, useTheme, t } from '@apache-superset/core/ui'; +import { Label, Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; +import { useRealTimeDashboard } from '../../hooks/useRealTimeDashboard'; +import { useCurrentTime } from '../../hooks/useCurrentTime'; +import { StatusIndicatorDot } from '../AutoRefreshStatus/StatusIndicatorDot'; +import { StatusTooltipContent } from '../AutoRefreshStatus/StatusTooltipContent'; + +export interface AutoRefreshIndicatorProps { + onTogglePause: () => void; +} + +/** + * Unified auto-refresh indicator component that displays: + * - Status dot (green/yellow/red/blue) + * - Pause/Play button + * + * All contained within a bordered container. + * Only renders when the dashboard has an auto-refresh interval configured. + */ +export const AutoRefreshIndicator: FC = ({ + onTogglePause, +}) => { + const theme = useTheme(); + const { + isRealTimeDashboard, + isPaused, + effectiveStatus, + lastSuccessfulRefresh, + lastAutoRefreshTime, + refreshErrorCount, + refreshFrequency, + isPausedByTab, + } = useRealTimeDashboard(); + const currentTime = useCurrentTime(isRealTimeDashboard, lastAutoRefreshTime); + + const iconPixelSize = theme.fontSizeSM; + + const labelStyles = useMemo( + () => css` + background-color: ${theme.colorBgContainer}; + border-color: ${theme.colorSplit}; + color: ${theme.colorTextSecondary}; + padding: ${theme.sizeUnit}px; + column-gap: ${theme.marginXS}px; + align-items: center; + display: inline-flex; + `, + [theme], + ); + + const iconButtonStyles = useMemo( + () => css` + display: flex; + align-items: center; + justify-content: center; + width: ${iconPixelSize}px; + height: ${iconPixelSize}px; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: ${theme.colorTextSecondary}; + transition: color ${theme.motionDurationMid}; + + &:hover { + color: ${theme.colorText}; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + `, + [iconPixelSize, theme], + ); + + const dotWrapperStyles = useMemo( + () => css` + display: flex; + align-items: center; + justify-content: center; + width: ${iconPixelSize}px; + height: ${iconPixelSize}px; + + & > span { + margin: 0; + box-shadow: none; + cursor: pointer; + } + `, + [iconPixelSize], + ); + + const pauseButton = useMemo(() => { + const tooltipTitle = isPaused + ? t('Resume auto-refresh') + : t('Pause auto-refresh'); + + return ( + + + + ); + }, [isPaused, onTogglePause, iconButtonStyles]); + + if (!isRealTimeDashboard) { + return null; + } + + return ( + + ); +}; + +export default AutoRefreshIndicator; diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/AutoRefreshStatus.test.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/AutoRefreshStatus.test.tsx new file mode 100644 index 000000000000..6ed10381ef36 --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/AutoRefreshStatus.test.tsx @@ -0,0 +1,133 @@ +/** + * 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, screen } from 'spec/helpers/testing-library'; +import { AutoRefreshStatus as AutoRefreshStatusComponent } from './index'; +import { + AutoRefreshStatus, + AUTO_REFRESH_STATE_DEFAULTS, +} from '../../types/autoRefresh'; + +// Helper to create mock state for rendering with Redux +const createMockState = (overrides = {}) => ({ + dashboardState: { + ...AUTO_REFRESH_STATE_DEFAULTS, + refreshFrequency: 0, + ...overrides, + }, +}); + +test('does not render when refreshFrequency is 0', () => { + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 0 }), + }); + expect(screen.queryByTestId('auto-refresh-status')).not.toBeInTheDocument(); +}); + +test('renders when refreshFrequency is greater than 0', () => { + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }); + expect(screen.getByTestId('auto-refresh-status')).toBeInTheDocument(); +}); + +test('renders status indicator dot', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Success, + }), + }); + expect(screen.getByTestId('status-indicator-dot')).toBeInTheDocument(); +}); + +test('shows paused status when manually paused', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshPaused: true, + autoRefreshStatus: AutoRefreshStatus.Success, + }), + }); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Paused); +}); + +test('shows paused status when paused by tab', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshPausedByTab: true, + autoRefreshStatus: AutoRefreshStatus.Fetching, + }), + }); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Paused); +}); + +test('shows fetching status when fetching', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Fetching, + }), + }); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Fetching); +}); + +test('shows error status after 2+ consecutive errors', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Error, + refreshErrorCount: 2, + }), + }); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Error); +}); + +test('shows delayed status after one refresh error', () => { + render(, { + useRedux: true, + initialState: createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Success, + refreshErrorCount: 1, + }), + }); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Delayed); +}); + +test('accepts className prop', () => { + render(, { + useRedux: true, + initialState: createMockState({ refreshFrequency: 5 }), + }); + const container = screen.getByTestId('auto-refresh-status'); + expect(container).toHaveClass('custom-class'); +}); diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.test.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.test.tsx new file mode 100644 index 000000000000..544dbf2a2c4a --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.test.tsx @@ -0,0 +1,114 @@ +/** + * 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, screen, act } from 'spec/helpers/testing-library'; +import { StatusIndicatorDot } from './StatusIndicatorDot'; +import { AutoRefreshStatus } from '../../types/autoRefresh'; + +afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +test('renders with success status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toBeInTheDocument(); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Success); +}); + +test('renders with fetching status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Fetching); +}); + +test('renders with delayed status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Delayed); +}); + +test('renders with idle status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Idle); +}); + +test('renders with error status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Error); +}); + +test('renders with paused status', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Paused); +}); + +test('has correct accessibility attributes', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('role', 'status'); + expect(dot).toHaveAttribute('aria-label', 'Auto-refresh status: success'); +}); + +test('fetching status updates immediately without debounce', () => { + jest.useFakeTimers(); + + const { rerender } = render( + , + ); + + // Change to fetching - should be immediate + rerender(); + + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Fetching); +}); + +test('debounces non-fetching status changes to prevent flickering', () => { + jest.useFakeTimers(); + + const { rerender } = render( + , + ); + + // Change to error - should be debounced + rerender(); + + // Status should still show success (debounced) + let dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Success); + + // Fast forward past debounce time (100ms) + act(() => { + jest.advanceTimersByTime(150); + }); + + // Now should be error + dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-status', AutoRefreshStatus.Error); +}); + +test('accepts custom size prop', () => { + render(); + const dot = screen.getByTestId('status-indicator-dot'); + expect(dot).toHaveAttribute('data-size', '16'); +}); diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.tsx new file mode 100644 index 000000000000..c6c7c4342318 --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusIndicatorDot.tsx @@ -0,0 +1,168 @@ +/** + * 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 { FC, useMemo, useRef, useEffect, useState } from 'react'; +import { css, useTheme } from '@apache-superset/core/ui'; +import { AutoRefreshStatus } from '../../types/autoRefresh'; + +export interface StatusIndicatorDotProps { + /** Current status to display */ + status: AutoRefreshStatus; + /** Size of the dot in pixels */ + size?: number; +} + +/** + * Status indicator configuration mapping. + * + * - Green dot: Refreshed on schedule + * - Blue dot: Fetching data or waiting for first refresh + * - Yellow/warning dot: Delayed + * - Red dot: Error + * - White dot: Paused + */ +interface StatusConfig { + color: string; + needsBorder: boolean; +} + +const getStatusConfig = ( + theme: ReturnType, + status: AutoRefreshStatus, +): StatusConfig => { + switch (status) { + case AutoRefreshStatus.Success: + return { + color: theme.colorSuccess, + needsBorder: false, + }; + case AutoRefreshStatus.Idle: + return { + color: theme.colorInfo, + needsBorder: false, + }; + case AutoRefreshStatus.Fetching: + return { + color: theme.colorInfo, + needsBorder: false, + }; + case AutoRefreshStatus.Delayed: + return { + color: theme.colorWarning, + needsBorder: false, + }; + case AutoRefreshStatus.Error: + return { + color: theme.colorError, + needsBorder: false, + }; + case AutoRefreshStatus.Paused: + return { + color: theme.colorBgContainer, + needsBorder: true, + }; + default: + return { + color: theme.colorTextSecondary, + needsBorder: false, + }; + } +}; + +/** + * A colored dot indicator that shows the auto-refresh status. + * + * Uses CSS transitions to prevent flickering between states. + * The color change is animated smoothly rather than instantly. + */ +export const StatusIndicatorDot: FC = ({ + status, + size = 10, +}) => { + const theme = useTheme(); + + // Debounce rapid status changes to prevent flickering + const [displayStatus, setDisplayStatus] = useState(status); + const timerRef = useRef | null>(null); + + useEffect(() => { + // Clear any pending timer + if (timerRef.current) { + clearTimeout(timerRef.current); + } + + // For fetching state, update immediately to show user something is happening + if (status === AutoRefreshStatus.Fetching) { + setDisplayStatus(status); + } else { + // For other states, debounce to prevent flickering + timerRef.current = setTimeout(() => { + setDisplayStatus(status); + }, 100); + } + + return () => { + if (timerRef.current) { + clearTimeout(timerRef.current); + } + }; + }, [status]); + + const statusConfig = useMemo( + () => getStatusConfig(theme, displayStatus), + [theme, displayStatus], + ); + + const dotStyles = useMemo( + () => css` + display: inline-flex; + align-items: center; + justify-content: center; + width: ${size}px; + height: ${size}px; + border-radius: 50%; + background-color: ${statusConfig.color}; + transition: + background-color ${theme.motionDurationMid} ease-in-out, + border-color ${theme.motionDurationMid} ease-in-out; + border: ${statusConfig.needsBorder + ? `1px solid ${theme.colorBorder}` + : 'none'}; + box-shadow: ${statusConfig.needsBorder + ? 'none' + : `0 0 0 2px ${theme.colorBgContainer}`}; + margin-left: ${theme.marginXS}px; + margin-right: ${theme.marginXS}px; + cursor: help; + `, + [statusConfig, size, theme], + ); + + return ( + + ); +}; + +export default StatusIndicatorDot; diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.test.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.test.tsx new file mode 100644 index 000000000000..074b0fa612f8 --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.test.tsx @@ -0,0 +1,180 @@ +/** + * 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, screen } from 'spec/helpers/testing-library'; +import { StatusTooltipContent } from './StatusTooltipContent'; +import { AutoRefreshStatus } from '../../types/autoRefresh'; + +test('renders success status tooltip with precise seconds format', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + // Should show precise seconds format "X s ago" + expect(container).toHaveTextContent(/\d+ s ago/); + // Should contain info about the refresh interval + expect(container).toHaveTextContent('10'); +}); + +test('renders minutes format for older timestamps', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + // Should show minutes format "X min ago" + expect(container).toHaveTextContent(/\d+ min ago/); +}); + +test('renders fetching status tooltip', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); +}); + +test('renders paused status with last updated time and interval in parentheses', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + // Should show last updated time on first line + expect(container).toHaveTextContent(/Dashboard updated \d+ s ago/); + // Should show paused status with interval in parentheses + expect(container).toHaveTextContent( + 'Auto refresh paused (set to 10 seconds)', + ); +}); + +test('renders error status copy with last updated line', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + expect(container).toHaveTextContent( + 'There was a problem refreshing your dashboard.', + ); + expect(container).toHaveTextContent('Last updated'); + expect(container).not.toHaveTextContent('Network timeout'); +}); + +test('omits last updated line when there was no successful refresh', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + expect(container).toHaveTextContent( + 'There was a problem refreshing your dashboard.', + ); + expect(container).not.toHaveTextContent('Last updated'); +}); + +test('renders delayed status with missed refresh description', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + expect(container).toHaveTextContent('Delayed (missed 1 refresh)'); +}); + +test('shows waiting message when no refresh has occurred yet', () => { + const now = Date.now(); + render( + , + ); + + const container = screen.getByTestId('status-tooltip-content'); + expect(container).toBeInTheDocument(); + expect(container).toHaveTextContent('Waiting for first refresh'); +}); diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.tsx new file mode 100644 index 000000000000..d7e252b331de --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/StatusTooltipContent.tsx @@ -0,0 +1,178 @@ +/** + * 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 { FC } from 'react'; +import { t, tn } from '@apache-superset/core/ui'; +import { AutoRefreshStatus } from '../../types/autoRefresh'; + +export interface StatusTooltipContentProps { + status: AutoRefreshStatus; + lastSuccessfulRefresh: number | null; + lastAutoRefreshTime: number | null; + refreshErrorCount: number; + refreshFrequency: number; + isPausedByTab?: boolean; + /** Current timestamp for relative time calculations */ + currentTime: number; +} + +/** + * Calculates elapsed seconds between two timestamps. + */ +const getElapsedSeconds = ( + timestamp: number | null, + currentTime: number, +): number | null => { + if (timestamp === null) return null; + return Math.max(0, Math.floor((currentTime - timestamp) / 1000)); +}; + +/** + * Formats elapsed seconds into a human-readable relative time string. + */ +const formatElapsedTime = (seconds: number): string => { + if (seconds < 60) { + return tn('%s s ago', '%s s ago', seconds, seconds); + } + + const minutes = Math.floor(seconds / 60); + if (minutes < 60) { + return tn('%s min ago', '%s min ago', minutes, minutes); + } + + const hours = Math.floor(minutes / 60); + if (hours < 24) { + return tn('%s hr ago', '%s hr ago', hours, hours); + } + + const days = Math.floor(hours / 24); + return tn('%s day ago', '%s days ago', days, days); +}; + +const getNextRefreshLabel = (nextRefreshInSeconds: number | null): string => + nextRefreshInSeconds === null + ? t('a few seconds') + : tn('%s second', '%s seconds', nextRefreshInSeconds, nextRefreshInSeconds); + +const getLastUpdatedLine = ( + elapsedSeconds: number | null, +): string | undefined => + elapsedSeconds === null + ? undefined + : t('Last updated %s ago', formatElapsedTime(elapsedSeconds)); + +const getNextRefreshInSeconds = ( + lastAutoRefreshTime: number | null, + currentTime: number, + refreshFrequency: number, +): number | null => { + if (refreshFrequency <= 0) { + return null; + } + + if (lastAutoRefreshTime === null) { + return refreshFrequency; + } + + const elapsedSeconds = Math.floor( + Math.max(0, currentTime - lastAutoRefreshTime) / 1000, + ); + + return Math.max(0, refreshFrequency - elapsedSeconds); +}; + +export const StatusTooltipContent: FC = ({ + status, + lastSuccessfulRefresh, + lastAutoRefreshTime, + refreshErrorCount, + refreshFrequency, + isPausedByTab = false, + currentTime, +}) => { + const elapsedSeconds = getElapsedSeconds(lastSuccessfulRefresh, currentTime); + const missedRefreshes = Math.max(0, refreshErrorCount); + const nextRefreshInSeconds = getNextRefreshInSeconds( + lastAutoRefreshTime, + currentTime, + refreshFrequency, + ); + + const intervalLine = t('Auto refresh set to %s seconds', refreshFrequency); + + const getUpdatedLine = (): string => { + if (elapsedSeconds === null) { + return t('Waiting for first refresh'); + } + return t('Dashboard updated %s', formatElapsedTime(elapsedSeconds)); + }; + + let line1: string; + let line2: string | undefined = intervalLine; + let line3: string | undefined; + + switch (status) { + case AutoRefreshStatus.Fetching: + line1 = t('Fetching data...'); + break; + case AutoRefreshStatus.Delayed: + line1 = getUpdatedLine(); + line3 = + missedRefreshes > 0 + ? tn( + 'Delayed (missed %s refresh)', + 'Delayed (missed %s refreshes)', + missedRefreshes, + missedRefreshes, + ) + : t('Refresh delayed'); + break; + case AutoRefreshStatus.Error: + line1 = t( + "There was a problem refreshing your dashboard. We'll try again in %s, as scheduled.", + getNextRefreshLabel(nextRefreshInSeconds), + ); + line2 = getLastUpdatedLine(elapsedSeconds); + line3 = undefined; + break; + case AutoRefreshStatus.Paused: + line1 = getUpdatedLine(); + line2 = isPausedByTab + ? t( + 'Auto refresh paused - tab inactive (set to %s seconds)', + refreshFrequency, + ) + : t('Auto refresh paused (set to %s seconds)', refreshFrequency); + break; + case AutoRefreshStatus.Success: + case AutoRefreshStatus.Idle: + default: + line1 = getUpdatedLine(); + break; + } + + return ( +
+
{line1}
+ {line2 &&
{line2}
} + {line3 &&
{line3}
} +
+ ); +}; + +export default StatusTooltipContent; diff --git a/superset-frontend/src/dashboard/components/AutoRefreshStatus/index.tsx b/superset-frontend/src/dashboard/components/AutoRefreshStatus/index.tsx new file mode 100644 index 000000000000..82638fd643e6 --- /dev/null +++ b/superset-frontend/src/dashboard/components/AutoRefreshStatus/index.tsx @@ -0,0 +1,84 @@ +/** + * 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 { FC } from 'react'; +import { Tooltip } from '@superset-ui/core/components'; +import { useRealTimeDashboard } from '../../hooks/useRealTimeDashboard'; +import { useCurrentTime } from '../../hooks/useCurrentTime'; +import { StatusIndicatorDot } from './StatusIndicatorDot'; +import { StatusTooltipContent } from './StatusTooltipContent'; + +export interface AutoRefreshStatusProps { + /** Additional CSS class name */ + className?: string; +} + +/** + * Auto-refresh status indicator displayed in the dashboard header. + * Only renders when the dashboard has an auto-refresh interval configured. + * + * Shows different colored dots based on refresh state: + * - Green (success): Last refresh was successful + * - Blue (fetching): Currently fetching data + * - Yellow (delayed): Refresh is taking longer than expected + * - Red (error): Refresh failed with an error + * - White (paused): Auto-refresh is paused + */ +export const AutoRefreshStatus: FC = ({ + className, +}) => { + const { + isRealTimeDashboard, + effectiveStatus, + lastSuccessfulRefresh, + lastAutoRefreshTime, + refreshErrorCount, + refreshFrequency, + isPausedByTab, + } = useRealTimeDashboard(); + const currentTime = useCurrentTime(isRealTimeDashboard, lastAutoRefreshTime); + + // Don't render if not a real-time dashboard + if (!isRealTimeDashboard) { + return null; + } + + return ( + + } + > +
+ +
+
+ ); +}; + +export default AutoRefreshStatus; diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx index 9a49e751b624..63a3e641bfa6 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/FiltersBadge.test.tsx @@ -22,6 +22,7 @@ import { render } from 'spec/helpers/testing-library'; import { CHART_RENDERING_SUCCEEDED, CHART_UPDATE_SUCCEEDED, + CHART_UPDATE_STARTED, } from 'src/components/Chart/chartAction'; import { buildActiveFilters } from 'src/dashboard/util/activeDashboardFilters'; import { FiltersBadge } from 'src/dashboard/components/FiltersBadge'; @@ -34,13 +35,19 @@ import { sliceId } from 'spec/fixtures/mockChartQueries'; import { dashboardFilters } from 'spec/fixtures/mockDashboardFilters'; import { dashboardWithFilter } from 'spec/fixtures/mockDashboardLayout'; -jest.mock( - 'src/dashboard/components/FiltersBadge/DetailsPanel', - () => - ({ children }: { children: ReactNode }) => ( -
{children}
- ), -); +// Mock for auto-refresh context +let mockIsAutoRefreshing = false; + +jest.mock('src/dashboard/contexts/AutoRefreshContext', () => ({ + useIsAutoRefreshing: () => mockIsAutoRefreshing, +})); + +jest.mock('src/dashboard/components/FiltersBadge/DetailsPanel', () => { + const MockDetailsPanel = ({ children }: { children: ReactNode }) => ( +
{children}
+ ); + return MockDetailsPanel; +}); const defaultStore = getMockStoreWithFilters(); function setup(store: Store = defaultStore) { @@ -55,86 +62,168 @@ buildActiveFilters({ components: dashboardWithFilter, }); -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('for dashboard filters', () => { - test('does not show number when there are no active filters', () => { - const store = getMockStoreWithFilters(); - // start with basic dashboard state, dispatch an event to simulate query completion - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [], - rejected_filters: [], - }, - ], - dashboardFilters, - }); - const { queryByTestId } = setup(store); - expect(queryByTestId('applied-filter-count')).not.toBeInTheDocument(); +beforeEach(() => { + // Reset auto-refresh state before each test + mockIsAutoRefreshing = false; +}); + +// Dashboard filters tests +test('dashboard filters: does not show number when there are no active filters', () => { + const store = getMockStoreWithFilters(); + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [], + rejected_filters: [], + }, + ], + dashboardFilters, + }); + const { queryByTestId } = setup(store); + expect(queryByTestId('applied-filter-count')).not.toBeInTheDocument(); +}); + +test('dashboard filters: shows the indicator when filters have been applied', () => { + const store = getMockStoreWithFilters(); + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [{ column: 'region' }], + rejected_filters: [], + }, + ], + dashboardFilters, + }); + store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); + const { getByTestId } = setup(store); + expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); + expect(getByTestId('mock-details-panel')).toBeInTheDocument(); +}); + +// Native filters tests +test('native filters: does not show number when there are no active filters', () => { + const store = getMockStoreWithNativeFiltersButNoValues(); + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [], + rejected_filters: [], + }, + ], + }); + store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); + const { queryByTestId } = setup(store); + expect(queryByTestId('applied-filter-count')).not.toBeInTheDocument(); +}); + +test('native filters: shows the indicator when filters have been applied', () => { + const store = getMockStoreWithNativeFilters(); + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [{ column: 'region' }], + rejected_filters: [], + }, + ], + }); + store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); + const { getByTestId } = setup(store); + expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); + expect(getByTestId('mock-details-panel')).toBeInTheDocument(); +}); + +// Auto-refresh tests +test('auto-refresh: preserves indicator count during loading state', () => { + const store = getMockStoreWithNativeFilters(); + + // First, set up a chart with applied filters in rendered state + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [{ column: 'region' }], + rejected_filters: [], + }, + ], + }); + store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); + + // Render with filters applied + const { getByTestId, rerender } = render(, { + store, }); - test('shows the indicator when filters have been applied', () => { - const store = getMockStoreWithFilters(); - // start with basic dashboard state, dispatch an event to simulate query completion - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [{ column: 'region' }], - rejected_filters: [], - }, - ], - dashboardFilters, - }); - store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); - const { getByTestId } = setup(store); - expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); - expect(getByTestId('mock-details-panel')).toBeInTheDocument(); + // Verify badge is visible with count 1 + expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); + + // Now simulate auto-refresh by setting the context + mockIsAutoRefreshing = true; + + // Dispatch loading state (simulating auto-refresh started) + store.dispatch({ + type: CHART_UPDATE_STARTED, + key: sliceId, }); + + // Re-render to pick up the state changes + rerender(); + + // Badge should still be visible during auto-refresh loading + expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); }); -// eslint-disable-next-line no-restricted-globals -- TODO: Migrate from describe blocks -describe('for native filters', () => { - test('does not show number when there are no active filters', () => { - const store = getMockStoreWithNativeFiltersButNoValues(); - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [], - rejected_filters: [], - }, - ], - }); - store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); - const { queryByTestId } = setup(store); - expect(queryByTestId('applied-filter-count')).not.toBeInTheDocument(); +test('manual refresh: clears indicators during loading state', () => { + const store = getMockStoreWithNativeFilters(); + + // First, set up a chart with applied filters in rendered state + store.dispatch({ + type: CHART_UPDATE_SUCCEEDED, + key: sliceId, + queriesResponse: [ + { + status: 'success', + applied_filters: [{ column: 'region' }], + rejected_filters: [], + }, + ], }); + store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); + + // Render with filters applied + const { getByTestId, queryByTestId, rerender } = render( + , + { store }, + ); + + // Verify badge is visible with count 1 + expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); - test('shows the indicator when filters have been applied', () => { - const store = getMockStoreWithNativeFilters(); - // start with basic dashboard state, dispatch an event to simulate query completion - store.dispatch({ - type: CHART_UPDATE_SUCCEEDED, - key: sliceId, - queriesResponse: [ - { - status: 'success', - applied_filters: [{ column: 'region' }], - rejected_filters: [], - }, - ], - }); - store.dispatch({ type: CHART_RENDERING_SUCCEEDED, key: sliceId }); - const { getByTestId } = setup(store); - expect(getByTestId('applied-filter-count')).toHaveTextContent('1'); - expect(getByTestId('mock-details-panel')).toBeInTheDocument(); + // Keep auto-refresh as false (manual refresh) + mockIsAutoRefreshing = false; + + // Dispatch loading state (simulating manual refresh) + store.dispatch({ + type: CHART_UPDATE_STARTED, + key: sliceId, }); + + // Re-render to pick up the state changes + rerender(); + + // Badge should disappear during manual refresh loading + expect(queryByTestId('applied-filter-count')).not.toBeInTheDocument(); }); diff --git a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx index d8d571065e9b..41b2e2fd8136 100644 --- a/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx +++ b/superset-frontend/src/dashboard/components/FiltersBadge/index.tsx @@ -49,6 +49,7 @@ import { selectNativeIndicatorsForChart, } from '../nativeFilters/selectors'; import { Chart, RootState } from '../../types'; +import { useIsAutoRefreshing } from '../../contexts/AutoRefreshContext'; export interface FiltersBadgeProps { chartId: number; @@ -106,10 +107,14 @@ const indicatorsInitialState: Indicator[] = []; export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { const dispatch = useDispatch(); - const datasources = useSelector(state => state.datasources); - const dashboardFilters = useSelector( - state => state.dashboardFilters, + const isAutoRefreshing = useIsAutoRefreshing(); + const datasources = useSelector( + state => state.datasources, ); + const dashboardFilters = useSelector< + RootState, + RootState['dashboardFilters'] + >(state => state.dashboardFilters); const nativeFilters = useSelector( state => state.nativeFilters?.filters, ); @@ -161,7 +166,12 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { }, [popoverVisible]); useEffect(() => { - if (!showIndicators && dashboardIndicators.length > 0) { + // During auto-refresh, don't clear indicators - preserve previous state + if ( + !showIndicators && + dashboardIndicators.length > 0 && + !isAutoRefreshing + ) { setDashboardIndicators(indicatorsInitialState); } else if (prevChartStatus !== 'success') { if ( @@ -188,6 +198,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { dashboardFilters, dashboardIndicators.length, datasources, + isAutoRefreshing, prevChart?.queriesResponse, prevChartStatus, prevDashboardFilters, @@ -201,11 +212,10 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { const prevChartConfig = usePrevious(chartConfiguration); useEffect(() => { + // During auto-refresh, don't clear indicators - preserve previous state + // Clear indicators when chart is loading/not showing (unless auto-refreshing) const shouldReset = - (!chart || - chart.chartStatus === 'failed' || - chart.chartStatus === null) && - nativeIndicators.length > 0; + !showIndicators && nativeIndicators.length > 0 && !isAutoRefreshing; const shouldRecalculate = chart?.queriesResponse?.[0]?.rejected_filters !== @@ -238,6 +248,7 @@ export const FiltersBadge = ({ chartId }: FiltersBadgeProps) => { chartId, chartConfiguration, dataMask, + isAutoRefreshing, nativeFilters, nativeIndicators.length, prevChart?.queriesResponse, diff --git a/superset-frontend/src/dashboard/components/Header/Header.test.tsx b/superset-frontend/src/dashboard/components/Header/Header.test.tsx index 7dcc0a5953a1..b89efdebbff7 100644 --- a/superset-frontend/src/dashboard/components/Header/Header.test.tsx +++ b/superset-frontend/src/dashboard/components/Header/Header.test.tsx @@ -29,6 +29,7 @@ import reducerIndex from 'spec/helpers/reducerIndex'; import Header from '.'; import { DASHBOARD_HEADER_ID } from '../../util/constants'; import { UPDATE_COMPONENTS } from '../../actions/dashboardLayout'; +import { AutoRefreshStatus } from '../../types/autoRefresh'; const initialState = { dashboardInfo: { @@ -161,11 +162,38 @@ const setRefreshFrequency = jest.fn(); const onRefresh = jest.fn(); const dashboardInfoChanged = jest.fn(); const dashboardTitleChanged = jest.fn(); +const startAutoRefresh = jest.fn(); +const endAutoRefresh = jest.fn(); +const setRefreshInFlight = jest.fn(); +const setStatus = jest.fn(); +const setFetchStartTime = jest.fn(); +const recordSuccess = jest.fn(); +const recordError = jest.fn(); +const setPaused = jest.fn(); +const setPausedByTab = jest.fn(); jest.mock('src/hooks/useUnsavedChangesPrompt', () => ({ useUnsavedChangesPrompt: jest.fn(), })); +jest.mock('src/dashboard/contexts/AutoRefreshContext', () => ({ + useAutoRefreshContext: jest.fn(), +})); +jest.mock('src/dashboard/hooks/useRealTimeDashboard', () => ({ + useRealTimeDashboard: jest.fn(), +})); +jest.mock('src/dashboard/hooks/useAutoRefreshTabPause', () => ({ + useAutoRefreshTabPause: jest.fn(), +})); +const useAutoRefreshContextMock = jest.requireMock( + 'src/dashboard/contexts/AutoRefreshContext', +).useAutoRefreshContext as jest.Mock; +const useRealTimeDashboardMock = jest.requireMock( + 'src/dashboard/hooks/useRealTimeDashboard', +).useRealTimeDashboard as jest.Mock; +const useAutoRefreshTabPauseMock = jest.requireMock( + 'src/dashboard/hooks/useAutoRefreshTabPause', +).useAutoRefreshTabPause as jest.Mock; beforeAll(() => { jest.spyOn(redux, 'bindActionCreators').mockImplementation(() => ({ addSuccessToast, @@ -202,6 +230,23 @@ beforeEach(() => { handleConfirmNavigation: jest.fn(), handleSaveAndCloseModal: jest.fn(), }); + useAutoRefreshContextMock.mockReturnValue({ + startAutoRefresh, + endAutoRefresh, + setRefreshInFlight, + }); + useRealTimeDashboardMock.mockReturnValue({ + isPaused: false, + setStatus, + setPaused, + setPausedByTab, + recordSuccess, + recordError, + setFetchStartTime, + }); + useAutoRefreshTabPauseMock.mockImplementation(() => {}); + fetchCharts.mockImplementation(() => undefined); + onRefresh.mockResolvedValue(undefined); window.history.pushState({}, 'Test page', '/dashboard?standalone=1'); }); @@ -535,12 +580,93 @@ test('should render the dropdown icon', () => { }); test('should refresh the charts', async () => { - setup(); + setup({ + dashboardState: { + ...initialState.dashboardState, + sliceIds: [1], + }, + charts: { + 1: { latestQueryFormData: { metric: 'value' } }, + }, + }); await openActionsDropdown(); userEvent.click(screen.getByText('Refresh dashboard')); expect(onRefresh).toHaveBeenCalledTimes(1); }); +test('auto-refresh uses fetchCharts and toggles refresh state', async () => { + jest.useFakeTimers(); + fetchCharts.mockResolvedValue(undefined); + + const originalRequestAnimationFrame = window.requestAnimationFrame; + window.requestAnimationFrame = callback => { + callback(0); + return 0; + }; + + try { + setup({ + dashboardState: { + ...initialState.dashboardState, + refreshFrequency: 10, + sliceIds: [1, 2], + }, + charts: { + 1: { latestQueryFormData: { metric: 'a' }, chartStatus: 'success' }, + 2: { latestQueryFormData: { metric: 'b' }, chartStatus: 'success' }, + }, + }); + + jest.advanceTimersByTime(10000); + await waitFor(() => + expect(fetchCharts).toHaveBeenCalledWith([1, 2], true, 2000, 1), + ); + + expect(onRefresh).not.toHaveBeenCalled(); + expect(startAutoRefresh).toHaveBeenCalled(); + expect(setStatus).toHaveBeenCalledWith(AutoRefreshStatus.Fetching); + expect(setRefreshInFlight).toHaveBeenCalledWith(true); + expect(setRefreshInFlight).toHaveBeenCalledWith(false); + expect(endAutoRefresh).toHaveBeenCalled(); + } finally { + window.requestAnimationFrame = originalRequestAnimationFrame; + jest.useRealTimers(); + } +}); + +test('resume clears tab pause flag', () => { + useRealTimeDashboardMock.mockReturnValue({ + isRealTimeDashboard: true, + isPaused: true, + isPausedByTab: true, + effectiveStatus: AutoRefreshStatus.Paused, + lastSuccessfulRefresh: null, + lastAutoRefreshTime: null, + refreshErrorCount: 0, + refreshFrequency: 10, + setStatus, + setPaused, + setPausedByTab, + recordSuccess, + recordError, + setFetchStartTime, + autoRefreshPauseOnInactiveTab: true, + setPauseOnInactiveTab: jest.fn(), + }); + + setup({ + dashboardState: { + ...initialState.dashboardState, + refreshFrequency: 10, + }, + }); + + userEvent.click(screen.getByTestId('auto-refresh-toggle')); + + expect(setPaused).toHaveBeenCalledWith(false); + expect(setPausedByTab).toHaveBeenCalledWith(false); +}); + test('should render an extension component if one is supplied', () => { const extensionsRegistry = getExtensionsRegistry(); extensionsRegistry.set('dashboard.nav.right', () => ( diff --git a/superset-frontend/src/dashboard/components/Header/index.tsx b/superset-frontend/src/dashboard/components/Header/index.tsx index d952420eb492..7c2ba5bdac75 100644 --- a/superset-frontend/src/dashboard/components/Header/index.tsx +++ b/superset-frontend/src/dashboard/components/Header/index.tsx @@ -17,24 +17,15 @@ * under the License. */ /* eslint-env browser */ -import 'dayjs/plugin/duration'; -import { extendedDayjs } from '@superset-ui/core/utils/dates'; -import { - useCallback, - useEffect, - useMemo, - useRef, - useState, - type ReactElement, -} from 'react'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { isFeatureEnabled, FeatureFlag, getExtensionsRegistry, } from '@superset-ui/core'; -import { styled, css, t } from '@apache-superset/core/ui'; +import { styled, css, SupersetTheme, t } from '@apache-superset/core/ui'; import { Global } from '@emotion/react'; -import { shallowEqual, useDispatch, useSelector } from 'react-redux'; +import { shallowEqual, useDispatch, useSelector, useStore } from 'react-redux'; import { bindActionCreators } from 'redux'; import { LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, @@ -50,6 +41,11 @@ import { } from '@superset-ui/core/components'; import { findPermission } from 'src/utils/findPermission'; import { safeStringify } from 'src/utils/safeStringify'; +import Role from 'src/types/Role'; +import Owner from 'src/types/Owner'; +import { DashboardLayout, RootState } from 'src/dashboard/types'; +import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; +import { AlertObject } from 'src/features/alerts/types'; import PublishedStatus from 'src/dashboard/components/PublishedStatus'; import UndoRedoKeyListeners from 'src/dashboard/components/UndoRedoKeyListeners'; import PropertiesModal from 'src/dashboard/components/PropertiesModal'; @@ -60,16 +56,14 @@ import { DASHBOARD_POSITION_DATA_LIMIT, DASHBOARD_HEADER_ID, } from 'src/dashboard/util/constants'; -import { TagTypeEnum } from 'src/components/Tag/TagType'; -import setPeriodicRunner, { - stopPeriodicRender, -} from 'src/dashboard/util/setPeriodicRunner'; +import { TagType, TagTypeEnum } from 'src/components/Tag/TagType'; import ReportModal from 'src/features/reports/ReportModal'; -import { deleteActiveReport } from 'src/features/reports/ReportModal/actions'; -import type { ReportObject } from 'src/features/reports/types'; +import { + deleteActiveReport, + DeletableReport, +} from 'src/features/reports/ReportModal/actions'; import { PageHeaderWithActions } from '@superset-ui/core/components/PageHeaderWithActions'; import { useUnsavedChangesPrompt } from 'src/hooks/useUnsavedChangesPrompt'; -import type { RootState, DashboardInfo } from 'src/dashboard/types'; import DashboardEmbedModal from '../EmbeddedModal'; import OverwriteConfirm from '../OverwriteConfirm'; import { @@ -100,24 +94,87 @@ import { } from '../../actions/dashboardState'; import { logEvent } from '../../../logger/actions'; import { dashboardInfoChanged } from '../../actions/dashboardInfo'; -import isDashboardLoading, { - type ChartLoadTimestamps, -} from '../../util/isDashboardLoading'; +import { ChartState } from 'src/explore/types'; import { useChartIds } from '../../util/charts/useChartIds'; import { useDashboardMetadataBar } from './useDashboardMetadataBar'; import { useHeaderActionsMenu } from './useHeaderActionsDropdownMenu'; +import AutoRefreshIndicator from '../AutoRefreshIndicator'; +import { RefreshButton } from '../RefreshButton'; +import { useRealTimeDashboard } from '../../hooks/useRealTimeDashboard'; +import { useAutoRefreshTabPause } from '../../hooks/useAutoRefreshTabPause'; +import { useAutoRefreshContext } from '../../contexts/AutoRefreshContext'; +import { AutoRefreshStatus as AutoRefreshStatusEnum } from '../../types/autoRefresh'; + +type DashboardPropertiesUpdate = { + slug?: string; + jsonMetadata?: string; + certifiedBy?: string; + certificationDetails?: string; + owners?: Owner[]; + roles?: Role[]; + tags?: TagType[]; + themeId?: number | null; + css?: string; + title?: string; +}; + +type RefreshLogEventPayload = { + action: string; + metadata: Record; +}; + +type DashboardLayoutStateWithHistory = RootState['dashboardLayout'] & { + past: DashboardLayout[]; + future: DashboardLayout[]; +}; + +type DashboardInfoState = RootState['dashboardInfo'] & { + dash_save_perm?: boolean; + dash_share_perm?: boolean; + is_managed_externally?: boolean; + slug?: string; + last_modified_time?: number; + certified_by?: string; + certification_details?: string; + roles?: Role[]; + tags?: TagType[]; + metadata: RootState['dashboardInfo']['metadata'] & { + timed_refresh_immune_slices?: number[]; + refresh_frequency?: number; + }; +}; + +type DashboardStateWithExtras = RootState['dashboardState'] & { + expandedSlices: Record; + shouldPersistRefreshFrequency?: boolean; + colorNamespace?: string; + isStarred?: boolean; + maxUndoHistoryExceeded?: boolean; +}; + +type HeaderRootState = Omit< + RootState, + 'dashboardLayout' | 'dashboardInfo' | 'dashboardState' | 'charts' | 'user' +> & { + dashboardLayout: DashboardLayoutStateWithHistory; + dashboardInfo: DashboardInfoState; + dashboardState: DashboardStateWithExtras; + charts: Record; + user: UserWithPermissionsAndRoles; + lastModifiedTime: number; +}; const extensionsRegistry = getExtensionsRegistry(); -const headerContainerStyle = (theme: { colorBorder: string }) => css` +const headerContainerStyle = (theme: SupersetTheme) => css` border-bottom: 1px solid ${theme.colorBorder}; `; -const editButtonStyle = (theme: { colorPrimary: string }) => css` +const editButtonStyle = (theme: SupersetTheme) => css` color: ${theme.colorPrimary}; `; -const actionButtonsStyle = (theme: { sizeUnit: number }) => css` +const actionButtonsStyle = (theme: SupersetTheme) => css` display: flex; align-items: center; @@ -139,25 +196,22 @@ const StyledUndoRedoButton = styled(Button)` } `; -const undoRedoStyle = (theme: { - colorIcon: string; - colorIconHover: string; -}) => css` +const undoRedoStyle = (theme: SupersetTheme) => css` color: ${theme.colorIcon}; &:hover { color: ${theme.colorIconHover}; } `; -const undoRedoEmphasized = (theme: { colorIcon: string }) => css` +const undoRedoEmphasized = (theme: SupersetTheme) => css` color: ${theme.colorIcon}; `; -const undoRedoDisabled = (theme: { colorTextDisabled: string }) => css` +const undoRedoDisabled = (theme: SupersetTheme) => css` color: ${theme.colorTextDisabled}; `; -const saveBtnStyle = (theme: { sizeUnit: number }) => css` +const saveBtnStyle = (theme: SupersetTheme) => css` min-width: ${theme.sizeUnit * 17}px; height: ${theme.sizeUnit * 8}px; span > :first-of-type { @@ -165,57 +219,45 @@ const saveBtnStyle = (theme: { sizeUnit: number }) => css` } `; -const discardBtnStyle = (theme: { sizeUnit: number }) => css` +const discardBtnStyle = (theme: SupersetTheme) => css` min-width: ${theme.sizeUnit * 22}px; height: ${theme.sizeUnit * 8}px; `; -const discardChanges = (): void => { +const discardChanges = () => { const url = new URL(window.location.href); url.searchParams.delete('edit'); window.location.assign(url); }; -interface PropertiesChanges { - slug?: string; - jsonMetadata?: string; - certifiedBy?: string; - certificationDetails?: string; - owners?: DashboardInfo['owners']; - roles?: DashboardInfo['roles']; - tags?: DashboardInfo['tags']; - themeId?: number | null; - css?: string; - title?: string; -} - -const Header = (): ReactElement => { +const Header = (): JSX.Element => { const dispatch = useDispatch(); + const store = useStore(); const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] = - useState(false); - const [emphasizeUndo, setEmphasizeUndo] = useState(false); - const [emphasizeRedo, setEmphasizeRedo] = useState(false); - const [showingPropertiesModal, setShowingPropertiesModal] = - useState(false); - const [showingRefreshModal, setShowingRefreshModal] = - useState(false); - const [showingEmbedModal, setShowingEmbedModal] = useState(false); - const [showingReportModal, setShowingReportModal] = useState(false); + useState(false); + const [emphasizeUndo, setEmphasizeUndo] = useState(false); + const [emphasizeRedo, setEmphasizeRedo] = useState(false); + const [showingPropertiesModal, setShowingPropertiesModal] = useState(false); + const [showingRefreshModal, setShowingRefreshModal] = useState(false); + const [showingEmbedModal, setShowingEmbedModal] = useState(false); + const [showingReportModal, setShowingReportModal] = useState(false); const [currentReportDeleting, setCurrentReportDeleting] = - useState(null); - const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo); + useState(null); + const dashboardInfo = useSelector( + (state: HeaderRootState) => state.dashboardInfo, + ); const layout = useSelector( - (state: RootState) => state.dashboardLayout.present, + (state: HeaderRootState) => state.dashboardLayout.present, ); const undoLength = useSelector( - (state: RootState) => state.dashboardLayout.past.length, + (state: HeaderRootState) => state.dashboardLayout.past.length, ); const redoLength = useSelector( - (state: RootState) => state.dashboardLayout.future.length, + (state: HeaderRootState) => state.dashboardLayout.future.length, ); - const dataMask = useSelector((state: RootState) => state.dataMask); - const user = useSelector((state: RootState) => state.user); + const dataMask = useSelector((state: HeaderRootState) => state.dataMask); + const user = useSelector((state: HeaderRootState) => state.user); const chartIds = useChartIds(); const { @@ -232,12 +274,12 @@ const Header = (): ReactElement => { editMode, lastModifiedTime, } = useSelector( - (state: RootState) => ({ - expandedSlices: state.dashboardState.expandedSlices, - refreshFrequency: state.dashboardState.refreshFrequency, + (state: HeaderRootState) => ({ + expandedSlices: state.dashboardState.expandedSlices ?? {}, + refreshFrequency: state.dashboardState.refreshFrequency ?? 0, shouldPersistRefreshFrequency: !!state.dashboardState.shouldPersistRefreshFrequency, - customCss: state.dashboardInfo.css, + customCss: state.dashboardInfo.css ?? '', colorNamespace: state.dashboardState.colorNamespace, colorScheme: state.dashboardState.colorScheme, isStarred: !!state.dashboardState.isStarred, @@ -245,31 +287,50 @@ const Header = (): ReactElement => { hasUnsavedChanges: !!state.dashboardState.hasUnsavedChanges, maxUndoHistoryExceeded: !!state.dashboardState.maxUndoHistoryExceeded, editMode: !!state.dashboardState.editMode, - lastModifiedTime: state.lastModifiedTime, + lastModifiedTime: state.lastModifiedTime ?? 0, }), shallowEqual, ); - const isLoading = useSelector((state: RootState) => - isDashboardLoading( - state.charts as unknown as Record, - ), + const isLoading = useSelector((state: HeaderRootState) => + Object.values(state.charts).some(chart => { + const start = chart.chartUpdateStartTime ?? 0; + const end = chart.chartUpdateEndTime ?? 0; + return start > end; + }), ); - const refreshTimer = useRef(0); - const ctrlYTimeout = useRef>( - 0 as unknown as ReturnType, - ); - const ctrlZTimeout = useRef>( - 0 as unknown as ReturnType, - ); + // Real-time dashboard state and actions + const { + isPaused, + setStatus, + setPaused, + setPausedByTab, + recordSuccess, + recordError, + setFetchStartTime, + autoRefreshPauseOnInactiveTab, + setPauseOnInactiveTab, + } = useRealTimeDashboard(); + + const { startAutoRefresh, endAutoRefresh, setRefreshInFlight } = + useAutoRefreshContext(); + + const refreshInFlightRef = useRef(false); + const refreshPromiseRef = useRef | null>(null); + const refreshTimer = useRef | null>(null); + const refreshSequenceRef = useRef(0); + const ctrlYTimeout = useRef | null>(null); + const ctrlZTimeout = useRef | null>(null); + const isPeriodicRefreshStoppedRef = useRef(true); const previousThemeRef = useRef(dashboardInfo.theme); - const dashboardTitle = layout[DASHBOARD_HEADER_ID]?.meta?.text; - const { slug } = dashboardInfo; + const dashboardTitle = layout[DASHBOARD_HEADER_ID]?.meta?.text ?? ''; + const slug = dashboardInfo.slug ?? ''; const actualLastModifiedTime = Math.max( lastModifiedTime, - dashboardInfo.last_modified_time, + dashboardInfo.last_modified_time ?? 0, ); + const themeId = dashboardInfo.theme ? dashboardInfo.theme.id : null; const boundActionCreators = useMemo( () => bindActionCreators( @@ -302,72 +363,249 @@ const Header = (): ReactElement => { [dispatch], ); - const startPeriodicRender = useCallback( - (interval: number) => { - let intervalMessage: string | undefined; - - if (interval) { - const periodicRefreshOptions = - dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_INTERVALS; - const predefinedValue = periodicRefreshOptions.find( - (option: [string, string]) => Number(option[0]) === interval / 1000, - ); + const executeRefresh = useCallback( + ( + affectedCharts: number[], + force = false, + suppressSpinners = false, + interval = 0, + logEventPayload: RefreshLogEventPayload | null = null, + updateLastRefreshTime = false, + ): Promise => { + if (affectedCharts.length === 0) { + return Promise.resolve(); + } - if (predefinedValue) { - intervalMessage = t(predefinedValue[1]); - } else { - intervalMessage = extendedDayjs - .duration(interval, 'millisecond') - .humanize(); - } + if (refreshInFlightRef.current && refreshPromiseRef.current) { + return refreshPromiseRef.current; } - const fetchChartsAction = (charts: number[], force = false) => - boundActionCreators.fetchCharts( - charts, - force, - interval * 0.2, - dashboardInfo.id, + const { charts: chartsState } = store.getState(); + const chartsToRefresh = affectedCharts.filter(chartId => { + const chart = chartsState[chartId]; + return ( + chart?.latestQueryFormData && + Object.keys(chart.latestQueryFormData).length > 0 ); + }); - const periodicRender = () => { - const { metadata } = dashboardInfo; - const immune: number[] = metadata.timed_refresh_immune_slices || []; - const affectedCharts = chartIds.filter( - (chartId: number) => immune.indexOf(chartId) === -1, + if (chartsToRefresh.length === 0) { + return Promise.resolve(); + } + + refreshInFlightRef.current = true; + setRefreshInFlight(true); + + if (logEventPayload) { + boundActionCreators.logEvent( + logEventPayload.action, + logEventPayload.metadata, ); + } - boundActionCreators.logEvent(LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, { - interval, - chartCount: affectedCharts.length, - }); - boundActionCreators.addWarningToast( - t( - `This dashboard is currently auto refreshing; the next auto refresh will be in %s.`, - intervalMessage, + if (suppressSpinners) { + startAutoRefresh(); + setStatus(AutoRefreshStatusEnum.Fetching); + setFetchStartTime(Date.now()); + } + + let innerPromise: Promise; + if (!suppressSpinners) { + innerPromise = Promise.resolve( + boundActionCreators.onRefresh( + chartsToRefresh, + force, + 0, + dashboardInfo.id, + ), + ); + } else if (updateLastRefreshTime) { + innerPromise = Promise.resolve( + boundActionCreators.onRefresh( + chartsToRefresh, + force, + 0, + dashboardInfo.id, + true, + ), + ); + } else { + innerPromise = Promise.resolve( + boundActionCreators.fetchCharts( + chartsToRefresh, + force, + interval * 0.2, + dashboardInfo.id, ), ); + } + + const wrappedPromise: Promise = new Promise((resolve, reject) => { + innerPromise + .then(() => { + if (suppressSpinners) { + const { charts } = store.getState(); + const anyFailed = chartsToRefresh.some( + chartId => charts[chartId]?.chartStatus === 'failed', + ); + if (anyFailed) { + const failedChart = chartsToRefresh.find( + chartId => charts[chartId]?.chartStatus === 'failed', + ); + if (failedChart !== undefined) { + const errorMsg = + charts[failedChart]?.chartAlert || 'Chart refresh failed'; + recordError(errorMsg); + } else { + recordError('Chart refresh failed'); + } + } else { + recordSuccess(); + } + setFetchStartTime(null); + } + + if (suppressSpinners) { + requestAnimationFrame(() => { + endAutoRefresh(); + refreshInFlightRef.current = false; + refreshPromiseRef.current = null; + setRefreshInFlight(false); + resolve(); + }); + } else { + refreshInFlightRef.current = false; + refreshPromiseRef.current = null; + setRefreshInFlight(false); + resolve(); + } + }) + .catch(error => { + if (suppressSpinners) { + recordError(error?.message || 'Refresh failed'); + setFetchStartTime(null); + requestAnimationFrame(() => { + endAutoRefresh(); + refreshInFlightRef.current = false; + refreshPromiseRef.current = null; + setRefreshInFlight(false); + reject(error); + }); + } else { + refreshInFlightRef.current = false; + refreshPromiseRef.current = null; + setRefreshInFlight(false); + reject(error); + } + }); + }); + + refreshPromiseRef.current = wrappedPromise; + return wrappedPromise; + }, + [ + boundActionCreators, + dashboardInfo.id, + store, + startAutoRefresh, + endAutoRefresh, + setStatus, + setFetchStartTime, + recordSuccess, + recordError, + setRefreshInFlight, + ], + ); + + // Extract stable values from dashboardInfo for use in callbacks + // This prevents unnecessary recreations when unrelated dashboardInfo properties change + const timedRefreshImmuneSlices = useMemo( + () => dashboardInfo.metadata?.timed_refresh_immune_slices || [], + [dashboardInfo.metadata?.timed_refresh_immune_slices], + ); + const autoRefreshMode = + dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_MODE; + + const stopPeriodicRender = useCallback(() => { + if (refreshTimer.current !== null) { + clearTimeout(refreshTimer.current); + refreshTimer.current = null; + } + isPeriodicRefreshStoppedRef.current = true; + refreshSequenceRef.current += 1; + }, []); + + const startPeriodicRender = useCallback( + (intervalMs: number) => { + stopPeriodicRender(); + + if (intervalMs <= 0) { + return; + } + + isPeriodicRefreshStoppedRef.current = false; + const sequenceId = refreshSequenceRef.current; + + const runPeriodicRefresh = () => { if ( - dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_MODE === 'fetch' + isPeriodicRefreshStoppedRef.current || + refreshSequenceRef.current !== sequenceId ) { - // force-refresh while auto-refresh in dashboard - return fetchChartsAction(affectedCharts); + return; } - return fetchChartsAction(affectedCharts, true); + const affectedCharts = chartIds.filter( + chartId => timedRefreshImmuneSlices.indexOf(chartId) === -1, + ); + + const force = autoRefreshMode !== 'fetch'; + + Promise.resolve( + executeRefresh( + affectedCharts, + force, + true, + intervalMs, + { + action: LOG_ACTIONS_PERIODIC_RENDER_DASHBOARD, + metadata: { + interval: intervalMs, + chartCount: affectedCharts.length, + }, + }, + false, + ), + ) + .catch(() => undefined) + .finally(() => { + if ( + isPeriodicRefreshStoppedRef.current || + refreshSequenceRef.current !== sequenceId + ) { + return; + } + refreshTimer.current = setTimeout(runPeriodicRefresh, intervalMs); + }); }; - refreshTimer.current = setPeriodicRunner({ - interval, - periodicRender, - refreshTimer: refreshTimer.current, - }); + refreshTimer.current = setTimeout(runPeriodicRefresh, intervalMs); }, - [boundActionCreators, chartIds, dashboardInfo], + [ + autoRefreshMode, + chartIds, + executeRefresh, + stopPeriodicRender, + timedRefreshImmuneSlices, + ], ); useEffect(() => { + if (isPaused) { + stopPeriodicRender(); + return; + } + startPeriodicRender(refreshFrequency * 1000); - }, [refreshFrequency, startPeriodicRender]); + }, [isPaused, refreshFrequency, startPeriodicRender, stopPeriodicRender]); // Track theme changes as unsaved changes, and sync ref when navigating between dashboards useEffect(() => { @@ -394,12 +632,16 @@ const Header = (): ReactElement => { useEffect( () => () => { - stopPeriodicRender(refreshTimer.current); + stopPeriodicRender(); boundActionCreators.setRefreshFrequency(0); - clearTimeout(ctrlYTimeout.current); - clearTimeout(ctrlZTimeout.current); + if (ctrlYTimeout.current !== null) { + clearTimeout(ctrlYTimeout.current); + } + if (ctrlZTimeout.current !== null) { + clearTimeout(ctrlZTimeout.current); + } }, - [boundActionCreators], + [boundActionCreators, stopPeriodicRender], ); const handleChangeText = useCallback( @@ -415,7 +657,7 @@ const Header = (): ReactElement => { const handleCtrlY = useCallback(() => { boundActionCreators.onRedo(); setEmphasizeRedo(true); - if (ctrlYTimeout.current) { + if (ctrlYTimeout.current !== null) { clearTimeout(ctrlYTimeout.current); } ctrlYTimeout.current = setTimeout(() => { @@ -426,7 +668,7 @@ const Header = (): ReactElement => { const handleCtrlZ = useCallback(() => { boundActionCreators.onUndo(); setEmphasizeUndo(true); - if (ctrlZTimeout.current) { + if (ctrlZTimeout.current !== null) { clearTimeout(ctrlZTimeout.current); } ctrlZTimeout.current = setTimeout(() => { @@ -435,16 +677,21 @@ const Header = (): ReactElement => { }, [boundActionCreators]); const forceRefresh = useCallback(() => { - if (!isLoading) { - boundActionCreators.logEvent(LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, { + if (refreshInFlightRef.current && refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + if (isLoading) { + return Promise.resolve(); + } + return executeRefresh(chartIds, true, false, 0, { + action: LOG_ACTIONS_FORCE_REFRESH_DASHBOARD, + metadata: { force: true, interval: 0, chartCount: chartIds.length, - }); - return boundActionCreators.onRefresh(chartIds, true, 0, dashboardInfo.id); - } - return false; - }, [boundActionCreators, chartIds, dashboardInfo.id, isLoading]); + }, + }); + }, [chartIds, isLoading, executeRefresh]); const toggleEditMode = useCallback(() => { boundActionCreators.logEvent(LOG_ACTIONS_TOGGLE_EDIT_DASHBOARD, { @@ -469,10 +716,9 @@ const Header = (): ReactElement => { roles: dashboardInfo.roles, slug, tags: (dashboardInfo.tags || []).filter( - (item: { type?: string | number }) => - item.type === TagTypeEnum.Custom || !item.type, - ) as { id: number }[], - theme_id: dashboardInfo.theme ? dashboardInfo.theme.id : null, + item => item.type === TagTypeEnum.Custom || !item.type, + ), + theme_id: themeId, metadata: { ...dashboardInfo?.metadata, color_namespace: currentColorNamespace, @@ -523,6 +769,7 @@ const Header = (): ReactElement => { refreshFrequency, shouldPersistRefreshFrequency, slug, + themeId, ]); const { @@ -569,16 +816,16 @@ const Header = (): ReactElement => { const userCanEdit = dashboardInfo.dash_edit_perm && !dashboardInfo.is_managed_externally; - const userCanShare = dashboardInfo.dash_share_perm; - const userCanSaveAs = dashboardInfo.dash_save_perm; + const userCanShare = !!dashboardInfo.dash_share_perm; + const userCanSaveAs = !!dashboardInfo.dash_save_perm; const userCanCurate = isFeatureEnabled(FeatureFlag.EmbeddedSuperset) && findPermission('can_set_embedded', 'Dashboard', user.roles); - const userCanExport = dashboardInfo.dash_export_perm; + const userCanExport = !!dashboardInfo.dash_export_perm; const isEmbedded = !dashboardInfo?.userId; const handleOnPropertiesChange = useCallback( - (updates: PropertiesChanges) => { + (updates: DashboardPropertiesUpdate) => { boundActionCreators.dashboardInfoChanged({ slug: updates.slug, metadata: JSON.parse(updates.jsonMetadata || '{}'), @@ -601,11 +848,8 @@ const Header = (): ReactElement => { ); const handleRefreshChange = useCallback( - (newRefreshFrequency: number, isEditMode: boolean) => { - boundActionCreators.setRefreshFrequency( - newRefreshFrequency, - !!isEditMode, - ); + (refreshFrequency: number, editMode: boolean) => { + boundActionCreators.setRefreshFrequency(refreshFrequency, !!editMode); }, [boundActionCreators], ); @@ -620,7 +864,7 @@ const Header = (): ReactElement => { const editableTitleProps = useMemo( () => ({ - title: dashboardTitle ?? '', + title: dashboardTitle, canEdit: userCanEdit && editMode, onSave: handleChangeText, placeholder: t('Add the name of the dashboard'), @@ -654,15 +898,87 @@ const Header = (): ReactElement => { ], ); + // Handle pause toggle for auto-refresh + const handlePauseToggle = useCallback(() => { + if (isPaused) { + // Resume: fetch immediately, then restart timer + setPaused(false); + setPausedByTab(false); + const affectedCharts = chartIds.filter( + chartId => timedRefreshImmuneSlices.indexOf(chartId) === -1, + ); + executeRefresh(affectedCharts, true, true, 0, null, true).finally(() => { + startPeriodicRender(refreshFrequency * 1000); + }); + } else { + // Pause: stop the timer + setPaused(true); + setStatus(AutoRefreshStatusEnum.Paused); + stopPeriodicRender(); + } + }, [ + isPaused, + setPaused, + setPausedByTab, + setStatus, + timedRefreshImmuneSlices, + chartIds, + executeRefresh, + startPeriodicRender, + refreshFrequency, + stopPeriodicRender, + ]); + + // Callback for tab visibility refresh + const handleTabVisibilityRefresh = useCallback(() => { + if (refreshInFlightRef.current && refreshPromiseRef.current) { + return refreshPromiseRef.current; + } + if (isLoading) { + return Promise.resolve(); + } + const affectedCharts = chartIds.filter( + chartId => timedRefreshImmuneSlices.indexOf(chartId) === -1, + ); + return executeRefresh(affectedCharts, true, true, 0, null, true); + }, [timedRefreshImmuneSlices, chartIds, isLoading, executeRefresh]); + + // Callback to restart the periodic timer + const handleRestartTimer = useCallback(() => { + startPeriodicRender(refreshFrequency * 1000); + }, [startPeriodicRender, refreshFrequency]); + + // Callback to stop the periodic timer + const handleStopTimer = useCallback(() => { + stopPeriodicRender(); + }, [stopPeriodicRender]); + + // Auto-pause when browser tab is inactive + useAutoRefreshTabPause({ + onRefresh: handleTabVisibilityRefresh, + onRestartTimer: handleRestartTimer, + onStopTimer: handleStopTimer, + }); + const titlePanelAdditionalItems = useMemo( () => [ + !editMode && ( + + ), + !editMode && ( + + ), !editMode && ( ), !editMode && !isEmbedded && metadataBar, @@ -676,6 +992,8 @@ const Header = (): ReactElement => { isPublished, userCanEdit, userCanSaveAs, + handlePauseToggle, + forceRefresh, ], ); @@ -783,7 +1101,6 @@ const Header = (): ReactElement => { NavExtension, boundActionCreators.onRedo, boundActionCreators.onUndo, - boundActionCreators.clearDashboardHistory, editMode, emphasizeRedo, emphasizeUndo, @@ -793,15 +1110,14 @@ const Header = (): ReactElement => { hasUnsavedChanges, overwriteDashboard, redoLength, - toggleEditMode, undoLength, userCanEdit, userCanSaveAs, ], ); - const handleReportDelete = async (report: ReportObject): Promise => { - await dispatch(deleteActiveReport(report)); + const handleReportDelete = async (report: AlertObject) => { + await dispatch(deleteActiveReport(report as unknown as DeletableReport)); setCurrentReportDeleting(null); }; @@ -851,14 +1167,11 @@ const Header = (): ReactElement => { titlePanelAdditionalItems={titlePanelAdditionalItems} rightPanelAdditionalItems={rightPanelAdditionalItems} menuDropdownProps={{ - open: isDropdownVisible as boolean, - onOpenChange: setIsDropdownVisible as ( - open: boolean, - info: { source: 'menu' | 'trigger' }, - ) => void, + open: isDropdownVisible, + onOpenChange: setIsDropdownVisible, }} - additionalActionsMenu={menu as ReactElement} - showFaveStar={!!(user?.userId && dashboardInfo?.id)} + additionalActionsMenu={menu} + showFaveStar={Boolean(user?.userId && dashboardInfo?.id)} showTitlePanelItems /> {showingPropertiesModal && ( @@ -880,14 +1193,9 @@ const Header = (): ReactElement => { refreshFrequency={refreshFrequency} onChange={handleRefreshChange} editMode={editMode} - refreshLimit={ - dashboardInfo.common?.conf - ?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT - } - refreshWarning={ - dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_WARNING_MESSAGE - } addSuccessToast={boundActionCreators.addSuccessToast} + pauseOnInactiveTab={autoRefreshPauseOnInactiveTab} + onPauseOnInactiveTabChange={setPauseOnInactiveTab} /> )} diff --git a/superset-frontend/src/dashboard/components/Header/types.ts b/superset-frontend/src/dashboard/components/Header/types.ts index 7956542b35a7..168d3844e080 100644 --- a/superset-frontend/src/dashboard/components/Header/types.ts +++ b/superset-frontend/src/dashboard/components/Header/types.ts @@ -23,12 +23,15 @@ import { DashboardInfo as DashboardInfoType, Layout, } from 'src/dashboard/types'; -import type { ReportObject } from 'src/features/reports/types'; import { ChartState } from 'src/explore/types'; +import { AlertObject } from 'src/features/alerts/types'; +import { ToastMeta } from 'src/components/MessageToasts/types'; + +type ToastOptions = Partial>; export interface HeaderDropdownProps { - addSuccessToast: (msg: string) => void; - addDangerToast: (msg: string) => void; + addSuccessToast: (msg: string, options?: ToastOptions) => void; + addDangerToast: (msg: string, options?: ToastOptions) => void; customCss?: string; colorNamespace?: string; colorScheme?: string; @@ -59,13 +62,13 @@ export interface HeaderDropdownProps { refreshWarning?: string; directPathToChild?: string[]; showReportModal: () => void; - setCurrentReportDeleting: Dispatch>; + setCurrentReportDeleting: Dispatch>; } export interface HeaderProps { - addSuccessToast: () => void; - addDangerToast: () => void; - addWarningToast: () => void; + addSuccessToast: (msg: string, options?: ToastOptions) => void; + addDangerToast: (msg: string, options?: ToastOptions) => void; + addWarningToast: (msg: string, options?: ToastOptions) => void; colorNamespace?: string; charts: ChartState | JsonObject; colorScheme?: string; @@ -78,16 +81,16 @@ export interface HeaderProps { isStarred: boolean; isPublished: boolean; onChange: () => void; - onSave: () => void; + onSave: (...args: unknown[]) => unknown; fetchFaveStar: () => void; saveFaveStar: () => void; savePublished: (dashboardId: number, isPublished: boolean) => void; - updateDashboardTitle: () => void; + updateDashboardTitle: (nextTitle: string) => void; editMode: boolean; setEditMode: () => void; showBuilderPane: () => void; updateCss: () => void; - logEvent: () => void; + logEvent: (eventName: string, eventData: JsonObject) => void; hasUnsavedChanges: boolean; maxUndoHistoryExceeded: boolean; lastModifiedTime: number; diff --git a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx index 4f4691a5990e..5eeff33dd658 100644 --- a/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx +++ b/superset-frontend/src/dashboard/components/Header/useHeaderActionsDropdownMenu.tsx @@ -16,6 +16,7 @@ * specific language governing permissions and limitations * under the License. */ +import type { Dispatch, ReactElement, SetStateAction } from 'react'; import { useState, useEffect, useCallback, useMemo } from 'react'; import { useSelector } from 'react-redux'; import { useHistory } from 'react-router-dom'; @@ -65,7 +66,11 @@ export const useHeaderActionsMenu = ({ dashboardTitle, logEvent, setCurrentReportDeleting, -}: HeaderDropdownProps) => { +}: HeaderDropdownProps): [ + ReactElement, + boolean, + Dispatch>, +] => { const [isDropdownVisible, setIsDropdownVisible] = useState(false); const history = useHistory(); const directPathToChild = useSelector( @@ -117,6 +122,7 @@ export const useHeaderActionsMenu = ({ showPropertiesModal, showRefreshModal, manageEmbedded, + history, ], ); @@ -198,7 +204,10 @@ export const useHeaderActionsMenu = ({ // Auto-refresh settings (session-only in view mode) menuItems.push({ key: MenuKeys.AutorefreshModal, - label: t('Set auto-refresh'), + label: + refreshFrequency > 0 + ? t('Update auto-refresh') + : t('Set auto-refresh'), disabled: isLoading, }); } diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx index 4b4ec463f23f..7af5731cb3fd 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/index.tsx @@ -51,6 +51,7 @@ import { } from 'src/dashboard/actions/dashboardState'; import { areObjectsEqual } from 'src/reduxUtils'; import { StandardModal, useModalValidation } from 'src/components/Modal'; +import { validateRefreshFrequency } from '../RefreshFrequency'; import { BasicInfoSection, AccessSection, @@ -504,7 +505,7 @@ const PropertiesModal = ({ // Section handlers for extracted components const handleThemeChange = (value: any) => setSelectedThemeId(value || null); - const handleRefreshFrequencyChange = (value: any) => + const handleRefreshFrequencyChange = (value: number) => setRefreshFrequency(value); // Helper function for styling section @@ -544,23 +545,10 @@ const PropertiesModal = ({ key: 'refresh', name: t('Refresh settings'), validator: () => { - const errors = []; const refreshLimit = dashboardInfo?.common?.conf ?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT; - if ( - refreshLimit && - refreshFrequency > 0 && - refreshFrequency < refreshLimit - ) { - errors.push( - t( - 'Refresh frequency must be at least %s seconds', - refreshLimit / 1000, - ), - ); - } - return errors; + return validateRefreshFrequency(refreshFrequency, refreshLimit); }, }, { diff --git a/superset-frontend/src/dashboard/components/PropertiesModal/sections/RefreshSection.tsx b/superset-frontend/src/dashboard/components/PropertiesModal/sections/RefreshSection.tsx index 5c4264fefc22..6f9bfa746db6 100644 --- a/superset-frontend/src/dashboard/components/PropertiesModal/sections/RefreshSection.tsx +++ b/superset-frontend/src/dashboard/components/PropertiesModal/sections/RefreshSection.tsx @@ -22,7 +22,7 @@ import { RefreshFrequencySelect } from '../../RefreshFrequency/RefreshFrequencyS interface RefreshSectionProps { refreshFrequency: number; - onRefreshFrequencyChange: (value: any) => void; + onRefreshFrequencyChange: (value: number) => void; } const RefreshSection = ({ diff --git a/superset-frontend/src/dashboard/components/RefreshButton/index.tsx b/superset-frontend/src/dashboard/components/RefreshButton/index.tsx new file mode 100644 index 000000000000..a16d11d8fbdd --- /dev/null +++ b/superset-frontend/src/dashboard/components/RefreshButton/index.tsx @@ -0,0 +1,81 @@ +/** + * 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 { FC, useState, useCallback } from 'react'; +import { css, useTheme, t } from '@apache-superset/core/ui'; +import { Tooltip } from '@superset-ui/core/components'; +import { Icons } from '@superset-ui/core/components/Icons'; + +export interface RefreshButtonProps { + onRefresh: () => Promise | void; +} + +export const RefreshButton: FC = ({ onRefresh }) => { + const theme = useTheme(); + const [isSpinning, setIsSpinning] = useState(false); + + const buttonStyles = css` + display: flex; + align-items: center; + justify-content: center; + padding: 0; + border: none; + background: transparent; + cursor: pointer; + color: ${theme.colorTextSecondary}; + transition: color ${theme.motionDurationMid}; + margin-left: ${theme.marginXS}px; + margin-right: ${theme.marginSM}px; + + &:hover { + color: ${theme.colorText}; + } + + &:disabled { + cursor: not-allowed; + opacity: 0.5; + } + `; + + const handleClick = useCallback(() => { + if (isSpinning) { + return; + } + setIsSpinning(true); + Promise.resolve(onRefresh()).finally(() => { + setIsSpinning(false); + }); + }, [isSpinning, onRefresh]); + + return ( + + + + ); +}; + +export default RefreshButton; diff --git a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx new file mode 100644 index 000000000000..dbcbc5c63e01 --- /dev/null +++ b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.test.tsx @@ -0,0 +1,40 @@ +/** + * 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 { + getRefreshWarningMessage, + validateRefreshFrequency, +} from './RefreshFrequencySelect'; + +test('validateRefreshFrequency treats millisecond refreshLimit as seconds', () => { + const errors = validateRefreshFrequency(5, 10000); + + expect(errors[0]).toContain('10'); +}); + +test('validateRefreshFrequency treats second refreshLimit as seconds', () => { + const errors = validateRefreshFrequency(5, 10); + + expect(errors[0]).toContain('10'); +}); + +test('getRefreshWarningMessage normalizes refreshLimit', () => { + expect(getRefreshWarningMessage(5, 10000, 'warn')).toBe('warn'); + expect(getRefreshWarningMessage(5, 10, 'warn')).toBe('warn'); + expect(getRefreshWarningMessage(15, 10000, 'warn')).toBeNull(); +}); diff --git a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx index 453a063ed66c..c1d918fc5b25 100644 --- a/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx +++ b/superset-frontend/src/dashboard/components/RefreshFrequency/RefreshFrequencySelect.tsx @@ -16,13 +16,14 @@ * specific language governing permissions and limitations * under the License. */ -import { useState } from 'react'; +import { ChangeEvent, useEffect, useState } from 'react'; import { t } from '@apache-superset/core'; import { styled } from '@apache-superset/core/ui'; -import { Radio, Input } from '@superset-ui/core/components'; +import { Input } from '@superset-ui/core/components'; +import { Radio, RadioChangeEvent } from '@superset-ui/core/components/Radio'; -// Minimum safe refresh interval to prevent server overload -export const MINIMUM_REFRESH_INTERVAL = 10; +// Minimum custom refresh interval in seconds +export const MINIMUM_REFRESH_INTERVAL = 1; const StyledRadioGroup = styled(Radio.Group)` padding-left: ${({ theme }) => theme.sizeUnit * 2}px; @@ -33,7 +34,7 @@ const StyledRadioGroup = styled(Radio.Group)` margin-bottom: ${({ theme }) => theme.sizeUnit * 0.5}px; &:last-child { - margin-bottom: 0; + margin-bottom: ${({ theme }) => theme.sizeUnit}px; } } `; @@ -64,10 +65,31 @@ export const REFRESH_FREQUENCY_OPTIONS = [ { value: -1, label: t('Custom') }, ]; +const isPresetValue = (frequency: number) => + REFRESH_FREQUENCY_OPTIONS.some( + option => option.value === frequency && option.value !== -1, + ); + +const getCustomValue = (frequency: number) => + !isPresetValue(frequency) && frequency > 0 ? frequency.toString() : ''; + +const normalizeRefreshLimitSeconds = ( + refreshLimit?: number, +): number | undefined => { + if (!refreshLimit || refreshLimit <= 0) { + return undefined; + } + + if (refreshLimit >= 1000 && refreshLimit % 1000 === 0) { + return refreshLimit / 1000; + } + + return refreshLimit; +}; + interface RefreshFrequencySelectProps { value: number; onChange: (value: number) => void; - ariaLabel?: string; } /** @@ -77,21 +99,22 @@ interface RefreshFrequencySelectProps { export const RefreshFrequencySelect = ({ value, onChange, - ariaLabel = t('Refresh frequency'), }: RefreshFrequencySelectProps) => { // Separate radio selection state from value state const [radioSelection, setRadioSelection] = useState(() => - REFRESH_FREQUENCY_OPTIONS.find(opt => opt.value === value) ? value : -1, + isPresetValue(value) ? value : -1, ); - const [customValue, setCustomValue] = useState(() => - REFRESH_FREQUENCY_OPTIONS.find(opt => opt.value === value) - ? '' - : value.toString(), - ); + const [customValue, setCustomValue] = useState(() => getCustomValue(value)); + + useEffect(() => { + const selection = isPresetValue(value) ? value : -1; + setRadioSelection(selection); + setCustomValue(selection === -1 ? getCustomValue(value) : ''); + }, [value]); - const handleRadioChange = (e: any) => { - const selectedValue = parseInt(e.target.value, 10); + const handleRadioChange = (event: RadioChangeEvent) => { + const selectedValue = Number(event.target.value); setRadioSelection(selectedValue); if (selectedValue === -1) { @@ -106,8 +129,8 @@ export const RefreshFrequencySelect = ({ } }; - const handleCustomInputChange = (e: any) => { - const inputValue = e.target.value; + const handleCustomInputChange = (event: ChangeEvent) => { + const inputValue = event.target.value; setCustomValue(inputValue); const numValue = parseInt(inputValue, 10); @@ -151,9 +174,10 @@ export const validateRefreshFrequency = ( refreshLimit?: number, ): string[] => { const errors = []; - if (refreshLimit && frequency > 0 && frequency < refreshLimit) { + const normalizedLimit = normalizeRefreshLimitSeconds(refreshLimit); + if (normalizedLimit && frequency > 0 && frequency < normalizedLimit) { errors.push( - t('Refresh frequency must be at least %s seconds', refreshLimit / 1000), + t('Refresh frequency must be at least %s seconds', normalizedLimit), ); } return errors; @@ -167,10 +191,11 @@ export const getRefreshWarningMessage = ( refreshLimit?: number, refreshWarning?: string, ): string | null => { + const normalizedLimit = normalizeRefreshLimitSeconds(refreshLimit); if ( frequency > 0 && - refreshLimit && - frequency < refreshLimit && + normalizedLimit && + frequency < normalizedLimit && refreshWarning ) { return refreshWarning; diff --git a/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx b/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx index fec8821f7349..a0ae4aa959c4 100644 --- a/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx +++ b/superset-frontend/src/dashboard/components/RefreshIntervalModal.tsx @@ -16,29 +16,36 @@ * specific language governing permissions and limitations * under the License. */ -import { useState } from 'react'; +import { useMemo, useState } from 'react'; +import { useSelector } from 'react-redux'; import { t } from '@apache-superset/core'; -import { styled, Alert } from '@apache-superset/core/ui'; -import { Form } from '@superset-ui/core/components'; +import { styled } from '@apache-superset/core/ui'; +import { Form, Checkbox } from '@superset-ui/core/components'; import { StandardModal } from 'src/components/Modal'; +import { RootState } from 'src/dashboard/types'; import { RefreshFrequencySelect, + validateRefreshFrequency, getRefreshWarningMessage, -} from './RefreshFrequency/RefreshFrequencySelect'; +} from './RefreshFrequency'; const ModalContent = styled.div` padding: ${({ theme }) => theme.sizeUnit * 4}px; `; +const CheckboxFormItem = styled(Form.Item)` + padding-top: ${({ theme }) => theme.sizeUnit * 4}px; +`; + interface RefreshIntervalModalProps { show: boolean; onHide: () => void; refreshFrequency: number; onChange: (refreshLimit: number, editMode: boolean) => void; editMode: boolean; - refreshLimit?: number; - refreshWarning?: string; addSuccessToast: (msg: string) => void; + pauseOnInactiveTab: boolean; + onPauseOnInactiveTabChange: (checked: boolean) => void; } /** @@ -51,18 +58,43 @@ const RefreshIntervalModal = ({ refreshFrequency: initialFrequency, onChange, editMode, - refreshLimit = 0, - refreshWarning, addSuccessToast, + pauseOnInactiveTab, + onPauseOnInactiveTabChange, }: RefreshIntervalModalProps) => { const [refreshFrequency, setRefreshFrequency] = useState(initialFrequency); + const [localPauseOnInactiveTab, setLocalPauseOnInactiveTab] = + useState(pauseOnInactiveTab); + const refreshLimit = useSelector( + (state: RootState) => + state.dashboardInfo?.common?.conf + ?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_LIMIT, + ); + const refreshWarning = useSelector( + (state: RootState) => + state.dashboardInfo?.common?.conf + ?.SUPERSET_DASHBOARD_PERIODICAL_REFRESH_WARNING_MESSAGE, + ); + const refreshErrors = useMemo( + () => validateRefreshFrequency(refreshFrequency, refreshLimit), + [refreshFrequency, refreshLimit], + ); + const refreshWarningMessage = useMemo( + () => + getRefreshWarningMessage(refreshFrequency, refreshLimit, refreshWarning), + [refreshFrequency, refreshLimit, refreshWarning], + ); const handleFrequencyChange = (value: number) => { setRefreshFrequency(value); }; const handleSave = () => { + if (refreshErrors.length > 0) { + return; + } onChange(refreshFrequency, editMode); + onPauseOnInactiveTabChange(localPauseOnInactiveTab); onHide(); addSuccessToast( editMode @@ -73,15 +105,10 @@ const RefreshIntervalModal = ({ const handleCancel = () => { setRefreshFrequency(initialFrequency); + setLocalPauseOnInactiveTab(pauseOnInactiveTab); onHide(); }; - const warningMessage = getRefreshWarningMessage( - refreshFrequency, - refreshLimit, - refreshWarning, - ); - return ( 0} + errorTooltip={refreshErrors[0]} >
+ + setLocalPauseOnInactiveTab(e.target.checked)} + > + {t('Pause auto refresh if tab is inactive')} + +
- - {warningMessage && ( - - )}
); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx index c67e3b7cc19b..fa015ff49916 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx @@ -46,6 +46,7 @@ import { convertChartStateToOwnState, hasChartStateConverter, } from '../../../util/chartStateConverter'; +import { useIsAutoRefreshing } from 'src/dashboard/contexts/AutoRefreshContext'; import SliceHeader from '../../SliceHeader'; import MissingChart from '../../MissingChart'; @@ -232,6 +233,7 @@ const Chart = (props: ChartProps) => { (state.dashboardInfo?.metadata as JsonObject)?.show_chart_timestamps ?? false, ); + const suppressLoadingSpinner = useIsAutoRefreshing(); const isCached: boolean[] = useMemo( () => @@ -708,7 +710,7 @@ const Chart = (props: ChartProps) => { className={cx('dashboard-chart')} aria-label={slice.description} > - {isLoading && ( + {isLoading && !suppressLoadingSpinner && ( { isInView={props.isInView} emitCrossFilters={emitCrossFilters} onChartStateChange={handleChartStateChange} + suppressLoadingSpinner={suppressLoadingSpinner} /> diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx index ef4d389079ec..43bf712bf2c4 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.test.tsx @@ -578,6 +578,7 @@ test('Should refresh charts when tab becomes active after dashboard refresh', as true, // Force refresh 0, // Interval 23, // Dashboard ID + false, // skipFiltersRefresh true, // isLazyLoad flag ); }); @@ -718,6 +719,7 @@ test('Should use isLazyLoad flag for tab refreshes', async () => { true, // force 0, // interval 42, // dashboardId + false, // skipFiltersRefresh true, // isLazyLoad should be true to prevent infinite loops ); }); diff --git a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx index e309983378a9..6b2fe507e91d 100644 --- a/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx +++ b/superset-frontend/src/dashboard/components/gridComponents/Tab/Tab.tsx @@ -35,6 +35,10 @@ import getChartIdsFromComponent from 'src/dashboard/util/getChartIdsFromComponen import DashboardComponent from 'src/dashboard/containers/DashboardComponent'; import AnchorLink from 'src/dashboard/components/AnchorLink'; import { Typography } from '@superset-ui/core/components/Typography'; +import { + useIsAutoRefreshing, + useIsRefreshInFlight, +} from 'src/dashboard/contexts/AutoRefreshContext'; import { DragDroppable, Droppable, @@ -159,6 +163,8 @@ const Tab = (props: TabProps): ReactElement => { (state: RootState) => state.dashboardState.tabActivationTimes?.[props.id], ); const dashboardInfo = useSelector((state: RootState) => state.dashboardInfo); + const isAutoRefreshing = useIsAutoRefreshing(); + const isRefreshInFlight = useIsRefreshInFlight(); // Track which refresh we've already handled to prevent duplicates const handledRefreshRef = useRef(null); @@ -179,9 +185,14 @@ const Tab = (props: TabProps): ReactElement => { const chartIds = getChartIdsFromComponent(props.id, dashboardLayout); if (chartIds.length > 0) { - // Use lazy load flag to avoid updating global refresh time + if (isAutoRefreshing || isRefreshInFlight) { + return; + } + // Use lazy load flags to avoid updating global refresh time and filters setTimeout(() => { - dispatch(onRefresh(chartIds, true, 0, dashboardInfo.id, true)); + dispatch( + onRefresh(chartIds, true, 0, dashboardInfo.id, false, true), + ); }, CHART_MOUNT_DELAY); } } @@ -195,6 +206,8 @@ const Tab = (props: TabProps): ReactElement => { tabActivationTime, dashboardLayout, dashboardInfo.id, + isAutoRefreshing, + isRefreshInFlight, dispatch, ]); diff --git a/superset-frontend/src/dashboard/containers/DashboardPage.tsx b/superset-frontend/src/dashboard/containers/DashboardPage.tsx index 3a6316f9d662..6c9198ffc5f1 100644 --- a/superset-frontend/src/dashboard/containers/DashboardPage.tsx +++ b/superset-frontend/src/dashboard/containers/DashboardPage.tsx @@ -63,6 +63,7 @@ import { import SyncDashboardState, { getDashboardContextLocalStorage, } from '../components/SyncDashboardState'; +import { AutoRefreshProvider } from '../contexts/AutoRefreshContext'; export const DashboardPageIdContext = createContext(''); @@ -300,12 +301,14 @@ export const DashboardPage: FC = ({ idOrSlug }: PageProps) => { : dashboard?.theme?.id } > - - {DashboardBuilderComponent} - + + + {DashboardBuilderComponent} + + diff --git a/superset-frontend/src/dashboard/contexts/AutoRefreshContext.test.tsx b/superset-frontend/src/dashboard/contexts/AutoRefreshContext.test.tsx new file mode 100644 index 000000000000..b64b0e653e0c --- /dev/null +++ b/superset-frontend/src/dashboard/contexts/AutoRefreshContext.test.tsx @@ -0,0 +1,137 @@ +/** + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { ReactNode } from 'react'; +import { + AutoRefreshProvider, + useAutoRefreshContext, + useIsAutoRefreshing, + useIsRefreshInFlight, +} from './AutoRefreshContext'; + +const wrapper = ({ children }: { children: ReactNode }) => ( + {children} +); + +test('provides default value of false when not inside provider', () => { + const { result } = renderHook(() => useIsAutoRefreshing()); + expect(result.current).toBe(false); +}); + +test('provides default refresh in-flight value of false when not inside provider', () => { + const { result } = renderHook(() => useIsRefreshInFlight()); + expect(result.current).toBe(false); +}); + +test('isAutoRefreshing starts as false inside provider', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + expect(result.current.isAutoRefreshing).toBe(false); +}); + +test('isRefreshInFlight starts as false inside provider', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + expect(result.current.isRefreshInFlight).toBe(false); +}); + +test('startAutoRefresh sets isAutoRefreshing to true', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + + act(() => { + result.current.startAutoRefresh(); + }); + + expect(result.current.isAutoRefreshing).toBe(true); +}); + +test('endAutoRefresh sets isAutoRefreshing to false', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + + act(() => { + result.current.startAutoRefresh(); + }); + expect(result.current.isAutoRefreshing).toBe(true); + + act(() => { + result.current.endAutoRefresh(); + }); + expect(result.current.isAutoRefreshing).toBe(false); +}); + +test('setIsAutoRefreshing sets the value directly', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + + act(() => { + result.current.setIsAutoRefreshing(true); + }); + expect(result.current.isAutoRefreshing).toBe(true); + + act(() => { + result.current.setIsAutoRefreshing(false); + }); + expect(result.current.isAutoRefreshing).toBe(false); +}); + +test('setRefreshInFlight sets the value directly', () => { + const { result } = renderHook(() => useAutoRefreshContext(), { wrapper }); + + act(() => { + result.current.setRefreshInFlight(true); + }); + expect(result.current.isRefreshInFlight).toBe(true); + + act(() => { + result.current.setRefreshInFlight(false); + }); + expect(result.current.isRefreshInFlight).toBe(false); +}); + +test('useIsAutoRefreshing hook returns correct value inside provider', () => { + const { result } = renderHook( + () => ({ + context: useAutoRefreshContext(), + isAutoRefreshing: useIsAutoRefreshing(), + }), + { wrapper }, + ); + + expect(result.current.isAutoRefreshing).toBe(false); + + act(() => { + result.current.context.startAutoRefresh(); + }); + expect(result.current.isAutoRefreshing).toBe(true); +}); + +test('useIsRefreshInFlight hook returns correct value inside provider', () => { + const { result } = renderHook( + () => ({ + context: useAutoRefreshContext(), + isRefreshInFlight: useIsRefreshInFlight(), + }), + { wrapper }, + ); + + expect(result.current.isRefreshInFlight).toBe(false); + + act(() => { + result.current.context.setRefreshInFlight(true); + }); + + expect(result.current.isRefreshInFlight).toBe(true); +}); diff --git a/superset-frontend/src/dashboard/contexts/AutoRefreshContext.tsx b/superset-frontend/src/dashboard/contexts/AutoRefreshContext.tsx new file mode 100644 index 000000000000..c6b0143e8829 --- /dev/null +++ b/superset-frontend/src/dashboard/contexts/AutoRefreshContext.tsx @@ -0,0 +1,101 @@ +/** + * 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 { + createContext, + useContext, + useState, + useCallback, + useMemo, + ReactNode, + FC, +} from 'react'; + +export interface AutoRefreshContextValue { + isAutoRefreshing: boolean; + isRefreshInFlight: boolean; + setIsAutoRefreshing: (value: boolean) => void; + setRefreshInFlight: (value: boolean) => void; + startAutoRefresh: () => void; + endAutoRefresh: () => void; +} + +const AutoRefreshContext = createContext({ + isAutoRefreshing: false, + isRefreshInFlight: false, + setIsAutoRefreshing: () => {}, + setRefreshInFlight: () => {}, + startAutoRefresh: () => {}, + endAutoRefresh: () => {}, +}); + +export interface AutoRefreshProviderProps { + children: ReactNode; +} + +/** + * Provider that tracks whether an auto-refresh cycle is in progress. + * Charts can use this context to suppress loading spinners during auto-refresh. + */ +export const AutoRefreshProvider: FC = ({ + children, +}) => { + const [isAutoRefreshing, setIsAutoRefreshing] = useState(false); + const [isRefreshInFlight, setRefreshInFlight] = useState(false); + + const startAutoRefresh = useCallback(() => { + setIsAutoRefreshing(true); + }, []); + + const endAutoRefresh = useCallback(() => { + setIsAutoRefreshing(false); + }, []); + + const value = useMemo( + () => ({ + isAutoRefreshing, + isRefreshInFlight, + setIsAutoRefreshing, + setRefreshInFlight, + startAutoRefresh, + endAutoRefresh, + }), + [isAutoRefreshing, isRefreshInFlight, startAutoRefresh, endAutoRefresh], + ); + + return ( + + {children} + + ); +}; + +export const useAutoRefreshContext = (): AutoRefreshContextValue => + useContext(AutoRefreshContext); + +export const useIsAutoRefreshing = (): boolean => { + const { isAutoRefreshing } = useContext(AutoRefreshContext); + return isAutoRefreshing; +}; + +export const useIsRefreshInFlight = (): boolean => { + const { isRefreshInFlight } = useContext(AutoRefreshContext); + return isRefreshInFlight; +}; + +export default AutoRefreshContext; diff --git a/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.test.tsx b/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.test.tsx new file mode 100644 index 000000000000..72b30fb6531a --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.test.tsx @@ -0,0 +1,378 @@ +/** + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { Provider } from 'react-redux'; +import { createStore, AnyAction } from 'redux'; +import { ReactNode } from 'react'; +import { useAutoRefreshTabPause } from './useAutoRefreshTabPause'; +import { + AUTO_REFRESH_STATE_DEFAULTS, + AutoRefreshStatus, +} from '../types/autoRefresh'; +import { + SET_AUTO_REFRESH_PAUSED_BY_TAB, + SET_AUTO_REFRESH_STATUS, +} from '../actions/autoRefresh'; + +// Helper to create mock Redux store with proper reducer +const createMockStore = (overrides = {}) => { + const initialState = { + dashboardState: { + ...AUTO_REFRESH_STATE_DEFAULTS, + refreshFrequency: 5, + ...overrides, + }, + }; + + const reducer = ( + state = initialState, + action: AnyAction, + ): typeof initialState => { + switch (action.type) { + case SET_AUTO_REFRESH_PAUSED_BY_TAB: + return { + ...state, + dashboardState: { + ...state.dashboardState, + autoRefreshPausedByTab: action.isPausedByTab, + }, + }; + case SET_AUTO_REFRESH_STATUS: + return { + ...state, + dashboardState: { + ...state.dashboardState, + autoRefreshStatus: action.status as AutoRefreshStatus, + }, + }; + default: + return state; + } + }; + + return createStore(reducer); +}; + +// Wrapper component for Redux +const createWrapper = (store: ReturnType) => + function ReduxWrapper({ children }: { children: ReactNode }) { + return {children}; + }; + +// Helper to mock document.visibilityState +const mockVisibilityState = (state: 'visible' | 'hidden') => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); +}; + +// Helper to fire visibilitychange event +const fireVisibilityChange = () => { + document.dispatchEvent(new Event('visibilitychange')); +}; + +// Store original visibility state +let originalVisibilityState: PropertyDescriptor | undefined; + +beforeEach(() => { + originalVisibilityState = Object.getOwnPropertyDescriptor( + document, + 'visibilityState', + ); + mockVisibilityState('visible'); +}); + +afterEach(() => { + if (originalVisibilityState) { + Object.defineProperty(document, 'visibilityState', originalVisibilityState); + } +}); + +test('does nothing when not a real-time dashboard (refreshFrequency = 0)', () => { + const store = createMockStore({ refreshFrequency: 0 }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // Simulate tab hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onStopTimer).not.toHaveBeenCalled(); +}); + +test('pauses immediately when mounted in a hidden tab', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + mockVisibilityState('hidden'); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + expect(onStopTimer).toHaveBeenCalledTimes(1); +}); + +test('stops timer when tab becomes hidden for real-time dashboard', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // Simulate tab hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onStopTimer).toHaveBeenCalledTimes(1); +}); + +test('does not stop timer when manually paused', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPaused: true, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // Simulate tab hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + // Should not stop timer because already manually paused + expect(onStopTimer).not.toHaveBeenCalled(); +}); + +test('refreshes and restarts timer when tab becomes visible after being paused by tab', async () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + // Start with tab visible + mockVisibilityState('visible'); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // First, simulate tab becoming hidden (this sets shouldResumeRef) + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onStopTimer).toHaveBeenCalledTimes(1); + + // Now simulate tab becoming visible again + await act(async () => { + mockVisibilityState('visible'); + fireVisibilityChange(); + // Wait for promise to resolve + await Promise.resolve(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(onRestartTimer).toHaveBeenCalledTimes(1); +}); + +test('does not refresh when returning to visible if manually paused', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPausedByTab: true, + autoRefreshPaused: true, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + // Start with tab hidden + mockVisibilityState('hidden'); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // Simulate tab becoming visible + act(() => { + mockVisibilityState('visible'); + fireVisibilityChange(); + }); + + // Should not refresh because manually paused + expect(onRefresh).not.toHaveBeenCalled(); +}); + +test('restarts timer when refresh fails after tab resumes', async () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: true, + }); + const onRefresh = jest.fn().mockRejectedValue(new Error('boom')); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + // Start with tab visible + mockVisibilityState('visible'); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + // First, simulate tab becoming hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + // Now simulate tab becoming visible + await act(async () => { + mockVisibilityState('visible'); + fireVisibilityChange(); + await Promise.resolve(); + }); + + expect(onRefresh).toHaveBeenCalledTimes(1); + expect(onRestartTimer).toHaveBeenCalledTimes(1); +}); + +test('does nothing when pause-on-inactive is disabled', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: false, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onStopTimer).not.toHaveBeenCalled(); +}); + +test('clears tab pause and restarts timer when pause-on-inactive is disabled', () => { + const store = createMockStore({ + refreshFrequency: 5, + autoRefreshPauseOnInactiveTab: false, + autoRefreshPausedByTab: true, + }); + const onRefresh = jest.fn().mockResolvedValue(undefined); + const onRestartTimer = jest.fn(); + const onStopTimer = jest.fn(); + + renderHook( + () => + useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, + }), + { wrapper: createWrapper(store) }, + ); + + expect(onRestartTimer).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.ts b/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.ts new file mode 100644 index 000000000000..2cb9f75c1e30 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useAutoRefreshTabPause.ts @@ -0,0 +1,146 @@ +/** + * 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 { useCallback, useRef, useEffect } from 'react'; +import { useTabVisibility } from './useTabVisibility'; +import { useRealTimeDashboard } from './useRealTimeDashboard'; +import { AutoRefreshStatus } from '../types/autoRefresh'; + +export interface UseAutoRefreshTabPauseOptions { + onRefresh: () => Promise; + onRestartTimer: () => void; + onStopTimer: () => void; +} + +/** + * Hook that automatically pauses auto-refresh when the browser tab is inactive. + * + * Behavior: + * - When tab becomes hidden: Stop the refresh timer, set status to paused + * - When tab becomes visible: If not manually paused, fetch data immediately and restart timer + * + * This behavior is enabled for real-time dashboards when the user opts in. + * Manual pause state is respected - if the user manually paused, returning to the tab won't auto-resume. + */ +export function useAutoRefreshTabPause({ + onRefresh, + onRestartTimer, + onStopTimer, +}: UseAutoRefreshTabPauseOptions): void { + const { + isRealTimeDashboard, + isManuallyPaused, + isPausedByTab, + autoRefreshPauseOnInactiveTab, + setPausedByTab, + setStatus, + } = useRealTimeDashboard(); + + const shouldResumeRef = useRef(false); + + const handleHidden = useCallback(() => { + if (!isRealTimeDashboard || !autoRefreshPauseOnInactiveTab) { + return; + } + + if (!isManuallyPaused) { + shouldResumeRef.current = true; + setPausedByTab(true); + setStatus(AutoRefreshStatus.Paused); + onStopTimer(); + } + }, [ + isRealTimeDashboard, + isManuallyPaused, + autoRefreshPauseOnInactiveTab, + setPausedByTab, + setStatus, + onStopTimer, + ]); + + const handleVisible = useCallback(() => { + if (!isRealTimeDashboard || !autoRefreshPauseOnInactiveTab) { + return; + } + + if (shouldResumeRef.current && !isManuallyPaused) { + setPausedByTab(false); + + onRefresh() + .then(() => { + onRestartTimer(); + }) + .catch(() => { + onRestartTimer(); + }); + + shouldResumeRef.current = false; + } + }, [ + isRealTimeDashboard, + isManuallyPaused, + autoRefreshPauseOnInactiveTab, + setPausedByTab, + onRefresh, + onRestartTimer, + ]); + + useTabVisibility({ + onVisible: handleVisible, + onHidden: handleHidden, + enabled: isRealTimeDashboard && autoRefreshPauseOnInactiveTab, + }); + + useEffect(() => { + if (!isRealTimeDashboard || !autoRefreshPauseOnInactiveTab) { + return; + } + + if (document.visibilityState === 'hidden') { + handleHidden(); + } + }, [isRealTimeDashboard, autoRefreshPauseOnInactiveTab, handleHidden]); + + useEffect(() => { + if (!isRealTimeDashboard || autoRefreshPauseOnInactiveTab) { + return; + } + + if (!isPausedByTab) { + return; + } + + shouldResumeRef.current = false; + setPausedByTab(false); + + if (!isManuallyPaused) { + setStatus(AutoRefreshStatus.Idle); + onRestartTimer(); + } + }, [ + isRealTimeDashboard, + autoRefreshPauseOnInactiveTab, + isPausedByTab, + isManuallyPaused, + setPausedByTab, + setStatus, + onRestartTimer, + ]); +} + +export default useAutoRefreshTabPause; diff --git a/superset-frontend/src/dashboard/hooks/useCurrentTime.test.ts b/superset-frontend/src/dashboard/hooks/useCurrentTime.test.ts new file mode 100644 index 000000000000..e5349633c873 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useCurrentTime.test.ts @@ -0,0 +1,182 @@ +/** + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useCurrentTime } from './useCurrentTime'; + +test('returns initial timestamp on mount', () => { + const before = Date.now(); + const { result } = renderHook(() => useCurrentTime()); + const after = Date.now(); + + // Should be between before and after + expect(result.current).toBeGreaterThanOrEqual(before); + expect(result.current).toBeLessThanOrEqual(after); +}); + +test('returns initial timestamp when disabled', () => { + const before = Date.now(); + const { result } = renderHook(() => useCurrentTime(false)); + const after = Date.now(); + + expect(result.current).toBeGreaterThanOrEqual(before); + expect(result.current).toBeLessThanOrEqual(after); +}); + +test('syncTrigger causes immediate time update', () => { + const { result, rerender } = renderHook( + ({ enabled, syncTrigger }) => useCurrentTime(enabled, syncTrigger), + { initialProps: { enabled: true, syncTrigger: null as number | null } }, + ); + + const initialTime = result.current; + + // Small delay to ensure time has changed + const syncTime = Date.now() + 1; + + // Trigger sync + act(() => { + rerender({ enabled: true, syncTrigger: syncTime }); + }); + + // currentTime should have updated (be different from or equal to initial, + // depending on timing, but definitely a valid timestamp) + expect(result.current).toBeGreaterThanOrEqual(initialTime); +}); + +test('syncTrigger update keeps ticking interval in sync', () => { + jest.useFakeTimers(); + const nowRef = { value: 1000 }; + const nowSpy = jest.spyOn(Date, 'now').mockImplementation(() => nowRef.value); + + const { result, rerender } = renderHook( + ({ syncTrigger }) => useCurrentTime(true, syncTrigger), + { initialProps: { syncTrigger: null as number | null } }, + ); + + expect(result.current).toBe(1000); + + nowRef.value = 2000; + act(() => { + rerender({ syncTrigger: 2000 }); + }); + expect(result.current).toBe(2000); + + nowRef.value = 3000; + act(() => { + jest.advanceTimersByTime(1000); + }); + expect(result.current).toBe(3000); + + nowSpy.mockRestore(); + jest.runOnlyPendingTimers(); + jest.useRealTimers(); +}); + +test('backward compatibility - works without syncTrigger parameter', () => { + const before = Date.now(); + const { result } = renderHook(() => useCurrentTime(true)); + const after = Date.now(); + + expect(result.current).toBeGreaterThanOrEqual(before); + expect(result.current).toBeLessThanOrEqual(after); +}); + +test('syncTrigger=null does not trigger sync', () => { + const { result, rerender } = renderHook( + ({ syncTrigger }) => useCurrentTime(true, syncTrigger), + { initialProps: { syncTrigger: null as number | null } }, + ); + + const initialTime = result.current; + + // Re-render with null (should NOT trigger sync effect) + act(() => { + rerender({ syncTrigger: null }); + }); + + // Time should be the same (no sync triggered) + expect(result.current).toBe(initialTime); +}); + +test('syncTrigger=0 triggers sync (valid timestamp)', () => { + const { result, rerender } = renderHook( + ({ syncTrigger }) => useCurrentTime(true, syncTrigger), + { initialProps: { syncTrigger: null as number | null } }, + ); + + const initialTime = result.current; + + // Trigger sync with timestamp 0 (epoch - valid number) + act(() => { + rerender({ syncTrigger: 0 }); + }); + + // currentTime should update (0 != null, so sync triggers) + expect(result.current).toBeGreaterThanOrEqual(initialTime); +}); + +test('changing syncTrigger value triggers sync each time', () => { + const { result, rerender } = renderHook( + ({ syncTrigger }) => useCurrentTime(true, syncTrigger), + { initialProps: { syncTrigger: 1000 } }, + ); + + const time1 = result.current; + + // Change trigger value + act(() => { + rerender({ syncTrigger: 2000 }); + }); + + const time2 = result.current; + + // Change trigger value again + act(() => { + rerender({ syncTrigger: 3000 }); + }); + + const time3 = result.current; + + // Each sync should update to a valid timestamp + expect(time1).toBeGreaterThan(0); + expect(time2).toBeGreaterThanOrEqual(time1); + expect(time3).toBeGreaterThanOrEqual(time2); +}); + +test('cleanup clears interval on unmount', () => { + const clearIntervalSpy = jest.spyOn(globalThis, 'clearInterval'); + + const { unmount } = renderHook(() => useCurrentTime(true)); + + unmount(); + + expect(clearIntervalSpy).toHaveBeenCalled(); + clearIntervalSpy.mockRestore(); +}); + +test('disabled hook does not set up interval', () => { + const setIntervalSpy = jest.spyOn(globalThis, 'setInterval'); + const callCountBefore = setIntervalSpy.mock.calls.length; + + renderHook(() => useCurrentTime(false, null)); + + // Should not have called setInterval (disabled and no sync trigger) + expect(setIntervalSpy.mock.calls.length).toBe(callCountBefore); + setIntervalSpy.mockRestore(); +}); diff --git a/superset-frontend/src/dashboard/hooks/useCurrentTime.ts b/superset-frontend/src/dashboard/hooks/useCurrentTime.ts new file mode 100644 index 000000000000..0058c9220e63 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useCurrentTime.ts @@ -0,0 +1,67 @@ +/** + * 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 { useState, useEffect, useRef, useCallback } from 'react'; + +/** + * Hook that provides the current time, updating every second. + * + * @param enabled - Whether the timer should be running + * @param syncTrigger - When this value changes, the timer restarts in phase + * with the new value. This ensures the display timer is + * synchronized with refresh cycles. + * @returns The current timestamp in milliseconds + */ +export const useCurrentTime = ( + enabled = true, + syncTrigger?: number | null, +): number => { + const [currentTime, setCurrentTime] = useState(() => Date.now()); + const intervalRef = useRef | null>(null); + + const clearExistingInterval = useCallback(() => { + if (intervalRef.current !== null) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }, []); + + // When syncTrigger changes (refresh completes), restart the interval + // This keeps the display timer aligned with the refresh cycle + useEffect(() => { + if (!enabled) { + clearExistingInterval(); + return undefined; + } + + if (syncTrigger != null) { + setCurrentTime(Date.now()); + } + + clearExistingInterval(); + intervalRef.current = setInterval(() => { + setCurrentTime(Date.now()); + }, 1000); + + return clearExistingInterval; + }, [enabled, syncTrigger, clearExistingInterval]); + + return currentTime; +}; + +export default useCurrentTime; diff --git a/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.test.ts b/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.test.ts new file mode 100644 index 000000000000..9d1f02be3ee9 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.test.ts @@ -0,0 +1,175 @@ +/** + * 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 { + selectIsRealTimeDashboard, + selectEffectiveRefreshStatus, + selectIsPaused, +} from './useRealTimeDashboard'; +import { + AutoRefreshStatus, + AUTO_REFRESH_STATE_DEFAULTS, +} from '../types/autoRefresh'; + +// Helper to create mock dashboard state +const createMockState = (overrides = {}) => ({ + dashboardState: { + ...AUTO_REFRESH_STATE_DEFAULTS, + refreshFrequency: 0, + ...overrides, + }, +}); + +// Tests for selectIsRealTimeDashboard +test('selectIsRealTimeDashboard returns true when refreshFrequency > 0', () => { + const state = createMockState({ refreshFrequency: 5 }); + expect(selectIsRealTimeDashboard(state)).toBe(true); +}); + +test('selectIsRealTimeDashboard returns false when refreshFrequency is 0', () => { + const state = createMockState({ refreshFrequency: 0 }); + expect(selectIsRealTimeDashboard(state)).toBe(false); +}); + +test('selectIsRealTimeDashboard returns false when refreshFrequency is undefined', () => { + const state = createMockState({ refreshFrequency: undefined }); + expect(selectIsRealTimeDashboard(state)).toBe(false); +}); + +// Tests for selectIsPaused +test('selectIsPaused returns true when manually paused', () => { + const state = createMockState({ autoRefreshPaused: true }); + expect(selectIsPaused(state)).toBe(true); +}); + +test('selectIsPaused returns true when paused by tab', () => { + const state = createMockState({ autoRefreshPausedByTab: true }); + expect(selectIsPaused(state)).toBe(true); +}); + +test('selectIsPaused returns true when both manually paused and paused by tab', () => { + const state = createMockState({ + autoRefreshPaused: true, + autoRefreshPausedByTab: true, + }); + expect(selectIsPaused(state)).toBe(true); +}); + +test('selectIsPaused returns false when not paused', () => { + const state = createMockState({ + autoRefreshPaused: false, + autoRefreshPausedByTab: false, + }); + expect(selectIsPaused(state)).toBe(false); +}); + +// Tests for selectEffectiveRefreshStatus +test('selectEffectiveRefreshStatus returns Paused when manually paused', () => { + const state = createMockState({ + refreshFrequency: 5, + autoRefreshPaused: true, + autoRefreshStatus: AutoRefreshStatus.Success, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Paused); +}); + +test('selectEffectiveRefreshStatus returns Paused when paused by tab', () => { + const state = createMockState({ + refreshFrequency: 5, + autoRefreshPausedByTab: true, + autoRefreshStatus: AutoRefreshStatus.Fetching, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Paused); +}); + +test('selectEffectiveRefreshStatus returns actual status when not paused', () => { + const state = createMockState({ + refreshFrequency: 5, + autoRefreshPaused: false, + autoRefreshPausedByTab: false, + autoRefreshStatus: AutoRefreshStatus.Success, + refreshErrorCount: 0, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Success); +}); + +test('selectEffectiveRefreshStatus returns Fetching when status is Fetching with one error', () => { + const state = createMockState({ + refreshFrequency: 3, + autoRefreshStatus: AutoRefreshStatus.Fetching, + refreshErrorCount: 1, + }); + + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Fetching); +}); + +test('selectEffectiveRefreshStatus returns Fetching when no refresh errors', () => { + const state = createMockState({ + refreshFrequency: 3, + autoRefreshStatus: AutoRefreshStatus.Fetching, + refreshErrorCount: 0, + }); + + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Fetching); +}); + +test('selectEffectiveRefreshStatus returns Error after two refresh errors', () => { + const state = createMockState({ + refreshFrequency: 3, + autoRefreshStatus: AutoRefreshStatus.Success, + refreshErrorCount: 2, + }); + + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Error); +}); + +test('selectEffectiveRefreshStatus returns Delayed when not fetching and one refresh error', () => { + const state = createMockState({ + refreshFrequency: 3, + autoRefreshStatus: AutoRefreshStatus.Success, + refreshErrorCount: 1, + }); + + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Delayed); +}); + +test('selectEffectiveRefreshStatus returns Idle when not a real-time dashboard', () => { + const state = createMockState({ + refreshFrequency: 0, + autoRefreshStatus: AutoRefreshStatus.Success, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Idle); +}); + +test('selectEffectiveRefreshStatus returns Error status when error count >= 2', () => { + const state = createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Error, + refreshErrorCount: 2, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Error); +}); + +test('selectEffectiveRefreshStatus returns Delayed status for 1 error', () => { + const state = createMockState({ + refreshFrequency: 5, + autoRefreshStatus: AutoRefreshStatus.Delayed, + refreshErrorCount: 1, + }); + expect(selectEffectiveRefreshStatus(state)).toBe(AutoRefreshStatus.Delayed); +}); diff --git a/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.ts b/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.ts new file mode 100644 index 000000000000..0a1cdef43ea7 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useRealTimeDashboard.ts @@ -0,0 +1,245 @@ +/** + * 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 { useCallback, useMemo } from 'react'; +import { useSelector, useDispatch } from 'react-redux'; +import { AutoRefreshStatus } from '../types/autoRefresh'; +import { DashboardState, RootState } from '../types'; +import { + setAutoRefreshStatus, + setAutoRefreshPaused, + setAutoRefreshPausedByTab, + recordAutoRefreshSuccess, + recordAutoRefreshError, + setAutoRefreshFetchStartTime, + setAutoRefreshPauseOnInactiveTab, +} from '../actions/autoRefresh'; + +type DashboardStateRoot = { + dashboardState: Partial; +}; + +/** + * Selector: Determines if this is a "real-time" dashboard. + * A dashboard is real-time if it has an auto-refresh frequency > 0. + */ +export const selectIsRealTimeDashboard = (state: DashboardStateRoot): boolean => + (state.dashboardState?.refreshFrequency ?? 0) > 0; + +/** + * Selector: Determines if auto-refresh is manually paused (by user action). + * Does NOT include tab visibility pause. + */ +export const selectIsManuallyPaused = (state: DashboardStateRoot): boolean => + state.dashboardState?.autoRefreshPaused === true; + +/** + * Selector: Determines if auto-refresh is paused. + * Paused can be due to manual pause or tab visibility. + */ +export const selectIsPaused = (state: DashboardStateRoot): boolean => + state.dashboardState?.autoRefreshPaused === true || + state.dashboardState?.autoRefreshPausedByTab === true; + +/** + * Selector: Computes the effective refresh status for the indicator. + * + * Priority order: + * 1. If not a real-time dashboard → Idle + * 2. If paused (manually or by tab) → Paused + * 3. If fetching → Fetching + * 4. If refreshErrorCount >= 2 → Error + * 5. If refreshErrorCount === 1 → Delayed + * 6. Otherwise → Current status from state + */ +export const selectEffectiveRefreshStatus = ( + state: DashboardStateRoot, +): AutoRefreshStatus => { + const { dashboardState } = state; + + // Not a real-time dashboard + if ((dashboardState?.refreshFrequency ?? 0) <= 0) { + return AutoRefreshStatus.Idle; + } + + // Check if paused + if ( + dashboardState?.autoRefreshPaused || + dashboardState?.autoRefreshPausedByTab + ) { + return AutoRefreshStatus.Paused; + } + + const currentStatus = + dashboardState?.autoRefreshStatus ?? AutoRefreshStatus.Idle; + const refreshErrorCount = dashboardState?.refreshErrorCount ?? 0; + + if (currentStatus === AutoRefreshStatus.Fetching) { + return AutoRefreshStatus.Fetching; + } + + if (refreshErrorCount >= 2) { + return AutoRefreshStatus.Error; + } + + if (refreshErrorCount === 1) { + return AutoRefreshStatus.Delayed; + } + + return currentStatus; +}; + +export const useRealTimeDashboard = () => { + const dispatch = useDispatch(); + + // Selectors + const isRealTimeDashboard = useSelector(selectIsRealTimeDashboard); + const isManuallyPaused = useSelector(selectIsManuallyPaused); + const isPaused = useSelector(selectIsPaused); + const effectiveStatus = useSelector(selectEffectiveRefreshStatus); + + const lastSuccessfulRefresh = useSelector( + (state: RootState) => state.dashboardState?.lastSuccessfulRefresh ?? null, + ); + + const lastAutoRefreshTime = useSelector( + (state: RootState) => state.dashboardState?.lastAutoRefreshTime ?? null, + ); + + const lastError = useSelector( + (state: RootState) => state.dashboardState?.lastRefreshError ?? null, + ); + + const refreshErrorCount = useSelector( + (state: RootState) => state.dashboardState?.refreshErrorCount ?? 0, + ); + + const refreshFrequency = useSelector( + (state: RootState) => state.dashboardState?.refreshFrequency ?? 0, + ); + + const autoRefreshFetchStartTime = useSelector( + (state: RootState) => + state.dashboardState?.autoRefreshFetchStartTime ?? null, + ); + + const autoRefreshPauseOnInactiveTab = useSelector( + (state: RootState) => + state.dashboardState?.autoRefreshPauseOnInactiveTab ?? false, + ); + + const isPausedByTab = useSelector( + (state: RootState) => state.dashboardState?.autoRefreshPausedByTab ?? false, + ); + + // Action dispatchers + const setStatus = useCallback( + (status: AutoRefreshStatus) => { + dispatch(setAutoRefreshStatus(status)); + }, + [dispatch], + ); + + const setPaused = useCallback( + (paused: boolean) => { + dispatch(setAutoRefreshPaused(paused)); + }, + [dispatch], + ); + + const setPausedByTab = useCallback( + (pausedByTab: boolean) => { + dispatch(setAutoRefreshPausedByTab(pausedByTab)); + }, + [dispatch], + ); + + const recordSuccess = useCallback(() => { + dispatch(recordAutoRefreshSuccess()); + }, [dispatch]); + + const recordError = useCallback( + (error?: string) => { + dispatch(recordAutoRefreshError(error)); + }, + [dispatch], + ); + + const setFetchStartTime = useCallback( + (timestamp: number | null) => { + dispatch(setAutoRefreshFetchStartTime(timestamp)); + }, + [dispatch], + ); + + const setPauseOnInactiveTab = useCallback( + (pauseOnInactiveTab: boolean) => { + dispatch(setAutoRefreshPauseOnInactiveTab(pauseOnInactiveTab)); + }, + [dispatch], + ); + + return useMemo( + () => ({ + // State + isRealTimeDashboard, + isManuallyPaused, + isPaused, + isPausedByTab, + effectiveStatus, + lastSuccessfulRefresh, + lastAutoRefreshTime, + lastError, + refreshErrorCount, + refreshFrequency, + autoRefreshFetchStartTime, + autoRefreshPauseOnInactiveTab, + // Actions + setStatus, + setPaused, + setPausedByTab, + recordSuccess, + recordError, + setFetchStartTime, + setPauseOnInactiveTab, + }), + [ + isRealTimeDashboard, + isManuallyPaused, + isPaused, + isPausedByTab, + effectiveStatus, + lastSuccessfulRefresh, + lastAutoRefreshTime, + lastError, + refreshErrorCount, + refreshFrequency, + autoRefreshFetchStartTime, + autoRefreshPauseOnInactiveTab, + setStatus, + setPaused, + setPausedByTab, + recordSuccess, + recordError, + setFetchStartTime, + setPauseOnInactiveTab, + ], + ); +}; + +export default useRealTimeDashboard; diff --git a/superset-frontend/src/dashboard/hooks/useTabVisibility.test.ts b/superset-frontend/src/dashboard/hooks/useTabVisibility.test.ts new file mode 100644 index 000000000000..4fe0a646f802 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useTabVisibility.test.ts @@ -0,0 +1,175 @@ +/** + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { useTabVisibility } from './useTabVisibility'; + +// Helper to mock document.visibilityState +const mockVisibilityState = (state: 'visible' | 'hidden') => { + Object.defineProperty(document, 'visibilityState', { + configurable: true, + get: () => state, + }); +}; + +// Helper to fire visibilitychange event +const fireVisibilityChange = () => { + document.dispatchEvent(new Event('visibilitychange')); +}; + +test('returns initial visibility state as visible', () => { + mockVisibilityState('visible'); + const { result } = renderHook(() => useTabVisibility()); + expect(result.current.isVisible).toBe(true); +}); + +test('returns initial visibility state as hidden', () => { + mockVisibilityState('hidden'); + const { result } = renderHook(() => useTabVisibility()); + expect(result.current.isVisible).toBe(false); +}); + +test('calls onHidden when tab becomes hidden', () => { + mockVisibilityState('visible'); + const onHidden = jest.fn(); + const onVisible = jest.fn(); + + renderHook(() => useTabVisibility({ onHidden, onVisible })); + + // Simulate tab becoming hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onHidden).toHaveBeenCalledTimes(1); + expect(onVisible).not.toHaveBeenCalled(); +}); + +test('calls onVisible when tab becomes visible', () => { + mockVisibilityState('hidden'); + const onHidden = jest.fn(); + const onVisible = jest.fn(); + + renderHook(() => useTabVisibility({ onHidden, onVisible })); + + // Simulate tab becoming visible + act(() => { + mockVisibilityState('visible'); + fireVisibilityChange(); + }); + + expect(onVisible).toHaveBeenCalledTimes(1); + expect(onHidden).not.toHaveBeenCalled(); +}); + +test('updates isVisible state when visibility changes', () => { + mockVisibilityState('visible'); + const { result } = renderHook(() => useTabVisibility()); + + expect(result.current.isVisible).toBe(true); + + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(result.current.isVisible).toBe(false); + + act(() => { + mockVisibilityState('visible'); + fireVisibilityChange(); + }); + + expect(result.current.isVisible).toBe(true); +}); + +test('does not add listener when disabled', () => { + mockVisibilityState('visible'); + const addEventListenerSpy = jest.spyOn(document, 'addEventListener'); + + renderHook(() => useTabVisibility({ enabled: false })); + + expect(addEventListenerSpy).not.toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + + addEventListenerSpy.mockRestore(); +}); + +test('removes listener on unmount', () => { + mockVisibilityState('visible'); + const removeEventListenerSpy = jest.spyOn(document, 'removeEventListener'); + + const { unmount } = renderHook(() => useTabVisibility()); + unmount(); + + expect(removeEventListenerSpy).toHaveBeenCalledWith( + 'visibilitychange', + expect.any(Function), + ); + + removeEventListenerSpy.mockRestore(); +}); + +test('does not call callbacks on same visibility state', () => { + mockVisibilityState('visible'); + const onHidden = jest.fn(); + const onVisible = jest.fn(); + + renderHook(() => useTabVisibility({ onHidden, onVisible })); + + // Fire event without actually changing visibility + act(() => { + fireVisibilityChange(); + }); + + // Should not call either callback since state didn't change + expect(onHidden).not.toHaveBeenCalled(); + expect(onVisible).not.toHaveBeenCalled(); +}); + +test('handles multiple visibility changes', () => { + mockVisibilityState('visible'); + const onHidden = jest.fn(); + const onVisible = jest.fn(); + + renderHook(() => useTabVisibility({ onHidden, onVisible })); + + // Hidden + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + // Visible + act(() => { + mockVisibilityState('visible'); + fireVisibilityChange(); + }); + + // Hidden again + act(() => { + mockVisibilityState('hidden'); + fireVisibilityChange(); + }); + + expect(onHidden).toHaveBeenCalledTimes(2); + expect(onVisible).toHaveBeenCalledTimes(1); +}); diff --git a/superset-frontend/src/dashboard/hooks/useTabVisibility.ts b/superset-frontend/src/dashboard/hooks/useTabVisibility.ts new file mode 100644 index 000000000000..5d65aa067537 --- /dev/null +++ b/superset-frontend/src/dashboard/hooks/useTabVisibility.ts @@ -0,0 +1,102 @@ +/** + * 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 { useEffect, useRef, useCallback, useState } from 'react'; + +export interface UseTabVisibilityOptions { + /** Callback when tab becomes visible */ + onVisible?: () => void; + /** Callback when tab becomes hidden */ + onHidden?: () => void; + /** Whether the hook is enabled */ + enabled?: boolean; +} + +export interface UseTabVisibilityResult { + /** Whether the tab is visible */ + isVisible: boolean; +} + +/** + * Hook to track browser tab visibility state. + * Uses the Page Visibility API to detect when the user switches tabs. + * + * @example + * ```tsx + * const { isVisible } = useTabVisibility({ + * onVisible: () => console.log('Tab is visible'), + * onHidden: () => console.log('Tab is hidden'), + * }); + * ``` + */ +export function useTabVisibility({ + onVisible, + onHidden, + enabled = true, +}: UseTabVisibilityOptions = {}): UseTabVisibilityResult { + const [isVisible, setIsVisible] = useState( + () => document.visibilityState === 'visible', + ); + + // Track previous visibility state to detect transitions + const previousVisibilityRef = useRef(document.visibilityState); + + const handleVisibilityChange = useCallback(() => { + const currentVisibility = document.visibilityState; + const previousVisibility = previousVisibilityRef.current; + + // Update state + const nowVisible = currentVisibility === 'visible'; + setIsVisible(nowVisible); + + // Detect transition from hidden to visible + if (previousVisibility === 'hidden' && currentVisibility === 'visible') { + onVisible?.(); + } + // Detect transition from visible to hidden + else if ( + previousVisibility === 'visible' && + currentVisibility === 'hidden' + ) { + onHidden?.(); + } + + // Update previous state + previousVisibilityRef.current = currentVisibility; + }, [onVisible, onHidden]); + + useEffect(() => { + if (!enabled) { + return undefined; + } + + // Add event listener + document.addEventListener('visibilitychange', handleVisibilityChange); + + // Cleanup + return () => { + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; + }, [enabled, handleVisibilityChange]); + + return { + isVisible, + }; +} + +export default useTabVisibility; diff --git a/superset-frontend/src/dashboard/reducers/autoRefreshReducer.test.ts b/superset-frontend/src/dashboard/reducers/autoRefreshReducer.test.ts new file mode 100644 index 000000000000..64ee9057d34a --- /dev/null +++ b/superset-frontend/src/dashboard/reducers/autoRefreshReducer.test.ts @@ -0,0 +1,246 @@ +/** + * 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 dashboardStateReducer from './dashboardState'; +import { + setAutoRefreshStatus, + setAutoRefreshPaused, + setAutoRefreshPausedByTab, + recordAutoRefreshSuccess, + recordAutoRefreshError, + setAutoRefreshFetchStartTime, + setAutoRefreshPauseOnInactiveTab, +} from '../actions/autoRefresh'; +import { + AutoRefreshStatus, + AUTO_REFRESH_STATE_DEFAULTS, +} from '../types/autoRefresh'; + +// Helper to create initial state with auto-refresh defaults +const createInitialState = (overrides = {}) => ({ + ...AUTO_REFRESH_STATE_DEFAULTS, + refreshFrequency: 5, + editMode: false, + isPublished: false, + directPathToChild: [], + activeTabs: [], + fullSizeChartId: null, + isRefreshing: false, + isFiltersRefreshing: false, + hasUnsavedChanges: false, + dashboardIsSaving: false, + colorScheme: '', + sliceIds: [], + directPathLastUpdated: 0, + nativeFiltersBarOpen: false, + ...overrides, +}); + +test('SET_AUTO_REFRESH_STATUS updates status', () => { + const initialState = createInitialState({ + autoRefreshStatus: AutoRefreshStatus.Idle, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshStatus(AutoRefreshStatus.Fetching), + ); + + expect(result.autoRefreshStatus).toBe(AutoRefreshStatus.Fetching); +}); + +test('SET_AUTO_REFRESH_STATUS preserves other state', () => { + const initialState = createInitialState({ + autoRefreshStatus: AutoRefreshStatus.Idle, + refreshFrequency: 10, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshStatus(AutoRefreshStatus.Success), + ); + + expect(result.refreshFrequency).toBe(10); + expect(result.autoRefreshStatus).toBe(AutoRefreshStatus.Success); +}); + +test('SET_AUTO_REFRESH_PAUSED sets paused to true', () => { + const initialState = createInitialState({ + autoRefreshPaused: false, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshPaused(true), + ); + + expect(result.autoRefreshPaused).toBe(true); +}); + +test('SET_AUTO_REFRESH_PAUSED sets paused to false', () => { + const initialState = createInitialState({ + autoRefreshPaused: true, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshPaused(false), + ); + + expect(result.autoRefreshPaused).toBe(false); +}); + +test('SET_AUTO_REFRESH_PAUSED_BY_TAB sets pausedByTab', () => { + const initialState = createInitialState({ + autoRefreshPausedByTab: false, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshPausedByTab(true), + ); + + expect(result.autoRefreshPausedByTab).toBe(true); +}); + +test('RECORD_AUTO_REFRESH_SUCCESS updates timestamp and resets errors', () => { + const initialState = createInitialState({ + lastSuccessfulRefresh: null, + lastRefreshError: 'Previous error', + refreshErrorCount: 2, + autoRefreshStatus: AutoRefreshStatus.Fetching, + }); + + const action = recordAutoRefreshSuccess(); + const result = dashboardStateReducer(initialState, action); + + expect(result.lastSuccessfulRefresh).toBe(action.timestamp); + expect(result.lastAutoRefreshTime).toBe(action.timestamp); + expect(result.lastRefreshError).toBeNull(); + expect(result.refreshErrorCount).toBe(0); + expect(result.autoRefreshStatus).toBe(AutoRefreshStatus.Success); +}); + +test('RECORD_AUTO_REFRESH_ERROR increments error count', () => { + const initialState = createInitialState({ + refreshErrorCount: 0, + lastRefreshError: null, + }); + + const action = recordAutoRefreshError('Network error'); + const result = dashboardStateReducer(initialState, action); + + expect(result.refreshErrorCount).toBe(1); + expect(result.lastRefreshError).toBe('Network error'); + expect(result.lastAutoRefreshTime).toBe(action.timestamp); +}); + +test('RECORD_AUTO_REFRESH_ERROR sets delayed status for 1 error', () => { + const initialState = createInitialState({ + refreshErrorCount: 0, + autoRefreshStatus: AutoRefreshStatus.Fetching, + }); + + const result = dashboardStateReducer( + initialState, + recordAutoRefreshError('Timeout'), + ); + + // 1st error should set delayed status + expect(result.refreshErrorCount).toBe(1); + expect(result.autoRefreshStatus).toBe(AutoRefreshStatus.Delayed); +}); + +test('RECORD_AUTO_REFRESH_ERROR sets error status for 2+ errors', () => { + const initialState = createInitialState({ + refreshErrorCount: 1, + autoRefreshStatus: AutoRefreshStatus.Delayed, + }); + + const result = dashboardStateReducer( + initialState, + recordAutoRefreshError('Server error'), + ); + + // 2nd error should set error status + expect(result.refreshErrorCount).toBe(2); + expect(result.autoRefreshStatus).toBe(AutoRefreshStatus.Error); +}); + +test('SET_AUTO_REFRESH_FETCH_START_TIME sets timestamp', () => { + const initialState = createInitialState({ + autoRefreshFetchStartTime: null, + }); + const timestamp = Date.now(); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshFetchStartTime(timestamp), + ); + + expect(result.autoRefreshFetchStartTime).toBe(timestamp); +}); + +test('SET_AUTO_REFRESH_FETCH_START_TIME clears with null', () => { + const initialState = createInitialState({ + autoRefreshFetchStartTime: Date.now(), + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshFetchStartTime(null), + ); + + expect(result.autoRefreshFetchStartTime).toBeNull(); +}); + +test('SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB enables setting', () => { + const initialState = createInitialState({ + autoRefreshPauseOnInactiveTab: false, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshPauseOnInactiveTab(true), + ); + + expect(result.autoRefreshPauseOnInactiveTab).toBe(true); +}); + +test('SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB disables setting', () => { + const initialState = createInitialState({ + autoRefreshPauseOnInactiveTab: true, + }); + + const result = dashboardStateReducer( + initialState, + setAutoRefreshPauseOnInactiveTab(false), + ); + + expect(result.autoRefreshPauseOnInactiveTab).toBe(false); +}); + +test('reducer returns unchanged state for unknown action', () => { + const initialState = createInitialState(); + + const result = dashboardStateReducer(initialState, { + type: 'UNKNOWN_ACTION', + }); + + expect(result).toBe(initialState); +}); diff --git a/superset-frontend/src/dashboard/reducers/dashboardState.ts b/superset-frontend/src/dashboard/reducers/dashboardState.ts index 4940b29e5f90..292f1f3cd930 100644 --- a/superset-frontend/src/dashboard/reducers/dashboardState.ts +++ b/superset-frontend/src/dashboard/reducers/dashboardState.ts @@ -57,6 +57,16 @@ import { CLEAR_ALL_CHART_STATES, } from '../actions/dashboardState'; import { HYDRATE_DASHBOARD } from '../actions/hydrate'; +import { + SET_AUTO_REFRESH_STATUS, + SET_AUTO_REFRESH_PAUSED, + SET_AUTO_REFRESH_PAUSED_BY_TAB, + RECORD_AUTO_REFRESH_SUCCESS, + RECORD_AUTO_REFRESH_ERROR, + SET_AUTO_REFRESH_FETCH_START_TIME, + SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB, +} from '../actions/autoRefresh'; +import { AutoRefreshStatus, ERROR_THRESHOLD_COUNT } from '../types/autoRefresh'; interface ChartStateEntry { chartId: number; @@ -97,6 +107,15 @@ interface DashboardStateShape { chartStates?: Record; css?: string; preselectNativeFilters?: JsonObject; + autoRefreshStatus?: AutoRefreshStatus; + autoRefreshPaused?: boolean; + autoRefreshPausedByTab?: boolean; + lastSuccessfulRefresh?: number | null; + lastAutoRefreshTime?: number | null; + lastRefreshError?: string | null; + refreshErrorCount?: number; + autoRefreshFetchStartTime?: number | null; + autoRefreshPauseOnInactiveTab?: boolean; [key: string]: unknown; } @@ -129,6 +148,11 @@ interface DashboardStateAction { lastModified?: number; chartStates?: Record; status?: string; + isPaused?: boolean; + isPausedByTab?: boolean; + timestamp?: number | null; + error?: string | null; + pauseOnInactiveTab?: boolean; payload?: { maxUndoHistoryExceeded?: boolean; hasUnsavedChanges?: boolean; @@ -436,6 +460,63 @@ export default function dashboardStateReducer( chartStates: {}, }; }, + // Auto-refresh status indicator handlers + [SET_AUTO_REFRESH_STATUS]() { + return { + ...state, + autoRefreshStatus: action.status as AutoRefreshStatus, + }; + }, + [SET_AUTO_REFRESH_PAUSED]() { + return { + ...state, + autoRefreshPaused: action.isPaused, + }; + }, + [SET_AUTO_REFRESH_PAUSED_BY_TAB]() { + return { + ...state, + autoRefreshPausedByTab: action.isPausedByTab, + }; + }, + [RECORD_AUTO_REFRESH_SUCCESS]() { + return { + ...state, + autoRefreshStatus: AutoRefreshStatus.Success, + lastSuccessfulRefresh: action.timestamp, + lastAutoRefreshTime: action.timestamp, + lastRefreshError: null, + refreshErrorCount: 0, + }; + }, + [RECORD_AUTO_REFRESH_ERROR]() { + const newErrorCount = (state.refreshErrorCount || 0) + 1; + // Determine status based on error count threshold + // 1 error = Delayed (yellow), 2+ errors = Error (red) + const newStatus = + newErrorCount >= ERROR_THRESHOLD_COUNT + ? AutoRefreshStatus.Error + : AutoRefreshStatus.Delayed; + return { + ...state, + autoRefreshStatus: newStatus, + lastRefreshError: action.error, + refreshErrorCount: newErrorCount, + lastAutoRefreshTime: action.timestamp, + }; + }, + [SET_AUTO_REFRESH_FETCH_START_TIME]() { + return { + ...state, + autoRefreshFetchStartTime: action.timestamp, + }; + }, + [SET_AUTO_REFRESH_PAUSE_ON_INACTIVE_TAB]() { + return { + ...state, + autoRefreshPauseOnInactiveTab: action.pauseOnInactiveTab, + }; + }, }; if (action.type in actionHandlers) { diff --git a/superset-frontend/src/dashboard/types.ts b/superset-frontend/src/dashboard/types.ts index e77574b15009..85df31047bc4 100644 --- a/superset-frontend/src/dashboard/types.ts +++ b/superset-frontend/src/dashboard/types.ts @@ -38,7 +38,10 @@ import { UrlParamEntries } from 'src/utils/urlUtils'; import { ResourceStatus } from 'src/hooks/apiResources/apiResources'; import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes'; import Owner from 'src/types/Owner'; +import Role from 'src/types/Role'; +import { TagType } from 'src/components/Tag/TagType'; import { ChartState } from '../explore/types'; +import { AutoRefreshStatus } from './types/autoRefresh'; export type { Dashboard } from 'src/types/Dashboard'; @@ -141,6 +144,15 @@ export type DashboardState = { data: JsonObject; }; chartStates?: Record; + autoRefreshStatus?: AutoRefreshStatus; + autoRefreshPaused?: boolean; + autoRefreshPausedByTab?: boolean; + lastSuccessfulRefresh?: number | null; + lastAutoRefreshTime?: number | null; + lastRefreshError?: string | null; + refreshErrorCount?: number; + autoRefreshFetchStartTime?: number | null; + autoRefreshPauseOnInactiveTab?: boolean; labelsColorMapMustSync?: boolean; sharedLabelsColorsMustSync?: boolean; maxUndoHistoryExceeded?: boolean; @@ -203,8 +215,8 @@ export type DashboardInfo = { last_modified_time: number; certified_by?: string; certification_details?: string; - roles?: { id: number }[] | number[]; - tags?: { type?: string | number }[]; + roles?: Role[]; + tags?: TagType[]; is_managed_externally?: boolean; dash_share_perm?: boolean; dash_save_perm?: boolean; diff --git a/superset-frontend/src/dashboard/types/autoRefresh.ts b/superset-frontend/src/dashboard/types/autoRefresh.ts new file mode 100644 index 000000000000..d02089234f0f --- /dev/null +++ b/superset-frontend/src/dashboard/types/autoRefresh.ts @@ -0,0 +1,64 @@ +/** + * 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. + */ + +/** + * Status states for the auto-refresh indicator. + * + * Per requirements: + * - Green (Success): Refreshed on schedule + * - Blue (Idle): Waiting for first refresh + * - Blue (Fetching): Currently fetching data + * - Yellow (Delayed): Refresh taking longer than expected OR 1 consecutive error + * - Red (Error): 2+ consecutive errors + * - White (Paused): Auto-refresh is paused (manually or by tab visibility) + */ +export enum AutoRefreshStatus { + Idle = 'idle', + Success = 'success', + Fetching = 'fetching', + Delayed = 'delayed', + Error = 'error', + Paused = 'paused', +} + +export interface AutoRefreshState { + autoRefreshStatus: AutoRefreshStatus; + autoRefreshPaused: boolean; + autoRefreshPausedByTab: boolean; + lastSuccessfulRefresh: number | null; + lastAutoRefreshTime: number | null; + lastRefreshError: string | null; + refreshErrorCount: number; + autoRefreshFetchStartTime: number | null; + autoRefreshPauseOnInactiveTab: boolean; +} + +export const AUTO_REFRESH_STATE_DEFAULTS: AutoRefreshState = { + autoRefreshStatus: AutoRefreshStatus.Idle, + autoRefreshPaused: false, + autoRefreshPausedByTab: false, + lastSuccessfulRefresh: null, + lastAutoRefreshTime: null, + lastRefreshError: null, + refreshErrorCount: 0, + autoRefreshFetchStartTime: null, + autoRefreshPauseOnInactiveTab: false, +}; + +export const ERROR_THRESHOLD_COUNT = 2;