From 594f39bb7cf7f909dc1358c45a6a8d280a1ce98a Mon Sep 17 00:00:00 2001 From: Amin Khorramii Date: Fri, 3 Oct 2025 21:19:38 +0200 Subject: [PATCH] results being able to send api --- .../src/components/tabs/ai/ResultsPanel.tsx | 45 +- .../components/tabs/query/QueryWorkspace.tsx | 38 +- .../query-results/ExportResultsModal.tsx | 265 ++++++++++++ .../query/query-results/SaveAsTableModal.tsx | 218 ---------- .../export-panels/LocalTablePanel.tsx | 164 ++++++++ .../export-panels/RestAPIPanel.tsx | 386 ++++++++++++++++++ frontend/src/hooks/query/useExportProvider.ts | 210 ++++++++++ 7 files changed, 1068 insertions(+), 258 deletions(-) create mode 100644 frontend/src/components/tabs/query/query-results/ExportResultsModal.tsx delete mode 100644 frontend/src/components/tabs/query/query-results/SaveAsTableModal.tsx create mode 100644 frontend/src/components/tabs/query/query-results/export-panels/LocalTablePanel.tsx create mode 100644 frontend/src/components/tabs/query/query-results/export-panels/RestAPIPanel.tsx create mode 100644 frontend/src/hooks/query/useExportProvider.ts diff --git a/frontend/src/components/tabs/ai/ResultsPanel.tsx b/frontend/src/components/tabs/ai/ResultsPanel.tsx index 638ca37..42ce5b3 100644 --- a/frontend/src/components/tabs/ai/ResultsPanel.tsx +++ b/frontend/src/components/tabs/ai/ResultsPanel.tsx @@ -4,8 +4,9 @@ import { useTranslation } from 'react-i18next'; import { useAIStore } from "@/store/aiStore"; import { useQueryResultsImport } from "@/hooks/query/useQueryResultsImport"; +import { useExportProvider } from "@/hooks/query/useExportProvider"; import QueryResults from "@/components/tabs/query/query-results/QueryResults"; -import SaveAsTableModal from "@/components/tabs/query/query-results/SaveAsTableModal"; +import ExportResultsModal from "@/components/tabs/query/query-results/ExportResultsModal"; interface ResultsPanelProps { height: number; @@ -21,31 +22,24 @@ const ResultsPanel: React.FC = ({ height, activeFile }) => { const { queryResults } = useAIStore(); const { isImporting, importQueryResultsAsTable } = useQueryResultsImport(); const [showSaveAsTableModal, setShowSaveAsTableModal] = useState(false); + + // Hook for export functionality + const { handleExportWithProvider } = useExportProvider( + { + results: queryResults?.data || [], + columns: queryResults?.columns || [], + query: queryResults?.executedSQL, + sourceFileName: activeFile?.fileName || activeFile?.tableName || 'ai_query_results' + }, + activeFile, + () => setShowSaveAsTableModal(false) + ); // Handle opening the save as table modal const handleImportAsTable = useCallback(() => { setShowSaveAsTableModal(true); }, []); - // Handle confirming table import with custom name - const handleConfirmImportAsTable = useCallback(async (tableName: string) => { - if (!queryResults?.data || !queryResults?.columns) return; - - - // Use dynamic source file name based on active file or fallback to ai_query_results - const sourceFileName = activeFile?.fileName || activeFile?.tableName || 'ai_query_results'; - // Pass the executed SQL for VIEW creation of large datasets and the custom table name - const success = await importQueryResultsAsTable( - queryResults.data, - queryResults.columns, - sourceFileName, - queryResults.executedSQL, - tableName - ); - if (success) { - setShowSaveAsTableModal(false); - } - }, [queryResults, importQueryResultsAsTable, activeFile]); if (!queryResults) { return ( @@ -95,12 +89,15 @@ const ResultsPanel: React.FC = ({ height, activeFile }) => { /> - {/* Save As Table Modal */} - setShowSaveAsTableModal(false)} - onConfirm={handleConfirmImportAsTable} - isImporting={isImporting} + onConfirm={handleExportWithProvider} + isExporting={isImporting} + results={queryResults.data} + columns={queryResults.columns} + query={queryResults.executedSQL} rowCount={queryResults.totalRows} columnCount={queryResults.columns?.length || 0} sourceFileName={activeFile?.fileName || activeFile?.tableName || 'ai_query_results'} diff --git a/frontend/src/components/tabs/query/QueryWorkspace.tsx b/frontend/src/components/tabs/query/QueryWorkspace.tsx index c705b6a..d8dd981 100644 --- a/frontend/src/components/tabs/query/QueryWorkspace.tsx +++ b/frontend/src/components/tabs/query/QueryWorkspace.tsx @@ -30,7 +30,7 @@ import SchemaBrowser from "./SchemaBrowser"; import MonacoEditor from "./MonacoEditor"; import QueryHistory from "./QueryHistory"; import QueryResults from "./query-results/QueryResults"; -import SaveAsTableModal from "./query-results/SaveAsTableModal"; +import ExportResultsModal from "./query-results/ExportResultsModal"; import { DraftBadge } from "@/components/tabs/query/DraftBadge"; import { Button } from "@/components/ui/Button"; @@ -39,6 +39,7 @@ import { useQueryExecution } from "@/hooks/query/useQueryExecution"; import { useQueryHistory } from "@/hooks/query/useQueryHistory"; import { useQueryOptimization } from "@/hooks/query/useQueryOptimization"; import { useQueryResultsImport } from "@/hooks/query/useQueryResultsImport"; +import { useExportProvider } from "@/hooks/query/useExportProvider"; import { useWorkspaceUIState } from "./useWorkspaceUIState"; // Constants for panel dimensions @@ -136,7 +137,19 @@ const QueryWorkspace: React.FC = () => { useQueryOptimization(); // Hook for importing query results as table - const { isImporting: isImportingAsTable, importQueryResultsAsTable } = useQueryResultsImport(); + const { isImporting: isImportingAsTable } = useQueryResultsImport(); + + // Hook for export functionality + const { handleExportWithProvider } = useExportProvider( + { + results, + columns, + query, + sourceFileName: activeFile?.fileName || activeFile?.tableName + }, + activeFile, + () => setShowSaveAsTableModal(false) + ); // Schema browser width state const [schemaBrowserWidth, setSchemaBrowserWidth] = useState(() => { @@ -242,16 +255,6 @@ const QueryWorkspace: React.FC = () => { setShowSaveAsTableModal(true); }, []); - // Handle confirming table import with custom name - const handleConfirmImportAsTable = useCallback(async (tableName: string) => { - // Get the source file name from active file context - const sourceFileName = activeFile?.fileName || activeFile?.tableName; - // Pass the current query for VIEW creation of large datasets and the custom table name - const success = await importQueryResultsAsTable(results, columns, sourceFileName, query, tableName); - if (success) { - setShowSaveAsTableModal(false); - } - }, [results, columns, activeFile, query, importQueryResultsAsTable]); // Memoized event handlers to prevent unnecessary re-renders const handleExecuteQuery = useCallback(async () => { @@ -836,12 +839,15 @@ const QueryWorkspace: React.FC = () => { )} - {/* Save As Table Modal */} - setShowSaveAsTableModal(false)} - onConfirm={handleConfirmImportAsTable} - isImporting={isImportingAsTable} + onConfirm={handleExportWithProvider} + isExporting={isImportingAsTable} + results={results} + columns={columns} + query={query} rowCount={totalRows} columnCount={columns?.length || 0} sourceFileName={activeFile?.fileName || activeFile?.tableName} diff --git a/frontend/src/components/tabs/query/query-results/ExportResultsModal.tsx b/frontend/src/components/tabs/query/query-results/ExportResultsModal.tsx new file mode 100644 index 0000000..f2b5cb1 --- /dev/null +++ b/frontend/src/components/tabs/query/query-results/ExportResultsModal.tsx @@ -0,0 +1,265 @@ +import React, { useState, useEffect } from 'react'; +import { motion, AnimatePresence } from 'framer-motion'; +import { X, Grid3x3, Send } from 'lucide-react'; +import { useTranslation } from 'react-i18next'; +import { Button } from '@/components/ui/Button'; +import { cn } from '@/lib/utils'; +import LocalTablePanel from './export-panels/LocalTablePanel'; +import RestAPIPanel from './export-panels/RestAPIPanel'; + +type ExportProvider = + | 'local-table' // Current functionality + | 'rest-api'; // API integration + +interface ExportProviderConfig { + id: ExportProvider; + label: string; + icon: React.ReactNode; + description: string; + badge?: string; +} + +export interface ExportResultsModalProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (provider: ExportProvider, config: any) => void; + isExporting: boolean; + results: any[]; + columns: string[]; + query?: string; + rowCount: number; + columnCount: number; + sourceFileName?: string; +} + +const ExportResultsModal: React.FC = ({ + isOpen, + onClose, + onConfirm, + isExporting, + results, + columns, + query, + rowCount, + columnCount, + sourceFileName +}) => { + const { t } = useTranslation(); + const [activeProvider, setActiveProvider] = useState('local-table'); + + const EXPORT_PROVIDERS: ExportProviderConfig[] = [ + { + id: 'local-table', + label: t('export.providers.localTable.label', { defaultValue: 'Local Table' }), + icon: , + description: t('export.providers.localTable.description', { defaultValue: 'Save in browser' }), + }, + { + id: 'rest-api', + label: t('export.providers.restApi.label', { defaultValue: 'REST API' }), + icon: , + description: t('export.providers.restApi.description', { defaultValue: 'Send to your API endpoint' }), + }, + ]; + + // Load last used provider + useEffect(() => { + if (isOpen) { + const saved = localStorage.getItem('datakit-last-export-provider'); + if (saved === 'rest-api' || saved === 'local-table') { + setActiveProvider(saved as ExportProvider); + } + } + }, [isOpen]); + + const handleProviderChange = (provider: ExportProvider) => { + setActiveProvider(provider); + localStorage.setItem('datakit-last-export-provider', provider); + }; + + const handleExport = (config: any) => { + onConfirm(activeProvider, config); + }; + + const renderProviderPanel = () => { + const commonProps = { + results, + columns, + query, + rowCount, + columnCount, + sourceFileName, + onExport: handleExport, + isExporting, + }; + + switch (activeProvider) { + case 'local-table': + return ; + case 'rest-api': + return ; + default: + return null; + } + }; + + return ( + + {isOpen && ( + + e.stopPropagation()} + > + {/* Left Sidebar */} +
+ {/* Header */} +
+
+

+ {t('export.title', { defaultValue: 'Save Results' })} +

+
+
+ {rowCount.toLocaleString()} rows + + {columnCount} columns +
+
+ + {/* Provider Selection */} +
+
+ {EXPORT_PROVIDERS.map((provider) => ( + + ))} +
+
+
+ + {/* Right Content Area */} +
+ {/* Content Header */} +
+
+
+ {EXPORT_PROVIDERS.find((p) => p.id === activeProvider)?.icon} +
+
+

+ {EXPORT_PROVIDERS.find((p) => p.id === activeProvider)?.label} +

+

+ {EXPORT_PROVIDERS.find((p) => p.id === activeProvider)?.description} +

+
+ +
+
+ + {/* Dynamic Content */} +
+ + + {renderProviderPanel()} + + +
+
+
+
+ )} +
+ ); +}; + +export default ExportResultsModal; \ No newline at end of file diff --git a/frontend/src/components/tabs/query/query-results/SaveAsTableModal.tsx b/frontend/src/components/tabs/query/query-results/SaveAsTableModal.tsx deleted file mode 100644 index 0a5aa9b..0000000 --- a/frontend/src/components/tabs/query/query-results/SaveAsTableModal.tsx +++ /dev/null @@ -1,218 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { motion, AnimatePresence } from 'framer-motion'; -import { X, Info } from 'lucide-react'; -import { Button } from '@/components/ui/Button'; - -interface SaveAsTableModalProps { - isOpen: boolean; - onClose: () => void; - onConfirm: (tableName: string) => void; - isImporting: boolean; - rowCount: number; - columnCount: number; - sourceFileName?: string; -} - -const SaveAsTableModal: React.FC = ({ - isOpen, - onClose, - onConfirm, - isImporting, - rowCount, - columnCount, - sourceFileName -}) => { - const [tableName, setTableName] = useState(''); - const [error, setError] = useState(''); - - // Generate suggested name when modal opens - useEffect(() => { - if (isOpen) { - const now = new Date(); - const dateStr = now.toISOString().split('T')[0].replace(/-/g, ''); - const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '').substring(0, 4); - - if (sourceFileName) { - const baseName = sourceFileName - .replace(/\.[^/.]+$/, '') - .replace(/[^a-zA-Z0-9_]/g, '_') - .toLowerCase(); - setTableName(`${baseName}_${dateStr}_${timeStr}`); - } else { - setTableName(`results_${dateStr}_${timeStr}`); - } - setError(''); - } - }, [isOpen, sourceFileName]); - - const validateTableName = (name: string): boolean => { - if (!name.trim()) { - setError('Table name is required'); - return false; - } - - if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { - setError('Table name must start with a letter or underscore and contain only letters, numbers, and underscores'); - return false; - } - - if (name.length > 63) { - setError('Table name must be 63 characters or less'); - return false; - } - - const reservedWords = ['select', 'from', 'where', 'table', 'view', 'create', 'drop', 'insert', 'update', 'delete']; - if (reservedWords.includes(name.toLowerCase())) { - setError('Table name cannot be a reserved SQL keyword'); - return false; - } - - setError(''); - return true; - }; - - const handleConfirm = () => { - if (validateTableName(tableName)) { - onConfirm(tableName); - } - }; - - const handleTableNameChange = (e: React.ChangeEvent) => { - const value = e.target.value; - setTableName(value); - validateTableName(value); - }; - - return ( - - {isOpen && ( - - {/* Backdrop */} - - - {/* Modal */} - - {/* Header */} -
-
-

Save Results

-
- -
- - {/* Content */} -
- {/* Info Box */} -
-
- -
-

How it works:

-
    -
  • • The table will appear as a new tab for easy access
  • -
  • • You can query this table directly using SQL
  • -
  • • This table exists only in your browser - download the file to save permanently
  • -
-
-
-
- - {/* Dataset Info */} -
-
-
Rows
-
{rowCount.toLocaleString()}
-
-
-
Columns
-
{columnCount}
-
-
- - {/* Table Name Input */} -
- - - {error && ( -

{error}

- )} -

- Choose a descriptive name for your table. Only letters, numbers, and underscores are allowed. -

-
-
- - {/* Footer */} -
- - -
-
-
- )} -
- ); -}; - -export default SaveAsTableModal; \ No newline at end of file diff --git a/frontend/src/components/tabs/query/query-results/export-panels/LocalTablePanel.tsx b/frontend/src/components/tabs/query/query-results/export-panels/LocalTablePanel.tsx new file mode 100644 index 0000000..272e091 --- /dev/null +++ b/frontend/src/components/tabs/query/query-results/export-panels/LocalTablePanel.tsx @@ -0,0 +1,164 @@ +import React, { useState, useEffect } from 'react'; +import { Info } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +interface LocalTablePanelProps { + results: any[]; + columns: string[]; + query?: string; + rowCount: number; + columnCount: number; + sourceFileName?: string; + onExport: (config: { tableName: string }) => void; + isExporting: boolean; +} + +const LocalTablePanel: React.FC = ({ + rowCount, + columnCount, + sourceFileName, + onExport, + isExporting, +}) => { + const [tableName, setTableName] = useState(''); + const [error, setError] = useState(''); + + // Generate suggested name when panel opens + useEffect(() => { + const now = new Date(); + const dateStr = now.toISOString().split('T')[0].replace(/-/g, ''); + const timeStr = now.toTimeString().split(' ')[0].replace(/:/g, '').substring(0, 4); + + if (sourceFileName) { + const baseName = sourceFileName + .replace(/\.[^/.]+$/, '') + .replace(/[^a-zA-Z0-9_]/g, '_') + .toLowerCase(); + setTableName(`${baseName}_${dateStr}_${timeStr}`); + } else { + setTableName(`results_${dateStr}_${timeStr}`); + } + setError(''); + }, [sourceFileName]); + + const validateTableName = (name: string): boolean => { + if (!name.trim()) { + setError('Table name is required'); + return false; + } + + if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(name)) { + setError('Table name must start with a letter or underscore and contain only letters, numbers, and underscores'); + return false; + } + + if (name.length > 63) { + setError('Table name must be 63 characters or less'); + return false; + } + + const reservedWords = ['select', 'from', 'where', 'table', 'view', 'create', 'drop', 'insert', 'update', 'delete']; + if (reservedWords.includes(name.toLowerCase())) { + setError('Table name cannot be a reserved SQL keyword'); + return false; + } + + setError(''); + return true; + }; + + const handleTableNameChange = (e: React.ChangeEvent) => { + const value = e.target.value; + setTableName(value); + validateTableName(value); + }; + + const handleExport = () => { + if (validateTableName(tableName)) { + onExport({ tableName }); + } + }; + + return ( +
+
+
+ {/* Info Box */} +
+
+ +
+

How it works:

+
    +
  • • The table will appear as a new tab for easy access
  • +
  • • You can query this table directly using SQL
  • +
  • • This table exists only in your browser - download the file to save permanently
  • +
+
+
+
+ + {/* Dataset Info */} +
+
+
Rows to Export
+
{rowCount.toLocaleString()}
+
+
+
Columns
+
{columnCount}
+
+
+ + {/* Table Name Input */} +
+ + + {error && ( +

{error}

+ )} +

+ Choose a descriptive name for your table. Only letters, numbers, and underscores are allowed. +

+
+
+
+ + {/* Footer */} +
+
+ +
+
+
+ ); +}; + +export default LocalTablePanel; \ No newline at end of file diff --git a/frontend/src/components/tabs/query/query-results/export-panels/RestAPIPanel.tsx b/frontend/src/components/tabs/query/query-results/export-panels/RestAPIPanel.tsx new file mode 100644 index 0000000..2413cb9 --- /dev/null +++ b/frontend/src/components/tabs/query/query-results/export-panels/RestAPIPanel.tsx @@ -0,0 +1,386 @@ +import React, { useState, useEffect } from 'react'; +import { AlertTriangle, Copy, Eye, EyeOff } from 'lucide-react'; +import { Button } from '@/components/ui/Button'; + +interface RestAPIPanelProps { + results: any[]; + columns: string[]; + query?: string; + rowCount: number; + columnCount: number; + sourceFileName?: string; + onExport: (config: RestAPIConfig) => void; + isExporting: boolean; +} + +interface RestAPIConfig { + endpoint: string; + method: 'POST' | 'PUT'; + headers: Record; + authentication: { + type: 'none' | 'bearer' | 'api-key' | 'basic'; + value?: string; + headerName?: string; + }; + dataFormat: 'json' | 'csv' | 'ndjson'; + batchSize: number; + includeMetadata: boolean; +} + +const RestAPIPanel: React.FC = ({ + results, + columns, + rowCount, + sourceFileName, + onExport, + isExporting, +}) => { + const [config, setConfig] = useState({ + endpoint: '', + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + authentication: { + type: 'none', + }, + dataFormat: 'json', + batchSize: 1000, + includeMetadata: true, + }); + + const [errors, setErrors] = useState>({}); + const [showAuthValue, setShowAuthValue] = useState(false); + const [customHeaders, setCustomHeaders] = useState(''); + const [testResponse, setTestResponse] = useState(null); + + // Load saved configuration + useEffect(() => { + const saved = localStorage.getItem('datakit-api-export-config'); + if (saved) { + try { + const parsed = JSON.parse(saved); + // Don't restore sensitive auth values + setConfig({ + ...parsed, + authentication: { + ...parsed.authentication, + value: '', + }, + }); + } catch (e) { + // Invalid saved config + } + } + }, []); + + const validateConfig = (): boolean => { + const newErrors: Record = {}; + + if (!config.endpoint.trim()) { + newErrors.endpoint = 'API endpoint is required'; + } else if (!/^https?:\/\/.+/.test(config.endpoint)) { + newErrors.endpoint = 'Must be a valid HTTP/HTTPS URL'; + } + + if (config.authentication.type !== 'none' && !config.authentication.value?.trim()) { + newErrors.auth = 'Authentication value is required'; + } + + if (config.authentication.type === 'api-key' && !config.authentication.headerName?.trim()) { + newErrors.authHeader = 'API key header name is required'; + } + + if (config.batchSize < 1 || config.batchSize > 10000) { + newErrors.batchSize = 'Batch size must be between 1 and 10,000'; + } + + setErrors(newErrors); + return Object.keys(newErrors).length === 0; + }; + + const handleExport = () => { + if (validateConfig()) { + // Save config (without sensitive data) + const configToSave = { + ...config, + authentication: { + ...config.authentication, + value: undefined, + }, + }; + localStorage.setItem('datakit-api-export-config', JSON.stringify(configToSave)); + + onExport(config); + } + }; + + const generateCurlCommand = (): string => { + const headers = { ...config.headers }; + + // Add authentication headers + if (config.authentication.type === 'bearer') { + headers['Authorization'] = `Bearer YOUR_TOKEN`; + } else if (config.authentication.type === 'api-key' && config.authentication.headerName) { + headers[config.authentication.headerName] = 'YOUR_API_KEY'; + } else if (config.authentication.type === 'basic') { + headers['Authorization'] = 'Basic YOUR_BASE64_CREDENTIALS'; + } + + const headerString = Object.entries(headers) + .map(([key, value]) => `-H "${key}: ${value}"`) + .join(' \\\n '); + + const sampleData = config.dataFormat === 'json' + ? JSON.stringify({ + metadata: config.includeMetadata ? { + source: sourceFileName || 'DataKit Query', + timestamp: new Date().toISOString(), + rowCount: 3, + columns, + } : undefined, + data: results.slice(0, 3), + }, null, 2) + : 'CSV_OR_NDJSON_DATA'; + + return `curl -X ${config.method} "${config.endpoint}" \\ + ${headerString} \\ + -d '${sampleData}'`; + }; + + const copyToClipboard = () => { + navigator.clipboard.writeText(generateCurlCommand()); + }; + + return ( +
+
+
+ {/* Warning Box */} +
+
+ +
+

Data Will Leave Your Browser

+

+ By using this export method, your data will be sent to the specified API endpoint. + Ensure you trust the destination and have proper authorization. +

+
+
+
+ + {/* Endpoint Configuration */} +
+
+ + setConfig({ ...config, endpoint: e.target.value })} + className="w-full px-3 py-2 bg-background border border-white/10 rounded-lg focus:outline-none focus:border-primary transition-colors text-white" + placeholder="https://api.example.com/data/import" + disabled={isExporting} + /> + {errors.endpoint && ( +

{errors.endpoint}

+ )} +
+ +
+
+ + +
+ +
+ + +
+
+
+ + {/* Authentication */} +
+

Authentication

+ +
+ +
+ + {config.authentication.type !== 'none' && ( +
+ {config.authentication.type === 'api-key' && ( + setConfig({ + ...config, + authentication: { ...config.authentication, headerName: e.target.value } + })} + className="w-full px-3 py-2 bg-background border border-white/10 rounded-lg focus:outline-none focus:border-primary text-white" + placeholder="Header name (e.g., X-API-Key)" + disabled={isExporting} + /> + )} + +
+ setConfig({ + ...config, + authentication: { ...config.authentication, value: e.target.value } + })} + className="w-full px-3 py-2 pr-10 bg-background border border-white/10 rounded-lg focus:outline-none focus:border-primary text-white" + placeholder={ + config.authentication.type === 'bearer' ? 'Bearer token' : + config.authentication.type === 'api-key' ? 'API key value' : + 'username:password' + } + disabled={isExporting} + /> + +
+ {errors.auth && ( +

{errors.auth}

+ )} +
+ )} +
+ + {/* Batch Configuration */} +
+

Batch Configuration

+ +
+
+ + setConfig({ ...config, batchSize: parseInt(e.target.value) || 1000 })} + className="w-full px-3 py-2 bg-background border border-white/10 rounded-lg focus:outline-none focus:border-primary text-white" + min="1" + max="10000" + disabled={isExporting} + /> + {errors.batchSize && ( +

{errors.batchSize}

+ )} +
+ +
+ +
+ {Math.ceil(rowCount / config.batchSize)} +
+
+
+ +
+ setConfig({ ...config, includeMetadata: e.target.checked })} + className="rounded border-white/20 bg-background text-primary" + disabled={isExporting} + /> + +
+
+ + {/* CURL Preview */} +
+
+

Example CURL Command

+ +
+
+              {generateCurlCommand()}
+            
+
+
+
+ + {/* Footer */} +
+
+
+ Will send {Math.ceil(rowCount / config.batchSize)} request(s) with {rowCount.toLocaleString()} total rows +
+ +
+
+
+ ); +}; + +export default RestAPIPanel; \ No newline at end of file diff --git a/frontend/src/hooks/query/useExportProvider.ts b/frontend/src/hooks/query/useExportProvider.ts new file mode 100644 index 0000000..d4338ee --- /dev/null +++ b/frontend/src/hooks/query/useExportProvider.ts @@ -0,0 +1,210 @@ +import { useCallback } from 'react'; +import { useQueryResultsImport } from './useQueryResultsImport'; + +interface ExportData { + results: any[]; + columns: string[]; + query?: string; + sourceFileName?: string; +} + +interface ActiveFile { + fileName?: string; + tableName?: string; +} + +interface RestAPIConfig { + endpoint: string; + method: 'POST' | 'PUT'; + headers: Record; + authentication: { + type: 'none' | 'bearer' | 'api-key' | 'basic'; + value?: string; + headerName?: string; + }; + dataFormat: 'json' | 'csv' | 'ndjson'; + batchSize: number; + includeMetadata: boolean; +} + +interface LocalTableConfig { + tableName: string; +} + +type ExportProvider = 'local-table' | 'rest-api'; +type ExportConfig = LocalTableConfig | RestAPIConfig; + +export const useExportProvider = ( + exportData: ExportData, + activeFile?: ActiveFile | null, + onSuccess?: () => void +) => { + const { importQueryResultsAsTable } = useQueryResultsImport(); + + const handleExportWithProvider = useCallback(async ( + provider: ExportProvider, + config: ExportConfig + ) => { + const { results, columns, query, sourceFileName } = exportData; + + if (!results || !columns) { + console.warn('[Export] No results or columns available for export'); + return; + } + + if (provider === 'local-table') { + // Handle local table export + const localConfig = config as LocalTableConfig; + const finalSourceFileName = sourceFileName || activeFile?.fileName || activeFile?.tableName; + + try { + const success = await importQueryResultsAsTable( + results, + columns, + finalSourceFileName, + query, + localConfig.tableName + ); + + if (success && onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('[Export] Local table export failed:', error); + throw error; + } + } else if (provider === 'rest-api') { + // Handle REST API export + const apiConfig = config as RestAPIConfig; + + try { + const { + endpoint, + method, + headers, + authentication, + dataFormat, + batchSize, + includeMetadata + } = apiConfig; + + // Build auth headers + const authHeaders: Record = {}; + if (authentication.type === 'bearer' && authentication.value) { + authHeaders['Authorization'] = `Bearer ${authentication.value}`; + } else if (authentication.type === 'api-key' && authentication.value && authentication.headerName) { + authHeaders[authentication.headerName] = authentication.value; + } else if (authentication.type === 'basic' && authentication.value) { + const encoded = btoa(authentication.value); + authHeaders['Authorization'] = `Basic ${encoded}`; + } + + // Prepare metadata + const metadata = includeMetadata ? { + source: sourceFileName || activeFile?.fileName || activeFile?.tableName || 'DataKit Export', + timestamp: new Date().toISOString(), + totalRows: results.length, + columns: columns, + query: query, + } : null; + + // Helper function to convert data to specific format + const convertToFormat = (data: any[], includeHeader = true) => { + if (dataFormat === 'csv') { + const csvRows = []; + if (includeHeader) { + csvRows.push(columns.join(',')); + } + csvRows.push(...data.map(row => + columns.map(col => { + const val = row[col]; + if (val === null || val === undefined) return ''; + const str = String(val); + return str.includes(',') || str.includes('"') || str.includes('\n') + ? `"${str.replace(/"/g, '""')}"` + : str; + }).join(',') + )); + return csvRows.join('\n'); + } else if (dataFormat === 'ndjson') { + return data.map(row => JSON.stringify(row)).join('\n'); + } else { + // JSON format + return JSON.stringify({ + ...(metadata && { metadata }), + data, + }); + } + }; + + // Set content type + const finalHeaders = { ...headers }; + if (dataFormat === 'csv') { + finalHeaders['Content-Type'] = 'text/csv'; + } else if (dataFormat === 'ndjson') { + finalHeaders['Content-Type'] = 'application/x-ndjson'; + } else { + finalHeaders['Content-Type'] = 'application/json'; + } + + // Send in batches if needed + const batches = []; + for (let i = 0; i < results.length; i += batchSize) { + batches.push(results.slice(i, i + batchSize)); + } + + console.log(`[Export] Sending ${batches.length} batch(es) to ${endpoint}`); + + // Send requests + for (let i = 0; i < batches.length; i++) { + const batchData = batches[i]; + let batchBody: string; + + if (dataFormat === 'json') { + // For JSON, include metadata only in first batch + const batchMetadata = metadata && i === 0 + ? { ...metadata, batchNumber: i + 1, totalBatches: batches.length } + : null; + + batchBody = JSON.stringify({ + ...(batchMetadata && { metadata: batchMetadata }), + data: batchData, + }); + } else { + // For CSV/NDJSON, convert batch data + batchBody = convertToFormat(batchData, i === 0); // Include header only in first batch for CSV + } + + const response = await fetch(endpoint, { + method, + headers: { + ...finalHeaders, + ...authHeaders, + }, + body: batchBody, + }); + + if (!response.ok) { + throw new Error(`API request failed: ${response.status} ${response.statusText}`); + } + + console.log(`[Export] Batch ${i + 1}/${batches.length} sent successfully`); + } + + console.log('[Export] All data exported successfully'); + + if (onSuccess) { + onSuccess(); + } + } catch (error) { + console.error('[Export] API export failed:', error); + alert(`Export failed: ${error instanceof Error ? error.message : 'Unknown error'}`); + throw error; + } + } + }, [exportData, activeFile, importQueryResultsAsTable, onSuccess]); + + return { + handleExportWithProvider, + }; +}; \ No newline at end of file