Copilot commented on code in PR #37679: URL: https://github.com/apache/superset/pull/37679#discussion_r2885677650
########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/useBulkExport.ts: ########## @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useCallback } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; + +export interface ChartExportData { + chartId: number; + chartName: string; + data: any[]; Review Comment: New code introduces `any[]` for `ChartExportData.data`. AGENTS.md asks to avoid `any`; prefer a typed row shape (often `Record<string, unknown>`/`JsonObject`) or `unknown[]` with narrowing so exported data handling stays type-safe. ```suggestion data: Record<string, unknown>[]; ``` ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/index.tsx: ########## @@ -0,0 +1,286 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useMemo } from 'react'; +import { styled, t } from '@apache-superset/core'; +import { Modal, Button } from '@superset-ui/core/components'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { ChartSelector, ChartInfo } from './ChartSelector'; +import { ExportProgress } from './ExportProgress'; +import { useBulkExport } from './useBulkExport'; + +const ModalContent = styled.div` + min-height: 400px; +`; + +const Description = styled.div` + ${({ theme }) => ` + margin-bottom: ${theme.sizeUnit * 4}px; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const WarningMessage = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${theme.colorWarningBg}; + border: 1px solid ${theme.colorWarningBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${theme.colorWarningText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +const SummaryMessage = styled.div<{ success?: boolean }>` + ${({ theme, success }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${success ? theme.colorSuccessBg : theme.colorErrorBg}; + border: 1px solid ${success ? theme.colorSuccessBorder : theme.colorErrorBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${success ? theme.colorSuccessText : theme.colorErrorText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +export interface ExportDashboardDataModalProps { + show: boolean; + onHide: () => void; + dashboardTitle: string; + charts: ChartInfo[]; + slices: Record<number, any>; // Slice entities from Redux +} + +export const ExportDashboardDataModal = ({ + show, + onHide, + dashboardTitle, + charts, + slices, +}: ExportDashboardDataModalProps) => { + const { addSuccessToast, addDangerToast, addWarningToast } = useToasts(); + const [selectedChartIds, setSelectedChartIds] = useState<Set<number>>( + new Set(), + ); + const [exportComplete, setExportComplete] = useState(false); + + const { isExporting, progress, exportCharts, generateExcelFile, reset } = + useBulkExport({ + onComplete: async results => { + const successCount = results.filter(r => r.status === 'success').length; + const failureCount = results.length - successCount; + + if (successCount > 0) { + try { + const filename = await generateExcelFile(results, dashboardTitle); + addSuccessToast( + failureCount > 0 + ? t( + '%s of %s charts exported successfully to %s', + successCount, + results.length, + filename, + ) + : t( + 'All %s charts exported successfully to %s', + successCount, + filename, + ), + ); + setExportComplete(true); + } catch (error: any) { + addDangerToast( + t( + 'Failed to generate Excel file: %s', + error.message || 'Unknown error', + ), + ); + } + } + }, + onError: error => { + addDangerToast(error); + }, + }); + + // Initialize with all charts selected + useEffect(() => { + if (show && charts.length > 0) { + const allIds = new Set(charts.map(c => c.id)); + setSelectedChartIds(allIds); + setExportComplete(false); + reset(); + } + }, [show, charts, reset]); + + const handleExport = async () => { + if (selectedChartIds.size === 0) { + addWarningToast(t('Please select at least one chart to export')); + return; + } + + if (selectedChartIds.size > 20) { + addWarningToast( + t( + 'Exporting more than 20 charts may take a while and could impact browser performance', + ), + ); + } + + // Prepare chart data for export + const chartsToExport = Array.from(selectedChartIds) + .map(id => { + const slice = slices[id]; + if (!slice) return null; + + return { + id, + name: slice.slice_name || `Chart ${id}`, + formData: slice.form_data || {}, + }; Review Comment: Export requests are built from `slice.form_data` only, which means dashboard-applied native filters / dataMask / cross-filter state may be ignored. The codebase already has utilities to build chart formData that reflects dashboard state (e.g., getFormDataWithExtraFilters + ownState merging in embedded/utils.ts); consider reusing that approach so exported data matches what users are viewing. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/useBulkExport.ts: ########## @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useCallback } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; + +export interface ChartExportData { + chartId: number; + chartName: string; + data: any[]; + columns: string[]; + status: 'pending' | 'exporting' | 'success' | 'error'; + error?: string; +} + +export interface BulkExportProgress { + current: number; + total: number; + charts: ChartExportData[]; +} + +export interface UseBulkExportProps { + onComplete?: (results: ChartExportData[]) => void; + onError?: (error: string) => void; +} + +export const useBulkExport = ({ + onComplete, + onError, +}: UseBulkExportProps = {}) => { + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState<BulkExportProgress>({ + current: 0, + total: 0, + charts: [], + }); + + const sanitizeSheetName = (name: string): string => { + // Excel sheet names: max 31 chars, no special characters + const sanitized = name + .replace(/[:\\/?*[\]]/g, '_') // Replace invalid chars + .substring(0, 31); // Truncate to 31 chars + + return sanitized || 'Sheet'; + }; + + const exportSingleChart = async ( + chartId: number, + chartName: string, + formData: any, + ): Promise<ChartExportData> => { + const chartData: ChartExportData = { + chartId, + chartName, + data: [], + columns: [], + status: 'exporting', + }; + + try { + // Build query payload + const payload = await buildV1ChartDataPayload({ + formData: { + ...formData, + slice_id: chartId, + }, + force: false, + resultFormat: 'json', + resultType: 'full', + setDataMask: () => {}, + ownState: {}, + }); + + // Call chart data API + const response = await SupersetClient.post({ + endpoint: ensureAppRoot('/api/v1/chart/data'), + jsonPayload: payload, + }); + + // Extract data from response + if (response.json?.result?.[0]) { + const [queryResult] = response.json.result; + chartData.data = queryResult.data || []; + chartData.columns = queryResult.colnames || []; + chartData.status = 'success'; + } else { + throw new Error('No data returned from API'); + } + } catch (error: any) { + chartData.status = 'error'; + chartData.error = error.message || 'Failed to export chart'; + } + + return chartData; + }; + + const exportCharts = useCallback( + async (charts: { id: number; name: string; formData: any }[]) => { + setIsExporting(true); + + const initialCharts: ChartExportData[] = charts.map(chart => ({ + chartId: chart.id, + chartName: chart.name, + data: [], + columns: [], + status: 'pending', + })); + + setProgress({ + current: 0, + total: charts.length, + charts: initialCharts, + }); + + const results: ChartExportData[] = []; + + // Export charts sequentially to avoid overwhelming the server + for (let i = 0; i < charts.length; i += 1) { + const chart = charts[i]; + + // Update progress: mark current chart as exporting + setProgress(prev => ({ + ...prev, + current: i + 1, + charts: prev.charts.map(c => + c.chartId === chart.id ? { ...c, status: 'exporting' } : c, + ), + })); + + // Export the chart + const result = await exportSingleChart( + chart.id, + chart.name, + chart.formData, + ); + results.push(result); + + // Update progress: mark chart as complete + setProgress(prev => ({ + ...prev, + charts: prev.charts.map(c => (c.chartId === chart.id ? result : c)), + })); + } + + setIsExporting(false); + + // Check if any succeeded + const successCount = results.filter(r => r.status === 'success').length; + + if (successCount === 0) { + onError?.('All chart exports failed'); + } else { + onComplete?.(results); + } + + return results; + }, + [onComplete, onError], + ); + + const generateExcelFile = useCallback( + async (results: ChartExportData[], dashboardTitle: string) => { + // Dynamically import xlsx to reduce initial bundle size + const XLSX = await import('xlsx'); + + const workbook = XLSX.utils.book_new(); + + // Track sheet names to handle duplicates + const usedSheetNames = new Set<string>(); + + results.forEach(result => { + if (result.status !== 'success' || !result.data.length) { + return; // Skip failed or empty charts + } + + // Generate unique sheet name + let sheetName = sanitizeSheetName(result.chartName); + let counter = 1; + + while (usedSheetNames.has(sheetName)) { + const suffix = `(${counter})`; + const baseName = sanitizeSheetName(result.chartName).substring( + 0, + 31 - suffix.length, + ); + sheetName = `${baseName}${suffix}`; + counter += 1; + } + + usedSheetNames.add(sheetName); + + // Convert data to worksheet + const worksheet = XLSX.utils.json_to_sheet(result.data); + + // Auto-size columns (approximate) + const columnWidths = result.columns.map(col => ({ + wch: Math.max(10, col.length + 2), + })); + worksheet['!cols'] = columnWidths; + + // Add worksheet to workbook + XLSX.utils.book_append_sheet(workbook, worksheet, sheetName); + }); + + // Generate filename with timestamp + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, '-') + .substring(0, 19); + const sanitizedTitle = dashboardTitle.replace(/[^a-zA-Z0-9_-]/g, '_'); + const filename = `${sanitizedTitle}_${timestamp}.xlsx`; + + // Trigger download + XLSX.writeFile(workbook, filename); + + return filename; + }, + [], Review Comment: `generateExcelFile` is memoized with useCallback but uses values from the outer scope (e.g. sanitizeSheetName). With react-hooks/exhaustive-deps enabled, an empty dependency array here will be flagged. Move helpers like sanitizeSheetName to module scope, or include dependencies / inline the helper to satisfy the hook lint rule. ```suggestion [sanitizeSheetName], ``` ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/useBulkExport.ts: ########## @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useCallback } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; + +export interface ChartExportData { + chartId: number; + chartName: string; + data: any[]; + columns: string[]; + status: 'pending' | 'exporting' | 'success' | 'error'; + error?: string; +} + +export interface BulkExportProgress { + current: number; + total: number; + charts: ChartExportData[]; +} + +export interface UseBulkExportProps { + onComplete?: (results: ChartExportData[]) => void; + onError?: (error: string) => void; +} + +export const useBulkExport = ({ + onComplete, + onError, +}: UseBulkExportProps = {}) => { + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState<BulkExportProgress>({ + current: 0, + total: 0, + charts: [], + }); + + const sanitizeSheetName = (name: string): string => { + // Excel sheet names: max 31 chars, no special characters + const sanitized = name + .replace(/[:\\/?*[\]]/g, '_') // Replace invalid chars + .substring(0, 31); // Truncate to 31 chars + + return sanitized || 'Sheet'; + }; + + const exportSingleChart = async ( + chartId: number, + chartName: string, + formData: any, + ): Promise<ChartExportData> => { + const chartData: ChartExportData = { + chartId, + chartName, + data: [], + columns: [], + status: 'exporting', + }; + + try { + // Build query payload + const payload = await buildV1ChartDataPayload({ + formData: { + ...formData, + slice_id: chartId, + }, + force: false, + resultFormat: 'json', + resultType: 'full', + setDataMask: () => {}, + ownState: {}, + }); + + // Call chart data API + const response = await SupersetClient.post({ + endpoint: ensureAppRoot('/api/v1/chart/data'), + jsonPayload: payload, Review Comment: The chart data request is parsed with SupersetClient’s default `parseMethod` (json), but chart payloads are typically parsed with `json-bigint` to preserve large numeric values (ChartMetadata defaults parseMethod to json-bigint). Consider passing `parseMethod: 'json-bigint'` (or deriving parseMethod from the chart’s viz metadata) to avoid precision loss in exported data. ```suggestion jsonPayload: payload, parseMethod: 'json-bigint', ``` ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/index.tsx: ########## @@ -0,0 +1,286 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useMemo } from 'react'; +import { styled, t } from '@apache-superset/core'; +import { Modal, Button } from '@superset-ui/core/components'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { ChartSelector, ChartInfo } from './ChartSelector'; +import { ExportProgress } from './ExportProgress'; +import { useBulkExport } from './useBulkExport'; + +const ModalContent = styled.div` + min-height: 400px; +`; + +const Description = styled.div` + ${({ theme }) => ` + margin-bottom: ${theme.sizeUnit * 4}px; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const WarningMessage = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${theme.colorWarningBg}; + border: 1px solid ${theme.colorWarningBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${theme.colorWarningText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +const SummaryMessage = styled.div<{ success?: boolean }>` + ${({ theme, success }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${success ? theme.colorSuccessBg : theme.colorErrorBg}; + border: 1px solid ${success ? theme.colorSuccessBorder : theme.colorErrorBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${success ? theme.colorSuccessText : theme.colorErrorText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +export interface ExportDashboardDataModalProps { + show: boolean; + onHide: () => void; + dashboardTitle: string; + charts: ChartInfo[]; + slices: Record<number, any>; // Slice entities from Redux Review Comment: New frontend code here introduces `any` types (e.g. `slices: Record<number, any>`). AGENTS.md explicitly calls out avoiding `any`; please replace this with an existing slice entity type (or `unknown` + narrowing) so type errors surface at compile time. ```suggestion slices: Record<number, unknown>; // Slice entities from Redux ``` ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/index.tsx: ########## @@ -0,0 +1,286 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useMemo } from 'react'; +import { styled, t } from '@apache-superset/core'; +import { Modal, Button } from '@superset-ui/core/components'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { ChartSelector, ChartInfo } from './ChartSelector'; +import { ExportProgress } from './ExportProgress'; +import { useBulkExport } from './useBulkExport'; + +const ModalContent = styled.div` + min-height: 400px; +`; + +const Description = styled.div` + ${({ theme }) => ` + margin-bottom: ${theme.sizeUnit * 4}px; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const WarningMessage = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${theme.colorWarningBg}; + border: 1px solid ${theme.colorWarningBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${theme.colorWarningText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +const SummaryMessage = styled.div<{ success?: boolean }>` + ${({ theme, success }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${success ? theme.colorSuccessBg : theme.colorErrorBg}; + border: 1px solid ${success ? theme.colorSuccessBorder : theme.colorErrorBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${success ? theme.colorSuccessText : theme.colorErrorText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +export interface ExportDashboardDataModalProps { + show: boolean; + onHide: () => void; + dashboardTitle: string; + charts: ChartInfo[]; + slices: Record<number, any>; // Slice entities from Redux +} + +export const ExportDashboardDataModal = ({ + show, + onHide, + dashboardTitle, + charts, + slices, +}: ExportDashboardDataModalProps) => { + const { addSuccessToast, addDangerToast, addWarningToast } = useToasts(); + const [selectedChartIds, setSelectedChartIds] = useState<Set<number>>( + new Set(), + ); + const [exportComplete, setExportComplete] = useState(false); + + const { isExporting, progress, exportCharts, generateExcelFile, reset } = + useBulkExport({ + onComplete: async results => { + const successCount = results.filter(r => r.status === 'success').length; + const failureCount = results.length - successCount; + + if (successCount > 0) { + try { + const filename = await generateExcelFile(results, dashboardTitle); + addSuccessToast( + failureCount > 0 + ? t( + '%s of %s charts exported successfully to %s', + successCount, + results.length, + filename, + ) + : t( + 'All %s charts exported successfully to %s', + successCount, + filename, + ), + ); + setExportComplete(true); + } catch (error: any) { + addDangerToast( + t( + 'Failed to generate Excel file: %s', + error.message || 'Unknown error', + ), + ); + } + } + }, + onError: error => { + addDangerToast(error); + }, + }); + + // Initialize with all charts selected + useEffect(() => { + if (show && charts.length > 0) { + const allIds = new Set(charts.map(c => c.id)); + setSelectedChartIds(allIds); + setExportComplete(false); + reset(); + } + }, [show, charts, reset]); Review Comment: This initialization effect depends on the `charts` prop, so it will reset selection and call reset() any time the `charts` array reference changes while the modal is open. Since the parent currently constructs `charts` inline, this can cause unexpected resets. Consider initializing only when `show` becomes true (and/or ensuring `charts` is memoized). ```suggestion }, [show, reset, charts.length]); ``` ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/ChartSelector.tsx: ########## @@ -0,0 +1,181 @@ +/** + * 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 { styled, t } from '@apache-superset/core'; +import { Checkbox } from '@superset-ui/core/components'; + +const Container = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 4}px 0; + `} +`; + +const HeaderRow = styled.div` + ${({ theme }) => ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${theme.sizeUnit * 3}px; + padding: 0 ${theme.sizeUnit * 4}px; + `} +`; + +const SelectionInfo = styled.div` + ${({ theme }) => ` + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const Actions = styled.div` + ${({ theme }) => ` + display: flex; + gap: ${theme.sizeUnit * 2}px; + `} +`; + +const ActionLink = styled.a` + ${({ theme }) => ` + color: ${theme.colorPrimary}; + cursor: pointer; + font-size: ${theme.sizeUnit * 3.5}px; + + &:hover { + text-decoration: underline; + } + `} +`; + +const ChartList = styled.div` + ${({ theme }) => ` + max-height: 400px; + overflow-y: auto; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.sizeUnit}px; + `} +`; + +const ChartItem = styled.div<{ disabled?: boolean }>` + ${({ theme, disabled }) => ` + display: flex; + align-items: center; + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + border-bottom: 1px solid ${theme.colorBorderSecondary}; + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + opacity: ${disabled ? 0.5 : 1}; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: ${disabled ? 'transparent' : theme.colorBgTextHover}; + } + `} +`; + +const ChartName = styled.span` + ${({ theme }) => ` + margin-left: ${theme.sizeUnit * 2}px; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const ChartMeta = styled.span` + ${({ theme }) => ` + margin-left: auto; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3}px; + `} +`; + +export interface ChartInfo { + id: number; + name: string; + vizType?: string; +} + +export interface ChartSelectorProps { + charts: ChartInfo[]; + selectedChartIds: Set<number>; + onSelectionChange: (selectedIds: Set<number>) => void; + disabled?: boolean; +} + +export const ChartSelector = ({ + charts, + selectedChartIds, + onSelectionChange, + disabled = false, +}: ChartSelectorProps) => { + const handleSelectAll = () => { + const allIds = new Set(charts.map(c => c.id)); + onSelectionChange(allIds); + }; + + const handleDeselectAll = () => { + onSelectionChange(new Set()); + }; + + const handleToggleChart = (chartId: number) => { + if (disabled) return; + + const newSelection = new Set(selectedChartIds); + if (newSelection.has(chartId)) { + newSelection.delete(chartId); + } else { + newSelection.add(chartId); + } + onSelectionChange(newSelection); + }; + + return ( + <Container> + <HeaderRow> + <SelectionInfo> + {t('%s of %s charts selected', selectedChartIds.size, charts.length)} + </SelectionInfo> + <Actions> + <ActionLink onClick={handleSelectAll}>{t('Select all')}</ActionLink> + <span>|</span> + <ActionLink onClick={handleDeselectAll}> + {t('Deselect all')} + </ActionLink> Review Comment: The “Select all / Deselect all” controls are rendered as <a> elements without an href. Anchors without href are not consistently keyboard-focusable and are not ideal for button-like actions. Use a <button> (or add role="button", tabIndex, and key handlers) to ensure accessibility. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/index.tsx: ########## @@ -0,0 +1,286 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useEffect, useMemo } from 'react'; +import { styled, t } from '@apache-superset/core'; +import { Modal, Button } from '@superset-ui/core/components'; +import { useToasts } from 'src/components/MessageToasts/withToasts'; +import { ChartSelector, ChartInfo } from './ChartSelector'; +import { ExportProgress } from './ExportProgress'; +import { useBulkExport } from './useBulkExport'; + +const ModalContent = styled.div` + min-height: 400px; +`; + +const Description = styled.div` + ${({ theme }) => ` + margin-bottom: ${theme.sizeUnit * 4}px; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const WarningMessage = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${theme.colorWarningBg}; + border: 1px solid ${theme.colorWarningBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${theme.colorWarningText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +const SummaryMessage = styled.div<{ success?: boolean }>` + ${({ theme, success }) => ` + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + background-color: ${success ? theme.colorSuccessBg : theme.colorErrorBg}; + border: 1px solid ${success ? theme.colorSuccessBorder : theme.colorErrorBorder}; + border-radius: ${theme.sizeUnit}px; + color: ${success ? theme.colorSuccessText : theme.colorErrorText}; + font-size: ${theme.sizeUnit * 3.5}px; + margin-bottom: ${theme.sizeUnit * 4}px; + `} +`; + +export interface ExportDashboardDataModalProps { + show: boolean; + onHide: () => void; + dashboardTitle: string; + charts: ChartInfo[]; + slices: Record<number, any>; // Slice entities from Redux +} + +export const ExportDashboardDataModal = ({ + show, + onHide, + dashboardTitle, + charts, + slices, +}: ExportDashboardDataModalProps) => { + const { addSuccessToast, addDangerToast, addWarningToast } = useToasts(); + const [selectedChartIds, setSelectedChartIds] = useState<Set<number>>( + new Set(), + ); + const [exportComplete, setExportComplete] = useState(false); + + const { isExporting, progress, exportCharts, generateExcelFile, reset } = + useBulkExport({ + onComplete: async results => { + const successCount = results.filter(r => r.status === 'success').length; + const failureCount = results.length - successCount; + + if (successCount > 0) { + try { + const filename = await generateExcelFile(results, dashboardTitle); + addSuccessToast( + failureCount > 0 + ? t( + '%s of %s charts exported successfully to %s', + successCount, + results.length, + filename, + ) + : t( + 'All %s charts exported successfully to %s', + successCount, + filename, + ), + ); + setExportComplete(true); + } catch (error: any) { + addDangerToast( + t( + 'Failed to generate Excel file: %s', + error.message || 'Unknown error', + ), + ); + } + } + }, + onError: error => { + addDangerToast(error); + }, + }); + + // Initialize with all charts selected + useEffect(() => { + if (show && charts.length > 0) { + const allIds = new Set(charts.map(c => c.id)); + setSelectedChartIds(allIds); + setExportComplete(false); + reset(); + } + }, [show, charts, reset]); + + const handleExport = async () => { + if (selectedChartIds.size === 0) { + addWarningToast(t('Please select at least one chart to export')); + return; + } + + if (selectedChartIds.size > 20) { + addWarningToast( + t( + 'Exporting more than 20 charts may take a while and could impact browser performance', + ), + ); + } + + // Prepare chart data for export + const chartsToExport = Array.from(selectedChartIds) + .map(id => { + const slice = slices[id]; + if (!slice) return null; + + return { + id, + name: slice.slice_name || `Chart ${id}`, + formData: slice.form_data || {}, + }; + }) + .filter(Boolean) as { id: number; name: string; formData: any }[]; + Review Comment: `chartsToExport` is cast to a type with `formData: any`. Per AGENTS.md, please avoid `any` in new frontend code—prefer `QueryFormData`/`JsonObject` (or `unknown` with runtime narrowing) for `formData` so payload building remains type-safe. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/ChartSelector.tsx: ########## @@ -0,0 +1,181 @@ +/** + * 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 { styled, t } from '@apache-superset/core'; +import { Checkbox } from '@superset-ui/core/components'; + +const Container = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 4}px 0; + `} +`; + +const HeaderRow = styled.div` + ${({ theme }) => ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${theme.sizeUnit * 3}px; + padding: 0 ${theme.sizeUnit * 4}px; + `} +`; + +const SelectionInfo = styled.div` + ${({ theme }) => ` + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const Actions = styled.div` + ${({ theme }) => ` + display: flex; + gap: ${theme.sizeUnit * 2}px; + `} +`; + +const ActionLink = styled.a` + ${({ theme }) => ` + color: ${theme.colorPrimary}; + cursor: pointer; + font-size: ${theme.sizeUnit * 3.5}px; + + &:hover { + text-decoration: underline; + } + `} +`; + +const ChartList = styled.div` + ${({ theme }) => ` + max-height: 400px; + overflow-y: auto; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.sizeUnit}px; + `} +`; + +const ChartItem = styled.div<{ disabled?: boolean }>` + ${({ theme, disabled }) => ` + display: flex; + align-items: center; + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + border-bottom: 1px solid ${theme.colorBorderSecondary}; + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + opacity: ${disabled ? 0.5 : 1}; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: ${disabled ? 'transparent' : theme.colorBgTextHover}; + } + `} +`; + +const ChartName = styled.span` + ${({ theme }) => ` + margin-left: ${theme.sizeUnit * 2}px; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const ChartMeta = styled.span` + ${({ theme }) => ` + margin-left: auto; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3}px; + `} +`; + +export interface ChartInfo { + id: number; + name: string; + vizType?: string; +} + +export interface ChartSelectorProps { + charts: ChartInfo[]; + selectedChartIds: Set<number>; + onSelectionChange: (selectedIds: Set<number>) => void; + disabled?: boolean; +} + +export const ChartSelector = ({ + charts, + selectedChartIds, + onSelectionChange, + disabled = false, +}: ChartSelectorProps) => { + const handleSelectAll = () => { + const allIds = new Set(charts.map(c => c.id)); + onSelectionChange(allIds); + }; + + const handleDeselectAll = () => { + onSelectionChange(new Set()); + }; + + const handleToggleChart = (chartId: number) => { + if (disabled) return; + + const newSelection = new Set(selectedChartIds); + if (newSelection.has(chartId)) { + newSelection.delete(chartId); + } else { + newSelection.add(chartId); + } + onSelectionChange(newSelection); + }; + + return ( + <Container> + <HeaderRow> + <SelectionInfo> + {t('%s of %s charts selected', selectedChartIds.size, charts.length)} + </SelectionInfo> + <Actions> + <ActionLink onClick={handleSelectAll}>{t('Select all')}</ActionLink> + <span>|</span> + <ActionLink onClick={handleDeselectAll}> + {t('Deselect all')} + </ActionLink> + </Actions> + </HeaderRow> + + <ChartList> + {charts.map(chart => ( + <ChartItem + key={chart.id} + onClick={() => handleToggleChart(chart.id)} + disabled={disabled} + > + <Checkbox + checked={selectedChartIds.has(chart.id)} + onChange={() => handleToggleChart(chart.id)} Review Comment: Clicking the checkbox will likely toggle selection twice because the row is clickable (ChartItem onClick) and the Checkbox onChange also toggles. This can result in no net change when the checkbox itself is clicked. Consider removing one handler, or stopping propagation from the checkbox interaction so only one toggle occurs. ```suggestion onChange={event => { event.stopPropagation(); handleToggleChart(chart.id); }} ``` ########## superset-frontend/src/dashboard/components/Header/index.tsx: ########## @@ -942,6 +952,18 @@ const Header = (): ReactElement => { onConfirmNavigation={handleConfirmNavigation} handleSave={handleSaveAndCloseModal} /> + + <ExportDashboardDataModal + show={showExportModal} + onHide={() => setShowExportModal(false)} + dashboardTitle={dashboardTitle ?? ''} + charts={chartIds.map(id => ({ + id, + name: sliceEntities[id]?.slice_name || `Chart ${id}`, + vizType: sliceEntities[id]?.viz_type, + }))} Review Comment: `charts` is passed as `chartIds.map(...)`, which creates a new array every Header render. Since the modal initialization effect depends on the `charts` prop reference, this can trigger re-initialization/resets while the modal is open. Memoize the mapped `charts` array (useMemo) or adjust the modal effect dependencies to prevent unintended resets. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/useBulkExport.ts: ########## @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useCallback } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; + +export interface ChartExportData { + chartId: number; + chartName: string; + data: any[]; + columns: string[]; + status: 'pending' | 'exporting' | 'success' | 'error'; + error?: string; +} + +export interface BulkExportProgress { + current: number; + total: number; + charts: ChartExportData[]; +} + +export interface UseBulkExportProps { + onComplete?: (results: ChartExportData[]) => void; + onError?: (error: string) => void; +} + +export const useBulkExport = ({ + onComplete, + onError, +}: UseBulkExportProps = {}) => { + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState<BulkExportProgress>({ + current: 0, + total: 0, + charts: [], + }); + + const sanitizeSheetName = (name: string): string => { + // Excel sheet names: max 31 chars, no special characters + const sanitized = name + .replace(/[:\\/?*[\]]/g, '_') // Replace invalid chars + .substring(0, 31); // Truncate to 31 chars + + return sanitized || 'Sheet'; + }; + + const exportSingleChart = async ( + chartId: number, + chartName: string, + formData: any, + ): Promise<ChartExportData> => { + const chartData: ChartExportData = { + chartId, + chartName, + data: [], + columns: [], + status: 'exporting', + }; + + try { + // Build query payload + const payload = await buildV1ChartDataPayload({ + formData: { + ...formData, + slice_id: chartId, + }, + force: false, + resultFormat: 'json', + resultType: 'full', + setDataMask: () => {}, + ownState: {}, + }); + + // Call chart data API + const response = await SupersetClient.post({ + endpoint: ensureAppRoot('/api/v1/chart/data'), + jsonPayload: payload, + }); + + // Extract data from response + if (response.json?.result?.[0]) { + const [queryResult] = response.json.result; + chartData.data = queryResult.data || []; + chartData.columns = queryResult.colnames || []; + chartData.status = 'success'; + } else { + throw new Error('No data returned from API'); + } + } catch (error: any) { + chartData.status = 'error'; + chartData.error = error.message || 'Failed to export chart'; + } + + return chartData; + }; + + const exportCharts = useCallback( + async (charts: { id: number; name: string; formData: any }[]) => { + setIsExporting(true); Review Comment: `exportCharts` is wrapped in useCallback but closes over `exportSingleChart` (declared in the component scope) without listing it in the dependency array. With react-hooks/exhaustive-deps enabled, this will be flagged and can create stale-closure issues if `exportSingleChart` starts using props/state. Consider moving `exportSingleChart` to module scope or memoizing it and adding it to deps. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/ChartSelector.tsx: ########## @@ -0,0 +1,181 @@ +/** + * 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 { styled, t } from '@apache-superset/core'; +import { Checkbox } from '@superset-ui/core/components'; + +const Container = styled.div` + ${({ theme }) => ` + padding: ${theme.sizeUnit * 4}px 0; + `} +`; + +const HeaderRow = styled.div` + ${({ theme }) => ` + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: ${theme.sizeUnit * 3}px; + padding: 0 ${theme.sizeUnit * 4}px; + `} +`; + +const SelectionInfo = styled.div` + ${({ theme }) => ` + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const Actions = styled.div` + ${({ theme }) => ` + display: flex; + gap: ${theme.sizeUnit * 2}px; + `} +`; + +const ActionLink = styled.a` + ${({ theme }) => ` + color: ${theme.colorPrimary}; + cursor: pointer; + font-size: ${theme.sizeUnit * 3.5}px; + + &:hover { + text-decoration: underline; + } + `} +`; + +const ChartList = styled.div` + ${({ theme }) => ` + max-height: 400px; + overflow-y: auto; + border: 1px solid ${theme.colorBorder}; + border-radius: ${theme.sizeUnit}px; + `} +`; + +const ChartItem = styled.div<{ disabled?: boolean }>` + ${({ theme, disabled }) => ` + display: flex; + align-items: center; + padding: ${theme.sizeUnit * 3}px ${theme.sizeUnit * 4}px; + border-bottom: 1px solid ${theme.colorBorderSecondary}; + cursor: ${disabled ? 'not-allowed' : 'pointer'}; + opacity: ${disabled ? 0.5 : 1}; + + &:last-child { + border-bottom: none; + } + + &:hover { + background-color: ${disabled ? 'transparent' : theme.colorBgTextHover}; + } + `} +`; + +const ChartName = styled.span` + ${({ theme }) => ` + margin-left: ${theme.sizeUnit * 2}px; + font-size: ${theme.sizeUnit * 3.5}px; + `} +`; + +const ChartMeta = styled.span` + ${({ theme }) => ` + margin-left: auto; + color: ${theme.colorTextSecondary}; + font-size: ${theme.sizeUnit * 3}px; + `} +`; + +export interface ChartInfo { + id: number; + name: string; + vizType?: string; +} + +export interface ChartSelectorProps { + charts: ChartInfo[]; + selectedChartIds: Set<number>; + onSelectionChange: (selectedIds: Set<number>) => void; + disabled?: boolean; +} + +export const ChartSelector = ({ + charts, + selectedChartIds, + onSelectionChange, + disabled = false, +}: ChartSelectorProps) => { + const handleSelectAll = () => { + const allIds = new Set(charts.map(c => c.id)); + onSelectionChange(allIds); + }; + + const handleDeselectAll = () => { + onSelectionChange(new Set()); + }; + + const handleToggleChart = (chartId: number) => { + if (disabled) return; + + const newSelection = new Set(selectedChartIds); + if (newSelection.has(chartId)) { + newSelection.delete(chartId); + } else { + newSelection.add(chartId); + } + onSelectionChange(newSelection); + }; + + return ( + <Container> + <HeaderRow> + <SelectionInfo> + {t('%s of %s charts selected', selectedChartIds.size, charts.length)} + </SelectionInfo> + <Actions> + <ActionLink onClick={handleSelectAll}>{t('Select all')}</ActionLink> + <span>|</span> + <ActionLink onClick={handleDeselectAll}> + {t('Deselect all')} + </ActionLink> + </Actions> + </HeaderRow> + + <ChartList> + {charts.map(chart => ( + <ChartItem + key={chart.id} + onClick={() => handleToggleChart(chart.id)} + disabled={disabled} + > Review Comment: Chart rows are clickable but rendered as a plain <div>, which is not keyboard accessible by default. Consider using a semantic button/list item pattern, or add role/tabIndex and handle Enter/Space key events so users can toggle selection via keyboard. ########## superset-frontend/src/dashboard/components/menu/DownloadMenuItems/index.tsx: ########## @@ -175,7 +176,17 @@ export const useDownloadMenuItems = ( }, ]; + const dataExportMenuItem: MenuItem = { + key: 'export-dashboard-data', + label: t('Export Dashboard Data'), + onClick: props.onExportDashboardData, + }; + const exportMenuItems: MenuItem[] = [ + ...(props.onExportDashboardData ? [dataExportMenuItem] : []), + ...(props.onExportDashboardData + ? [{ type: 'divider' as const, key: 'data-export-divider' }] + : []), Review Comment: This file already has Jest tests (DownloadMenuItems.test.tsx), but the new `onExportDashboardData` behavior isn’t covered. Consider adding a test that passes `onExportDashboardData` and asserts the new “Export Dashboard Data” item (and divider) are rendered and invoke the callback when clicked. ########## superset-frontend/src/dashboard/components/Header/index.tsx: ########## @@ -194,6 +195,9 @@ const Header = (): ReactElement => { const dispatch = useDispatch(); const [didNotifyMaxUndoHistoryToast, setDidNotifyMaxUndoHistoryToast] = useState<boolean>(false); + const sliceEntities = useSelector( + (state: RootState) => state.sliceEntities?.slices || {}, + ); Review Comment: Using `state.sliceEntities?.slices || {}` in a selector returns a new object literal when sliceEntities is undefined, which can trigger unnecessary rerenders. Prefer `??` with a module-level EMPTY object (or handle defaulting outside useSelector) to keep selector results referentially stable. ########## superset-frontend/src/dashboard/components/ExportDashboardDataModal/useBulkExport.ts: ########## @@ -0,0 +1,254 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { useState, useCallback } from 'react'; +import { SupersetClient } from '@superset-ui/core'; +import { buildV1ChartDataPayload } from 'src/explore/exploreUtils'; +import { ensureAppRoot } from 'src/utils/pathUtils'; + +export interface ChartExportData { + chartId: number; + chartName: string; + data: any[]; + columns: string[]; + status: 'pending' | 'exporting' | 'success' | 'error'; + error?: string; +} + +export interface BulkExportProgress { + current: number; + total: number; + charts: ChartExportData[]; +} + +export interface UseBulkExportProps { + onComplete?: (results: ChartExportData[]) => void; + onError?: (error: string) => void; +} + +export const useBulkExport = ({ + onComplete, + onError, +}: UseBulkExportProps = {}) => { + const [isExporting, setIsExporting] = useState(false); + const [progress, setProgress] = useState<BulkExportProgress>({ + current: 0, + total: 0, + charts: [], + }); + + const sanitizeSheetName = (name: string): string => { + // Excel sheet names: max 31 chars, no special characters + const sanitized = name + .replace(/[:\\/?*[\]]/g, '_') // Replace invalid chars + .substring(0, 31); // Truncate to 31 chars + + return sanitized || 'Sheet'; + }; + + const exportSingleChart = async ( + chartId: number, + chartName: string, + formData: any, + ): Promise<ChartExportData> => { Review Comment: `exportSingleChart` accepts `formData: any`. Per AGENTS.md, please use an existing form-data type (e.g. `QueryFormData`) or `unknown` + narrowing to avoid losing type safety when building the chart payload. -- 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]
