mirror of
https://github.com/apache/superset.git
synced 2026-07-05 06:15:31 +00:00
Compare commits
1 Commits
fix/105973
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bc30677690 |
@@ -44,8 +44,3 @@ export const FILTER_CONDITION_BODY_INDEX = {
|
||||
} as const;
|
||||
|
||||
export const ROW_NUMBER_COL_ID = '__row_number__';
|
||||
|
||||
// Non-enumerable key used to attach a row's basic (increase/decrease) color
|
||||
// formatter to the row data object so it travels with the row through AG Grid
|
||||
// client-side sorting (#105973).
|
||||
export const BASIC_COLOR_FORMATTERS_ROW_KEY = '__basicColorFormatters__';
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
import { CustomCellRendererProps } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { BasicColorFormatterType, InputColumn, ValueRange } from '../types';
|
||||
import { useIsDark } from '../utils/useTableTheme';
|
||||
import getRowBasicColorFormatter from '../utils/getRowBasicColorFormatter';
|
||||
|
||||
const StyledTotalCell = styled.div`
|
||||
${() => `
|
||||
@@ -164,13 +163,13 @@ export const NumericCellRenderer = (
|
||||
let arrow = '';
|
||||
let arrowColor = '';
|
||||
if (hasBasicColorFormatters && col?.metricName) {
|
||||
const rowFormatter = getRowBasicColorFormatter(
|
||||
node,
|
||||
node?.rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName];
|
||||
arrow = rowFormatter?.mainArrow;
|
||||
arrowColor = rowFormatter?.arrowColor?.toLowerCase();
|
||||
arrow =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[col.metricName]
|
||||
?.mainArrow;
|
||||
arrowColor =
|
||||
basicColorFormatters?.[node?.rowIndex as number]?.[
|
||||
col.metricName
|
||||
]?.arrowColor?.toLowerCase();
|
||||
}
|
||||
|
||||
const alignment =
|
||||
|
||||
@@ -46,7 +46,6 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import isEqualColumns from './utils/isEqualColumns';
|
||||
import DateWithFormatter from './utils/DateWithFormatter';
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from './consts';
|
||||
import {
|
||||
DataColumnMeta,
|
||||
TableChartProps,
|
||||
@@ -704,23 +703,6 @@ const transformProps = (
|
||||
|
||||
const basicColorFormatters =
|
||||
comparisonColorEnabled && getBasicColorFormatter(baseQuery?.data, columns);
|
||||
|
||||
// Attach each row's basic (increase/decrease) color formatter to the row data
|
||||
// object so it travels with the row through AG Grid client-side sorting.
|
||||
// basicColorFormatters is built in the original query order and was previously
|
||||
// read positionally by the displayed rowIndex, which applied colors to the
|
||||
// wrong rows once the table was sorted (#105973). The property is
|
||||
// non-enumerable so it never leaks into exports, cross-filters or spreads.
|
||||
if (basicColorFormatters) {
|
||||
passedData.forEach((row, index) => {
|
||||
Object.defineProperty(row, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: basicColorFormatters[index],
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
const columnColorFormatters =
|
||||
getColorFormatters(conditionalFormatting, passedData, theme) ?? [];
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ import {
|
||||
} from '@superset-ui/chart-controls';
|
||||
import { CellClassParams } from '@superset-ui/core/components/ThemedAgGridReact';
|
||||
import { BasicColorFormatterType, InputColumn } from '../types';
|
||||
import getRowBasicColorFormatter from './getRowBasicColorFormatter';
|
||||
|
||||
type CellStyleParams = CellClassParams & {
|
||||
hasColumnColorFormatters: boolean | undefined;
|
||||
@@ -85,11 +84,8 @@ const getCellStyle = (params: CellStyleParams) => {
|
||||
col?.metricName &&
|
||||
node?.rowPinned !== 'bottom'
|
||||
) {
|
||||
backgroundColor = getRowBasicColorFormatter(
|
||||
node,
|
||||
rowIndex,
|
||||
basicColorFormatters,
|
||||
)?.[col.metricName]?.backgroundColor;
|
||||
backgroundColor =
|
||||
basicColorFormatters?.[rowIndex]?.[col.metricName]?.backgroundColor;
|
||||
}
|
||||
|
||||
const textAlign =
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../consts';
|
||||
import { BasicColorFormatterType } from '../types';
|
||||
|
||||
type RowFormatters = { [key: string]: BasicColorFormatterType };
|
||||
|
||||
/**
|
||||
* Resolves the basic (increase/decrease) color formatters for a given AG Grid
|
||||
* row node.
|
||||
*
|
||||
* The formatter is attached to the row data object itself (see transformProps),
|
||||
* so it follows the row through client-side sorting. Looking it up positionally
|
||||
* by the displayed `rowIndex` was wrong once the user sorted the table, because
|
||||
* the displayed index no longer matched the original data order (#105973).
|
||||
*
|
||||
* Falls back to the positional array for safety when no attached formatter is
|
||||
* present.
|
||||
*/
|
||||
export default function getRowBasicColorFormatter(
|
||||
node: { data?: Record<string, unknown> } | undefined,
|
||||
rowIndex: number | null | undefined,
|
||||
basicColorFormatters: RowFormatters[] | undefined,
|
||||
): RowFormatters | undefined {
|
||||
const attached = node?.data?.[BASIC_COLOR_FORMATTERS_ROW_KEY] as
|
||||
| RowFormatters
|
||||
| undefined;
|
||||
if (attached) {
|
||||
return attached;
|
||||
}
|
||||
if (rowIndex == null) {
|
||||
return undefined;
|
||||
}
|
||||
return basicColorFormatters?.[rowIndex];
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import getRowBasicColorFormatter from '../../src/utils/getRowBasicColorFormatter';
|
||||
import { BASIC_COLOR_FORMATTERS_ROW_KEY } from '../../src/consts';
|
||||
|
||||
const red = { sales: { backgroundColor: 'red', mainArrow: '↓', arrowColor: 'red' } };
|
||||
const green = {
|
||||
sales: { backgroundColor: 'green', mainArrow: '↑', arrowColor: 'green' },
|
||||
};
|
||||
|
||||
// Positional array in the original (unsorted) query order: row 0 -> green, row 1 -> red.
|
||||
const positional = [green, red] as any;
|
||||
|
||||
test('uses the formatter attached to the row, not the displayed rowIndex (#105973)', () => {
|
||||
// After sorting, the row whose original formatter is `red` is displayed first
|
||||
// (rowIndex 0). The positional lookup would wrongly return `green`.
|
||||
const rowData: Record<string, unknown> = { sales: 5 };
|
||||
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: red,
|
||||
enumerable: false,
|
||||
});
|
||||
const node = { data: rowData };
|
||||
|
||||
expect(getRowBasicColorFormatter(node, 0, positional)).toBe(red);
|
||||
expect(
|
||||
getRowBasicColorFormatter(node, 0, positional)?.sales.backgroundColor,
|
||||
).toBe('red');
|
||||
});
|
||||
|
||||
test('falls back to positional lookup when no formatter is attached', () => {
|
||||
const node = { data: { sales: 5 } };
|
||||
expect(getRowBasicColorFormatter(node, 1, positional)).toBe(red);
|
||||
});
|
||||
|
||||
test('returns undefined when nothing matches', () => {
|
||||
expect(getRowBasicColorFormatter(undefined, null, positional)).toBeUndefined();
|
||||
expect(
|
||||
getRowBasicColorFormatter({ data: {} }, null, positional),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('attached formatter is non-enumerable so it does not leak into the row', () => {
|
||||
const rowData: Record<string, unknown> = { sales: 5 };
|
||||
Object.defineProperty(rowData, BASIC_COLOR_FORMATTERS_ROW_KEY, {
|
||||
value: green,
|
||||
enumerable: false,
|
||||
});
|
||||
expect(Object.keys(rowData)).toEqual(['sales']);
|
||||
});
|
||||
@@ -282,11 +282,6 @@ export default function transformProps(
|
||||
? formatTime
|
||||
: numberFormatter;
|
||||
|
||||
const lineWidth = 2;
|
||||
// Pad the grid by half the stroke width so the trendline isn't clipped at
|
||||
// the edges of the chart area (the stroke extends beyond the data point).
|
||||
const strokePad = lineWidth / 2;
|
||||
|
||||
const echartOptions: EChartsCoreOption = trendLineData
|
||||
? {
|
||||
series: [
|
||||
@@ -298,9 +293,6 @@ export default function transformProps(
|
||||
symbolSize: 10,
|
||||
showSymbol: false,
|
||||
color: mainColor ?? BRAND_COLOR,
|
||||
lineStyle: {
|
||||
width: lineWidth,
|
||||
},
|
||||
areaStyle: {
|
||||
color: new graphic.LinearGradient(0, 0, 0, 1, [
|
||||
{
|
||||
@@ -354,10 +346,10 @@ export default function transformProps(
|
||||
top: TIMESERIES_CONSTANTS.gridOffsetTop,
|
||||
}
|
||||
: {
|
||||
bottom: strokePad,
|
||||
left: strokePad,
|
||||
right: strokePad,
|
||||
top: strokePad,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
},
|
||||
tooltip: {
|
||||
...getDefaultTooltip(refs),
|
||||
|
||||
@@ -17,7 +17,6 @@
|
||||
* under the License.
|
||||
*/
|
||||
import { DatasourceType, TimeGranularity, VizType } from '@superset-ui/core';
|
||||
import type { LineSeriesOption } from 'echarts';
|
||||
import { supersetTheme } from '@apache-superset/core/theme';
|
||||
import transformProps from '../../src/BigNumber/BigNumberWithTrendline/transformProps';
|
||||
import {
|
||||
@@ -270,18 +269,11 @@ describe('BigNumberWithTrendline', () => {
|
||||
},
|
||||
});
|
||||
|
||||
const series = (
|
||||
transformed.echartOptions?.series as LineSeriesOption[]
|
||||
)?.[0];
|
||||
const lineWidth = series?.lineStyle?.width;
|
||||
expect(lineWidth).toBe(2);
|
||||
|
||||
const expectedPad = (lineWidth as number) / 2;
|
||||
expect(transformed.echartOptions?.grid).toEqual({
|
||||
bottom: expectedPad,
|
||||
left: expectedPad,
|
||||
right: expectedPad,
|
||||
top: expectedPad,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 0,
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ import { EmptyState, Loading } from '@superset-ui/core/components';
|
||||
import { ErrorBoundary, BasicErrorAlert } from 'src/components';
|
||||
import BuilderComponentPane from 'src/dashboard/components/BuilderComponentPane';
|
||||
import DashboardHeader from 'src/dashboard/components/Header';
|
||||
import HeadlessAutoRefresh from 'src/dashboard/components/Header/HeadlessAutoRefresh';
|
||||
import { Icons } from '@superset-ui/core/components/Icons';
|
||||
import IconButton from 'src/dashboard/components/IconButton';
|
||||
import { Droppable } from 'src/dashboard/components/dnd/DragDroppable';
|
||||
@@ -515,7 +516,7 @@ const DashboardBuilder = () => {
|
||||
const headerContent = useMemo(
|
||||
() => (
|
||||
<>
|
||||
{!hideDashboardHeader && <DashboardHeader />}
|
||||
{hideDashboardHeader ? <HeadlessAutoRefresh /> : <DashboardHeader />}
|
||||
{showFilterBar &&
|
||||
filterBarOrientation === FilterBarOrientation.Horizontal && (
|
||||
<FilterBar
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { render } from 'spec/helpers/testing-library';
|
||||
import { AutoRefreshProvider } from 'src/dashboard/contexts/AutoRefreshContext';
|
||||
import HeadlessAutoRefresh from './HeadlessAutoRefresh';
|
||||
|
||||
const mockUseHeaderAutoRefresh = jest.fn();
|
||||
|
||||
jest.mock('./useHeaderAutoRefresh', () => ({
|
||||
useHeaderAutoRefresh: (props: unknown) => {
|
||||
mockUseHeaderAutoRefresh(props);
|
||||
return {
|
||||
forceRefresh: jest.fn(),
|
||||
handlePauseToggle: jest.fn(),
|
||||
autoRefreshPauseOnInactiveTab: false,
|
||||
setPauseOnInactiveTab: jest.fn(),
|
||||
};
|
||||
},
|
||||
}));
|
||||
|
||||
const initialState = {
|
||||
dashboardInfo: {
|
||||
id: 42,
|
||||
metadata: { timed_refresh_immune_slices: [7] },
|
||||
common: { conf: { DASHBOARD_AUTO_REFRESH_MODE: 'fetch' } },
|
||||
},
|
||||
dashboardState: {
|
||||
refreshFrequency: 30,
|
||||
sliceIds: [1, 2, 3],
|
||||
},
|
||||
charts: {
|
||||
1: { id: 1, chartUpdateStartTime: 0, chartUpdateEndTime: 0 },
|
||||
2: { id: 2, chartUpdateStartTime: 0, chartUpdateEndTime: 0 },
|
||||
},
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
mockUseHeaderAutoRefresh.mockClear();
|
||||
});
|
||||
|
||||
test('drives the auto-refresh hook from redux state without a header', () => {
|
||||
const { container } = render(
|
||||
<AutoRefreshProvider>
|
||||
<HeadlessAutoRefresh />
|
||||
</AutoRefreshProvider>,
|
||||
{ useRedux: true, initialState },
|
||||
);
|
||||
|
||||
// The component renders nothing but must still start the refresh timer.
|
||||
expect(container).toBeEmptyDOMElement();
|
||||
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledTimes(1);
|
||||
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
chartIds: [1, 2, 3],
|
||||
dashboardId: 42,
|
||||
refreshFrequency: 30,
|
||||
timedRefreshImmuneSlices: [7],
|
||||
autoRefreshMode: 'fetch',
|
||||
isLoading: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('passes the redux refresh frequency through to the hook', () => {
|
||||
render(
|
||||
<AutoRefreshProvider>
|
||||
<HeadlessAutoRefresh />
|
||||
</AutoRefreshProvider>,
|
||||
{
|
||||
useRedux: true,
|
||||
initialState: {
|
||||
...initialState,
|
||||
dashboardState: { ...initialState.dashboardState, refreshFrequency: 0 },
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
expect(mockUseHeaderAutoRefresh).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ refreshFrequency: 0 }),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* Licensed to the Apache Software Foundation (ASF) under one
|
||||
* or more contributor license agreements. See the NOTICE file
|
||||
* distributed with this work for additional information
|
||||
* regarding copyright ownership. The ASF licenses this file
|
||||
* to you under the Apache License, Version 2.0 (the
|
||||
* "License"); you may not use this file except in compliance
|
||||
* with the License. You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing,
|
||||
* software distributed under the License is distributed on an
|
||||
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||
* KIND, either express or implied. See the License for the
|
||||
* specific language governing permissions and limitations
|
||||
* under the License.
|
||||
*/
|
||||
import { useMemo } from 'react';
|
||||
import { bindActionCreators } from 'redux';
|
||||
import { useDispatch, useSelector } from 'react-redux';
|
||||
import { RootState } from 'src/dashboard/types';
|
||||
import { useChartIds } from 'src/dashboard/util/charts/useChartIds';
|
||||
import { onRefresh, setRefreshFrequency } from '../../actions/dashboardState';
|
||||
import { logEvent } from '../../../logger/actions';
|
||||
import { useHeaderAutoRefresh } from './useHeaderAutoRefresh';
|
||||
|
||||
/**
|
||||
* Headless component that drives the dashboard auto-refresh timer when the
|
||||
* dashboard header is not rendered (e.g. standalone mode or `hideTitle: true`
|
||||
* in embedded dashboards). The auto-refresh logic lives in the header, so
|
||||
* hiding the header would otherwise stop the refresh interval from ever
|
||||
* starting. Rendering this component keeps the timer running independently of
|
||||
* header visibility. It renders nothing.
|
||||
*/
|
||||
const HeadlessAutoRefresh = (): null => {
|
||||
const dispatch = useDispatch();
|
||||
const chartIds = useChartIds();
|
||||
const dashboardId = useSelector((state: RootState) => state.dashboardInfo.id);
|
||||
const refreshFrequency = useSelector(
|
||||
(state: RootState) => state.dashboardState.refreshFrequency ?? 0,
|
||||
);
|
||||
const timedRefreshImmuneSlices = useSelector(
|
||||
(state: RootState) =>
|
||||
state.dashboardInfo.metadata?.timed_refresh_immune_slices || [],
|
||||
);
|
||||
const autoRefreshMode = useSelector(
|
||||
(state: RootState) =>
|
||||
state.dashboardInfo.common?.conf?.DASHBOARD_AUTO_REFRESH_MODE,
|
||||
);
|
||||
const isLoading = useSelector((state: RootState) =>
|
||||
Object.values(state.charts).some(chart => {
|
||||
const start = chart.chartUpdateStartTime ?? 0;
|
||||
const end = chart.chartUpdateEndTime ?? 0;
|
||||
return start > end;
|
||||
}),
|
||||
);
|
||||
|
||||
const boundActionCreators = useMemo(
|
||||
() =>
|
||||
bindActionCreators(
|
||||
{
|
||||
onRefresh,
|
||||
setRefreshFrequency,
|
||||
logEvent,
|
||||
},
|
||||
dispatch,
|
||||
),
|
||||
[dispatch],
|
||||
);
|
||||
|
||||
useHeaderAutoRefresh({
|
||||
chartIds,
|
||||
dashboardId,
|
||||
refreshFrequency,
|
||||
timedRefreshImmuneSlices,
|
||||
autoRefreshMode,
|
||||
isLoading,
|
||||
onRefresh: boundActionCreators.onRefresh,
|
||||
setRefreshFrequency: boundActionCreators.setRefreshFrequency,
|
||||
logEvent: boundActionCreators.logEvent,
|
||||
});
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
export default HeadlessAutoRefresh;
|
||||
Reference in New Issue
Block a user