codeant-ai-for-open-source[bot] commented on code in PR #41551: URL: https://github.com/apache/superset/pull/41551#discussion_r3689521049
########## superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx: ########## @@ -0,0 +1,300 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useMemo } from 'react'; +import { t } from '@apache-superset/core/translation'; +import { styled } from '@apache-superset/core/theme'; +import { Alert } from '@apache-superset/core/components'; +import { + Button, + EmptyState, + Icons, + Input, + Select, + Skeleton, +} from '@superset-ui/core/components'; +import type { + ActivityInclude, + ActivityRecord, + SaveGroup, + SessionLogEntry, + VersionedEntityType, +} from './types'; +import type { UseVersionActivityResult } from './useVersionActivity'; +import { relatedEntryKey } from './grouping'; +import { formatVersionDateTime } from './display'; +import SaveGroupItem from './SaveGroupItem'; +import RelatedUpdateRow from './RelatedUpdateRow'; +import CurrentVersionSection from './CurrentVersionSection'; + +const VERSION_HISTORY_PANEL_WIDTH = 320; + +const Panel = styled.aside` + ${({ theme }) => ` + width: ${VERSION_HISTORY_PANEL_WIDTH}px; + min-width: ${VERSION_HISTORY_PANEL_WIDTH}px; + height: 100%; + display: flex; + flex-direction: column; + border-left: 1px solid ${theme.colorSplit}; + background-color: ${theme.colorBgContainer}; + `} +`; + +const PanelHeader = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + justify-content: space-between; + padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px; + border-bottom: 1px solid ${theme.colorSplit}; + `} +`; + +const PanelTitle = styled.span` + ${({ theme }) => ` + font-size: ${theme.fontSize}px; + line-height: ${theme.lineHeight}; + color: ${theme.colorText}; + `} +`; + +const Controls = styled.div` + ${({ theme }) => ` + display: flex; + align-items: center; + gap: ${theme.sizeUnit * 2}px; + padding: ${theme.sizeUnit * 4}px ${theme.sizeUnit * 6}px; + > * { + flex: 1 1 0; + min-width: 0; + } + `} +`; + +const Body = styled.div` + ${({ theme }) => ` + flex: 1; + overflow-y: auto; + padding: 0 ${theme.sizeUnit * 4}px; + `} +`; + +// Icon-only trigger: neutral icon color instead of the link-button blue. +const CloseButton = styled(Button)` + ${({ theme }) => ` + && { + color: ${theme.colorText}; + } + &&:hover, + &&:focus { + color: ${theme.colorTextSecondary}; + } + `} +`; + +const Footer = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 2}px; + text-align: center; + `} +`; + +const PaddedContent = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 4}px; + `} +`; + +export interface VersionHistoryPanelProps { + entityType: VersionedEntityType; + activity: UseVersionActivityResult; + include: ActivityInclude; + canRestore?: boolean; + onIncludeChange: (include: ActivityInclude) => void; + /** + * Free-text search is controlled by the container, which debounces it + * and feeds it to the activity hook so the server filters the full + * history (not just the loaded pages). The panel renders the matches + * verbatim. + */ + searchTerm: string; + onSearchChange: (value: string) => void; + previewedTransactionId: number | null; + onClose: () => void; + onPreview: (group: SaveGroup) => void; + /** Leave an active historical preview (back to the live version). */ + onExitPreview?: () => void; + onRestore: (group: SaveGroup) => void; + onOpenAsNew: (group: SaveGroup) => void; + onOpenRelated?: (record: ActivityRecord) => void; + sessionEntries?: SessionLogEntry[]; +} + +export default function VersionHistoryPanel({ + entityType, + activity, + include, + canRestore = true, + onIncludeChange, + searchTerm, + onSearchChange, + previewedTransactionId, + onClose, + onPreview, + onExitPreview, + onRestore, + onOpenAsNew, + onOpenRelated, + sessionEntries = [], +}: VersionHistoryPanelProps) { + const { timeline, newestGroup, isLoading, error, hasMore, loadMore } = + activity; + + const includeOptions = useMemo( + () => [ + { value: 'all', label: t('All changes') }, + { + value: 'self', + label: + entityType === 'chart' + ? t('This chart only') + : t('This dashboard only'), + }, + { value: 'related', label: t('Related items only') }, + ], + [entityType], + ); + + const hasSearch = searchTerm.trim().length > 0; + + // The newest self save IS the live state: it gets a "Current" tag, + // no preview affordances, and no restore action. The newest save + // being a restore additionally means the live entity matches an + // older version; surface that in the "Current version" section. + // newestGroup comes from the hook's last unfiltered fetch — the visible + // timeline is search-filtered, so its first group may be an older save. + const currentTransactionId = newestGroup?.transactionId ?? null; Review Comment: **Suggestion:** The current-version marker is derived from `newestGroup`, which the activity hook computes only from the first 25 raw records. When newer related-entity records occupy that page, the newest self save can be on a later page, causing an older visible save to be incorrectly marked as Current and potentially hiding restore/preview actions for the actual live version. Determine the newest self transaction independently of the mixed activity page or have the API return it explicitly. [logic error] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ❌ Version history can identify the wrong save as current. - ⚠️ Restore and preview controls appear incorrect. - ⚠️ Loading additional pages does not repair the marker. ``` </details> [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=70954007122543a08b490922381b7fa4&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=70954007122543a08b490922381b7fa4&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) *(Use Cmd/Ctrl + Click for best experience)* <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/src/features/versionHistory/VersionHistoryPanel.tsx **Line:** 192:192 **Comment:** *Logic Error: The current-version marker is derived from `newestGroup`, which the activity hook computes only from the first 25 raw records. When newer related-entity records occupy that page, the newest self save can be on a later page, causing an older visible save to be incorrectly marked as Current and potentially hiding restore/preview actions for the actual live version. Determine the newest self transaction independently of the mixed activity page or have the API return it explicitly. 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. Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=c0c20c0bf5a9103be845faab17d2772d53b218f145e85c6b29511994b00a8055&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=c0c20c0bf5a9103be845faab17d2772d53b218f145e85c6b29511994b00a8055&reaction=dislike'>👎</a> ########## superset-frontend/src/features/versionHistory/grouping.ts: ########## @@ -0,0 +1,270 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import type { + ActivityRecord, + RelatedEntry, + SaveGroup, + TimelineEntry, +} from './types'; + +/** + * Stable identity for one activity record, used to deduplicate rows + * when merging pages (offset pagination can re-serve rows if a new + * save lands between page fetches). + */ +export function recordKey(record: ActivityRecord): string { + return [ + record.transaction_id, + record.entity_kind, + record.entity_uuid ?? record.entity_name, + record.source, + record.kind, + record.operation, + JSON.stringify(record.path), + ].join('|'); +} + +/** Merge a newly fetched page into already loaded records, deduplicated. */ +export function mergeActivityPages( + existing: ActivityRecord[], + incoming: ActivityRecord[], +): ActivityRecord[] { + const seen = new Set(existing.map(recordKey)); + const merged = [...existing]; + incoming.forEach(record => { + const key = recordKey(record); + if (!seen.has(key)) { + seen.add(key); + merged.push(record); + } + }); + return merged; +} + +/** One related row per save of a related entity, regardless of how many + * change records that save produced. */ +export function relatedEntryKey(record: ActivityRecord): string { + return [ + record.transaction_id, + record.entity_kind, + record.entity_uuid ?? record.entity_name, + ].join('|'); +} + +/** Identity of the entity a related record belongs to, ignoring the + * transaction. */ +function relatedEntityKey(record: ActivityRecord): string { + return [record.entity_kind, record.entity_uuid ?? record.entity_name].join( + '|', + ); +} + +const RELATED_MERGE_WINDOW_MS = 60_000; + +/** + * The backend can split one logical save of a related entity into + * several transactions issued at (nearly) the same instant, which + * would render as duplicate-looking rows. Collapse adjacent related + * entries — no self save between them — for the same entity issued + * within a short window into the newer entry: its representative + * record is kept and the older save's records are absorbed so search + * over collapsed records keeps working. + */ +// TODO(version-history): backend workaround — remove when upstream emits +// one transaction per logical save. +function mergeAdjacentRelatedEntries( + entries: TimelineEntry[], +): TimelineEntry[] { + const merged: TimelineEntry[] = []; + entries.forEach(entry => { + const previous = merged[merged.length - 1]; + if ( + entry.type === 'related' && + previous?.type === 'related' && + relatedEntityKey(previous.record) === relatedEntityKey(entry.record) && + Math.abs( + Date.parse(previous.record.issued_at) - + Date.parse(entry.record.issued_at), + ) <= RELATED_MERGE_WINDOW_MS + ) { Review Comment: **Suggestion:** This merges any adjacent related entries for the same entity whose timestamps are within one minute, regardless of whether they belong to the same logical save or transaction. Two legitimate edits made within that window are therefore displayed as one history row, with the older records absorbed into the newer row and their separate save boundary lost. Restrict the merge to a backend-provided logical-save identifier or another condition that proves the entries are split parts of one save. [logic error] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ⚠️ Version history can hide separate dataset-related saves. - ⚠️ Timeline timestamps and save boundaries become misleading. - ⚠️ Restore/preview rows may represent multiple transactions. ``` </details> [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=26fd9905467f411a924f00952e1521fb&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=26fd9905467f411a924f00952e1521fb&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) *(Use Cmd/Ctrl + Click for best experience)* <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/src/features/versionHistory/grouping.ts **Line:** 97:105 **Comment:** *Logic Error: This merges any adjacent related entries for the same entity whose timestamps are within one minute, regardless of whether they belong to the same logical save or transaction. Two legitimate edits made within that window are therefore displayed as one history row, with the older records absorbed into the newer row and their separate save boundary lost. Restrict the merge to a backend-provided logical-save identifier or another condition that proves the entries are split parts of one 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. Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=bced7643810875f6d619f44281fe59a1ad6eb564d557d0657ddaee2b6c762825&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=bced7643810875f6d619f44281fe59a1ad6eb564d557d0657ddaee2b6c762825&reaction=dislike'>👎</a> ########## superset-frontend/src/features/versionHistory/useDashboardVersionPreview.ts: ########## @@ -0,0 +1,339 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useEffect, useRef } from 'react'; +import { useDispatch, useSelector, useStore } from 'react-redux'; +import { useHistory } from 'react-router-dom'; +import type { DataMaskStateWithId, JsonObject } from '@superset-ui/core'; +import { t } from '@apache-superset/core/translation'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { + hydrateDashboard, + HydrateChartData, + HydrateDashboardData, +} from 'src/dashboard/actions/hydrate'; +import { clearDataMaskState } from 'src/dataMask/actions'; +import type { RootState } from 'src/dashboard/types'; +import { + fetchDashboardHydrationData, + fetchExploreRehydrationData, + fetchVersionSnapshot, + layoutChartId, + swapUnreachableChartSlots, + DashboardHydrationData, +} from './api'; +import { + clearVersionPreview, + selectVersionPreview, + selectVersionRestoreCount, + versionPreviewApplied, +} from './reducer'; + +export interface SnapshotChartResolution { + charts: HydrateChartData[]; + positionData: JsonObject | null; +} + +/** + * A version snapshot stores the layout (position_json) but not the charts + * themselves, while the live dashboard payload only includes the charts the + * dashboard references at present. Reconcile the two: keep live charts the + * snapshot layout references (dropping ones added after the snapshot, so the + * hydrate "append new slices" path never fires), fetch metadata for charts + * the dashboard no longer includes, and swap layout slots whose chart cannot + * be fetched (e.g. deleted) for a markdown placeholder. + */ +export async function resolveSnapshotCharts( + liveCharts: HydrateChartData[], + positionData: JsonObject | null, +): Promise<SnapshotChartResolution> { + if (!positionData || Object.keys(positionData).length === 0) { + // The snapshot has no layout; hydrate falls back to an empty layout and + // appends any charts it is given, so pass none. + return { charts: [], positionData }; + } + + const snapshotChartIds = new Set<number>(); + Object.values(positionData).forEach(item => { + const chartId = layoutChartId(item as JsonObject); + if (chartId !== null) { + snapshotChartIds.add(chartId); + } + }); + + const liveById = new Map( + liveCharts.map(chart => [ + (chart.form_data?.slice_id as number | undefined) ?? chart.slice_id, + chart, + ]), + ); + const charts: HydrateChartData[] = []; + const missingIds: number[] = []; + snapshotChartIds.forEach(id => { + const live = liveById.get(id); + if (live) { + charts.push(live); + } else { + missingIds.push(id); + } + }); + + const unreachable = new Set<number>(); + await Promise.all( + missingIds.map(async id => { + try { + const { slice, form_data } = await fetchExploreRehydrationData(id); + charts.push({ + slice_id: id, + slice_url: `/explore/?slice_id=${id}`, + slice_name: slice?.slice_name ?? t('Untitled chart'), + form_data: { ...form_data, slice_id: id }, + description: slice?.description ?? '', + description_markeddown: '', + editors: [], + modified: '', + changed_on: new Date().toISOString(), + }); + } catch { + unreachable.add(id); + } + }), + ); + + return { + charts, + positionData: swapUnreachableChartSlots(positionData, unreachable), + }; +} + +/** + * Applies a previewed dashboard version by re-hydrating the page with the + * snapshot's title/css/metadata/layout and the charts that layout references, + * and re-hydrates the live dashboard when the preview is closed. + */ +export function useDashboardVersionPreview(uuid: string | undefined) { + const dispatch = useDispatch(); + const store = useStore<RootState>(); + const history = useHistory(); + const { addDangerToast } = useToasts(); + const preview = useSelector(selectVersionPreview); + const dashboardId = useSelector<RootState, number | undefined>( + state => state.dashboardInfo?.id, + ); + const liveDataRef = useRef<DashboardHydrationData | null>(null); + // The user's filter selections at the moment they entered preview, restored + // when the preview closes. Captured once per live -> preview transition so + // switching between previewed versions keeps the original live state. + const liveDataMaskRef = useRef<DataMaskStateWithId | null>(null); + const appliedVersionRef = useRef<string | null>(null); + const fetchIdRef = useRef(0); + const restoreCount = useSelector(selectVersionRestoreCount); + const lastRestoreCountRef = useRef(restoreCount); + // Saves bump one of two redux signals depending on the path: edit-mode + // saves round-trip through ON_SAVE (dashboardState.lastModifiedTime), + // while native-filter and properties saves bump + // dashboardInfo.last_modified_time. + const saveSignal = useSelector<RootState, string>(state => + [ + state.dashboardState?.lastModifiedTime ?? '', + state.dashboardInfo?.last_modified_time ?? '', + ].join('|'), + ); + const lastSaveSignalRef = useRef(saveSignal); + + const versionUuid = preview?.versionUuid; + + // A save that lands while no preview is applied makes the cached live + // copy stale — exiting a later preview would rehydrate the pre-save + // state (and a subsequent edit-mode save could overwrite the newer + // server state with it). Drop the cache so the next preview entry + // fetches a fresh live copy. While a preview is applied, saves are + // gated off in the UI and the cache must be kept for exit-preview, so + // only the not-previewing case clears it. + useEffect(() => { + if (saveSignal === lastSaveSignalRef.current) { + return; + } + lastSaveSignalRef.current = saveSignal; + if (appliedVersionRef.current === null) { + liveDataRef.current = null; + liveDataMaskRef.current = null; + } + }, [saveSignal]); + + useEffect(() => { + const hydrateWith = ( + dashboard: HydrateDashboardData, + charts: HydrateChartData[], + dataMask: DataMaskStateWithId, + editMode?: boolean, + ) => { + // Hydration merges into any existing dataMask entries, which would let + // filter selections from one version leak into another; reset first so + // each hydrate starts from exactly the dataMask passed in. The two + // dispatches are synchronous back-to-back, so React batches them into + // a single render. + dispatch(clearDataMaskState()); + dispatch( + hydrateDashboard({ + history, + dashboard, + charts, + dataMask, + activeTabs: null, + chartStates: null, + editMode, + }), + ); + }; + + if (restoreCount !== lastRestoreCountRef.current) { + // The dashboard changed on the server (a version was restored); + // drop the cached live data and rehydrate with a fresh copy. + lastRestoreCountRef.current = restoreCount; + appliedVersionRef.current = null; + liveDataRef.current = null; + liveDataMaskRef.current = null; + if (!dashboardId) { + return; + } + fetchIdRef.current += 1; + const fetchId = fetchIdRef.current; + fetchDashboardHydrationData(dashboardId) + .then(data => { + if (fetchId !== fetchIdRef.current) { + return; + } + liveDataRef.current = data; + // A restored version behaves like a fresh page load: its own + // filter defaults, no carried-over selections. + hydrateWith(data.dashboard, data.charts, {}); + }) + .catch(() => { + if (fetchId === fetchIdRef.current) { + addDangerToast(t('Failed to reload the restored version')); + } + }); + return; + } + + if (versionUuid && uuid && dashboardId) { + if (appliedVersionRef.current === versionUuid) { + return; + } + fetchIdRef.current += 1; + const fetchId = fetchIdRef.current; + const apply = async () => { + // Work on a local copy and commit it to the cache only after the + // staleness check below — an in-flight fetch resolving after a + // restore (which cleared the cache) must not repopulate it with + // pre-restore content. + let liveData = liveDataRef.current; + if (!liveData) { + liveData = await fetchDashboardHydrationData(dashboardId); + } + const snapshot = await fetchVersionSnapshot( + 'dashboard', + uuid, + versionUuid, + ); Review Comment: **Suggestion:** The save-signal effect clears `liveDataRef` when a save occurs before preview application finishes, but it does not invalidate this in-flight `apply` operation. If the pending request then resolves, this code unconditionally repopulates `liveDataRef` with the pre-save dashboard data; closing the preview can consequently rehydrate stale state and overwrite the freshly saved Redux state. Invalidate the preview request when the live save signal changes, or re-check the save generation before caching and hydrating. [race condition] <details> <summary><b>Severity Level:</b> Major ⚠️</summary> ```mdx - ❌ Closing preview can restore pre-save dashboard state. - ⚠️ Recent live edits may disappear from the user interface. - ⚠️ A later save can submit stale hydrated dashboard data. ``` </details> [](https://app.codeant.ai/fix-in-ide?tool=cursor&prompt_id=8107aa29e02943b19a9fb673fa297e94&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) [](https://app.codeant.ai/fix-in-ide?tool=vscode-claude&prompt_id=8107aa29e02943b19a9fb673fa297e94&service=github&base_url=https%3A%2F%2Fgithub.com&org=apache&repo=apache%2Fsuperset) *(Use Cmd/Ctrl + Click for best experience)* <details> <summary><b>Prompt for AI Agent 🤖 </b></summary> ```mdx This is a comment left during a code review. **Path:** superset-frontend/src/features/versionHistory/useDashboardVersionPreview.ts **Line:** 246:254 **Comment:** *Race Condition: The save-signal effect clears `liveDataRef` when a save occurs before preview application finishes, but it does not invalidate this in-flight `apply` operation. If the pending request then resolves, this code unconditionally repopulates `liveDataRef` with the pre-save dashboard data; closing the preview can consequently rehydrate stale state and overwrite the freshly saved Redux state. Invalidate the preview request when the live save signal changes, or re-check the save generation before caching and hydrating. 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. Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix ``` </details> <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=419b59a9d4fc0db60993dd17ee3c9bc1219f6af4a7ef3242c8f525ddcfe38f71&reaction=like'>👍</a> | <a href='https://app.codeant.ai/feedback?pr_url=https%3A%2F%2Fgithub.com%2Fapache%2Fsuperset%2Fpull%2F41551&comment_hash=419b59a9d4fc0db60993dd17ee3c9bc1219f6af4a7ef3242c8f525ddcfe38f71&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]
