codeant-ai-for-open-source[bot] commented on code in PR #37902:
URL: https://github.com/apache/superset/pull/37902#discussion_r2794553882
##########
superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx:
##########
@@ -67,103 +66,102 @@ export type ChartDataProviderState = {
error?: ProvidedProps['error'];
};
-class ChartDataProvider extends PureComponent<
- ChartDataProviderProps,
- ChartDataProviderState
-> {
- readonly chartClient: ChartClient;
-
- constructor(props: ChartDataProviderProps) {
- super(props);
- this.state = { status: 'uninitialized' };
- this.chartClient = new ChartClient({ client: props.client });
- }
-
- componentDidMount() {
- this.handleFetchData();
- }
-
- componentDidUpdate(prevProps: ChartDataProviderProps) {
- const { formData, sliceId } = this.props;
- if (formData !== prevProps.formData || sliceId !== prevProps.sliceId) {
- this.handleFetchData();
- }
- }
-
- private extractSliceIdAndFormData() {
- const { formData, sliceId } = this.props;
- return formData ? { formData } : { sliceId: sliceId as number };
- }
-
- private handleFetchData = () => {
- const {
- loadDatasource,
- formDataRequestOptions,
- datasourceRequestOptions,
- queryRequestOptions,
- } = this.props;
-
- this.setState({ status: 'loading' }, () => {
- try {
- this.chartClient
- .loadFormData(
- this.extractSliceIdAndFormData(),
- formDataRequestOptions,
- )
- .then(formData =>
- Promise.all([
- loadDatasource
- ? this.chartClient.loadDatasource(
- formData.datasource,
- datasourceRequestOptions,
- )
- : Promise.resolve(undefined),
- this.chartClient.loadQueryData(formData, queryRequestOptions),
- ]).then(
- ([datasource, queriesData]) =>
- // eslint-disable-next-line
@typescript-eslint/consistent-type-assertions
- ({
- datasource,
- formData,
- queriesData,
- }) as Payload,
- ),
- )
- .then(this.handleReceiveData)
- .catch(this.handleError);
- } catch (error) {
- this.handleError(error as Error);
- }
- });
- };
-
- private handleReceiveData = (payload?: Payload) => {
- const { onLoaded } = this.props;
- if (onLoaded) onLoaded(payload);
- this.setState({ payload, status: 'loaded' });
- };
-
- private handleError = (error: ProvidedProps['error']) => {
- const { onError } = this.props;
- if (onError) onError(error);
- this.setState({ error, status: 'error' });
- };
-
- render() {
- const { children } = this.props;
- const { status, payload, error } = this.state;
-
- switch (status) {
- case 'loading':
- return children({ loading: true });
- case 'loaded':
- return children({ payload });
- case 'error':
- return children({ error });
- case 'uninitialized':
- default:
- return null;
+function ChartDataProvider({
+ children,
+ client,
+ formData,
+ sliceId,
+ loadDatasource,
+ onError,
+ onLoaded,
+ formDataRequestOptions,
+ datasourceRequestOptions,
+ queryRequestOptions,
+}: ChartDataProviderProps) {
+ const [state, setState] = useState<ChartDataProviderState>({
+ status: 'uninitialized',
+ });
+
+ const chartClient = useMemo(() => new ChartClient({ client }), [client]);
+
+ const extractSliceIdAndFormData = useCallback(
+ (): SliceIdAndOrFormData =>
+ formData ? { formData } : { sliceId: sliceId as number },
+ [formData, sliceId],
+ );
+
+ const handleReceiveData = useCallback(
+ (payload?: Payload) => {
+ if (onLoaded) onLoaded(payload);
+ setState({ payload, status: 'loaded' });
+ },
+ [onLoaded],
+ );
+
+ const handleError = useCallback(
+ (error: ProvidedProps['error']) => {
+ if (onError) onError(error);
+ setState({ error, status: 'error' });
+ },
+ [onError],
+ );
+
+ const handleFetchData = useCallback(() => {
+ setState({ status: 'loading' });
+ try {
+ chartClient
+ .loadFormData(extractSliceIdAndFormData(), formDataRequestOptions)
+ .then(loadedFormData =>
+ Promise.all([
+ loadDatasource
+ ? chartClient.loadDatasource(
+ loadedFormData.datasource,
+ datasourceRequestOptions,
+ )
+ : Promise.resolve(undefined),
+ chartClient.loadQueryData(loadedFormData, queryRequestOptions),
+ ]).then(
+ ([datasource, queriesData]) =>
+ ({
+ datasource,
+ formData: loadedFormData,
+ queriesData,
+ }) as Payload,
+ ),
+ )
+ .then(handleReceiveData)
+ .catch(handleError);
+ } catch (error) {
+ handleError(error as Error);
}
+ }, [
+ chartClient,
+ extractSliceIdAndFormData,
+ formDataRequestOptions,
+ loadDatasource,
+ datasourceRequestOptions,
+ queryRequestOptions,
+ handleReceiveData,
+ handleError,
+ ]);
+
+ // Fetch data on mount and when formData or sliceId changes
+ useEffect(() => {
+ handleFetchData();
+ }, [formData, sliceId, handleFetchData]);
Review Comment:
**Suggestion:** The `useEffect` that triggers data fetching currently
depends on `handleFetchData` as well as `formData` and `sliceId`, but
`handleFetchData` itself is recreated whenever callback props (like `onLoaded`,
`onError`) or request options change identity; this causes `ChartDataProvider`
to refetch data whenever those props change even if `formData` and `sliceId`
are unchanged, which is a behavioral change from the class-based implementation
and can lead to unnecessary or repeated network requests. To align with the
original semantics—only refetching when `formData` or `sliceId` change—you
should make the effect depend only on those two values so that changing
callbacks or options doesn't inadvertently trigger a new fetch. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ ChartDataProvider refetches when callbacks/options change, increasing
network load.
- ⚠️ Behavior contradicts comment 'only when formData or sliceId'.
```
</details>
```suggestion
}, [formData, sliceId]);
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open
`superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx`
and note that `handleFetchData` (lines 109–146) is created with
`useCallback` and depends
on `handleReceiveData` and `handleError`, which in turn depend on `onLoaded`
and `onError`
props (lines 93–107).
2. Note the effect at lines 148–151 in the same file: `useEffect(() => {
handleFetchData(); }, [formData, sliceId, handleFetchData]);` and the
preceding comment
"Fetch data on mount and when formData or sliceId changes".
3. In
`superset-frontend/packages/superset-ui-core/test/chart/components/ChartDataProvider.test.tsx`,
inside the `describe('callbacks', () => { ... })` block around lines
278–328, add a new
test that: (a) calls `setup({ onLoaded: jest.fn() })`, waits for the initial
load to
settle with `act`, (b) rerenders `ChartDataProvider` via
`rerender(<ChartDataProvider
{...props} onLoaded={jest.fn()} />);` without changing `formData` or
`sliceId`, and (c)
asserts how many times `mockLoadFormData` and `mockLoadQueryData` (defined
at lines 28–49)
are called.
4. Run this new test with the current implementation (e.g. `npm test --
ChartDataProvider.test.tsx`) and observe that `mockLoadFormData` and
`mockLoadQueryData`
are called again after the rerender with a new `onLoaded` callback, even
though `formData`
and `sliceId` are unchanged; this happens because `onLoaded`'s identity
changes, which
changes `handleReceiveData`, which changes `handleFetchData`, which is in
the `useEffect`
dependency array and thus retriggers a fetch, contrary to the stated intent
in the
comment.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/packages/superset-ui-core/src/chart/components/ChartDataProvider.tsx
**Line:** 151:151
**Comment:**
*Logic Error: The `useEffect` that triggers data fetching currently
depends on `handleFetchData` as well as `formData` and `sliceId`, but
`handleFetchData` itself is recreated whenever callback props (like `onLoaded`,
`onError`) or request options change identity; this causes `ChartDataProvider`
to refetch data whenever those props change even if `formData` and `sliceId`
are unchanged, which is a behavioral change from the class-based implementation
and can lead to unnecessary or repeated network requests. To align with the
original semantics—only refetching when `formData` or `sliceId` change—you
should make the effect depend only on those two values so that changing
callbacks or options doesn't inadvertently trigger a new fetch.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=a94a47a865a1e33c54666fe625def9f64632e17fdad00060c42c774c3d7ff2c8&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=a94a47a865a1e33c54666fe625def9f64632e17fdad00060c42c774c3d7ff2c8&reaction=dislike'>👎</a>
##########
superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx:
##########
@@ -49,66 +54,78 @@ export interface RenderFuncType<Props> {
propTypes?: WeakValidationMap<Props & ReactifyProps>;
}
+export interface ReactifiedComponentRef {
+ container?: HTMLDivElement;
+}
+
+type ReactifiedComponent<Props> = ForwardRefExoticComponent<
+ PropsWithoutRef<Props & ReactifyProps> &
RefAttributes<ReactifiedComponentRef>
+> & {
+ defaultProps?: Partial<Props & ReactifyProps>;
+ propTypes?: WeakValidationMap<Props & ReactifyProps>;
+};
+
export default function reactify<Props extends object>(
renderFn: RenderFuncType<Props>,
callbacks?: LifeCycleCallbacks,
-): ComponentClass<Props & ReactifyProps> {
- class ReactifiedComponent extends Component<Props & ReactifyProps> {
- container?: HTMLDivElement;
-
- constructor(props: Props & ReactifyProps) {
- super(props);
- this.setContainerRef = this.setContainerRef.bind(this);
- }
-
- componentDidMount() {
- this.execute();
- }
-
- componentDidUpdate() {
- this.execute();
- }
-
- componentWillUnmount() {
- this.container = undefined;
- if (callbacks?.componentWillUnmount) {
- callbacks.componentWillUnmount.bind(this)();
+): ReactifiedComponent<Props> {
+ const ReactifiedComponent = forwardRef<
+ ReactifiedComponentRef,
+ Props & ReactifyProps
+ >(function ReactifiedComponent(props, ref) {
+ const containerRef = useRef<HTMLDivElement>(null);
+
+ // Expose container via ref for external access
+ useImperativeHandle(
+ ref,
+ () => ({
+ get container() {
+ return containerRef.current ?? undefined;
+ },
+ }),
+ [],
+ );
+
+ // Execute renderFn on mount and every update (mimics componentDidMount +
componentDidUpdate)
+ useEffect(() => {
+ if (containerRef.current) {
+ renderFn(containerRef.current, props);
}
- }
+ });
- setContainerRef(ref: HTMLDivElement) {
- this.container = ref;
- }
+ // Cleanup on unmount
+ useEffect(
+ () => () => {
+ if (callbacks?.componentWillUnmount) {
+ callbacks.componentWillUnmount();
+ }
Review Comment:
**Suggestion:** The new hook-based implementation of `reactify` calls the
optional `componentWillUnmount` callback without binding `this`, whereas the
previous class-based version explicitly did
`callbacks.componentWillUnmount.bind(this)()`. Any existing
`componentWillUnmount` implementations that rely on `this` (for example
accessing `this.container` to clean up DOM or listeners) will now see `this ===
undefined` and can throw at runtime or silently skip required cleanup. To
preserve compatibility, you should invoke the callback with a `this` object
that at least exposes the expected `container` property, mimicking the old
behavior. [logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ NVD3 legacy charts throw error on unmounting React component.
- ❌ Tooltip cleanup skipped causing stuck tooltips on dashboards.
- ⚠️ React error boundaries may capture unmount crash logs.
```
</details>
```suggestion
// Preserve legacy behavior where `this` was a component instance
// exposing a `container` property.
callbacks.componentWillUnmount.call({ container: undefined });
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Note the legacy NVD3 wrapper at
`superset-frontend/plugins/legacy-preset-chart-nvd3/src/ReactNVD3.tsx:26-33`,
where
`function componentWillUnmount() { const { id } = this.props; ... }` reads
`this.props.id`
to decide whether to call `removeTooltip(id)` or `hideTooltips(true)`.
2. Observe that the same file wires this callback into `reactify` at
`ReactNVD3.tsx:35`
via `const ReactNVD3 = reactify(Component, { componentWillUnmount });`, so
`reactify`'s
`callbacks.componentWillUnmount` is this unbound function relying on its
`this` context.
3. In the new hook-based implementation of `reactify` at
`superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx:97-105`,
the cleanup effect calls `callbacks.componentWillUnmount();` without
`.bind(this)` or
`.call(...)`, so in strict mode the function's `this` is `undefined` instead
of the former
component instance with `this.props` and `this.container`.
4. Render any NVD3 legacy chart that uses `ReactNVD3` (e.g. via the chart
framework, which
imports this default export) and then unmount it (closing the Explore view,
removing the
chart from a dashboard, or programmatically unmounting its React subtree);
during unmount,
React runs the cleanup effect, `callbacks.componentWillUnmount()` executes
with `this ===
undefined`, and `componentWillUnmount` at `ReactNVD3.tsx:26-33` throws a
`TypeError` when
reading `this.props`, preventing tooltip cleanup and surfacing a React error
on unmount.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/packages/superset-ui-core/src/chart/components/reactify.tsx
**Line:** 101:101
**Comment:**
*Logic Error: The new hook-based implementation of `reactify` calls the
optional `componentWillUnmount` callback without binding `this`, whereas the
previous class-based version explicitly did
`callbacks.componentWillUnmount.bind(this)()`. Any existing
`componentWillUnmount` implementations that rely on `this` (for example
accessing `this.container` to clean up DOM or listeners) will now see `this ===
undefined` and can throw at runtime or silently skip required cleanup. To
preserve compatibility, you should invoke the callback with a `this` object
that at least exposes the expected `container` property, mimicking the old
behavior.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=64ca622e57e9b78b4498ca6db777163c4f8bb5b2cf608ccace92dbaa2521f8d1&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=64ca622e57e9b78b4498ca6db777163c4f8bb5b2cf608ccace92dbaa2521f8d1&reaction=dislike'>👎</a>
##########
superset-frontend/src/components/Chart/Chart.tsx:
##########
@@ -176,243 +163,310 @@ const MessageSpan = styled.span`
color: ${({ theme }) => theme.colorText};
`;
-class Chart extends PureComponent<ChartProps, {}> {
- static defaultProps = defaultProps;
-
- renderStartTime: any;
-
- constructor(props: ChartProps) {
- super(props);
- this.handleRenderContainerFailure =
- this.handleRenderContainerFailure.bind(this);
- }
-
- componentDidMount() {
- if (this.props.triggerQuery) {
- this.runQuery();
- }
- }
-
- componentDidUpdate() {
- if (this.props.triggerQuery) {
- this.runQuery();
- }
- }
-
- shouldRenderChart() {
- return (
- this.props.isInView ||
+function Chart({
+ addFilter = () => BLANK,
+ onFilterMenuOpen = () => BLANK,
+ onFilterMenuClose = () => BLANK,
+ initialValues = BLANK,
+ setControlValue = () => BLANK,
+ triggerRender = false,
+ dashboardId,
+ chartStackTrace,
+ force = false,
+ isInView = true,
+ ...restProps
+}: ChartProps): JSX.Element {
+ const {
+ actions,
+ chartId,
+ datasource,
+ formData,
+ timeout,
+ ownState,
+ chartAlert,
+ chartStatus,
+ queriesResponse = [],
+ errorMessage,
+ chartIsStale,
+ width,
+ height,
+ datasetsStatus,
+ onQuery,
+ annotationData,
+ labelColors: _labelColors,
+ sharedLabelColors: _sharedLabelColors,
+ vizType,
+ isFiltersInitialized: _isFiltersInitialized,
+ latestQueryFormData,
+ triggerQuery,
+ postTransformProps,
+ emitCrossFilters,
+ onChartStateChange,
+ } = restProps;
+
+ const renderStartTimeRef = useRef<number>(0);
Review Comment:
**Suggestion:** The `renderStartTimeRef` used when logging chart render
failures is never updated from its initial value of `0`, so `start_offset` in
the `LOG_ACTIONS_RENDER_CHART` error event is always `0`, which breaks the
timing metrics; initialize it with `Logger.getTimestamp()` so that error logs
record a meaningful start offset. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ Chart error logs use incorrect start_offset and duration.
- ⚠️ Misleading render timing metrics for LOG_ACTIONS_RENDER_CHART events.
```
</details>
```suggestion
const renderStartTimeRef = useRef<number>(Logger.getTimestamp());
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open a dashboard chart or Explore chart so that `ChartContainer`
(`superset-frontend/src/components/Chart/ChartContainer.tsx:24`) renders the
`Chart`
component from `superset-frontend/src/components/Chart/Chart.tsx`.
2. Observe that `Chart` wraps `ChartRenderer` in an `ErrorBoundary` at
`superset-frontend/src/components/Chart/Chart.tsx:458-469` with
`onError={handleRenderContainerFailure}`.
3. Cause a runtime error in the chart rendering tree so that React's error
boundary path
is exercised (for example, a bug in a viz plugin rendered by `SuperChart` in
`ChartRenderer` at
`superset-frontend/src/components/Chart/ChartRenderer.tsx:600-626` that
throws during render). This will trigger `ErrorBoundary` to call
`handleRenderContainerFailure` in `Chart.tsx:244-261`.
4. In `handleRenderContainerFailure`, the code logs
`LOG_ACTIONS_RENDER_CHART` using
`start_offset: renderStartTimeRef.current` and `duration:
Logger.getTimestamp() -
renderStartTimeRef.current`
(`superset-frontend/src/components/Chart/Chart.tsx:253-259`),
but `renderStartTimeRef` is only initialized as `useRef<number>(0)` at line
207 and never
updated elsewhere in this file (verified via repository-wide search for
`renderStartTimeRef`), so `start_offset` is always `0` and `duration` is
computed from
that zero baseline, producing misleading timing metrics for all such error
logs.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/components/Chart/Chart.tsx
**Line:** 207:207
**Comment:**
*Logic Error: The `renderStartTimeRef` used when logging chart render
failures is never updated from its initial value of `0`, so `start_offset` in
the `LOG_ACTIONS_RENDER_CHART` error event is always `0`, which breaks the
timing metrics; initialize it with `Logger.getTimestamp()` so that error logs
record a meaningful start offset.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=d8c90eac9a65889ba627e9febefcd6c03db87cab5f502118abec7b906d13eae8&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=d8c90eac9a65889ba627e9febefcd6c03db87cab5f502118abec7b906d13eae8&reaction=dislike'>👎</a>
##########
superset-frontend/src/dashboard/components/Dashboard.tsx:
##########
@@ -90,165 +90,61 @@ interface VisibilityEventData {
ts: number;
}
-class Dashboard extends PureComponent<DashboardProps> {
- static contextType = PluginContext;
-
- // Use type assertion when accessing context instead of declare field
- // to avoid babel transformation issues in Jest
-
- static defaultProps = {
- timeout: 60,
- userId: '',
- };
-
- appliedFilters: ActiveFilters;
-
- appliedOwnDataCharts: JsonObject;
-
- visibilityEventData: VisibilityEventData;
-
- static onBeforeUnload(hasChanged: boolean): void {
- if (hasChanged) {
- window.addEventListener('beforeunload', Dashboard.unload);
- } else {
- window.removeEventListener('beforeunload', Dashboard.unload);
- }
- }
-
- static unload(): string {
- const message = t('You have unsaved changes.');
- // Gecko + IE: returnValue is typed as boolean but historically accepts
string
- (window.event as BeforeUnloadEvent).returnValue = message;
- return message; // Gecko + Webkit, Safari, Chrome etc.
- }
-
- constructor(props: DashboardProps) {
- super(props);
- this.appliedFilters = props.activeFilters ?? {};
- this.appliedOwnDataCharts = props.ownDataCharts ?? {};
- this.visibilityEventData = { start_offset: 0, ts: 0 };
- this.onVisibilityChange = this.onVisibilityChange.bind(this);
- }
-
- componentDidMount(): void {
- const bootstrapData = getBootstrapData();
- const { editMode, isPublished, layout } = this.props;
- const eventData: Record<string, unknown> = {
- is_soft_navigation: Logger.timeOriginOffset > 0,
- is_edit_mode: editMode,
- mount_duration: Logger.getTimestamp(),
- is_empty: isDashboardEmpty(layout),
- is_published: isPublished,
- bootstrap_data_length: JSON.stringify(bootstrapData).length,
- };
- const directLinkComponentId = getLocationHash();
- if (directLinkComponentId) {
- eventData.target_id = directLinkComponentId;
- }
- this.props.actions.logEvent(LOG_ACTIONS_MOUNT_DASHBOARD, eventData);
-
- // Handle browser tab visibility change
- if (document.visibilityState === 'hidden') {
- this.visibilityEventData = {
- start_offset: Logger.getTimestamp(),
- ts: new Date().getTime(),
- };
- }
- window.addEventListener('visibilitychange', this.onVisibilityChange);
- this.applyCharts();
- }
-
- componentDidUpdate(prevProps: DashboardProps): void {
- this.applyCharts();
- const currentChartIds = getChartIdsFromLayout(prevProps.layout);
- const nextChartIds = getChartIdsFromLayout(this.props.layout);
-
- if (prevProps.dashboardId !== this.props.dashboardId) {
- // single-page-app navigation check
- return;
- }
-
- if (currentChartIds.length < nextChartIds.length) {
- const newChartIds = nextChartIds.filter(
- key => currentChartIds.indexOf(key) === -1,
- );
- newChartIds.forEach(newChartId =>
- this.props.actions.addSliceToDashboard(
- newChartId,
- getLayoutComponentFromChartId(this.props.layout, newChartId),
- ),
- );
- } else if (currentChartIds.length > nextChartIds.length) {
- // remove chart
- const removedChartIds = currentChartIds.filter(
- key => nextChartIds.indexOf(key) === -1,
- );
- removedChartIds.forEach(removedChartId =>
- this.props.actions.removeSliceFromDashboard(removedChartId),
- );
- }
- }
-
- applyCharts(): void {
- const {
- activeFilters,
- ownDataCharts,
- chartConfiguration,
- hasUnsavedChanges,
- editMode,
- } = this.props;
- const { appliedFilters, appliedOwnDataCharts } = this;
- if (!chartConfiguration) {
- // For a first loading we need to wait for cross filters charts data
loaded to get all active filters
- // for correct comparing of filters to avoid unnecessary requests
- return;
- }
-
- if (
- !editMode &&
- (!areObjectsEqual(appliedOwnDataCharts, ownDataCharts, {
- ignoreUndefined: true,
- }) ||
- !areObjectsEqual(appliedFilters, activeFilters, {
- ignoreUndefined: true,
- }))
- ) {
- this.applyFilters();
- }
-
- if (hasUnsavedChanges) {
- Dashboard.onBeforeUnload(true);
- } else {
- Dashboard.onBeforeUnload(false);
- }
+function onBeforeUnload(hasChanged: boolean): void {
+ if (hasChanged) {
+ window.addEventListener('beforeunload', unload);
+ } else {
+ window.removeEventListener('beforeunload', unload);
}
+}
- componentWillUnmount(): void {
- window.removeEventListener('visibilitychange', this.onVisibilityChange);
- this.props.actions.clearDataMaskState();
- this.props.actions.clearAllChartStates();
- }
+function unload(): string {
+ const message = t('You have unsaved changes.');
+ // Gecko + IE: returnValue is typed as boolean but historically accepts
string
Review Comment:
**Suggestion:** The `beforeunload` handler relies on `window.event` instead
of the event parameter, which is non-standard in modern browsers and can be
undefined, leading to a runtime TypeError and preventing the unsaved-changes
warning from working consistently. [logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- CRITICAL: Unsaved dashboard edits may be lost without browser warning.
- CRITICAL: Runtime TypeError possible in nonstandard `window.event`
environments.
- WARNING: Inconsistent beforeunload handling versus shared
`useBeforeUnload` hook.
```
</details>
```suggestion
function unload(event: BeforeUnloadEvent): string {
const message = t('You have unsaved changes.');
// Set returnValue on the actual event object to trigger the browser prompt
event.returnValue = message;
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Load any dashboard page so that `Dashboard` is mounted via the Redux
container at
`superset-frontend/src/dashboard/containers/Dashboard.ts:34-56`, which passes
`hasUnsavedChanges: dashboardState.hasUnsavedChanges` into the `Dashboard`
component
(`DashboardProps.hasUnsavedChanges` declared in `Dashboard.tsx:70-75`).
2. Put the dashboard into a state where `dashboardState.hasUnsavedChanges`
is true (e.g.,
by dispatching an `ON_CHANGE` or `SET_UNSAVED_CHANGES` action, which the
reducer handles
by setting `hasUnsavedChanges: true` at
`superset-frontend/src/dashboard/reducers/dashboardState.ts:245-247,
271-274`). This flows
through the container so that the `hasUnsavedChanges` prop received by
`Dashboard` becomes
`true`.
3. With `hasUnsavedChanges === true`, the `applyCharts` hook in
`superset-frontend/src/dashboard/components/Dashboard.tsx:225-256` calls
`onBeforeUnload(true)` at lines 244-247, which in turn attaches the
`beforeunload`
listener `unload` via `window.addEventListener('beforeunload', unload)` in
`Dashboard.tsx:93-99`.
4. Attempt to close or reload the browser tab while in this state. The
browser invokes the
registered `beforeunload` handler `unload` from `Dashboard.tsx:101-105`.
That function
currently reads `(window.event as BeforeUnloadEvent).returnValue =
message;`. In
environments where `window.event` is not populated for
`addEventListener`-registered
handlers (a non-standard, browser-dependent behavior documented on MDN and
avoided
elsewhere in this codebase, e.g., `useBeforeUnload` at
`superset-frontend/src/hooks/useBeforeUnload/index.ts:30-41` correctly
relies on the event
parameter), `window.event` will be `undefined`. The property write
`(...).returnValue =
message` then throws a runtime TypeError instead of setting
`event.returnValue`, which can
prevent the unsaved-changes warning dialog from appearing and may abort the
intended
unload handling.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/dashboard/components/Dashboard.tsx
**Line:** 101:103
**Comment:**
*Logic Error: The `beforeunload` handler relies on `window.event`
instead of the event parameter, which is non-standard in modern browsers and
can be undefined, leading to a runtime TypeError and preventing the
unsaved-changes warning from working consistently.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=5bed28ae6f0dfddee95c89ac5664c23ae8148e4770b131d10a5214fc0714d119&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=5bed28ae6f0dfddee95c89ac5664c23ae8148e4770b131d10a5214fc0714d119&reaction=dislike'>👎</a>
##########
superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx:
##########
@@ -401,251 +652,122 @@ export class TableRenderer extends Component<
rowKeys,
rowTotals,
colTotals,
- arrowCollapsed: subtotalOptions.arrowCollapsed,
- arrowExpanded: subtotalOptions.arrowExpanded,
+ arrowCollapsed: mergedSubtotalOptions.arrowCollapsed,
+ arrowExpanded: mergedSubtotalOptions.arrowExpanded,
colSubtotalDisplay,
rowSubtotalDisplay,
cellCallbacks,
rowTotalCallbacks,
colTotalCallbacks,
grandTotalCallback,
namesMapping,
- allowRenderHtml: props.allowRenderHtml,
- };
- }
-
- clickHandler(
- pivotData: InstanceType<typeof PivotData>,
- rowValues: string[],
- colValues: string[],
- ) {
- const colAttrs = this.props.cols;
- const rowAttrs = this.props.rows;
- const value = pivotData.getAggregator(rowValues, colValues).value();
- const filters: Record<string, string> = {};
- const colLimit = Math.min(colAttrs.length, colValues.length);
- for (let i = 0; i < colLimit; i += 1) {
- const attr = colAttrs[i];
- if (colValues[i] !== null) {
- filters[attr] = colValues[i];
- }
- }
- const rowLimit = Math.min(rowAttrs.length, rowValues.length);
- for (let i = 0; i < rowLimit; i += 1) {
- const attr = rowAttrs[i];
- if (rowValues[i] !== null) {
- filters[attr] = rowValues[i];
- }
- }
- const { clickCallback } = this.props.tableOptions;
- return (e: MouseEvent) => clickCallback?.(e, value, filters, pivotData);
- }
-
- clickHeaderHandler(
- pivotData: InstanceType<typeof PivotData>,
- values: string[],
- attrs: string[],
- attrIdx: number,
- callback: HeaderClickCallback | undefined,
- isSubtotal = false,
- isGrandTotal = false,
- ) {
- const filters: Record<string, string> = {};
- for (let i = 0; i <= attrIdx; i += 1) {
- const attr = attrs[i];
- filters[attr] = values[i];
- }
- return (e: MouseEvent) =>
- callback?.(
- e,
- values[attrIdx],
- filters,
- pivotData,
- isSubtotal,
- isGrandTotal,
- );
- }
-
- collapseAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- // Collapse an entire attribute.
- e.stopPropagation();
- const keyLen = attrIdx + 1;
- const collapsed = allKeys
- .filter((k: string[]) => k.length === keyLen)
- .map(flatKey);
-
- const updates: Record<string, boolean> = {};
- collapsed.forEach((k: string) => {
- updates[k] = true;
- });
-
- if (rowOrCol) {
- this.setState(state => ({
- collapsedRows: { ...state.collapsedRows, ...updates },
- }));
- } else {
- this.setState(state => ({
- collapsedCols: { ...state.collapsedCols, ...updates },
- }));
- }
- };
- }
-
- expandAttr(rowOrCol: boolean, attrIdx: number, allKeys: string[][]) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- // Expand an entire attribute. This implicitly implies expanding all of
the
- // parents as well. It's a bit inefficient but ah well...
- e.stopPropagation();
- const updates: Record<string, boolean> = {};
- allKeys.forEach((k: string[]) => {
- for (let i = 0; i <= attrIdx; i += 1) {
- updates[flatKey(k.slice(0, i + 1))] = false;
- }
- });
-
- if (rowOrCol) {
- this.setState(state => ({
- collapsedRows: { ...state.collapsedRows, ...updates },
- }));
- } else {
- this.setState(state => ({
- collapsedCols: { ...state.collapsedCols, ...updates },
- }));
- }
- };
- }
-
- toggleRowKey(flatRowKey: string) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- e.stopPropagation();
- this.setState(state => ({
- collapsedRows: {
- ...state.collapsedRows,
- [flatRowKey]: !state.collapsedRows[flatRowKey],
- },
- }));
- };
- }
-
- toggleColKey(flatColKey: string) {
- return (e: MouseEvent<HTMLSpanElement>) => {
- e.stopPropagation();
- this.setState(state => ({
- collapsedCols: {
- ...state.collapsedCols,
- [flatColKey]: !state.collapsedCols[flatColKey],
- },
- }));
+ allowRenderHtml,
};
- }
-
- calcAttrSpans(attrArr: string[][], numAttrs: number) {
- // Given an array of attribute values (i.e. each element is another array
with
- // the value at every level), compute the spans for every attribute value
at
- // every level. The return value is a nested array of the same shape. It
has
- // -1's for repeated values and the span number otherwise.
-
- const spans = [];
- // Index of the last new value
- const li = Array(numAttrs).map(() => 0);
- let lv: (string | null)[] = Array(numAttrs).map(() => null);
- for (let i = 0; i < attrArr.length; i += 1) {
- // Keep increasing span values as long as the last keys are the same. For
- // the rest, record spans of 1. Update the indices too.
- const cv = attrArr[i];
- const ent = [];
- let depth = 0;
- const limit = Math.min(lv.length, cv.length);
- while (depth < limit && lv[depth] === cv[depth]) {
- ent.push(-1);
- spans[li[depth]][depth] += 1;
- depth += 1;
- }
- while (depth < cv.length) {
- li[depth] = i;
- ent.push(1);
- depth += 1;
- }
- spans.push(ent);
- lv = cv;
- }
- return spans;
- }
+ }, [
+ cols,
+ rows,
+ tableOptions,
+ namesMappingProp,
+ subtotalOptions,
+ props,
+ allowRenderHtml,
+ clickHandler,
+ ]);
+
+ const visibleKeys = useCallback(
+ (
+ keys: string[][],
+ collapsed: Record<string, boolean>,
+ numAttrs: number,
+ subtotalDisplay: SubtotalDisplay,
+ ) =>
+ keys.filter(
+ (key: string[]) =>
+ // Is the key hidden by one of its parents?
+ !key.some(
+ (_k: string, j: number) => collapsed[flatKey(key.slice(0, j))],
+ ) &&
+ // Leaf key.
+ (key.length === numAttrs ||
+ // Children hidden. Must show total.
+ flatKey(key) in collapsed ||
+ // Don't hide totals.
+ !subtotalDisplay.hideOnExpand),
+ ),
+ [],
+ );
- getAggregatedData(
- pivotData: InstanceType<typeof PivotData>,
- visibleColName: string[],
- rowPartialOnTop: boolean | undefined,
- ) {
- // Transforms flat row keys into a hierarchical group structure where each
level
- // represents a grouping dimension. For each row key path, it calculates
the
- // aggregated value for the specified column and builds a nested object
that
- // preserves the hierarchy while storing aggregation values at each level.
- const groups: Record<string, HierarchicalNode> = {};
- const rows = pivotData.rowKeys;
- rows.forEach(rowKey => {
- const aggValue =
- pivotData.getAggregator(rowKey, visibleColName).value() ?? 0;
-
- if (rowPartialOnTop) {
- const parent = rowKey
- .slice(0, -1)
- .reduce(
- (acc: Record<string, HierarchicalNode>, key: string) =>
- (acc[key] ??= {}) as Record<string, HierarchicalNode>,
- groups,
- );
- parent[rowKey.at(-1)!] = { currentVal: aggValue as number };
- } else {
- rowKey.reduce((acc: Record<string, HierarchicalNode>, key: string) => {
- acc[key] = acc[key] || { currentVal: 0 };
- (acc[key] as HierarchicalNode).currentVal = aggValue as number;
- return acc[key] as Record<string, HierarchicalNode>;
- }, groups);
- }
- });
- return groups;
- }
+ const isDashboardEditMode = useCallback(
+ () => document.contains(document.querySelector('.dashboard--editing')),
+ [],
+ );
- sortAndCacheData(
- groups: Record<string, HierarchicalNode>,
- sortOrder: string,
- rowEnabled: boolean | undefined,
- rowPartialOnTop: boolean | undefined,
- maxRowIndex: number,
- ) {
- // Processes hierarchical data by first sorting it according to the
specified order
- // and then converting the sorted structure into a flat array format. This
function
- // serves as an intermediate step between hierarchical data representation
and
- // flat array representation needed for rendering.
- const sortedGroups = sortHierarchicalObject(
- groups,
- sortOrder,
- rowPartialOnTop,
- );
- return convertToArray(
- sortedGroups,
- rowEnabled,
- rowPartialOnTop,
- maxRowIndex,
- );
- }
+ // Compute base pivot settings, memoized based on relevant props
+ const basePivotSettings = useMemo(() => {
+ // Clear sort cache when props change
+ sortCacheRef.current.clear();
+ return getBasePivotSettings();
+ }, [getBasePivotSettings]);
+
+ // Reset sort state when props change
+ useEffect(() => {
+ setSortingOrder([]);
+ setActiveSortColumn(null);
+ setSortedRowKeys(null);
+ }, [props]);
Review Comment:
**Suggestion:** The effect that resets the sorting state depends on the
`props` object, which is recreated on every render (because of the
`...restProps` spread), causing the effect to run on every render and
immediately clear `sortingOrder`, `activeSortColumn`, and `sortedRowKeys` after
a sort is triggered, so the user can never see or maintain a sorted state.
[logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ PivotTable sorting appears broken in Explore view.
- ❌ Dashboard PivotTable charts cannot remain sorted by column.
- ⚠️ Users cannot analyze data ordered by metric results.
```
</details>
```suggestion
// Reset sort state when structural props change
useEffect(() => {
setSortingOrder([]);
setActiveSortColumn(null);
setSortedRowKeys(null);
}, [
cols,
rows,
aggregatorName,
tableOptions,
subtotalOptions,
namesMappingProp,
allowRenderHtml,
]);
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open any Pivot Table v2 chart in Explore (registered via
`PivotTableChartPluginV2` in
`superset-frontend/src/visualizations/presets/MainPreset.ts:86` which uses
`PivotTableChart.tsx:44` and ultimately
`react-pivottable/PivotTable.tsx:20-27` to render
`TableRenderer`).
2. With the chart rendered, click a sortable column header's sort icon in
the pivot table
UI (sorting handled by `sortData` in `TableRenderers.tsx:760-809`, which
updates
`sortedRowKeys`, `sortingOrder`, and `activeSortColumn` state).
3. React re-renders `TableRenderer` with the same logical props; during
render, the
memoized `props` object is rebuilt in `useMemo` at
`TableRenderers.tsx:300-323`, because
its dependency array includes `restProps`, a new object created on every
render from the
rest parameter in the component signature at `TableRenderers.tsx:276-286`.
4. After this render, the `useEffect` at `TableRenderers.tsx:712-717`, which
depends on
`props`, detects a changed `props` reference on every render and runs,
immediately
resetting `sortingOrder`, `activeSortColumn`, and `sortedRowKeys` back to
their initial
values, so the user never sees a persistent sorted table—the sort appears to
do nothing in
both Explore and dashboards that use `VizType.PivotTable`.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/plugin-chart-pivot-table/src/react-pivottable/TableRenderers.tsx
**Line:** 712:717
**Comment:**
*Logic Error: The effect that resets the sorting state depends on the
`props` object, which is recreated on every render (because of the
`...restProps` spread), causing the effect to run on every render and
immediately clear `sortingOrder`, `activeSortColumn`, and `sortedRowKeys` after
a sort is triggered, so the user can never see or maintain a sorted state.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=fdba3b5f4cf1a0575996b244a3c75375c2f91985592962182914620289bc0f9f&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=fdba3b5f4cf1a0575996b244a3c75375c2f91985592962182914620289bc0f9f&reaction=dislike'>👎</a>
##########
superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx:
##########
@@ -32,279 +32,295 @@ export interface DataEntry {
}
interface TTestTableProps {
- alpha: number;
+ alpha?: number;
data: DataEntry[];
groups: string[];
- liftValPrec: number;
+ liftValPrec?: number;
metric: string;
- pValPrec: number;
+ pValPrec?: number;
}
-interface TTestTableState {
- control: number;
- liftValues: (string | number)[];
- pValues: (string | number)[];
-}
-
-const defaultProps = {
- alpha: 0.05,
- liftValPrec: 4,
- pValPrec: 6,
-};
+function TTestTable({
+ alpha = 0.05,
+ data,
+ groups,
+ liftValPrec = 4,
+ metric,
+ pValPrec = 6,
+}: TTestTableProps) {
+ const [control, setControl] = useState(0);
+ const [liftValues, setLiftValues] = useState<(string | number)[]>([]);
+ const [pValues, setPValues] = useState<(string | number)[]>([]);
-class TTestTable extends Component<TTestTableProps, TTestTableState> {
- static defaultProps = defaultProps;
+ const computeLift = useCallback(
+ (values: DataPointValue[], controlValues: DataPointValue[]): string => {
+ // Compute the lift value between two time series
+ let sumValues = 0;
+ let sumControl = 0;
+ values.forEach((value, i) => {
+ sumValues += value.y;
+ sumControl += controlValues[i].y;
+ });
- constructor(props: TTestTableProps) {
- super(props);
- this.state = {
- control: 0,
- liftValues: [],
- pValues: [],
- };
- }
+ return (((sumValues - sumControl) / sumControl) * 100).toFixed(
+ liftValPrec,
+ );
+ },
+ [liftValPrec],
+ );
- componentDidMount() {
- const { control } = this.state;
- this.computeTTest(control); // initially populate table
- }
+ const computePValue = useCallback(
+ (
+ values: DataPointValue[],
+ controlValues: DataPointValue[],
+ ): string | number => {
+ // Compute the p-value from Student's t-test
+ // between two time series
+ let diffSum = 0;
+ let diffSqSum = 0;
+ let finiteCount = 0;
+ values.forEach((value, i) => {
+ const diff = controlValues[i].y - value.y;
+ /* eslint-disable-next-line */
+ if (isFinite(diff)) {
+ finiteCount += 1;
+ diffSum += diff;
+ diffSqSum += diff * diff;
+ }
+ });
+ const tvalue = -Math.abs(
+ diffSum *
+ Math.sqrt(
+ (finiteCount - 1) / (finiteCount * diffSqSum - diffSum * diffSum),
+ ),
+ );
+ try {
+ return (2 * new dist.Studentt(finiteCount - 1).cdf(tvalue)).toFixed(
+ pValPrec,
+ ); // two-sided test
+ } catch (error) {
+ return NaN;
+ }
+ },
+ [pValPrec],
+ );
- getLiftStatus(row: number): string {
- const { control, liftValues } = this.state;
- // Get a css class name for coloring
- if (row === control) {
- return 'control';
- }
- const liftVal = liftValues[row];
- if (Number.isNaN(liftVal) || !Number.isFinite(liftVal)) {
- return 'invalid'; // infinite or NaN values
- }
+ const computeTTest = useCallback(
+ (controlIndex: number) => {
+ // Compute lift and p-values for each row
+ // against the selected control
+ const newPValues: (string | number)[] = [];
+ const newLiftValues: (string | number)[] = [];
+ if (!data) {
+ return;
+ }
+ for (let i = 0; i < data.length; i += 1) {
+ if (i === controlIndex) {
+ newPValues.push('control');
+ newLiftValues.push('control');
+ } else {
+ newPValues.push(
+ computePValue(data[i].values, data[controlIndex].values),
+ );
+ newLiftValues.push(
+ computeLift(data[i].values, data[controlIndex].values),
+ );
+ }
+ }
+ setControl(controlIndex);
+ setLiftValues(newLiftValues);
+ setPValues(newPValues);
+ },
+ [data, computeLift, computePValue],
+ );
- return Number(liftVal) >= 0 ? 'true' : 'false'; // green on true, red on
false
- }
+ // Initially populate table on mount
+ useEffect(() => {
+ computeTTest(control);
+ }, [computeTTest, control]);
Review Comment:
**Suggestion:** When the underlying `data` prop changes to a shorter array
after the user has selected a control row with a higher index, the `useEffect`
hook will call `computeTTest(control)` using an out-of-range `control` index,
leading to `data[control]` being `undefined` inside
`computeLift`/`computePValue` and causing a runtime error when accessing
`.values[i].y`; the effect should clamp or reset the control index based on the
current `data.length` and recompute safely. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Paired t-test chart crashes after filter-driven data shrink.
- ⚠️ Dashboards using PairedTTest tiles may intermittently fail.
```
</details>
```suggestion
// Recompute table when data or control row changes, keeping control index
in range
useEffect(() => {
if (!data || data.length === 0) {
setControl(0);
setLiftValues([]);
setPValues([]);
return;
}
const safeControlIndex = Math.min(control, data.length - 1);
if (safeControlIndex !== control) {
setControl(safeControlIndex);
computeTTest(safeControlIndex);
} else {
computeTTest(control);
}
}, [computeTTest, control, data]);
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Create or open a Paired T-Test chart (viz type `VizType.PairedTTest`) in
Explore or a
dashboard; this viz type is registered via `MainPreset.ts:135` using `new
PairedTTestChartPlugin().configure({ key: VizType.PairedTTest })`, which
loads
`plugins/legacy-plugin-chart-paired-t-test/src/PairedTTest.tsx` through the
chart plugin
in `plugins/legacy-plugin-chart-paired-t-test/src/index.ts:41-44`.
2. When the chart renders, `PairedTTest` (`PairedTTest.tsx:109-139`) maps
metrics to
`<TTestTable>` components (`TTestTable.tsx:43-50`) passing `data[metric]` as
the `data`
prop; `TTestTable` initializes `control` state to `0` (`TTestTable.tsx:51`)
and the effect
at `TTestTable.tsx:137-140` calls `computeTTest(control)` to populate
`liftValues` and
`pValues`.
3. With an initial query where `data[metric]` has multiple rows (length N >
1), click on
the last table row in the rendered t-test table; this triggers
`handleRowClick`
(`TTestTable.tsx:186-191`), which calls `computeTTest(rowIndex)`
(`TTestTable.tsx:108-135`) and sets `control` in state to that high
`rowIndex` (e.g., N -
1).
4. Change chart controls (e.g., filters, groupby, or time range) so that the
next query
result `data[metric]` has fewer rows (length M where M <= previous `control`
index);
Superset re-renders `PairedTTest` with the new `data` while preserving
`TTestTable` state
(same `key` in `PairedTTest.tsx:123-132`), so `control` still holds the old
index. On the
subsequent render, the `useEffect` at `TTestTable.tsx:137-140` runs and calls
`computeTTest(control)` with the out-of-range index. Inside `computeTTest`
(`TTestTable.tsx:108-135`), the loop accesses `data[controlIndex].values`
(lines 122-127);
since `data[controlIndex]` is now `undefined`, this results in a runtime
TypeError
(attempting to read `.values` of `undefined`), causing the Paired T-Test
chart to error
and fail to render.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/legacy-plugin-chart-paired-t-test/src/TTestTable.tsx
**Line:** 137:140
**Comment:**
*Logic Error: When the underlying `data` prop changes to a shorter
array after the user has selected a control row with a higher index, the
`useEffect` hook will call `computeTTest(control)` using an out-of-range
`control` index, leading to `data[control]` being `undefined` inside
`computeLift`/`computePValue` and causing a runtime error when accessing
`.values[i].y`; the effect should clamp or reset the control index based on the
current `data.length` and recompute safely.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=b0fda6c32b671f9ce44108013884563a4aa9c2f9b710403307aa2924b527c9a0&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=b0fda6c32b671f9ce44108013884563a4aa9c2f9b710403307aa2924b527c9a0&reaction=dislike'>👎</a>
##########
superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx:
##########
@@ -335,126 +318,110 @@ class Markdown extends PureComponent<MarkdownProps,
MarkdownState> {
onReady={(handle: EditorInstance) => {
// The handle provides access to the underlying editor for resize
if (handle && typeof handle.focus === 'function') {
- this.setEditor(handle);
+ setEditor(handle);
}
}}
data-test="editor"
/>
- );
- }
-
- renderPreviewMode(): JSX.Element {
- const { hasError } = this.state;
+ ),
+ [id, markdownSource, handleMarkdownChange, setEditor],
+ );
- return (
+ const renderPreviewMode = useMemo(
+ () => (
<SafeMarkdown
source={
hasError
? MARKDOWN_ERROR_MESSAGE
- : this.state.markdownSource || MARKDOWN_PLACE_HOLDER
+ : markdownSource || MARKDOWN_PLACE_HOLDER
}
- htmlSanitization={this.props.htmlSanitization}
- htmlSchemaOverrides={this.props.htmlSchemaOverrides}
+ htmlSanitization={htmlSanitization}
+ htmlSchemaOverrides={htmlSchemaOverrides}
/>
- );
- }
-
- render() {
- const { isFocused, editorMode } = this.state;
-
- const {
- component,
- parentComponent,
- index,
- depth,
- availableColumnCount,
- columnWidth,
- onResize,
- onResizeStop,
- handleComponentDrop,
- editMode,
- } = this.props;
-
- // inherit the size of parent columns
- const widthMultiple =
- parentComponent.type === COLUMN_TYPE
- ? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
- : component.meta.width || GRID_MIN_COLUMN_COUNT;
-
- const isEditing = editorMode === 'edit';
-
- return (
- <Draggable
- component={component}
- parentComponent={parentComponent}
- orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
- index={index}
- depth={depth}
- onDrop={handleComponentDrop}
- disableDragDrop={isFocused}
- editMode={editMode}
- >
- {({ dragSourceRef }: DragChildProps) => (
- <WithPopoverMenu
- onChangeFocus={this.handleChangeFocus}
- shouldFocus={this.shouldFocusMarkdown}
- menuItems={[
- <MarkdownModeDropdown
- key={`${component.id}-mode`}
- id={`${component.id}-mode`}
- value={this.state.editorMode}
- onChange={this.handleChangeEditorMode}
- />,
- ]}
- editMode={editMode}
+ ),
Review Comment:
**Suggestion:** Inside the Markdown component, the `hasError` flag is
checked in the preview rendering but never set to `true`, so even when an error
occurs the user will never see `MARKDOWN_ERROR_MESSAGE`; adding an
`ErrorBoundary` around the preview and an `onError` handler that sets
`hasError` and shows the danger toast restores functional error handling.
[logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ Markdown preview fallback message is currently unreachable on errors.
- ⚠️ Users lose guidance to revert problematic markdown content.
- ⚠️ Error handling inconsistent with Chart and similar components.
```
</details>
```suggestion
const handleRenderError = useCallback(
(error: Error, info: { componentStack: string } | null): void => {
setHasError(true);
if (editorMode === 'preview') {
addDangerToast(
t(
'This markdown component has an error. Please revert your recent
changes.',
),
);
}
},
[addDangerToast, editorMode],
);
const renderEditMode = useMemo(
() => (
<EditorHost
id={`markdown-editor-${id}`}
onChange={handleMarkdownChange}
width="100%"
height="100%"
value={
// this allows "select all => delete" to give an empty editor
typeof markdownSource === 'string'
? markdownSource
: MARKDOWN_PLACE_HOLDER
}
language="markdown"
readOnly={false}
lineNumbers={false}
wordWrap
onReady={(handle: EditorInstance) => {
// The handle provides access to the underlying editor for resize
if (handle && typeof handle.focus === 'function') {
setEditor(handle);
}
}}
data-test="editor"
/>
),
[id, markdownSource, handleMarkdownChange, setEditor],
);
const renderPreviewMode = useMemo(
() => (
<ErrorBoundary onError={handleRenderError} showMessage={false}>
<SafeMarkdown
source={
hasError
? MARKDOWN_ERROR_MESSAGE
: markdownSource || MARKDOWN_PLACE_HOLDER
}
htmlSanitization={htmlSanitization}
htmlSchemaOverrides={htmlSchemaOverrides}
/>
</ErrorBoundary>
),
[
hasError,
markdownSource,
htmlSanitization,
htmlSchemaOverrides,
handleRenderError,
],
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open a dashboard containing a markdown cell so that the `Markdown`
component in
`superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx`
is used
via the `MARKDOWN_TYPE` mapping in
`src/dashboard/components/gridComponents/index.ts:43`
and type definition in `src/dashboard/util/componentTypes.ts:26`. In view
mode
(`editMode=false`), the component renders its preview using the
`renderPreviewMode` memo
at `Markdown.tsx:330–342`.
2. Note that the preview rendering logic selects between a fallback message
and the
markdown content based on the `hasError` flag: `source={hasError ?
MARKDOWN_ERROR_MESSAGE
: markdownSource || MARKDOWN_PLACE_HOLDER}` at `Markdown.tsx:332–337`, where
`MARKDOWN_ERROR_MESSAGE` is defined at `Markdown.tsx:93` as `t('This
markdown component
has an error.')`. However, `hasError` is initialized to `false` in state at
line 161 and
only ever set back to `false` in the undo/redo `useEffect` (line 177) and
when switching
to preview mode at line 252; there is no `setHasError(true)` anywhere in the
file.
3. Cause a runtime error in the markdown rendering path—for example, by
introducing a bug
in `SafeMarkdown` (imported at `Markdown.tsx:26` and used at
`Markdown.tsx:332–340`) or by
triggering an unexpected error in custom markdown content—so that the
preview render
fails. Because there is no `ErrorBoundary` around `SafeMarkdown` in
`renderPreviewMode`
and no `try/catch` logic in `Markdown.tsx`, the error will bubble to an
ancestor boundary
(such as the `ErrorBoundary` in `src/views/App.tsx:94–100` or the
dashboard-level
boundary) rather than being handled within this component. The internal
`hasError` flag
remains `false`, so the component never switches to `MARKDOWN_ERROR_MESSAGE`.
4. Compare this behavior with both the rest of the codebase and prior
expectations: the
translations file
`superset/translations/en/LC_MESSAGES/messages.po:10989–10992` contains
both `"This markdown component has an error."` and `"This markdown component
has an error.
Please revert your recent changes."`, indicating a design where markdown
errors show a
specific fallback message and guidance. The Chart component at
`src/components/Chart/Chart.tsx:458–469` wraps its render in an
`ErrorBoundary` with an
`onError` handler that logs and reports errors. In `Markdown.tsx`, by
contrast,
`addDangerToast` (line 75) is never called and `hasError` is never set to
`true`, so
markdown-specific error handling is effectively disabled. The suggested
change—adding a
`handleRenderError` callback that calls `setHasError(true)` and
`addDangerToast(...)`, and
wrapping `SafeMarkdown` in `<ErrorBoundary onError={handleRenderError}
showMessage={false}>`—aligns Markdown error handling with the established
pattern and
makes the fallback message reachable.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/src/dashboard/components/gridComponents/Markdown/Markdown.tsx
**Line:** 301:341
**Comment:**
*Logic Error: Inside the Markdown component, the `hasError` flag is
checked in the preview rendering but never set to `true`, so even when an error
occurs the user will never see `MARKDOWN_ERROR_MESSAGE`; adding an
`ErrorBoundary` around the preview and an `onError` handler that sets
`hasError` and shows the danger toast restores functional error handling.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=46d9fb4d24e7f3fe49de079247aab328e7676eded8ec9c0f577804938ca5d479&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=46d9fb4d24e7f3fe49de079247aab328e7676eded8ec9c0f577804938ca5d479&reaction=dislike'>👎</a>
##########
superset-frontend/src/explore/components/controls/CollectionControl/index.tsx:
##########
@@ -78,137 +67,158 @@ const SortableDragger = SortableHandle(() => (
/>
));
-class CollectionControl extends Component<CollectionControlProps> {
- static defaultProps = defaultProps;
+const defaultItemGenerator = () => ({ key: nanoid(11) });
+const defaultKeyAccessor = (o: CollectionItem) => o.key ?? '';
- constructor(props: CollectionControlProps) {
- super(props);
- this.onAdd = this.onAdd.bind(this);
- }
+export default function CollectionControl({
+ name,
+ label = null,
+ description = null,
+ placeholder = t('Empty collection'),
+ addTooltip = t('Add an item'),
+ itemGenerator = defaultItemGenerator,
+ keyAccessor = defaultKeyAccessor,
+ onChange = () => {},
+ value = [],
+ isFloat,
+ isInt,
+ controlName,
+}: CollectionControlProps) {
+ const theme = useTheme();
- onChange(i: number, value: CollectionItem) {
- const currentValue = this.props.value ?? [];
- const newValue = [...currentValue];
- newValue[i] = { ...currentValue[i], ...value };
- this.props.onChange?.(newValue);
- }
+ const handleChange = useCallback(
+ (i: number, itemValue: CollectionItem) => {
+ const newValue = [...value];
+ newValue[i] = { ...value[i], ...itemValue };
+ onChange(newValue);
+ },
+ [value, onChange],
+ );
- onAdd() {
- const currentValue = this.props.value ?? [];
- const newItem = this.props.itemGenerator?.();
+ const handleAdd = useCallback(() => {
+ const newItem = itemGenerator();
// Cast needed: original JS allowed undefined items from itemGenerator
- this.props.onChange?.(
- currentValue.concat([newItem] as unknown as CollectionItem[]),
- );
- }
+ onChange(value.concat([newItem] as unknown as CollectionItem[]));
+ }, [value, onChange, itemGenerator]);
- onSortEnd({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) {
- const currentValue = this.props.value ?? [];
- this.props.onChange?.(arrayMove(currentValue, oldIndex, newIndex));
- }
+ const handleSortEnd = useCallback(
+ ({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
+ onChange(arrayMove(value, oldIndex, newIndex));
+ },
+ [value, onChange],
+ );
- removeItem(i: number) {
- const currentValue = this.props.value ?? [];
- this.props.onChange?.(currentValue.filter((o, ix) => i !== ix));
- }
+ const removeItem = useCallback(
+ (i: number) => {
+ onChange(value.filter((o, ix) => i !== ix));
+ },
+ [value, onChange],
+ );
- renderList() {
- const currentValue = this.props.value ?? [];
- if (currentValue.length === 0) {
- return <div className="text-muted">{this.props.placeholder}</div>;
+ const renderList = () => {
+ if (value.length === 0) {
+ return <div className="text-muted">{placeholder}</div>;
}
const Control = (controlMap as Record<string, React.ComponentType<any>>)[
- this.props.controlName
+ controlName
];
- const keyAccessor =
- this.props.keyAccessor ?? ((o: CollectionItem) => o.key ?? '');
return (
<SortableList
useDragHandle
lockAxis="y"
- onSortEnd={this.onSortEnd.bind(this)}
+ onSortEnd={handleSortEnd}
bordered
- css={(theme: SupersetTheme) => ({
- borderRadius: theme.borderRadius,
+ css={(themeArg: SupersetTheme) => ({
+ borderRadius: themeArg.borderRadius,
})}
>
- {currentValue.map((o: CollectionItem, i: number) => {
- // label relevant only for header, not here
- const { label, theme, ...commonProps } = this.props;
- return (
- <SortableListItem
- selectable={false}
- className="clearfix"
- css={(theme: SupersetTheme) => ({
- alignItems: 'center',
- justifyContent: 'flex-start',
- display: 'flex',
- paddingInline: theme.sizeUnit * 6,
+ {value.map((o: CollectionItem, i: number) => (
+ <SortableListItem
+ selectable={false}
+ className="clearfix"
+ css={(themeArg: SupersetTheme) => ({
+ alignItems: 'center',
+ justifyContent: 'flex-start',
+ display: 'flex',
+ paddingInline: themeArg.sizeUnit * 6,
+ })}
+ key={keyAccessor(o)}
+ index={i}
+ >
+ <SortableDragger />
+ <div
+ css={(themeArg: SupersetTheme) => ({
+ flex: 1,
+ marginLeft: themeArg.sizeUnit * 2,
+ marginRight: themeArg.sizeUnit * 2,
})}
- key={keyAccessor(o)}
- index={i}
>
- <SortableDragger />
- <div
- css={(theme: SupersetTheme) => ({
- flex: 1,
- marginLeft: theme.sizeUnit * 2,
- marginRight: theme.sizeUnit * 2,
- })}
- >
- <Control
- {...commonProps}
- {...o}
- onChange={this.onChange.bind(this, i)}
- />
- </div>
- <IconTooltip
- className="pointer"
- placement="right"
- onClick={this.removeItem.bind(this, i)}
- tooltip={t('Remove item')}
- mouseEnterDelay={0}
- mouseLeaveDelay={0}
- css={(theme: SupersetTheme) => ({
- padding: 0,
- minWidth: 'auto',
- height: 'auto',
- lineHeight: 1,
- cursor: 'pointer',
- '& svg path': {
- fill: theme.colorIcon,
- transition: `fill ${theme.motionDurationMid} ease-out`,
- },
- '&:hover svg path': {
- fill: theme.colorError,
- },
- })}
- >
- <Icons.CloseOutlined iconSize="s" />
- </IconTooltip>
- </SortableListItem>
- );
- })}
+ <Control
+ name={name}
+ description={description}
+ placeholder={placeholder}
+ addTooltip={addTooltip}
+ itemGenerator={itemGenerator}
+ keyAccessor={keyAccessor}
+ value={value}
+ isFloat={isFloat}
+ isInt={isInt}
+ controlName={controlName}
+ {...o}
+ onChange={(itemValue: CollectionItem) =>
+ handleChange(i, itemValue)
+ }
+ />
+ </div>
+ <IconTooltip
+ className="pointer"
+ placement="right"
+ onClick={() => removeItem(i)}
+ tooltip={t('Remove item')}
+ mouseEnterDelay={0}
+ mouseLeaveDelay={0}
+ css={(themeArg: SupersetTheme) => ({
+ padding: 0,
+ minWidth: 'auto',
+ height: 'auto',
+ lineHeight: 1,
+ cursor: 'pointer',
+ '& svg path': {
+ fill: themeArg.colorIcon,
+ transition: `fill ${themeArg.motionDurationMid} ease-out`,
+ },
+ '&:hover svg path': {
+ fill: themeArg.colorError,
+ },
+ })}
+ >
+ <Icons.CloseOutlined iconSize="s" />
+ </IconTooltip>
+ </SortableListItem>
+ ))}
</SortableList>
);
- }
+ };
- render() {
- return (
- <div data-test="CollectionControl" className="CollectionControl">
- <HeaderContainer>
- <ControlHeader {...this.props} />
- <AddIconButton onClick={this.onAdd}>
- <Icons.PlusOutlined
- iconSize="s"
- iconColor={this.props.theme.colorTextLightSolid}
- />
- </AddIconButton>
- </HeaderContainer>
- {this.renderList()}
- </div>
- );
- }
-}
+ // Props for ControlHeader (excluding label and theme which are handled
separately)
+ const controlHeaderProps = {
+ name,
+ label,
Review Comment:
**Suggestion:** The functional refactor of the collection control no longer
forwards control-header–related props (like `validationErrors`, `hovered`,
`renderTrigger`, `warning`, etc.) down to `ControlHeader`, so this control will
not display validation errors or other header UI state even when the parent
passes them, unlike the previous class implementation which spread all props
into `ControlHeader`. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ Collection-type Explore controls lose per-control error icons.
- ⚠️ Hover-only header icons never appear for collection controls.
- ⚠️ Users must infer invalid collections from global messages.
- ⚠️ Behavior diverges from other controls using ControlHeader.
```
</details>
```suggestion
...headerProps
}: CollectionControlProps & { [key: string]: unknown }) {
const theme = useTheme();
const handleChange = useCallback(
(i: number, itemValue: CollectionItem) => {
const newValue = [...value];
newValue[i] = { ...value[i], ...itemValue };
onChange(newValue);
},
[value, onChange],
);
const handleAdd = useCallback(() => {
const newItem = itemGenerator();
// Cast needed: original JS allowed undefined items from itemGenerator
onChange(value.concat([newItem] as unknown as CollectionItem[]));
}, [value, onChange, itemGenerator]);
const handleSortEnd = useCallback(
({ oldIndex, newIndex }: { oldIndex: number; newIndex: number }) => {
onChange(arrayMove(value, oldIndex, newIndex));
},
[value, onChange],
);
const removeItem = useCallback(
(i: number) => {
onChange(value.filter((o, ix) => i !== ix));
},
[value, onChange],
);
const renderList = () => {
if (value.length === 0) {
return <div className="text-muted">{placeholder}</div>;
}
const Control = (controlMap as Record<string, React.ComponentType<any>>)[
controlName
];
return (
<SortableList
useDragHandle
lockAxis="y"
onSortEnd={handleSortEnd}
bordered
css={(themeArg: SupersetTheme) => ({
borderRadius: themeArg.borderRadius,
})}
>
{value.map((o: CollectionItem, i: number) => (
<SortableListItem
selectable={false}
className="clearfix"
css={(themeArg: SupersetTheme) => ({
alignItems: 'center',
justifyContent: 'flex-start',
display: 'flex',
paddingInline: themeArg.sizeUnit * 6,
})}
key={keyAccessor(o)}
index={i}
>
<SortableDragger />
<div
css={(themeArg: SupersetTheme) => ({
flex: 1,
marginLeft: themeArg.sizeUnit * 2,
marginRight: themeArg.sizeUnit * 2,
})}
>
<Control
name={name}
description={description}
placeholder={placeholder}
addTooltip={addTooltip}
itemGenerator={itemGenerator}
keyAccessor={keyAccessor}
value={value}
isFloat={isFloat}
isInt={isInt}
controlName={controlName}
{...o}
onChange={(itemValue: CollectionItem) =>
handleChange(i, itemValue)
}
/>
</div>
<IconTooltip
className="pointer"
placement="right"
onClick={() => removeItem(i)}
tooltip={t('Remove item')}
mouseEnterDelay={0}
mouseLeaveDelay={0}
css={(themeArg: SupersetTheme) => ({
padding: 0,
minWidth: 'auto',
height: 'auto',
lineHeight: 1,
cursor: 'pointer',
'& svg path': {
fill: themeArg.colorIcon,
transition: `fill ${themeArg.motionDurationMid} ease-out`,
},
'&:hover svg path': {
fill: themeArg.colorError,
},
})}
>
<Icons.CloseOutlined iconSize="s" />
</IconTooltip>
</SortableListItem>
))}
</SortableList>
);
};
// Props for ControlHeader, including any header-related props passed from
the parent
const controlHeaderProps = {
name,
label,
description,
...headerProps,
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Note how generic Explore controls are wired: `ControlPanelsContainer`
creates props for
each control, including `validationErrors`, and passes them into the generic
`Control`
component at `src/explore/components/ControlPanelsContainer.tsx` (e.g.
`validationErrors={validationErrors}` around lines 573 and 693 from Grep
output).
2. `Control` then renders the specific control component
(`CollectionControl` among
others) and forwards header-related props plus `hovered` down via JSX spread:
`ControlComponent onChange={onChange} hovered={hovered} {...props}` at
`src/explore/components/Control.tsx:120` (from Grep), so `CollectionControl`
receives
`validationErrors`, `hovered`, `renderTrigger`, etc.
3. In tests, `CollectionControl` is instantiated with `validationErrors` and
`hovered` to
mirror real Explore usage: see `createProps()` in
`src/explore/components/controls/CollectionControl/CollectionControl.test.tsx:50-84`,
where `hovered: false` (line 73) and `validationErrors: []` (line 81) are
passed into
`<CollectionControl {...props} />` (lines 87–88).
4. In the refactored implementation at
`src/explore/components/controls/CollectionControl/index.tsx:73-86`, the
function
destructures only named props (`name`, `label`, `description`, etc.) and
later builds
`controlHeaderProps` with just `{ name, label, description }` (lines
203–208), so any
`validationErrors`, `hovered`, `renderTrigger`, or `warning` props coming
from
`Control.tsx` are dropped and never forwarded into `ControlHeader`, which
expects those
props at `src/explore/components/ControlHeader.tsx:27-40`; as a result, when
a
CollectionControl has validation errors (e.g. for a Time Table control using
`{ type:
'CollectionControl' }` defined in
`src/visualizations/TimeTable/config/controlPanel/controlPanel.ts:42` per
Grep), the
per-control error icon and hover-triggered icons in the header are not
rendered even
though `validationErrors` are set upstream.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/src/explore/components/controls/CollectionControl/index.tsx
**Line:** 86:206
**Comment:**
*Logic Error: The functional refactor of the collection control no
longer forwards control-header–related props (like `validationErrors`,
`hovered`, `renderTrigger`, `warning`, etc.) down to `ControlHeader`, so this
control will not display validation errors or other header UI state even when
the parent passes them, unlike the previous class implementation which spread
all props into `ControlHeader`.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=a59a0dbd9b686c54cfd44b6843827692b460fa2ace08a50956b63a7a63fbb1d8&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=a59a0dbd9b686c54cfd44b6843827692b460fa2ace08a50956b63a7a63fbb1d8&reaction=dislike'>👎</a>
##########
superset-frontend/src/dashboard/components/SliceAdder.tsx:
##########
@@ -173,295 +163,265 @@ function getFilteredSortedSlices(
.filter(createFilter(searchTerm, KEYS_TO_FILTERS))
.sort(sortByComparator(sortBy));
}
-class SliceAdder extends Component<SliceAdderProps, SliceAdderState> {
- private slicesRequest?: AbortController | Promise<void>;
-
- static defaultProps = {
- selectedSliceIds: [],
- editMode: false,
- errorMessage: '',
- };
-
- constructor(props: SliceAdderProps) {
- super(props);
- this.state = {
- filteredSlices: [],
- searchTerm: '',
- sortBy: DEFAULT_SORT_KEY,
- selectedSliceIdsSet: new Set(props.selectedSliceIds),
- showOnlyMyCharts: getItem(
- LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
- true,
- ),
- };
- this.rowRenderer = this.rowRenderer.bind(this);
- this.searchUpdated = this.searchUpdated.bind(this);
- this.handleSelect = this.handleSelect.bind(this);
- this.userIdForFetch = this.userIdForFetch.bind(this);
- this.onShowOnlyMyCharts = this.onShowOnlyMyCharts.bind(this);
- }
- userIdForFetch() {
- return this.state.showOnlyMyCharts ? this.props.userId : undefined;
- }
-
- componentDidMount() {
- this.slicesRequest = this.props.fetchSlices(
- this.userIdForFetch(),
- '',
- this.state.sortBy,
- );
- }
-
- componentDidUpdate(prevProps: SliceAdderProps) {
- const nextState: SliceAdderState = {} as SliceAdderState;
- if (this.props.lastUpdated !== prevProps.lastUpdated) {
- nextState.filteredSlices = getFilteredSortedSlices(
- this.props.slices,
- this.state.searchTerm,
- this.state.sortBy,
- this.state.showOnlyMyCharts,
- this.props.userId,
- );
- }
-
- if (prevProps.selectedSliceIds !== this.props.selectedSliceIds) {
- nextState.selectedSliceIdsSet = new Set(this.props.selectedSliceIds);
- }
-
- if (Object.keys(nextState).length) {
- this.setState(nextState);
- }
- }
-
- componentWillUnmount() {
- // Clears the redux store keeping only selected items
- const selectedSlices = pickBy(this.props.slices, (value: Slice) =>
- this.state.selectedSliceIdsSet.has(value.slice_id),
- );
-
- this.props.updateSlices(selectedSlices);
- if (this.slicesRequest instanceof AbortController) {
- this.slicesRequest.abort();
- }
- }
-
- handleChange = debounce(value => {
- this.searchUpdated(value);
- this.slicesRequest = this.props.fetchSlices(
- this.userIdForFetch(),
- value,
- this.state.sortBy,
- );
- }, 300);
-
- searchUpdated(searchTerm: string) {
- this.setState(prevState => ({
- searchTerm,
- filteredSlices: getFilteredSortedSlices(
- this.props.slices,
+function SliceAdder({
+ fetchSlices,
+ updateSlices,
+ isLoading,
+ slices,
+ errorMessage = '',
+ userId,
+ selectedSliceIds = [],
+ editMode = false,
+ dashboardId,
+}: SliceAdderProps) {
+ const theme = useTheme();
+ const slicesRequestRef = useRef<AbortController | Promise<void>>();
+
+ const [searchTerm, setSearchTerm] = useState('');
+ const [sortBy, setSortBy] = useState<keyof Slice>(DEFAULT_SORT_KEY);
+ const [selectedSliceIdsSet, setSelectedSliceIdsSet] = useState(
+ () => new Set(selectedSliceIds),
+ );
+ const [showOnlyMyCharts, setShowOnlyMyCharts] = useState(() =>
+ getItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts, true),
+ );
+
+ const filteredSlices = useMemo(
+ () =>
+ getFilteredSortedSlices(
+ slices,
searchTerm,
- prevState.sortBy,
- prevState.showOnlyMyCharts,
- this.props.userId,
- ),
- }));
- }
-
- handleSelect(sortBy: keyof Slice) {
- this.setState(prevState => ({
- sortBy,
- filteredSlices: getFilteredSortedSlices(
- this.props.slices,
- prevState.searchTerm,
sortBy,
- prevState.showOnlyMyCharts,
- this.props.userId,
- ),
- }));
- this.slicesRequest = this.props.fetchSlices(
- this.userIdForFetch(),
- this.state.searchTerm,
- sortBy,
- );
- }
-
- rowRenderer({ index, style }: { index: number; style: React.CSSProperties })
{
- const { filteredSlices, selectedSliceIdsSet } = this.state;
- const cellData = filteredSlices[index];
-
- const isSelected = selectedSliceIdsSet.has(cellData.slice_id);
- const type = CHART_TYPE;
- const id = NEW_CHART_ID;
-
- const meta = {
- chartId: cellData.slice_id,
- sliceName: cellData.slice_name,
- };
- return (
- <DragDroppable
- key={cellData.slice_id}
- component={{ type, id, meta }}
- parentComponent={{
- id: NEW_COMPONENTS_SOURCE_ID,
- type: NEW_COMPONENT_SOURCE_TYPE,
- }}
- index={index}
- depth={0}
- disableDragDrop={isSelected}
- editMode={this.props.editMode}
- // we must use a custom drag preview within the List because
- // it does not seem to work within a fixed-position container
- useEmptyDragPreview
- // List library expect style props here
- // actual style should be applied to nested AddSliceCard component
- style={{}}
- >
- {({ dragSourceRef }: { dragSourceRef: ConnectDragSource }) => (
- <AddSliceCard
- innerRef={dragSourceRef}
- style={style}
- sliceName={cellData.slice_name}
- lastModified={cellData.changed_on_humanized}
- visType={cellData.viz_type}
- datasourceUrl={cellData.datasource_url}
- datasourceName={cellData.datasource_name}
- thumbnailUrl={cellData.thumbnail_url}
- isSelected={isSelected}
- />
- )}
- </DragDroppable>
- );
- }
-
- onShowOnlyMyCharts = (showOnlyMyCharts: boolean) => {
- if (!showOnlyMyCharts) {
- this.slicesRequest = this.props.fetchSlices(
- undefined,
- this.state.searchTerm,
- this.state.sortBy,
- );
- }
- this.setState(prevState => ({
- showOnlyMyCharts,
- filteredSlices: getFilteredSortedSlices(
- this.props.slices,
- prevState.searchTerm,
- prevState.sortBy,
showOnlyMyCharts,
- this.props.userId,
+ userId,
),
- }));
- setItem(LocalStorageKeys.DashboardEditorShowOnlyMyCharts,
showOnlyMyCharts);
- };
+ [slices, searchTerm, sortBy, showOnlyMyCharts, userId],
+ );
+
+ const userIdForFetch = useCallback(
+ () => (showOnlyMyCharts ? userId : undefined),
+ [showOnlyMyCharts, userId],
+ );
+
+ // componentDidMount
+ useEffect(() => {
+ slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
+ // Only run on mount
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Update selectedSliceIdsSet when selectedSliceIds prop changes
+ useEffect(() => {
+ setSelectedSliceIdsSet(new Set(selectedSliceIds));
+ }, [selectedSliceIds]);
+
+ // componentWillUnmount
+ useEffect(
+ () => () => {
+ // Clears the redux store keeping only selected items
+ const selectedSlices = pickBy(slices, (value: Slice) =>
+ selectedSliceIdsSet.has(value.slice_id),
+ );
- render() {
- const { theme } = this.props;
- return (
+ updateSlices(selectedSlices);
+ if (slicesRequestRef.current instanceof AbortController) {
+ slicesRequestRef.current.abort();
+ }
+ },
+ // Only run on unmount - capture current values
+ // eslint-disable-next-line react-hooks/exhaustive-deps
Review Comment:
**Suggestion:** The cleanup effect in the function component is declared
with an empty dependency array and directly closes over `slices` and
`selectedSliceIdsSet`, so on unmount it will use only their initial values
instead of the latest ones, causing the Redux store to be updated with an
out-of-date set of slices and selections. This can drop newly selected charts
or reintroduce stale ones when the component unmounts. Use refs to track the
latest `slices` and `selectedSliceIdsSet` and read from those refs in the
unmount cleanup so that the final state reflects the user's current selections.
[logic error]
<details>
<summary><b>Severity Level:</b> Critical 🚨</summary>
```mdx
- ❌ Dashboard edit sessions lose metadata for newly added charts.
- ⚠️ Redux sliceEntities.slices diverges from actual dashboard sliceIds.
```
</details>
```suggestion
const latestSlicesRef = useRef(slices);
const latestSelectedSliceIdsSetRef = useRef(selectedSliceIdsSet);
useEffect(() => {
latestSlicesRef.current = slices;
}, [slices]);
useEffect(() => {
latestSelectedSliceIdsSetRef.current = selectedSliceIdsSet;
}, [selectedSliceIdsSet]);
const filteredSlices = useMemo(
() =>
getFilteredSortedSlices(
slices,
searchTerm,
sortBy,
showOnlyMyCharts,
userId,
),
[slices, searchTerm, sortBy, showOnlyMyCharts, userId],
);
const userIdForFetch = useCallback(
() => (showOnlyMyCharts ? userId : undefined),
[showOnlyMyCharts, userId],
);
// componentDidMount
useEffect(() => {
slicesRequestRef.current = fetchSlices(userIdForFetch(), '', sortBy);
// Only run on mount
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Update selectedSliceIdsSet when selectedSliceIds prop changes
useEffect(() => {
setSelectedSliceIdsSet(new Set(selectedSliceIds));
}, [selectedSliceIds]);
// componentWillUnmount
useEffect(
() => () => {
// Clears the redux store keeping only selected items
const selectedSlices = pickBy(
latestSlicesRef.current,
(value: Slice) =>
latestSelectedSliceIdsSetRef.current.has(value.slice_id),
);
updateSlices(selectedSlices);
if (slicesRequestRef.current instanceof AbortController) {
slicesRequestRef.current.abort();
}
},
[updateSlices],
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open any dashboard in edit mode so that `DashboardBuilder` renders
`BuilderComponentPane` (see
`superset-frontend/src/dashboard/components/DashboardBuilder/DashboardBuilder.tsx:707`,
where `<BuilderComponentPane topOffset={barTopOffset} />` is rendered when
`editMode` is
true).
2. In the builder side pane, open the "Charts" tab which renders the
connected
`SliceAdder` container (see
`superset-frontend/src/dashboard/components/BuilderComponentPane/index.tsx:82-88`,
where
`<SliceAdder />` is used, and
`superset-frontend/src/dashboard/containers/SliceAdder.tsx:29-43`, which maps
`sliceEntities.slices` and `dashboardState.sliceIds` to the `slices` and
`selectedSliceIds` props of `SliceAdder`).
3. While staying in edit mode, change the available slices and selected
slice IDs after
the initial mount, e.g. search/filter charts (triggering `fetchSlices`) or
update the
dashboard layout so that `dashboardState.sliceIds` changes; `SliceAdder`'s
internal state
`selectedSliceIdsSet` is updated via the effect at `SliceAdder.tsx:214-217`,
and `slices`
from Redux is updated by `fetchSlices` (see
`superset-frontend/src/dashboard/actions/sliceEntities.ts:124-193`).
4. Exit edit mode or navigate away from the dashboard so that
`BuilderComponentPane` (and
thus `SliceAdder`) unmounts; on unmount, the cleanup effect at
`SliceAdder.tsx:219-235`
runs with an empty dependency array, so its closure still references the
initial `slices`
and the initial `selectedSliceIdsSet` from mount time. It calls
`updateSlices(selectedSlices)` (wired to `sliceEntities.updateSlices` in
`superset-frontend/src/dashboard/actions/sliceEntities.ts:118-122`), which
overwrites
`state.sliceEntities.slices` with a map based on stale IDs. Any slices added
or removed
after mount are ignored, so Redux ends up with an out-of-date slice map used
by other
components (e.g. chart rendering and filter code that read
`state.sliceEntities.slices` as
seen in
`superset-frontend/src/dashboard/components/gridComponents/Chart/Chart.tsx` and
`superset-frontend/src/dashboard/components/nativeFilters/FilterBar/utils.ts`),
leading to
newly added charts lacking slice metadata or removed charts lingering in
state.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/dashboard/components/SliceAdder.tsx
**Line:** 190:233
**Comment:**
*Logic Error: The cleanup effect in the function component is declared
with an empty dependency array and directly closes over `slices` and
`selectedSliceIdsSet`, so on unmount it will use only their initial values
instead of the latest ones, causing the Redux store to be updated with an
out-of-date set of slices and selections. This can drop newly selected charts
or reintroduce stale ones when the component unmounts. Use refs to track the
latest `slices` and `selectedSliceIdsSet` and read from those refs in the
unmount cleanup so that the final state reflects the user's current selections.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=cd38c6ee766dc5e5374df8ea8755e99108a653d6114504bb223144c62f9cdd0c&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=cd38c6ee766dc5e5374df8ea8755e99108a653d6114504bb223144c62f9cdd0c&reaction=dislike'>👎</a>
##########
superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx:
##########
@@ -52,162 +50,140 @@ interface HorizonRowProps {
yDomain?: [number, number];
}
-const defaultProps: Partial<HorizonRowProps> = {
- className: '',
- width: 800,
- height: 20,
- bands: DEFAULT_COLORS.length >> 1,
- colors: DEFAULT_COLORS,
- colorScale: 'series',
- mode: 'offset',
- offsetX: 0,
- title: '',
- yDomain: undefined,
-};
-
-class HorizonRow extends PureComponent<HorizonRowProps> {
- static defaultProps = defaultProps;
-
- private canvas: HTMLCanvasElement | null = null;
-
- componentDidMount() {
- this.drawChart();
- }
-
- componentDidUpdate() {
- this.drawChart();
- }
-
- componentWillUnmount() {
- this.canvas = null;
- }
-
- drawChart() {
- if (this.canvas) {
- const {
- data: rawData,
- yDomain,
- width = 800,
- height = 20,
- bands = DEFAULT_COLORS.length >> 1,
- colors = DEFAULT_COLORS,
- colorScale,
- offsetX = 0,
- mode,
- } = this.props;
-
- const data =
- colorScale === 'change'
- ? rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))
- : rawData;
-
- const context = this.canvas.getContext('2d');
- if (!context) return;
- context.imageSmoothingEnabled = false;
- context.clearRect(0, 0, width, height);
- // Reset transform
- context.setTransform(1, 0, 0, 1, 0, 0);
- context.translate(0.5, 0.5);
-
- const step = width / data.length;
- // the data frame currently being shown:
- const startIndex = Math.floor(Math.max(0, -(offsetX / step)));
- const endIndex = Math.floor(
- Math.min(data.length, startIndex + width / step),
- );
-
- // skip drawing if there's no data to be drawn
- if (startIndex > data.length) {
- return;
+function HorizonRow({
+ className = '',
+ width = 800,
+ height = 20,
+ data: rawData,
+ bands = DEFAULT_COLORS.length >> 1,
+ colors = DEFAULT_COLORS,
+ colorScale = 'series',
+ mode = 'offset',
+ offsetX = 0,
+ title = '',
+ yDomain,
+}: HorizonRowProps) {
+ const canvasRef = useRef<HTMLCanvasElement | null>(null);
+
+ const drawChart = useCallback(() => {
+ const canvas = canvasRef.current;
+ if (!canvas) return;
+
+ const data =
+ colorScale === 'change'
Review Comment:
**Suggestion:** When the color scale is set to "change" and the input data
array is empty, accessing `rawData[0].y` will throw at runtime because
`rawData[0]` is `undefined`; guarding against an empty array before using the
first element prevents this crash while preserving existing behavior for
non-empty data. [null pointer]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Horizon chart crashes when a series has no values.
- ❌ Affects Horizon charts using the "change" color scale.
- ⚠️ Can break dashboards containing such Horizon visualizations.
```
</details>
```suggestion
colorScale === 'change' && rawData.length > 0
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. In the Superset Explore view, select the Horizon chart type, which is
wired via
`HorizonChartPlugin` in
`superset-frontend/src/visualizations/presets/MainPreset.ts` and
implemented by `HorizonChart` in
`plugins/legacy-plugin-chart-horizon/src/HorizonChart.tsx:70-81`, ultimately
rendering
`HorizonRow` from `HorizonRow.tsx:53-65`.
2. In the Horizon chart control panel, set the "Horizon color scale" control
(`name:
'horizon_color_scale'` defined in
`plugins/legacy-plugin-chart-horizon/src/controlPanel.ts:83`) to the
`change` option so
that `transformProps` in
`plugins/legacy-plugin-chart-horizon/src/transformProps.ts:21-37`
forwards `horizon_color_scale` as `colorScale: 'change'` to `HorizonChart`.
3. Run or filter a query so that at least one series in
`queriesData[0].data` (passed
through `transformProps` as `data` to `HorizonChart` at
`HorizonChart.tsx:70-81`) contains
an empty `values` array; this results in a `row` where `row.values` is `[]`,
which
`HorizonChart` passes directly as the `data` prop (`rawData`) to
`HorizonRow` in
`HorizonRow.tsx:103-110`.
4. When the chart renders, `HorizonRow`'s `drawChart` function in
`HorizonRow.tsx:68-75`
executes `rawData.map(d => ({ ...d, y: d.y - rawData[0].y }))` with
`colorScale ===
'change'` and `rawData === []`, causing `rawData[0]` to be `undefined` and
throwing a
runtime TypeError when accessing `rawData[0].y`, which breaks rendering of
that Horizon
chart visualization.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/plugins/legacy-plugin-chart-horizon/src/HorizonRow.tsx
**Line:** 73:73
**Comment:**
*Null Pointer: When the color scale is set to "change" and the input
data array is empty, accessing `rawData[0].y` will throw at runtime because
`rawData[0]` is `undefined`; guarding against an empty array before using the
first element prevents this crash while preserving existing behavior for
non-empty data.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=7d50fc75354249cb1a6a605df3610ca4bc8d15a1a1a03d50a717f1a43812c2eb&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=7d50fc75354249cb1a6a605df3610ca4bc8d15a1a1a03d50a717f1a43812c2eb&reaction=dislike'>👎</a>
##########
superset-frontend/src/explore/components/SaveModal.tsx:
##########
@@ -728,25 +757,23 @@ class SaveModal extends Component<SaveModalProps,
SaveModalState> {
id="btn_modal_save_goto_dash"
buttonSize="small"
disabled={
- !this.state.newSliceName ||
- !this.state.dashboard ||
- (this.props.datasource?.type !== DatasourceType.Table &&
- !this.state.datasetName)
+ !newSliceName ||
Review Comment:
**Suggestion:** The "Save & go to dashboard" button does not take the
loading state into account when determining its disabled state, so while a save
operation is in progress `saveOrOverwrite(true)` can be triggered multiple
times by repeated clicks, causing duplicate saves/updates and inconsistent
state; this is especially problematic because the plain "Save" button is
correctly disabled by `isLoading`, so the behavior is inconsistent. You should
add `isLoading` to the disabled condition for the "Save & go to dashboard"
button to prevent multiple submissions during an ongoing save. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ❌ Explore save flow may create duplicate chart entries.
- ❌ Dashboard may receive chart added multiple times or positions.
- ⚠️ Extra backend calls to `/api/v1/chart` and `/api/v1/dashboard`.
- ⚠️ Inconsistent UX: Save disabled, Save & go still clickable.
```
</details>
```suggestion
isLoading ||
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open Explore and load a chart so that `ExploreViewContainer` renders (see
`superset-frontend/src/explore/components/ExploreViewContainer/index.tsx:903-924`),
then
click the main save button in the header to show the save modal; this sets
`saveModal.isVisible = true` and renders `SaveModal` (`index.tsx:1054-1061`).
2. In the save modal
(`superset-frontend/src/explore/components/SaveModal.tsx`), ensure
`newSliceName`, `dashboard`, and (for query datasources) `datasetName` are
all non-empty
so that the "Save & go to dashboard" button's disabled expression at
`SaveModal.tsx:756-763` evaluates to `false` and the button becomes enabled.
3. Click "Save & go to dashboard" once to start the save; this calls
`saveOrOverwrite(true)` (`SaveModal.tsx:764`), which immediately sets
`isLoading` to
`true` (`SaveModal.tsx:391-394`) and begins async work
(`actions.saveDataset`,
`actions.updateSlice`/`createSlice`, and `SupersetClient` calls at
`SaveModal.tsx:408-505`), but the button's disabled condition does not
reference
`isLoading`, so React re-renders with the button still enabled while the
async operations
are in progress.
4. While the first save is still in flight (e.g., under slow network/API),
rapidly click
"Save & go to dashboard" again; `saveOrOverwrite(true)` is invoked multiple
times in
parallel because nothing in the disabled condition (`SaveModal.tsx:756-763`)
or in the
click handler prevents re-entry based on `isLoading`, leading to duplicate
saves/updates
(multiple `actions.createSlice`/`actions.updateSlice` and
`addChartToDashboardTab` calls)
and duplicated or inconsistent chart/dashboard state compared to the
single-click
expectation, whereas the plain "Save" button is correctly disabled by
`isLoading`
(`SaveModal.tsx:768-777`).
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:** superset-frontend/src/explore/components/SaveModal.tsx
**Line:** 760:760
**Comment:**
*Logic Error: The "Save & go to dashboard" button does not take the
loading state into account when determining its disabled state, so while a save
operation is in progress `saveOrOverwrite(true)` can be triggered multiple
times by repeated clicks, causing duplicate saves/updates and inconsistent
state; this is especially problematic because the plain "Save" button is
correctly disabled by `isLoading`, so the behavior is inconsistent. You should
add `isLoading` to the disabled condition for the "Save & go to dashboard"
button to prevent multiple submissions during an ongoing save.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=6112eb6a26355c44aa9a0bf3b642330014d50b63a1c19e8faaa8aad68e10eb1b&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=6112eb6a26355c44aa9a0bf3b642330014d50b63a1c19e8faaa8aad68e10eb1b&reaction=dislike'>👎</a>
##########
superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx:
##########
@@ -824,571 +813,650 @@ const mapStateToProps = (state: RootState) => ({
const connector = connect(mapStateToProps, mapDispatchToProps);
type PropsFromRedux = ConnectedProps<typeof connector>;
-type DatasourceEditorProps = DatasourceEditorOwnProps &
- PropsFromRedux & {
- theme?: SupersetTheme;
- };
+type DatasourceEditorProps = DatasourceEditorOwnProps & PropsFromRedux;
+
+function DatasourceEditor({
+ datasource: propsDatasource,
+ onChange = () => {},
+ addSuccessToast,
+ addDangerToast,
+ setIsEditing = () => {},
+ database,
+ runQuery,
+ resetQuery,
+ formatQuery: formatQueryAction,
+}: DatasourceEditorProps) {
+ const theme = useTheme();
+ const isComponentMounted = useRef(false);
+ const abortControllers = useRef<AbortControllers>({
+ formatQuery: null,
+ formatSql: null,
+ syncMetadata: null,
+ fetchUsageData: null,
+ });
+
+ // Initialize datasource state with transformed owners and metrics
+ const [datasource, setDatasource] = useState<DatasourceObject>(() => ({
+ ...propsDatasource,
+ owners: propsDatasource.owners.map(owner => ({
+ value: owner.value || owner.id,
+ label: owner.label || `${owner.first_name} ${owner.last_name}`,
+ })),
+ metrics: propsDatasource.metrics?.map(metric => {
+ const {
+ certified_by: certifiedByMetric,
+ certification_details: certificationDetails,
+ } = metric;
+ const {
+ certification: {
+ details = undefined,
+ certified_by: certifiedBy = undefined,
+ } = {},
+ warning_markdown: warningMarkdown,
+ } = JSON.parse(metric.extra || '{}') || {};
+ return {
+ ...metric,
+ certification_details: certificationDetails || details,
+ warning_markdown: warningMarkdown || '',
+ certified_by: certifiedBy || certifiedByMetric,
+ };
+ }),
+ }));
-class DatasourceEditor extends PureComponent<
- DatasourceEditorProps,
- DatasourceEditorState
-> {
- private isComponentMounted: boolean;
+ const [errors, setErrors] = useState<string[]>([]);
+ const [isSqla] = useState(
+ propsDatasource.datasource_type === 'table' ||
+ propsDatasource.type === 'table',
+ );
+ const [isEditMode, setIsEditMode] = useState(false);
+ const [databaseColumns, setDatabaseColumns] = useState<Column[]>(
+ propsDatasource.columns.filter(col => !col.expression),
+ );
+ const [calculatedColumns, setCalculatedColumns] = useState<Column[]>(
+ propsDatasource.columns.filter(col => !!col.expression),
+ );
+ const [folders, setFolders] = useState<DatasourceFolder[]>(
+ propsDatasource.folders || [],
+ );
+ const [metadataLoading, setMetadataLoading] = useState(false);
+ const [activeTabKey, setActiveTabKey] = useState(TABS_KEYS.SOURCE);
+ const [datasourceType, setDatasourceType] = useState(
+ propsDatasource.sql
+ ? DATASOURCE_TYPES.virtual.key
+ : DATASOURCE_TYPES.physical.key,
+ );
+ const [usageCharts, setUsageCharts] = useState<ChartUsageData[]>([]);
+ const [usageChartsCount, setUsageChartsCount] = useState(0);
+
+ const findDuplicates = useCallback(
+ <T,>(arr: T[], accessor: (obj: T) => string): string[] => {
+ const seen: Record<string, null> = {};
+ const dups: string[] = [];
+ arr.forEach((obj: T) => {
+ const item = accessor(obj);
+ if (item in seen) {
+ dups.push(item);
+ } else {
+ seen[item] = null;
+ }
+ });
+ return dups;
+ },
+ [],
+ );
- private abortControllers: AbortControllers;
+ const validate = useCallback(
+ (callback: () => void) => {
+ let validationErrors: string[] = [];
+ let dups: string[];
- static defaultProps = {
- onChange: () => {},
- setIsEditing: () => {},
- };
+ // Looking for duplicate column_name
+ dups = findDuplicates(datasource.columns, obj => obj.column_name);
+ validationErrors = validationErrors.concat(
+ dups.map(name => t('Column name [%s] is duplicated', name)),
+ );
- constructor(props: DatasourceEditorProps) {
- super(props);
- this.state = {
- datasource: {
- ...props.datasource,
- owners: props.datasource.owners.map(owner => ({
- value: owner.value || owner.id,
- label: owner.label || `${owner.first_name} ${owner.last_name}`,
- })),
- metrics: props.datasource.metrics?.map(metric => {
- const {
- certified_by: certifiedByMetric,
- certification_details: certificationDetails,
- } = metric;
- const {
- certification: {
- details = undefined,
- certified_by: certifiedBy = undefined,
- } = {},
- warning_markdown: warningMarkdown,
- } = JSON.parse(metric.extra || '{}') || {};
- return {
- ...metric,
- certification_details: certificationDetails || details,
- warning_markdown: warningMarkdown || '',
- certified_by: certifiedBy || certifiedByMetric,
- };
- }),
- },
- errors: [],
- isSqla:
- props.datasource.datasource_type === 'table' ||
- props.datasource.type === 'table',
- isEditMode: false,
- databaseColumns: props.datasource.columns.filter(col => !col.expression),
- calculatedColumns: props.datasource.columns.filter(
- col => !!col.expression,
- ),
- folders: props.datasource.folders || [],
- metadataLoading: false,
- activeTabKey: TABS_KEYS.SOURCE,
- datasourceType: props.datasource.sql
- ? DATASOURCE_TYPES.virtual.key
- : DATASOURCE_TYPES.physical.key,
- usageCharts: [],
- usageChartsCount: 0,
- };
+ // Looking for duplicate metric_name
+ dups = findDuplicates(datasource.metrics ?? [], obj => obj.metric_name);
+ validationErrors = validationErrors.concat(
+ dups.map(name => t('Metric name [%s] is duplicated', name)),
+ );
- this.isComponentMounted = false;
- this.abortControllers = {
- formatQuery: null,
- formatSql: null,
- syncMetadata: null,
- fetchUsageData: null,
- };
+ // Making sure calculatedColumns have an expression defined
+ const noFilterCalcCols = calculatedColumns.filter(
+ col => !col.expression && !col.json,
+ );
+ validationErrors = validationErrors.concat(
+ noFilterCalcCols.map(col =>
+ t('Calculated column [%s] requires an expression', col.column_name),
+ ),
+ );
- this.onChange = this.onChange.bind(this);
- this.onChangeEditMode = this.onChangeEditMode.bind(this);
- this.onDatasourcePropChange = this.onDatasourcePropChange.bind(this);
- this.onDatasourceChange = this.onDatasourceChange.bind(this);
- this.tableChangeAndSyncMetadata =
- this.tableChangeAndSyncMetadata.bind(this);
- this.syncMetadata = this.syncMetadata.bind(this);
- this.setColumns = this.setColumns.bind(this);
- this.validateAndChange = this.validateAndChange.bind(this);
- this.handleTabSelect = this.handleTabSelect.bind(this);
- this.formatSql = this.formatSql.bind(this);
- this.fetchUsageData = this.fetchUsageData.bind(this);
- this.handleFoldersChange = this.handleFoldersChange.bind(this);
- }
+ // validate currency code (skip 'AUTO' - it's a placeholder for
auto-detection)
+ try {
+ datasource.metrics?.forEach(
+ metric =>
+ metric.currency?.symbol &&
+ metric.currency.symbol !== 'AUTO' &&
+ new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: metric.currency.symbol,
+ }),
+ );
+ } catch {
+ validationErrors = validationErrors.concat([
+ t('Invalid currency code in saved metrics'),
+ ]);
+ }
+
+ // Validate folders
+ if (folders?.length > 0) {
+ const folderValidation = validateFolders(folders);
+ validationErrors = validationErrors.concat(folderValidation.errors);
+ }
+
+ setErrors(validationErrors);
+ callback();
+ },
+ [datasource, calculatedColumns, folders, findDuplicates],
+ );
- onChange() {
+ const onChangeInternal = useCallback(() => {
// Emptying SQL if "Physical" radio button is selected
- // Currently the logic to know whether the source is
- // physical or virtual is based on whether SQL is empty or not.
- const { datasourceType, datasource } = this.state;
const sql =
datasourceType === DATASOURCE_TYPES.physical.key ? '' : datasource.sql;
const newDatasource = {
- ...this.state.datasource,
+ ...datasource,
sql,
- columns: [...this.state.databaseColumns,
...this.state.calculatedColumns],
- folders: this.state.folders,
+ columns: [...databaseColumns, ...calculatedColumns],
+ folders,
};
- this.props.onChange?.(newDatasource, this.state.errors);
- }
+ onChange(newDatasource, errors);
+ }, [
+ datasource,
+ datasourceType,
+ databaseColumns,
+ calculatedColumns,
+ folders,
+ errors,
+ onChange,
+ ]);
Review Comment:
**Suggestion:** The validation callback currently calls
`setErrors(validationErrors)` and then immediately invokes `onChangeInternal`,
which reads the `errors` state; because state updates are asynchronous in
function components, the parent `onChange` always receives stale errors from
the previous validation run instead of the freshly computed `validationErrors`.
This breaks the contract that callers see up-to-date validation errors and can
cause the UI to think the dataset is valid when it is not. To fix this, pass
the freshly computed `validationErrors` into the callback and have
`onChangeInternal` accept and forward those errors instead of reading from
`errors` state. [logic error]
<details>
<summary><b>Severity Level:</b> Major ⚠️</summary>
```mdx
- ⚠️ Dataset modal Save button may allow invalid configurations.
- ⚠️ Save button may stay disabled after fixing validation errors.
- ⚠️ Affects all dataset edits via DatasourceModal UI.
```
</details>
```suggestion
(callback: (validationErrors: string[]) => void) => {
let validationErrors: string[] = [];
let dups: string[];
// Looking for duplicate column_name
dups = findDuplicates(datasource.columns, obj => obj.column_name);
validationErrors = validationErrors.concat(
dups.map(name => t('Column name [%s] is duplicated', name)),
);
// Looking for duplicate metric_name
dups = findDuplicates(datasource.metrics ?? [], obj =>
obj.metric_name);
validationErrors = validationErrors.concat(
dups.map(name => t('Metric name [%s] is duplicated', name)),
);
// Making sure calculatedColumns have an expression defined
const noFilterCalcCols = calculatedColumns.filter(
col => !col.expression && !col.json,
);
validationErrors = validationErrors.concat(
noFilterCalcCols.map(col =>
t('Calculated column [%s] requires an expression',
col.column_name),
),
);
// validate currency code (skip 'AUTO' - it's a placeholder for
auto-detection)
try {
datasource.metrics?.forEach(
metric =>
metric.currency?.symbol &&
metric.currency.symbol !== 'AUTO' &&
new Intl.NumberFormat('en-US', {
style: 'currency',
currency: metric.currency.symbol,
}),
);
} catch {
validationErrors = validationErrors.concat([
t('Invalid currency code in saved metrics'),
]);
}
// Validate folders
if (folders?.length > 0) {
const folderValidation = validateFolders(folders);
validationErrors = validationErrors.concat(folderValidation.errors);
}
setErrors(validationErrors);
callback(validationErrors);
},
[datasource, calculatedColumns, folders, findDuplicates],
);
const onChangeInternal = useCallback(
(validationErrors: string[] = errors) => {
// Emptying SQL if "Physical" radio button is selected
const sql =
datasourceType === DATASOURCE_TYPES.physical.key ? '' :
datasource.sql;
const newDatasource = {
...datasource,
sql,
columns: [...databaseColumns, ...calculatedColumns],
folders,
};
onChange(newDatasource, validationErrors);
},
[
datasource,
datasourceType,
databaseColumns,
calculatedColumns,
folders,
errors,
onChange,
],
);
```
<details>
<summary><b>Steps of Reproduction ✅ </b></summary>
```mdx
1. Open the dataset edit modal, which renders `DatasourceModal` at
`superset-frontend/src/components/Datasource/DatasourceModal/index.tsx:88-399`.
This
mounts `DatasourceEditor` (async-loaded from
`../components/DatasourceEditor`) with
`onChange={onDatasourceChange}` at line 380.
2. Note that `onDatasourceChange` in `DatasourceModal` (lines 238-247)
receives `(data,
err)` from `DatasourceEditor` and stores `err` into local `errors` state via
`setErrors(err)`. The Save button in the modal is disabled when
`errors.length > 0` (lines
350-367).
3. In `DatasourceEditor` (`DatasourceEditor.tsx`), validation is performed
in `validate`
at lines 908-961, which computes `validationErrors`, calls
`setErrors(validationErrors)`,
and then immediately invokes the `callback` parameter. The callback passed by
`validateAndChange` (lines 987-989) is `onChangeInternal`.
4. `onChangeInternal` at lines 964-985 constructs `newDatasource` and calls
`onChange(newDatasource, errors)`, but `errors` here is the React state
value from line
866 that has not yet been updated by the preceding
`setErrors(validationErrors)`. Because
React state updates are asynchronous, the `err` argument seen by
`DatasourceModal.onDatasourceChange` (and thus the Save button
enable/disable logic) lags
one change behind the actual validation result, allowing the Save button to
remain enabled
when new validation errors exist or remain disabled after errors are fixed
until a
subsequent edit.
```
</details>
<details>
<summary><b>Prompt for AI Agent 🤖 </b></summary>
```mdx
This is a comment left during a code review.
**Path:**
superset-frontend/src/components/Datasource/components/DatasourceEditor/DatasourceEditor.tsx
**Line:** 909:985
**Comment:**
*Logic Error: The validation callback currently calls
`setErrors(validationErrors)` and then immediately invokes `onChangeInternal`,
which reads the `errors` state; because state updates are asynchronous in
function components, the parent `onChange` always receives stale errors from
the previous validation run instead of the freshly computed `validationErrors`.
This breaks the contract that callers see up-to-date validation errors and can
cause the UI to think the dataset is valid when it is not. To fix this, pass
the freshly computed `validationErrors` into the callback and have
`onChangeInternal` accept and forward those errors instead of reading from
`errors` state.
Validate the correctness of the flagged issue. If correct, How can I resolve
this? If you propose a fix, implement it and please make it concise.
```
</details>
<a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=5b3f9870ab1c688187d2517b25d17e2383f761a3b3adf741c138e41bc6c610c4&reaction=like'>👍</a>
| <a
href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F37902&comment_hash=5b3f9870ab1c688187d2517b25d17e2383f761a3b3adf741c138e41bc6c610c4&reaction=dislike'>👎</a>
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]