feat: auto refresh dashboard (#37459)

Co-authored-by: Richard <richard@ip-192-168-1-32.sa-east-1.compute.internal>
Co-authored-by: richard <richard@richards-MacBook-Pro-2.local>
Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
Co-authored-by: Evan Rusackas <evan@preset.io>
This commit is contained in:
Richard Fogaca Nienkotter
2026-02-24 11:37:28 -03:00
committed by GitHub
parent f60432e34c
commit fca8a49561
66 changed files with 4818 additions and 462 deletions

View File

@@ -97,6 +97,7 @@ export type BigNumberVizProps = {
trendLineData?: TimeSeriesDatum[];
mainColor?: string;
echartOptions?: EChartsCoreOption;
isRefreshing?: boolean;
onContextMenu?: (
clientX: number,
clientY: number,

View File

@@ -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),
},
],
});
}

View File

@@ -20,14 +20,13 @@ 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 } = props;
return (
<Echart
refs={refs}
height={height}
width={width}
echartOptions={echartOptions}
vizType={formData.vizType}
/>
);
}

View File

@@ -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 }),
},
]);
}

View File

@@ -110,6 +110,7 @@ export type TimeseriesChartTransformedProps =
ContextMenuTransformedProps &
CrossFilterTransformedProps & {
legendData?: OptionName[];
isRefreshing?: boolean;
xValueFormatter: TimeFormatter | StringConstructor;
xAxis: {
label: string;

View File

@@ -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),
},
],
});
}

View File

@@ -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,

View File

@@ -106,7 +106,7 @@ export default function EchartsTreemap({
setDataMask(dataMask);
}
},
[emitCrossFilters, getCrossFilterDataMask, setDataMask],
[emitCrossFilters, getCrossFilterDataMask, setDataMask, groupby.length],
);
const eventHandlers: EventHandlers = {

View File

@@ -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),
},
]);
}

View File

@@ -70,8 +70,11 @@ import { DEFAULT_LOCALE } from '../constants';
// Define this interface here to avoid creating a dependency back to superset-frontend,
// TODO: to move the type to @superset-ui/core
interface ExplorePageState {
common: {
locale: string;
common?: {
locale?: string;
};
dashboardState?: {
isRefreshing?: boolean;
};
}
@@ -156,6 +159,9 @@ function Echart(
const locale = useSelector(
(state: ExplorePageState) => state?.common?.locale ?? DEFAULT_LOCALE,
).toUpperCase();
const isDashboardRefreshing = useSelector((state: ExplorePageState) =>
Boolean(state?.dashboardState?.isRefreshing),
);
const handleSizeChange = useCallback(
({ width, height }: { width: number; height: number }) => {
@@ -244,15 +250,33 @@ function Echart(
? theme.echartsOptionsOverridesByChartType?.[vizType] || {}
: {};
// Disable animations during auto-refresh to reduce visual noise
const animationOverride = isDashboardRefreshing
? {
animation: false,
animationDuration: 0,
}
: {};
const themedEchartOptions = mergeReplaceArrays(
baseTheme,
echartOptions,
globalOverrides,
chartOverrides,
animationOverride,
);
chartRef.current?.setOption(themedEchartOptions, true);
const notMerge = !isDashboardRefreshing;
if (!notMerge) {
chartRef.current?.dispatchAction({ type: 'hideTip' });
}
chartRef.current?.setOption(themedEchartOptions, {
notMerge,
replaceMerge: notMerge ? undefined : ['series'],
lazyUpdate: isDashboardRefreshing,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- isDashboardRefreshing intentionally excluded to prevent extra setOption calls
}, [didMount, echartOptions, eventHandlers, zrEventHandlers, theme, vizType]);
useEffect(() => () => chartRef.current?.dispose(), []);

View File

@@ -128,6 +128,7 @@ export interface BaseTransformedProps<F> {
echartOptions: EChartsCoreOption;
formData: F;
height: number;
isRefreshing?: boolean;
onContextMenu?: (
clientX: number,
clientY: number,

View File

@@ -0,0 +1,33 @@
/**
* 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 { applyOrderBy } from './orderby';
test('does not apply orderby for numeric zero row limit', () => {
expect(applyOrderBy([['col', true]], 0)).toEqual({});
});
test('does not apply orderby for string zero row limit', () => {
expect(applyOrderBy([['col', true]], '0')).toEqual({});
});
test('applies orderby for non-zero string row limit', () => {
expect(applyOrderBy([['col', true]], '10')).toEqual({
orderby: [['col', true]],
});
});

View File

@@ -0,0 +1,51 @@
/**
* 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<string, never> {
const parsedRowLimit =
typeof rowLimit === 'string' ? Number(rowLimit) : rowLimit;
const shouldApply =
rowLimit !== undefined &&
rowLimit !== null &&
(Number.isNaN(parsedRowLimit) || parsedRowLimit !== 0);
return shouldApply && orderby.length > 0 ? { orderby } : {};
}

View File

@@ -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,