diff --git a/docs/en_US/release_notes_9_4.rst b/docs/en_US/release_notes_9_4.rst index 4f1339140..ce5afcbff 100644 --- a/docs/en_US/release_notes_9_4.rst +++ b/docs/en_US/release_notes_9_4.rst @@ -20,6 +20,7 @@ Bundled PostgreSQL Utilities New features ************ + | `Issue #3369 `_ - Enabled large file downloads for desktop users within the query tool. | `Issue #8583 `_ - Add all missing options to the Import/Export Data functionality, and update the syntax of the COPY command to align with the latest standards. | `Issue #8681 `_ - Add support for exporting table data based on a custom query. diff --git a/runtime/.eslintrc.js b/runtime/.eslintrc.js index da1faeecf..26924326e 100644 --- a/runtime/.eslintrc.js +++ b/runtime/.eslintrc.js @@ -8,6 +8,7 @@ ////////////////////////////////////////////////////////////// import globals from 'globals'; import js from '@eslint/js'; +import unusedImports from 'eslint-plugin-unused-imports'; export default [ js.configs.recommended, @@ -34,6 +35,9 @@ export default [ 'platform': 'readonly', }, }, + 'plugins': { + 'unused-imports': unusedImports, + }, 'rules': { 'indent': [ 'error', @@ -55,6 +59,17 @@ export default [ 'no-console': ['error', { allow: ['warn', 'error'] }], // We need to exclude below for RegEx case 'no-useless-escape': 0, + 'no-unused-vars': 'off', + 'unused-imports/no-unused-imports': 'error', + 'unused-imports/no-unused-vars': [ + 'warn', + { + 'vars': 'all', + 'varsIgnorePattern': '^_', + 'args': 'after-used', + 'argsIgnorePattern': '^_', + }, + ], }, }, ]; diff --git a/runtime/package.json b/runtime/package.json index 012e7f628..e38f8572e 100644 --- a/runtime/package.json +++ b/runtime/package.json @@ -13,12 +13,12 @@ "packageManager": "yarn@3.8.7", "devDependencies": { "electron": "36.2.0", - "eslint": "^9.26.0" + "eslint": "^9.26.0", + "eslint-plugin-unused-imports": "^4.1.4" }, "dependencies": { "axios": "^1.9.0", "electron-context-menu": "^4.0.5", - "electron-dl": "^4.0.0", - "electron-store": "^10.0.0" + "electron-store": "^10.0.1" } } diff --git a/runtime/src/js/downloader.js b/runtime/src/js/downloader.js new file mode 100644 index 000000000..1aa9c1617 --- /dev/null +++ b/runtime/src/js/downloader.js @@ -0,0 +1,121 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { app, ipcMain, dialog, BrowserWindow, shell } from 'electron'; +import fs from 'fs'; +import path from 'path'; +import { setBadge, clearBadge, clearProgress, setProgress } from './progress.js'; +import { writeServerLog } from './misc.js'; + +class DownloadItem { + constructor(filePath, onUpdate, onRemove) { + this.filePath = filePath; + this.currentLoaded = 0; + this.total = null; + this.stream = fs.createWriteStream(filePath);; + this.onUpdate = onUpdate; + this.onRemove = onRemove; + } + write(chunk) { + this.stream.write(chunk); + this.currentLoaded += chunk.length; + this.onUpdate?.(); + } + setTotal(total) { + this.total = total; + } + remove() { + this.stream.end(); + this.onRemove?.(); + } +} + +const downloadQueue = {}; + +function updateProgress(callerWindow) { + let count = Object.keys(downloadQueue).length; + if (count === 0) { + clearBadge(); + clearProgress.call(callerWindow); + return; + } + setBadge(Object.keys(downloadQueue).length); + let progress = 0; + if(Object.values(downloadQueue).some((item) => item.total === null)) { + // If any of the items in the queue does not have a total, we cannot calculate progress + // so we return 2 to indicate that the progress is indeterminate. + progress = 2; + } else { + const total = Object.values(downloadQueue).reduce((acc, item) => { + if (item.total) { + return acc + item.currentLoaded / item.total; + } + return acc + item.currentLoaded; + }, 0); + progress = total / Object.keys(downloadQueue).length; + } + setProgress.call(callerWindow, progress); +} + +export function setupDownloader() { + // Listen for the renderer's request to show the open dialog + ipcMain.handle('get-download-path', async (event, options, prompt=true) => { + try { + let filePath = path.join(app.getPath('downloads'), options.defaultPath); + const callerWindow = BrowserWindow.fromWebContents(event.sender); + // prompt is true when the user has set the preference to prompt for download location + if(prompt) { + const result = await dialog.showSaveDialog(callerWindow, { + title: 'Save File', + ...options, + }); + + if (result.canceled) { + return; + } + filePath = result.filePath; + } + + downloadQueue[filePath] = new DownloadItem(filePath, () => { + updateProgress(callerWindow); + }, () => { + delete downloadQueue[filePath]; + updateProgress(callerWindow); + }); + + updateProgress(callerWindow); + + return filePath; + } catch (error) { + writeServerLog(`Error in get-download-path: ${error}`); + } + }); + + ipcMain.on('download-data-save-total', (event, filePath, total) => { + const item = downloadQueue[filePath]; + if (item) { + item.setTotal(total); + } + }); + + ipcMain.on('download-data-save-chunk', (event, filePath, chunk) => { + const item = downloadQueue[filePath]; + if (item) { + item.write(chunk); + } + }); + + ipcMain.on('download-data-save-end', (event, filePath, openFile=false) => { + const item = downloadQueue[filePath]; + if (item) { + item.remove(); + openFile && shell.openPath(filePath); + } + }); +} \ No newline at end of file diff --git a/runtime/src/js/pgadmin.js b/runtime/src/js/pgadmin.js index 7ef8b7ef8..289521f45 100644 --- a/runtime/src/js/pgadmin.js +++ b/runtime/src/js/pgadmin.js @@ -16,7 +16,7 @@ import { spawn } from 'child_process'; import { fileURLToPath } from 'url'; import { setupMenu } from './menu.js'; import contextMenu from 'electron-context-menu'; -import { CancelError, download } from 'electron-dl'; +import { setupDownloader } from './downloader.js'; const configStore = new Store({ defaults: { @@ -153,28 +153,6 @@ function reloadApp() { currWin.webContents.reload(); } -async function desktopFileDownload(payload) { - const currWin = BrowserWindow.getFocusedWindow(); - try { - await download(currWin, payload.downloadUrl, { - filename: payload.fileName, - saveAs: payload.prompt_for_download_location, - onProgress: (progress) => { - currWin.webContents.send('download-progress', progress); - }, - onCompleted: (item) => { - currWin.webContents.send('download-complete', item); - if (payload.automatically_open_downloaded_file) - shell.openPath(item.path); - }, - }); - } catch (error) { - if (!(error instanceof CancelError)) { - misc.writeServerLog(error); - } - } -} - // This functions is used to start the pgAdmin4 server by spawning a // separate process. function startDesktopMode() { @@ -192,8 +170,9 @@ function startDesktopMode() { process.env.PGADMIN_SERVER_MODE = 'OFF'; // Start Page URL - startPageUrl = 'http://127.0.0.1:' + serverPort + '/?key=' + UUID; - serverCheckUrl = 'http://127.0.0.1:' + serverPort + '/misc/ping?key=' + UUID; + const baseUrl = `http://127.0.0.1:${serverPort}`; + startPageUrl = `${baseUrl}/?key=${UUID}`; + serverCheckUrl = `${baseUrl}/misc/ping?key=${UUID}`; // Write Python Path, pgAdmin file path and command in log file. misc.writeServerLog('pgAdmin Runtime Environment'); @@ -356,6 +335,8 @@ function launchPgAdminWindow() { 'reloadApp': reloadApp, }); + setupDownloader(); + pgAdminMainScreen.loadURL(startPageUrl); const bounds = configStore.get('bounds'); @@ -429,7 +410,6 @@ ipcMain.on('log', (text) => ()=>{ misc.writeServerLog(text); }); ipcMain.on('reloadApp', reloadApp); -ipcMain.on('onFileDownload', (_, payload) => desktopFileDownload(payload)); ipcMain.handle('checkPortAvailable', async (_e, fixedPort)=>{ try { await misc.getAvailablePort(fixedPort); diff --git a/runtime/src/js/pgadmin_preload.js b/runtime/src/js/pgadmin_preload.js index a2ebe98a2..6541aac74 100644 --- a/runtime/src/js/pgadmin_preload.js +++ b/runtime/src/js/pgadmin_preload.js @@ -24,5 +24,9 @@ contextBridge.exposeInMainWorld('electronUI', { showSaveDialog: (options) => ipcRenderer.invoke('showSaveDialog', options), log: (text)=> ipcRenderer.send('log', text), reloadApp: ()=>{ipcRenderer.send('reloadApp');}, - onFileDownload: (payload) => ipcRenderer.send('onFileDownload', payload), + // Download related functions + getDownloadPath: (...args) => ipcRenderer.invoke('get-download-path', ...args), + downloadDataSaveChunk: (...args) => ipcRenderer.send('download-data-save-chunk', ...args), + downloadDataSaveTotal: (...args) => ipcRenderer.send('download-data-save-total', ...args), + downloadDataSaveEnd: (...args) => ipcRenderer.send('download-data-save-end', ...args), }); \ No newline at end of file diff --git a/runtime/src/js/progress.js b/runtime/src/js/progress.js new file mode 100644 index 000000000..9b5d718b4 --- /dev/null +++ b/runtime/src/js/progress.js @@ -0,0 +1,39 @@ +///////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////// + +import { app } from 'electron'; + +export function setBadge(count) { + const badgeCount = parseInt(count, 10); + if (!isNaN(badgeCount)) { + app.setBadgeCount(badgeCount); + } +} + +// Function to clear badge +export function clearBadge() { + app.setBadgeCount(0); +} + +// Function to set progress bar +export function setProgress(progress) { + const progressValue = parseFloat(progress); + if (this && !isNaN(progressValue) && progressValue >= 0 && progressValue <= 1) { + this.setProgressBar(progressValue); + } else if (this && progress === -1) { + this.setProgressBar(-1); + } +} + +// Function to clear progress +export function clearProgress() { + if (this) { + this.setProgressBar(-1); + } +} \ No newline at end of file diff --git a/runtime/yarn.lock b/runtime/yarn.lock index e5bc7cf91..36de298a4 100644 --- a/runtime/yarn.lock +++ b/runtime/yarn.lock @@ -751,7 +751,7 @@ __metadata: languageName: node linkType: hard -"electron-store@npm:^10.0.0": +"electron-store@npm:^10.0.1": version: 10.0.1 resolution: "electron-store@npm:10.0.1" dependencies: @@ -881,6 +881,19 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-unused-imports@npm:^4.1.4": + version: 4.1.4 + resolution: "eslint-plugin-unused-imports@npm:4.1.4" + peerDependencies: + "@typescript-eslint/eslint-plugin": ^8.0.0-0 || ^7.0.0 || ^6.0.0 || ^5.0.0 + eslint: ^9.0.0 || ^8.0.0 + peerDependenciesMeta: + "@typescript-eslint/eslint-plugin": + optional: true + checksum: 1f4ce3e3972699345513840f3af1b783033dbc3a3e85b62ce12b3f6a89fd8c92afe46d0c00af40bacb14465445983ba0ccc326a6fd5132553061fb0e47bcba19 + languageName: node + linkType: hard + "eslint-scope@npm:^8.3.0": version: 8.3.0 resolution: "eslint-scope@npm:8.3.0" @@ -1886,9 +1899,9 @@ __metadata: axios: ^1.9.0 electron: 36.2.0 electron-context-menu: ^4.0.5 - electron-dl: ^4.0.0 - electron-store: ^10.0.0 + electron-store: ^10.0.1 eslint: ^9.26.0 + eslint-plugin-unused-imports: ^4.1.4 languageName: unknown linkType: soft diff --git a/web/pgadmin/dashboard/static/js/Dashboard.jsx b/web/pgadmin/dashboard/static/js/Dashboard.jsx index df7604301..a18e4cdff 100644 --- a/web/pgadmin/dashboard/static/js/Dashboard.jsx +++ b/web/pgadmin/dashboard/static/js/Dashboard.jsx @@ -40,7 +40,7 @@ import Replication from './Replication'; import { getExpandCell } from '../../../static/js/components/PgReactTableStyled'; import CodeMirror from '../../../static/js/components/ReactCodeMirror'; import GetAppRoundedIcon from '@mui/icons-material/GetAppRounded'; -import { downloadFile } from '../../../static/js/utils'; +import { downloadTextData } from '../../../static/js/download_utils'; import RefreshButton from './components/RefreshButtons'; function parseData(data) { @@ -451,7 +451,7 @@ function Dashboard({ let fileName = 'data-' + new Date().getTime() + extension; try { - downloadFile(respData, fileName, `text/${type}`); + downloadTextData(respData, fileName, `text/${type}`); } catch { setSsMsg(gettext('Failed to download the logs.')); } diff --git a/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx b/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx index 4f521882b..c189e2275 100644 --- a/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx +++ b/web/pgadmin/misc/file_manager/static/js/components/FileManager.jsx @@ -32,7 +32,7 @@ import Uploader from './Uploader'; import GridView from './GridView'; import convert from 'convert-units'; import PropTypes from 'prop-types'; -import { downloadBlob } from '../../../../../static/js/utils'; +import { downloadBlob } from '../../../../../static/js/download_utils'; import ErrorBoundary from '../../../../../static/js/helpers/ErrorBoundary'; import { MY_STORAGE } from './FileManagerConstants'; import _ from 'lodash'; diff --git a/web/pgadmin/static/js/Explain/svg_download.js b/web/pgadmin/static/js/Explain/svg_download.js index 7fdb30ee1..1d1f724ae 100644 --- a/web/pgadmin/static/js/Explain/svg_download.js +++ b/web/pgadmin/static/js/Explain/svg_download.js @@ -7,7 +7,7 @@ // ////////////////////////////////////////////////////////////// import getApiInstance from '../api_instance'; -import { downloadFile } from '../utils'; +import { downloadTextData } from '../download_utils'; function convertImageURLtoDataURI(api, image) { return new Promise(function(resolve, reject) { @@ -43,6 +43,6 @@ export function downloadSvg(svg, svgName) { } Promise.all(image_promises).then(function() { - downloadFile(svgElement.outerHTML, svgName, 'image/svg+xml'); + downloadTextData(svgElement.outerHTML, svgName, 'image/svg+xml'); }); } diff --git a/web/pgadmin/static/js/download_utils.js b/web/pgadmin/static/js/download_utils.js new file mode 100644 index 000000000..26886cd31 --- /dev/null +++ b/web/pgadmin/static/js/download_utils.js @@ -0,0 +1,112 @@ +////////////////////////////////////////////////////////////////////////// +// +// pgAdmin 4 - PostgreSQL Tools +// +// Copyright (C) 2013 - 2025, The pgAdmin Development Team +// This software is released under the PostgreSQL Licence +// +////////////////////////////////////////////////////////////////////////// + +import usePreferences from '../../preferences/static/js/store'; +import { callFetch, parseApiError } from './api_instance'; +import { getBrowser, toPrettySize } from './utils'; + +// This function is used to download the base64 data +// and create a link to download the file. +export async function downloadBase64UrlData(downloadUrl, fileName) { + let link = document.createElement('a'); + link.setAttribute('href', downloadUrl); + link.setAttribute('download', fileName); + link.style.setProperty('visibility ', 'hidden'); + + document.body.appendChild(link); + link.click(); + link.remove(); +} + +// This function is used to download the blob data +export async function downloadBlob(blob, fileName) { + const urlCreator = window.URL || window.webkitURL; + const downloadUrl = urlCreator.createObjectURL(blob); + + downloadBase64UrlData(downloadUrl, fileName); + window.URL.revokeObjectURL(downloadUrl); +} + +// This function is used to download the text data +export function downloadTextData(textData, fileName, fileType) { + const respBlob = new Blob([textData], {type : fileType}); + downloadBlob(respBlob, fileName); +} + +// This function is used to download the file from the given URL +// and use streaming to download the file in chunks where there +// is no limit on the file size. +export async function downloadFileStream(allOptions, fileName, fileType, onProgress) { + const {automatically_open_downloaded_file, prompt_for_download_location} = usePreferences.getState().getPreferencesForModule('misc'); + + const start = async (filePath, writer) => { + const response = await callFetch(allOptions.url, allOptions.options); + if(!response.ok) { + throw new Error(parseApiError(await response.json())); + } + if (!response.body) { + throw new Error(response.statusText); + } + + const contentLength = response.headers.get('Content-Length'); + writer.downloadDataSaveTotal(filePath, contentLength ? parseInt(contentLength, 10) : null); + + const reader = response.body.getReader(); + + let done = false; + let receivedLength = 0; // received bytes + while (!done) { + const { value, done: doneReading } = await reader.read(); + done = doneReading; + if (value) { + writer.downloadDataSaveChunk(filePath, value); + receivedLength += value.length; + onProgress?.(toPrettySize(receivedLength, 'B', 2)); + } + } + writer.downloadDataSaveEnd(filePath, automatically_open_downloaded_file); + }; + + let writer; + let filePath = ''; + try { + if(getBrowser().name != 'Electron') { + // In other browsers, we use the blob to download the file. + const data = []; + writer = { + downloadDataSaveChunk: (_fp, chunk) => { + data.push(chunk); + }, + downloadDataSaveTotal: () => { + // This is not used in the browser + }, + downloadDataSaveEnd: () => { + // This is not used in the browser + }, + }; + await start(filePath, writer); + const blob = new Blob(data, {type: fileType}); + downloadBlob(blob, fileName); + } else { + writer = window.electronUI; + filePath = await window.electronUI.getDownloadPath({ + defaultPath: fileName, + }, prompt_for_download_location); + + // If the user cancels the download, we will not proceed + if(!filePath) { + return; + } + await start(filePath, writer); + } + } catch (error) { + writer.downloadDataSaveEnd(filePath); + throw new Error('Download failed: ' + error.message); + } +} diff --git a/web/pgadmin/static/js/utils.js b/web/pgadmin/static/js/utils.js index 977d09419..7c39f6674 100644 --- a/web/pgadmin/static/js/utils.js +++ b/web/pgadmin/static/js/utils.js @@ -369,46 +369,7 @@ export function checkTrojanSource(content, isPasteEvent) { } } -export async function downloadBlob(blob, fileName) { - const {automatically_open_downloaded_file, prompt_for_download_location} = usePreferences.getState().getPreferencesForModule('misc'); - const urlCreator = window.URL || window.webkitURL; - const downloadUrl = urlCreator.createObjectURL(blob); - if (getBrowser().name == 'IE' && window.navigator.msSaveBlob) { - // IE10+ : (has Blob, but not a[download] or URL) - window.navigator.msSaveBlob(blob, fileName); - } else if (getBrowser().name == 'Electron') { - await window.electronUI.onFileDownload({downloadUrl, fileName, automatically_open_downloaded_file, prompt_for_download_location}); - } else { - const link = document.createElement('a'); - link.setAttribute('href', downloadUrl); - link.setAttribute('download', fileName); - link.style.setProperty('visibility ', 'hidden'); - - document.body.appendChild(link); - link.click(); - document.body.removeChild(link); - } -} - -export async function downloadUrlData(downloadUrl, fileName) { - const {automatically_open_downloaded_file, prompt_for_download_location} = usePreferences.getState().getPreferencesForModule('misc'); - if (getBrowser().name == 'Electron') { - window.electronUI.onFileDownload({downloadUrl, fileName, automatically_open_downloaded_file, prompt_for_download_location}); - } else { - let link = document.createElement('a'); - link.setAttribute('href', downloadUrl); - link.setAttribute('download', fileName); - link.click(); - link.remove(); - } -} - -export function downloadFile(textData, fileName, fileType) { - const respBlob = new Blob([textData], {type : fileType}); - downloadBlob(respBlob, fileName); -} - -export function toPrettySize(rawSize, from='B') { +export function toPrettySize(rawSize, from='B', decimalFixed=null) { try { //if the integer need to be converted to K for thousands, M for millions , B for billions only if (from == '') { @@ -416,6 +377,9 @@ export function toPrettySize(rawSize, from='B') { } let conVal = convert(rawSize).from(from).toBest(); conVal.val = Math.round(conVal.val * 100) / 100; + if(decimalFixed) { + conVal.val = conVal.val.toFixed(decimalFixed); + } return `${conVal.val} ${conVal.unit}`; } catch { diff --git a/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx b/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx index 54cece94e..dabab6e03 100644 --- a/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx +++ b/web/pgadmin/tools/erd/static/js/erd_tool/components/ERDTool.jsx @@ -37,7 +37,7 @@ import pgAdmin from 'sources/pgadmin'; import { styled } from '@mui/material/styles'; import BeforeUnload from './BeforeUnload'; import { isMac } from '../../../../../../static/js/keyboard_shortcuts'; -import { downloadUrlData } from '../../../../../../static/js/utils'; +import { downloadBase64UrlData } from '../../../../../../static/js/download_utils'; /* Custom react-diagram action for keyboard events */ export class KeyboardShortcutAction extends Action { @@ -601,7 +601,7 @@ export default class ERDTool extends React.Component { this.closeOnSave = closeOnSave; if(this.state.current_file && !isSaveAs) { this.saveFile(this.state.current_file); - } else if (this.diagram.getNodesData().length > 0){ { + } else if (this.diagram.getNodesData().length > 0){ let params = { 'supported_types': ['*','pgerd'], 'dialog_type': 'create_file', @@ -610,7 +610,6 @@ export default class ERDTool extends React.Component { }; this.props.pgAdmin.Tools.FileManager.show(params, this.saveFile.bind(this), null, this.context); } - } } saveFile(fileName) { @@ -761,7 +760,7 @@ export default class ERDTool extends React.Component { } toPng(this.canvasEle, {width, height}) .then((dataUrl)=>{ - downloadUrlData(dataUrl, `${this.getCurrentProjectName()}.png`); + downloadBase64UrlData(dataUrl, `${this.getCurrentProjectName()}.png`); }).catch((err)=>{ console.error(err); let msg = gettext('Unknown error. Check console logs'); diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx index b4d441d4d..4bcb5969e 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/GraphVisualiser.jsx @@ -26,7 +26,8 @@ import { LineChart, BarChart, PieChart, DATA_POINT_STYLE, DATA_POINT_SIZE, LightenDarkenColor} from 'sources/chartjs'; import { QueryToolEventsContext, QueryToolContext } from '../QueryToolComponent'; import { QUERY_TOOL_EVENTS, PANELS } from '../QueryToolConstants'; -import { downloadUrlData, getChartColor } from '../../../../../../static/js/utils'; +import { downloadBase64UrlData } from '../../../../../../static/js/download_utils'; +import { getChartColor } from '../../../../../../static/js/utils'; const StyledBox = styled(Box)(({theme}) => ({ width: '100%', @@ -380,7 +381,7 @@ export function GraphVisualiser({initColumns}) { const onDownloadGraph = async ()=> { let downloadUrl = chartObjRef.current.toBase64Image(), fileName = 'graph_visualiser-' + new Date().getTime() + '.png'; - downloadUrlData(downloadUrl, fileName); + downloadBase64UrlData(downloadUrl, fileName); }; // This plugin is used to set the background color of the canvas. Very useful diff --git a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx index 25cb9436e..a99feb887 100644 --- a/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx +++ b/web/pgadmin/tools/sqleditor/static/js/components/sections/ResultSet.jsx @@ -10,7 +10,7 @@ import _ from 'lodash'; import { styled } from '@mui/material/styles'; import React, { useContext, useEffect, useRef, useState } from 'react'; import QueryToolDataGrid, { GRID_ROW_SELECT_KEY } from '../QueryToolDataGrid'; -import {CONNECTION_STATUS, PANELS, QUERY_TOOL_EVENTS} from '../QueryToolConstants'; +import {CONNECTION_STATUS, PANELS, QUERY_TOOL_EVENTS, MODAL_DIALOGS} from '../QueryToolConstants'; import url_for from 'sources/url_for'; import getApiInstance, { parseApiError } from '../../../../../../static/js/api_instance'; import { QueryToolContext, QueryToolEventsContext } from '../QueryToolComponent'; @@ -22,7 +22,7 @@ import { LayoutDockerContext } from '../../../../../../static/js/helpers/Layout' import { GeometryViewer } from './GeometryViewer'; import Explain from '../../../../../../static/js/Explain'; import { QuerySources } from './QueryHistory'; -import { downloadFile } from '../../../../../../static/js/utils'; +import { downloadFileStream } from '../../../../../../static/js/download_utils'; import CopyData from '../QueryToolDataGrid/CopyData'; import moment from 'moment'; import ConfirmSaveContent from '../../../../../../static/js/Dialogs/ConfirmSaveContent'; @@ -31,7 +31,6 @@ import { GraphVisualiser } from './GraphVisualiser'; import { usePgAdmin } from '../../../../../../static/js/PgAdminProvider'; import pgAdmin from 'sources/pgadmin'; import ConnectServerContent from '../../../../../../static/js/Dialogs/ConnectServerContent'; -import { MODAL_DIALOGS } from '../QueryToolConstants'; const StyledBox = styled(Box)(({theme}) => ({ display: 'flex', @@ -514,23 +513,17 @@ export class ResultSetUtils { }); } - async saveResultsToFile(fileName) { + async saveResultsToFile(fileName, onProgress) { try { - let {data: respData} = await this.api.post( - url_for('sqleditor.query_tool_download', { + this.hasQueryCommitted = false; + await downloadFileStream({ + url: url_for('sqleditor.query_tool_download', { 'trans_id': this.transId, }), - {filename: fileName, query_commited: this.hasQueryCommitted} - ); - - if(!_.isUndefined(respData.data)) { - if(!respData.status) { - this.eventBus.fireEvent(QUERY_TOOL_EVENTS.SET_MESSAGE, respData.data.result); - } - } else { - this.hasQueryCommitted = false; - downloadFile(respData, fileName, 'text/csv'); - } + options: { + method: 'POST', + body: JSON.stringify({filename: fileName, query_commited: this.hasQueryCommitted}) + }}, fileName, 'text/csv', onProgress); this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END); } catch (error) { this.eventBus.fireEvent(QUERY_TOOL_EVENTS.TRIGGER_SAVE_RESULTS_END); @@ -1049,7 +1042,9 @@ export function ResultSet() { fileName = queryToolCtx.params.node_name + extension; } setLoaderText(gettext('Downloading results...')); - await rsu.current.saveResultsToFile(fileName); + await rsu.current.saveResultsToFile(fileName, (p)=>{ + setLoaderText(gettext('Downloading results(%s)...', p)); + }); setLoaderText(''); }); diff --git a/web/regression/javascript/file_manager/FileManager.spec.js b/web/regression/javascript/file_manager/FileManager.spec.js index 5f94592d3..9a101a52f 100644 --- a/web/regression/javascript/file_manager/FileManager.spec.js +++ b/web/regression/javascript/file_manager/FileManager.spec.js @@ -16,7 +16,7 @@ import FileManager, { FileManagerUtils, getComparator } from '../../../pgadmin/m import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import getApiInstance from '../../../pgadmin/static/js/api_instance'; -import * as pgUtils from '../../../pgadmin/static/js/utils'; +import * as downloadUtils from '../../../pgadmin/static/js/download_utils'; import userEvent from '@testing-library/user-event'; const files = [ @@ -116,8 +116,8 @@ describe('FileManger', ()=>{ let closeModal=jest.fn(), onOK=jest.fn(), onCancel=jest.fn(), - ctrlMount = async (props)=>{ - return await render( + ctrlMount = (props)=>{ + return render( { networkMock.onPost(`/file_manager/save_last_dir/${transId}`).reply(200, {'success':1,'errormsg':'','info':'','result':null,'data':null}); let ctrl; await act(async ()=>{ - ctrl = await ctrlMount({}); + ctrl = ctrlMount({}); }); const user = userEvent.setup(); await user.click(ctrl.container.querySelector('[name="menu-options"]')); @@ -157,7 +157,7 @@ describe('FileManger', ()=>{ let ctrl; const user = userEvent.setup(); await act(async ()=>{ - ctrl = await ctrlMount({}); + ctrl = ctrlMount({}); }); await user.click(ctrl.container.querySelector('[name="menu-shared-storage"]')); @@ -171,7 +171,7 @@ describe('FileManger', ()=>{ let ctrl; const user = userEvent.setup(); await act(async ()=>{ - ctrl = await ctrlMount({}); + ctrl = ctrlMount({}); }); await user.click(ctrl.container.querySelector('[name="menu-shared-storage"]')); @@ -345,9 +345,9 @@ describe('FileManagerUtils', ()=>{ }); it('downloadFile', async ()=>{ - jest.spyOn(pgUtils, 'downloadBlob').mockImplementation(() => {}); + jest.spyOn(downloadUtils, 'downloadBlob').mockImplementation(() => {}); let row = {Filename: 'newfile1', Path: '/home/newfile1', 'storage_folder': 'my_storage'}; await fmObj.downloadFile(row); - expect(pgUtils.downloadBlob).toHaveBeenCalledWith('blobdata', 'newfile1'); + expect(downloadUtils.downloadBlob).toHaveBeenCalledWith('blobdata', 'newfile1'); }); });