diff --git a/extensions/mssql/l10n/bundle.l10n.json b/extensions/mssql/l10n/bundle.l10n.json index 8ab519d34d..2c9a94b92b 100644 --- a/extensions/mssql/l10n/bundle.l10n.json +++ b/extensions/mssql/l10n/bundle.l10n.json @@ -907,6 +907,7 @@ "Expand all groups": "Expand all groups", "Export to CSV": "Export to CSV", "Export to Excel": "Export to Excel", + "Export to JSON": "Export to JSON", "Export to text format": "Export to text format", "Export to tab delimited": "Export to tab delimited", "Filter shortcuts": "Filter shortcuts", @@ -2155,6 +2156,14 @@ "comment": ["{0} is the table name"] }, "Discard": "Discard", + "Results exported successfully to {0}/{0} is the file path": { + "message": "Results exported successfully to {0}", + "comment": ["{0} is the file path"] + }, + "Failed to export results: {0}/{0} is the error message": { + "message": "Failed to export results: {0}", + "comment": ["{0} is the error message"] + }, "MSSQL: Welcome & What's New": "MSSQL: Welcome & What's New", "Try it": "Try it", "Watch demo": "Watch demo", diff --git a/extensions/mssql/src/constants/locConstants.ts b/extensions/mssql/src/constants/locConstants.ts index 9a805e31e6..8e4893b380 100644 --- a/extensions/mssql/src/constants/locConstants.ts +++ b/extensions/mssql/src/constants/locConstants.ts @@ -2371,6 +2371,20 @@ export class TableExplorer { public static Save = l10n.t("Save"); public static Discard = l10n.t("Discard"); public static Cancel = l10n.t("Cancel"); + + public static exportSuccessful = (filePath: string) => + l10n.t({ + message: "Results exported successfully to {0}", + args: [filePath], + comment: ["{0} is the file path"], + }); + + public static exportFailed = (errorMessage: string) => + l10n.t({ + message: "Failed to export results: {0}", + args: [errorMessage], + comment: ["{0} is the error message"], + }); } export class Changelog { diff --git a/extensions/mssql/src/models/contracts.ts b/extensions/mssql/src/models/contracts.ts index 36fd7f14f9..fe01334b6a 100644 --- a/extensions/mssql/src/models/contracts.ts +++ b/extensions/mssql/src/models/contracts.ts @@ -127,3 +127,110 @@ export namespace SaveResultsAsInsertRequest { >("query/saveInsert"); } // --------------------------------- ------------------------------------------ + +// --------------------------------- < Serialize Data Request > ------------------------------------------ +// Serialize data to CSV, JSON, or Excel format using the backend serialization service + +export class SerializeColumnInfo { + /** + * Name of this column + */ + public name: string; + + /** + * Data type name of this column + */ + public dataTypeName: string; +} + +export class SerializeDbCellValue { + /** + * Display value of the cell + */ + public displayValue: string; + + /** + * Whether the cell value is null + */ + public isNull: boolean; +} + +export class SerializeDataStartRequestParams { + /** + * The format to serialize the data to (csv, json, excel) + */ + public saveFormat: string; + + /** + * Path to file that the serialized results will be stored in + */ + public filePath: string; + + /** + * Results that are to be serialized + */ + public rows: SerializeDbCellValue[][]; + + /** + * Column information for the data + */ + public columns: SerializeColumnInfo[]; + + /** + * Whether this is the only batch (or last batch) for this file + */ + public isLastBatch: boolean; + + /** + * Whether to include column headers in the output + */ + public includeHeaders?: boolean; + + /** + * Delimiter for CSV format + */ + public delimiter?: string; + + /** + * Line separator for CSV format + */ + public lineSeparator?: string; + + /** + * Text identifier for CSV format + */ + public textIdentifier?: string; + + /** + * Encoding for the output file + */ + public encoding?: string; + + /** + * Whether to format JSON output + */ + public formatted?: boolean; +} + +export class SerializeDataResult { + /** + * Error or status messages + */ + public messages: string; + + /** + * Whether the serialization succeeded + */ + public succeeded: boolean; +} + +// Serialize data request to backend service +export namespace SerializeStartRequest { + export const type = new RequestType< + SerializeDataStartRequestParams, + SerializeDataResult, + void, + void + >("serialize/start"); +} +// --------------------------------- ------------------------------------------ diff --git a/extensions/mssql/src/reactviews/common/locConstants.ts b/extensions/mssql/src/reactviews/common/locConstants.ts index 74e694ee30..85e0f6de16 100644 --- a/extensions/mssql/src/reactviews/common/locConstants.ts +++ b/extensions/mssql/src/reactviews/common/locConstants.ts @@ -1348,9 +1348,12 @@ export class LocConstants { columnResizeByContent: l10n.t("Column resize by content"), commands: l10n.t("Commands"), copy: l10n.t("Copy"), + copyWithHeaders: l10n.t("Copy with Headers"), + copyHeaders: l10n.t("Copy Headers"), expandAllGroups: l10n.t("Expand all groups"), exportToCsv: l10n.t("Export to CSV"), exportToExcel: l10n.t("Export to Excel"), + exportToJson: l10n.t("Export to JSON"), exportToTextFormat: l10n.t("Export to text format"), exportToTabDelimited: l10n.t("Export to tab delimited"), filterShortcuts: l10n.t("Filter shortcuts"), diff --git a/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.css b/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.css index 3d984c7772..df089603d3 100644 --- a/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.css +++ b/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.css @@ -540,3 +540,13 @@ #tableExplorerGrid .slick-row.deleted-row .slick-cell .action-icon.pointer { color: var(--vscode-foreground); } + +/* Cell range selection styles (for Excel copy buffer / Ctrl+drag selection) */ +#tableExplorerGrid .slick-cell.selected { + background-color: var(--vscode-editor-selectionBackground, rgba(0, 120, 215, 0.3)) !important; +} + +#tableExplorerGrid .slick-cell.copied { + background-color: var(--vscode-editor-selectionBackground, rgba(0, 0, 255, 0.2)) !important; + transition: 0.5s background; +} diff --git a/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.tsx b/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.tsx index da4fd78878..6e114998c6 100644 --- a/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.tsx +++ b/extensions/mssql/src/reactviews/pages/TableExplorer/TableDataGrid.tsx @@ -22,7 +22,7 @@ import { ContextMenu, } from "slickgrid-react"; import { FluentCompoundFilter } from "./fluentCompoundFilter"; -import { EditSubsetResult } from "../../../sharedInterfaces/tableExplorer"; +import { EditSubsetResult, ExportData } from "../../../sharedInterfaces/tableExplorer"; import { ColorThemeKind } from "../../../sharedInterfaces/webview"; import { locConstants as loc } from "../../common/locConstants"; import TableExplorerCustomPager from "./TableExplorerCustomPager"; @@ -45,6 +45,7 @@ interface TableDataGridProps { onCellChangeCountChanged?: (count: number) => void; onDeletionCountChanged?: (count: number) => void; onSelectedRowsChanged?: (selectedRowIds: number[]) => void; + onSaveResults?: (format: "csv" | "json" | "excel", data: ExportData) => void; } export interface TableDataGridRef { @@ -71,6 +72,7 @@ export const TableDataGrid = forwardRef( onCellChangeCountChanged, onDeletionCountChanged, onSelectedRowsChanged, + onSaveResults, }, ref, ) => { @@ -467,12 +469,6 @@ export const TableDataGrid = forwardRef( hideFreezeColumnsCommand: true, // Hide freeze columns (not needed) }, - // Row selection - enableRowSelection: true, - rowSelectionOptions: { - selectActiveRow: false, // Don't auto-select on cell click - }, - // Sorting enableSorting: true, multiColumnSort: true, // Allow multi-column sorting @@ -482,8 +478,9 @@ export const TableDataGrid = forwardRef( showHeaderRow: true, // Show filter row headerRowHeight: FILTER_ROW_HEIGHT, - // Cell navigation + // Cell navigation and copy buffer enableCellNavigation: true, + enableExcelCopyBuffer: true, // Enables cell range selection + copy/paste (Ctrl+C, Ctrl+V) // Context menu enableContextMenu: true, @@ -771,9 +768,33 @@ export const TableDataGrid = forwardRef( const command = args.command; const dataContext = args.dataContext; - const rowId = dataContext.id; + const rowId = dataContext?.id; switch (command) { + case "copy": + copySelectionToClipboard(false, false); + break; + + case "copy-with-headers": + copySelectionToClipboard(true, false); + break; + + case "copy-headers": + copySelectionToClipboard(false, true); + break; + + case "export-csv": + exportToFile(); + break; + + case "export-excel": + exportToExcel(); + break; + + case "export-json": + exportToJson(); + break; + case "delete-row": if (onDeleteRow) { onDeleteRow(rowId); @@ -844,18 +865,306 @@ export const TableDataGrid = forwardRef( } } + /** + * Copy selected cells to clipboard + * @param includeHeaders - Whether to include column headers + * @param headersOnly - Whether to copy only headers (no data) + */ + function copySelectionToClipboard(includeHeaders: boolean, headersOnly: boolean) { + if (!reactGridRef.current?.slickGrid) { + return; + } + + const grid = reactGridRef.current.slickGrid; + const dataView = reactGridRef.current.dataView; + const visibleColumns = grid.getColumns(); + + // Get selection ranges from the cell selection model + const selectionModel = grid.getSelectionModel(); + const selectedRanges = selectionModel?.getSelectedRanges() || []; + + // Create array of range bounds to process + interface RangeBounds { + fromRow: number; + toRow: number; + fromCell: number; + toCell: number; + } + const rangesToProcess: RangeBounds[] = []; + + if (selectedRanges.length > 0) { + // Copy range properties from selected ranges + for (const r of selectedRanges) { + rangesToProcess.push({ + fromRow: r.fromRow, + toRow: r.toRow, + fromCell: r.fromCell, + toCell: r.toCell, + }); + } + } else { + // No cell range selected, try to use active cell + const activeCell = grid.getActiveCell(); + if (!activeCell) { + return; + } + // Create a single-cell range + rangesToProcess.push({ + fromRow: activeCell.row, + toRow: activeCell.row, + fromCell: activeCell.cell, + toCell: activeCell.cell, + }); + } + + const lines: string[] = []; + + // Process each selection range + for (const range of rangesToProcess) { + const fromRow = Math.min(range.fromRow, range.toRow); + const toRow = Math.max(range.fromRow, range.toRow); + const fromCell = Math.min(range.fromCell, range.toCell); + const toCell = Math.max(range.fromCell, range.toCell); + + // Get headers for the selected columns + if (includeHeaders || headersOnly) { + const headerValues: string[] = []; + for (let c = fromCell; c <= toCell; c++) { + const column = visibleColumns[c]; + if (column) { + headerValues.push(column.name?.toString() || ""); + } + } + lines.push(headerValues.join("\t")); + } + + // Get data for selected cells (skip if headersOnly) + if (!headersOnly && dataView) { + for (let r = fromRow; r <= toRow; r++) { + const rowValues: string[] = []; + const item = dataView.getItem(r); + if (item) { + for (let c = fromCell; c <= toCell; c++) { + const column = visibleColumns[c]; + if (column && column.field) { + const value = item[column.field]; + // Handle NULL values and convert to string + rowValues.push(value === "NULL" ? "" : value?.toString() || ""); + } + } + } + lines.push(rowValues.join("\t")); + } + } + } + + // Copy to clipboard + const textToCopy = lines.join("\n"); + void navigator.clipboard.writeText(textToCopy); + } + + /** + * Helper function to get export data from the grid + * If cells are selected, returns only the selected range data + * Otherwise returns all data respecting filters, sort, and visible columns + */ + function getExportData(): { headers: string[]; rows: string[][] } | null { + if (!reactGridRef.current?.dataView || !reactGridRef.current?.slickGrid) { + return null; + } + + const dataView = reactGridRef.current.dataView; + const grid = reactGridRef.current.slickGrid; + const visibleColumns = grid.getColumns(); + + // Check if there's a cell selection + const selectionModel = grid.getSelectionModel(); + const selectedRanges = selectionModel?.getSelectedRanges() || []; + + // If there's a selection, export only selected data + if (selectedRanges.length > 0) { + return getSelectedRangeData(selectedRanges, visibleColumns, dataView); + } + + // No selection - export all data + // Get headers from visible columns (skip action columns) + const headers = visibleColumns + .filter((col) => col.field && col.name && col.id !== "delete" && col.id !== "undo") + .map((col) => col.name?.toString() || ""); + + // Get all filtered/sorted items from the DataView + const items = dataView.getFilteredItems(); + + // Get rows data (skip action columns) + const rows = items.map((item: any) => { + return visibleColumns + .filter((col) => col.field && col.id !== "delete" && col.id !== "undo") + .map((col) => { + const value = item[col.field!]; + // Convert NULL to empty string for export + return value === "NULL" ? "" : value?.toString() || ""; + }); + }); + + return { headers, rows }; + } + + /** + * Helper function to get data from selected cell ranges + */ + function getSelectedRangeData( + selectedRanges: any[], + visibleColumns: Column[], + dataView: any, + ): { headers: string[]; rows: string[][] } | null { + // Process the first range (primary selection) + // For multiple ranges, we combine them + interface RangeBounds { + fromRow: number; + toRow: number; + fromCell: number; + toCell: number; + } + + const rangesToProcess: RangeBounds[] = selectedRanges.map((r) => ({ + fromRow: Math.min(r.fromRow, r.toRow), + toRow: Math.max(r.fromRow, r.toRow), + fromCell: Math.min(r.fromCell, r.toCell), + toCell: Math.max(r.fromCell, r.toCell), + })); + + // For simplicity, use the bounding box of all ranges + const minRow = Math.min(...rangesToProcess.map((r) => r.fromRow)); + const maxRow = Math.max(...rangesToProcess.map((r) => r.toRow)); + const minCell = Math.min(...rangesToProcess.map((r) => r.fromCell)); + const maxCell = Math.max(...rangesToProcess.map((r) => r.toCell)); + + // Get headers for selected columns (skip action columns) + const headers: string[] = []; + for (let c = minCell; c <= maxCell; c++) { + const column = visibleColumns[c]; + if (column && column.name && column.id !== "delete" && column.id !== "undo") { + headers.push(column.name.toString()); + } + } + + // Get rows data for selected range (skip action columns) + const rows: string[][] = []; + for (let r = minRow; r <= maxRow; r++) { + const item = dataView.getItem(r); + if (item) { + const rowData: string[] = []; + for (let c = minCell; c <= maxCell; c++) { + const column = visibleColumns[c]; + if ( + column && + column.field && + column.id !== "delete" && + column.id !== "undo" + ) { + const value = item[column.field]; + // Convert NULL to empty string for export + rowData.push(value === "NULL" ? "" : value?.toString() || ""); + } + } + rows.push(rowData); + } + } + + return { headers, rows }; + } + + /** + * Export grid data to CSV file + * Uses the filtered/sorted data from the DataView + */ + function exportToFile() { + const data = getExportData(); + if (!data || !onSaveResults) { + return; + } + onSaveResults("csv", data); + } + + /** + * Export grid data to Excel file + * Uses the filtered/sorted data from the DataView + */ + function exportToExcel() { + const data = getExportData(); + if (!data || !onSaveResults) { + return; + } + onSaveResults("excel", data); + } + + /** + * Export grid data to JSON file + * Uses the filtered/sorted data from the DataView + */ + function exportToJson() { + const data = getExportData(); + if (!data || !onSaveResults) { + return; + } + onSaveResults("json", data); + } + function getContextMenuOptions(): ContextMenu { return { hideCopyCellValueCommand: true, hideCloseButton: true, commandItems: [ + // Copy commands + { + command: "copy", + title: loc.slickGrid.copy, + iconCssClass: "mdi mdi-content-copy", + positionOrder: 1, + }, + { + command: "copy-with-headers", + title: loc.slickGrid.copyWithHeaders, + iconCssClass: "mdi mdi-content-copy", + positionOrder: 2, + }, + { + command: "copy-headers", + title: loc.slickGrid.copyHeaders, + iconCssClass: "mdi mdi-content-copy", + positionOrder: 3, + }, + // Divider before export + { divider: true, command: "", positionOrder: 4 }, + // Export commands + { + command: "export-csv", + title: loc.slickGrid.exportToCsv, + iconCssClass: "mdi mdi-download", + positionOrder: 5, + }, + { + command: "export-excel", + title: loc.slickGrid.exportToExcel, + iconCssClass: "mdi mdi-download", + positionOrder: 6, + }, + { + command: "export-json", + title: loc.slickGrid.exportToJson, + iconCssClass: "mdi mdi-download", + positionOrder: 7, + }, + // Divider before edit commands + { divider: true, command: "", positionOrder: 8 }, + // Edit commands { command: "delete-row", title: loc.tableExplorer.deleteRow, iconCssClass: "mdi mdi-close", cssClass: "red", textCssClass: "bold", - positionOrder: 1, + positionOrder: 9, itemVisibilityOverride: (args: any) => { // Hide "Delete Row" if row is already deleted const rowId = args.dataContext?.id; @@ -866,13 +1175,13 @@ export const TableDataGrid = forwardRef( command: "revert-cell", title: loc.tableExplorer.revertCell, iconCssClass: "mdi mdi-undo", - positionOrder: 2, + positionOrder: 10, }, { command: "revert-row", title: loc.tableExplorer.revertRow, iconCssClass: "mdi mdi-undo", - positionOrder: 3, + positionOrder: 11, }, ], onCommand: (e, args) => handleContextMenuCommand(e, args), diff --git a/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerPage.tsx b/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerPage.tsx index 2c4f2d4718..fe45bf5faa 100644 --- a/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerPage.tsx +++ b/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerPage.tsx @@ -147,6 +147,7 @@ export const TableExplorerPage: React.FC = () => { onLoadSubset={context?.loadSubset} onCellChangeCountChanged={handleCellChangeCountChanged} onDeletionCountChanged={handleDeletionCountChanged} + onSaveResults={context?.saveResults} /> )} diff --git a/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerStateProvider.tsx b/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerStateProvider.tsx index 71ccbcfc30..3763a9e37a 100644 --- a/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerStateProvider.tsx +++ b/extensions/mssql/src/reactviews/pages/TableExplorer/TableExplorerStateProvider.tsx @@ -8,6 +8,7 @@ import { TableExplorerWebViewState, TableExplorerReducers, TableExplorerContextProps, + ExportData, } from "../../../sharedInterfaces/tableExplorer"; import { useVscodeWebview2 } from "../../common/vscodeWebviewProvider2"; import { getCoreRPCs2 } from "../../common/utils"; @@ -71,6 +72,10 @@ export const TableExplorerStateProvider: React.FC<{ setCurrentPage: function (pageNumber: number): void { extensionRpc.action("setCurrentPage", { pageNumber }); }, + + saveResults: function (format: "csv" | "json" | "excel", data: ExportData): void { + extensionRpc.action("saveResults", { format, data }); + }, }), [extensionRpc], ); diff --git a/extensions/mssql/src/services/tableExplorerService.ts b/extensions/mssql/src/services/tableExplorerService.ts index d8ff3cef18..bf9d5bd6aa 100644 --- a/extensions/mssql/src/services/tableExplorerService.ts +++ b/extensions/mssql/src/services/tableExplorerService.ts @@ -16,6 +16,13 @@ import { EditSubsetRequest, EditUpdateCellRequest, } from "../models/contracts/tableExplorer"; +import { + SerializeColumnInfo, + SerializeDbCellValue, + SerializeDataResult, + SerializeDataStartRequestParams, + SerializeStartRequest, +} from "../models/contracts"; import { EditCommitParams, EditCommitResult, @@ -156,6 +163,22 @@ export interface ITableExplorerService { * @returns A promise that resolves to an EditScriptResult containing the generated scripts */ generateScripts(ownerUri: string): Promise; + + /** + * Serializes data to a file in the specified format using the backend serialization service. + * + * @param filePath - The path where the serialized file will be saved + * @param format - The format to serialize to: "csv", "json", or "excel" + * @param headers - Array of column header names + * @param rows - 2D array of row data as strings + * @returns A promise that resolves to the serialization result + */ + serializeData( + filePath: string, + format: string, + headers: string[], + rows: string[][], + ): Promise; } export class TableExplorerService implements ITableExplorerService { @@ -441,4 +464,53 @@ export class TableExplorerService implements ITableExplorerService { throw error; } } + + /** + * Serializes data to a file in the specified format using the backend serialization service. + * + * @param filePath - The path where the serialized file will be saved + * @param format - The format to serialize to: "csv", "json", or "excel" + * @param headers - Array of column header names + * @param rows - 2D array of row data as strings + * @returns A promise that resolves to the serialization result + * @throws Will throw an error if the serialization request fails + */ + public async serializeData( + filePath: string, + format: string, + headers: string[], + rows: string[][], + ): Promise { + try { + // Convert headers to SerializeColumnInfo array + const columns: SerializeColumnInfo[] = headers.map((header) => ({ + name: header, + dataTypeName: "nvarchar", // Default type for string data + })); + + // Convert string rows to SerializeDbCellValue format + const dbRows: SerializeDbCellValue[][] = rows.map((row) => + row.map((cell) => ({ + displayValue: cell, + isNull: cell === null || cell === "", + })), + ); + + const params: SerializeDataStartRequestParams = { + saveFormat: format, + filePath: filePath, + rows: dbRows, + columns: columns, + isLastBatch: true, + includeHeaders: true, + }; + + const result = await this._client.sendRequest(SerializeStartRequest.type, params); + + return result; + } catch (error) { + this._client.logger.error(getErrorMessage(error)); + throw error; + } + } } diff --git a/extensions/mssql/src/sharedInterfaces/tableExplorer.ts b/extensions/mssql/src/sharedInterfaces/tableExplorer.ts index 970c4c9564..356b1f8aaa 100644 --- a/extensions/mssql/src/sharedInterfaces/tableExplorer.ts +++ b/extensions/mssql/src/sharedInterfaces/tableExplorer.ts @@ -208,6 +208,7 @@ export interface TableExplorerContextProps { copyScriptToClipboard: () => void; toggleScriptPane: () => void; setCurrentPage: (pageNumber: number) => void; + saveResults: (format: SupportedSaveFormats, data: ExportData) => void; } export interface TableExplorerReducers { @@ -223,4 +224,15 @@ export interface TableExplorerReducers { copyScriptToClipboard: {}; toggleScriptPane: {}; setCurrentPage: { pageNumber: number }; + saveResults: { format: SupportedSaveFormats; data: ExportData }; } + +export interface ExportData { + headers: string[]; + rows: string[][]; +} + +/** + * Supported file formats for exporting table data. + */ +export type SupportedSaveFormats = "csv" | "json" | "excel"; diff --git a/extensions/mssql/src/tableExplorer/tableExplorerWebViewController.ts b/extensions/mssql/src/tableExplorer/tableExplorerWebViewController.ts index 43ee5c8839..b532a72a8b 100644 --- a/extensions/mssql/src/tableExplorer/tableExplorerWebViewController.ts +++ b/extensions/mssql/src/tableExplorer/tableExplorerWebViewController.ts @@ -121,9 +121,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< this.logger.error(`No target node provided - OperationId: ${this.operationId}`); endActivity.endFailed( new Error("No target node provided for table explorer"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -188,9 +188,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to initialize table explorer"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -293,9 +293,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to commit changes"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -373,9 +373,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to load subset"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -450,9 +450,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to create row"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -570,9 +570,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to delete row"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -714,9 +714,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to update cell"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -843,9 +843,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to revert cell"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -974,9 +974,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to revert row"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -1046,9 +1046,9 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< endActivity.endFailed( new Error("Failed to generate script"), - true, - undefined, - undefined, + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, { elapsedTime: (Date.now() - startTime).toString(), operationId: this.operationId, @@ -1154,6 +1154,105 @@ export class TableExplorerWebViewController extends ReactWebviewPanelController< return state; }); + + this.registerReducer("saveResults", async (state, payload) => { + this.logger.info( + `Saving results as ${payload.format} - OperationId: ${this.operationId}`, + ); + + const startTime = Date.now(); + const endActivity = startActivity( + TelemetryViews.TableExplorer, + TelemetryActions.SaveResults, + generateGuid(), + { + startTime: startTime.toString(), + operationId: this.operationId, + format: payload.format, + }, + ); + + try { + const { headers, rows } = payload.data; + let defaultExt: string; + let filters: { [name: string]: string[] }; + + switch (payload.format) { + case "csv": + defaultExt = "csv"; + filters = { "CSV Files": ["csv"], "All Files": ["*"] }; + break; + case "json": + defaultExt = "json"; + filters = { "JSON Files": ["json"], "All Files": ["*"] }; + break; + case "excel": + defaultExt = "xlsx"; + filters = { "Excel Files": ["xlsx"], "All Files": ["*"] }; + break; + default: + throw new Error(`Unsupported format: ${payload.format}`); + } + + // Show save dialog + const uri = await vscode.window.showSaveDialog({ + defaultUri: vscode.Uri.file(`${state.tableName}-export.${defaultExt}`), + filters: filters, + }); + + if (uri) { + // Use backend serialization service to generate and save the file + const result = await this._tableExplorerService.serializeData( + uri.fsPath, + payload.format, + headers, + rows, + ); + + if (result.succeeded) { + vscode.window.showInformationMessage( + LocConstants.TableExplorer.exportSuccessful(uri.fsPath), + ); + + this.logger.info( + `Results saved to ${uri.fsPath} - OperationId: ${this.operationId}`, + ); + + endActivity.end(ActivityStatus.Succeeded, { + elapsedTime: (Date.now() - startTime).toString(), + operationId: this.operationId, + format: payload.format, + rowCount: rows.length.toString(), + }); + } else { + throw new Error(result.messages || "Serialization failed"); + } + } else { + this.logger.info("Save dialog cancelled by user"); + } + } catch (error) { + this.logger.error( + `Error saving results: ${getErrorMessage(error)} - OperationId: ${this.operationId}`, + ); + + endActivity.endFailed( + new Error("Failed to save results"), + true /* includeErrorMessage */, + undefined /* errorCode */, + undefined /* errorType */, + { + elapsedTime: (Date.now() - startTime).toString(), + operationId: this.operationId, + }, + ); + + vscode.window.showErrorMessage( + LocConstants.TableExplorer.exportFailed(getErrorMessage(error)), + ); + } + + return state; + }); } /** diff --git a/extensions/mssql/test/unit/tableExplorerService.test.ts b/extensions/mssql/test/unit/tableExplorerService.test.ts index 4dcde20a26..106ffb3c40 100644 --- a/extensions/mssql/test/unit/tableExplorerService.test.ts +++ b/extensions/mssql/test/unit/tableExplorerService.test.ts @@ -19,6 +19,7 @@ import { EditSubsetRequest, EditUpdateCellRequest, } from "../../src/models/contracts/tableExplorer"; +import { SerializeStartRequest, SerializeDataResult } from "../../src/models/contracts"; import { EditCommitResult, EditCreateRowResult, @@ -705,6 +706,218 @@ suite("TableExplorerService Tests", () => { }); }); + suite("serializeData", () => { + const filePath = "/path/to/export.csv"; + const headers = ["id", "name", "email"]; + const rows = [ + ["1", "John Doe", "john@example.com"], + ["2", "Jane Smith", "jane@example.com"], + ]; + + test("should successfully serialize data to CSV format", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData(filePath, "csv", headers, rows); + + expect(result).to.equal(mockResult); + expect(result.succeeded, "Serialization should succeed").to.be.true; + expect(mockClient.sendRequest.calledOnce, "sendRequest should be called exactly once") + .to.be.true; + + const callArgs = mockClient.sendRequest.firstCall.args; + const params = callArgs[1] as any; + expect(callArgs[0]).to.equal(SerializeStartRequest.type); + expect(params.saveFormat).to.equal("csv"); + expect(params.filePath).to.equal(filePath); + expect(params.isLastBatch, "isLastBatch should be true for single batch").to.be.true; + expect(params.includeHeaders, "includeHeaders should be true by default").to.be.true; + }); + + test("should successfully serialize data to JSON format", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData( + "/path/to/export.json", + "json", + headers, + rows, + ); + + expect(result.succeeded, "JSON serialization should succeed").to.be.true; + + const callArgs = mockClient.sendRequest.firstCall.args; + expect((callArgs[1] as any).saveFormat).to.equal("json"); + }); + + test("should successfully serialize data to Excel format", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData( + "/path/to/export.xlsx", + "excel", + headers, + rows, + ); + + expect(result.succeeded, "Excel serialization should succeed").to.be.true; + + const callArgs = mockClient.sendRequest.firstCall.args; + expect((callArgs[1] as any).saveFormat).to.equal("excel"); + }); + + test("should convert headers to SerializeColumnInfo array", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + await tableExplorerService.serializeData(filePath, "csv", headers, rows); + + const callArgs = mockClient.sendRequest.firstCall.args; + const columns = (callArgs[1] as any).columns; + + expect(columns).to.have.lengthOf(3); + expect(columns[0]).to.deep.equal({ name: "id", dataTypeName: "nvarchar" }); + expect(columns[1]).to.deep.equal({ name: "name", dataTypeName: "nvarchar" }); + expect(columns[2]).to.deep.equal({ name: "email", dataTypeName: "nvarchar" }); + }); + + test("should convert string rows to SerializeDbCellValue format", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + await tableExplorerService.serializeData(filePath, "csv", headers, rows); + + const callArgs = mockClient.sendRequest.firstCall.args; + const dbRows = (callArgs[1] as any).rows; + + expect(dbRows).to.have.lengthOf(2); + expect(dbRows[0]).to.have.lengthOf(3); + expect(dbRows[0][0]).to.deep.equal({ displayValue: "1", isNull: false }); + expect(dbRows[0][1]).to.deep.equal({ displayValue: "John Doe", isNull: false }); + expect(dbRows[1][2]).to.deep.equal({ displayValue: "jane@example.com", isNull: false }); + }); + + test("should mark empty strings as null", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const rowsWithEmpty = [["1", "", "john@example.com"]]; + + await tableExplorerService.serializeData(filePath, "csv", headers, rowsWithEmpty); + + const callArgs = mockClient.sendRequest.firstCall.args; + const dbRows = (callArgs[1] as any).rows; + + expect(dbRows[0][1]).to.deep.equal({ displayValue: "", isNull: true }); + }); + + test("should handle serialization failure result", async () => { + const mockResult: SerializeDataResult = { + succeeded: false, + messages: "Failed to write file", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData(filePath, "csv", headers, rows); + + expect(result.succeeded, "Serialization should return failure status").to.be.false; + expect(result.messages).to.equal("Failed to write file"); + }); + + test("should handle serializeData error and log it", async () => { + const error = new Error("Serialization failed"); + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .rejects(error); + + try { + await tableExplorerService.serializeData(filePath, "csv", headers, rows); + expect.fail("Should have thrown an error"); + } catch (err) { + expect(err).to.equal(error); + expect(mockLogger.error.calledOnce, "Error should be logged").to.be.true; + expect(mockLogger.error.firstCall.args[0]).to.equal("Serialization failed"); + } + }); + + test("should handle empty rows array", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData(filePath, "csv", headers, []); + + expect(result.succeeded, "Serialization should succeed with empty rows").to.be.true; + + const callArgs = mockClient.sendRequest.firstCall.args; + expect((callArgs[1] as any).rows).to.have.lengthOf(0); + }); + + test("should handle empty headers array", async () => { + const mockResult: SerializeDataResult = { + succeeded: true, + messages: "", + }; + + mockClient.sendRequest + .withArgs(SerializeStartRequest.type, sinon.match.any) + .resolves(mockResult); + + const result = await tableExplorerService.serializeData(filePath, "csv", [], []); + + expect(result.succeeded, "Serialization should succeed with empty headers").to.be.true; + + const callArgs = mockClient.sendRequest.firstCall.args; + expect((callArgs[1] as any).columns).to.have.lengthOf(0); + }); + }); + suite("error handling", () => { test("should log error with proper message format", async () => { const errorMessage = "Connection timeout"; @@ -715,7 +928,7 @@ suite("TableExplorerService Tests", () => { await tableExplorerService.initialize("uri", "table", "schema", "type", undefined); expect.fail("Should have thrown an error"); } catch (err) { - expect(mockLogger.error.calledOnce).to.be.true; + expect(mockLogger.error.calledOnce, "Error should be logged").to.be.true; expect(mockLogger.error.firstCall.args[0]).to.contain(errorMessage); } }); @@ -728,7 +941,7 @@ suite("TableExplorerService Tests", () => { await tableExplorerService.commit("uri"); expect.fail("Should have thrown an error"); } catch (err) { - expect(mockLogger.error.calledOnce).to.be.true; + expect(mockLogger.error.calledOnce, "Error should be logged").to.be.true; } }); }); diff --git a/extensions/mssql/test/unit/tableExplorerWebViewController.test.ts b/extensions/mssql/test/unit/tableExplorerWebViewController.test.ts index c028ff041d..5b0772711b 100644 --- a/extensions/mssql/test/unit/tableExplorerWebViewController.test.ts +++ b/extensions/mssql/test/unit/tableExplorerWebViewController.test.ts @@ -7,7 +7,7 @@ import { expect } from "chai"; import * as sinon from "sinon"; import * as vscode from "vscode"; import { TableExplorerWebViewController } from "../../src/tableExplorer/tableExplorerWebViewController"; -import { ITableExplorerService } from "../../src/services/tableExplorerService"; +import { TableExplorerService } from "../../src/services/tableExplorerService"; import ConnectionManager from "../../src/controllers/connectionManager"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; import { TreeNodeInfo } from "../../src/objectExplorer/nodes/treeNodeInfo"; @@ -31,7 +31,7 @@ suite("TableExplorerWebViewController - Reducers", () => { let sandbox: sinon.SinonSandbox; let mockContext: vscode.ExtensionContext; let mockVscodeWrapper: VscodeWrapper; - let mockTableExplorerService: sinon.SinonStubbedInstance; + let mockTableExplorerService: sinon.SinonStubbedInstance; let mockConnectionManager: sinon.SinonStubbedInstance; let mockTargetNode: TreeNodeInfo; let controller: TableExplorerWebViewController; @@ -43,6 +43,8 @@ suite("TableExplorerWebViewController - Reducers", () => { let openTextDocumentStub: sinon.SinonStub; let showTextDocumentStub: sinon.SinonStub; let writeTextStub: sinon.SinonStub; + let showSaveDialogStub: sinon.SinonStub; + let writeFileStub: sinon.SinonStub; const mockConnectionProfile: IConnectionProfile = { server: "test-server", @@ -92,6 +94,11 @@ suite("TableExplorerWebViewController - Reducers", () => { sandbox.stub(vscode.env, "clipboard").value({ writeText: writeTextStub, }); + showSaveDialogStub = sandbox.stub(vscode.window, "showSaveDialog"); + writeFileStub = sandbox.stub(); + sandbox.stub(vscode.workspace, "fs").value({ + writeFile: writeFileStub, + }); // Setup mock webview and panel mockWebview = { @@ -124,21 +131,23 @@ suite("TableExplorerWebViewController - Reducers", () => { // Setup mock services mockVscodeWrapper = stubVscodeWrapper(sandbox); - mockTableExplorerService = { - initialize: sandbox.stub().resolves(), - subset: sandbox.stub().resolves(createMockSubsetResult()), - commit: sandbox.stub().resolves({}), - createRow: sandbox.stub().resolves(), - deleteRow: sandbox.stub().resolves({}), - updateCell: sandbox.stub().resolves(), - revertCell: sandbox.stub().resolves(), - revertRow: sandbox.stub().resolves(), - generateScripts: sandbox.stub().resolves(), - dispose: sandbox.stub().resolves({}), - sqlToolsClient: { - onNotification: sandbox.stub(), - } as any, - } as any; + mockTableExplorerService = sandbox.createStubInstance(TableExplorerService); + mockTableExplorerService.initialize.resolves(); + mockTableExplorerService.subset.resolves(createMockSubsetResult()); + mockTableExplorerService.commit.resolves({}); + mockTableExplorerService.createRow.resolves(); + mockTableExplorerService.deleteRow.resolves({}); + mockTableExplorerService.updateCell.resolves(); + mockTableExplorerService.revertCell.resolves(); + mockTableExplorerService.revertRow.resolves(); + mockTableExplorerService.generateScripts.resolves(); + mockTableExplorerService.dispose.resolves({}); + mockTableExplorerService.serializeData.resolves({ succeeded: true, messages: "" }); + // Setup sqlToolsClient property for notification handling + Object.defineProperty(mockTableExplorerService, "sqlToolsClient", { + value: { onNotification: sandbox.stub() }, + writable: true, + }); mockConnectionManager = { isConnected: sandbox.stub().returns(true), @@ -910,4 +919,217 @@ suite("TableExplorerWebViewController - Reducers", () => { expect(controller.state.currentPage).to.equal(10); }); }); + + suite("saveResults reducer", () => { + const mockHeaders = ["id", "firstName", "lastName"]; + const mockRows = [ + ["1", "John", "Doe"], + ["2", "Jane", "Smith"], + ]; + + test("should save results as CSV format", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.csv"); + showSaveDialogStub.resolves(mockUri); + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "csv", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(showSaveDialogStub.calledOnce).to.be.true; + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.filters).to.deep.equal({ + "CSV Files": ["csv"], + "All Files": ["*"], + }); + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + + // Verify serializeData was called with correct parameters + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("csv"); + expect(callArgs[2]).to.deep.equal(mockHeaders); + expect(callArgs[3]).to.deep.equal(mockRows); + + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + test("should save results as JSON format", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.json"); + showSaveDialogStub.resolves(mockUri); + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "json", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(showSaveDialogStub.calledOnce).to.be.true; + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.filters).to.deep.equal({ + "JSON Files": ["json"], + "All Files": ["*"], + }); + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + + // Verify serializeData was called with correct parameters + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("json"); + expect(callArgs[2]).to.deep.equal(mockHeaders); + expect(callArgs[3]).to.deep.equal(mockRows); + + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + test("should save results as Excel format", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.xlsx"); + showSaveDialogStub.resolves(mockUri); + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "excel", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(showSaveDialogStub.calledOnce).to.be.true; + const saveDialogOptions = showSaveDialogStub.firstCall.args[0]; + expect(saveDialogOptions.filters).to.deep.equal({ + "Excel Files": ["xlsx"], + "All Files": ["*"], + }); + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + + // Verify serializeData was called with correct parameters + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("excel"); + expect(callArgs[2]).to.deep.equal(mockHeaders); + expect(callArgs[3]).to.deep.equal(mockRows); + + expect(showInformationMessageStub.calledOnce).to.be.true; + }); + + test("should handle user cancelling save dialog", async () => { + // Arrange + controller.state.tableName = "TestTable"; + showSaveDialogStub.resolves(undefined); // User cancelled + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "csv", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(showSaveDialogStub.calledOnce).to.be.true; + expect(mockTableExplorerService.serializeData.notCalled).to.be.true; + expect(showInformationMessageStub.notCalled).to.be.true; + expect(showErrorMessageStub.notCalled).to.be.true; + }); + + test("should show error message when save fails", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.csv"); + showSaveDialogStub.resolves(mockUri); + const error = new Error("Serialization failed"); + (mockTableExplorerService.serializeData as sinon.SinonStub).rejects(error); + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "csv", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(showSaveDialogStub.calledOnce).to.be.true; + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + expect(showErrorMessageStub.calledOnce).to.be.true; + expect(showErrorMessageStub.firstCall.args[0]).to.include("Serialization failed"); + }); + + test("should call serializeData with correct parameters for CSV", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.csv"); + showSaveDialogStub.resolves(mockUri); + const headersWithComma = ["name", "address"]; + const rowsWithComma = [["John", "123 Main St, Apt 4"]]; + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "csv", + data: { headers: headersWithComma, rows: rowsWithComma }, + }); + + // Assert + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("csv"); + expect(callArgs[2]).to.deep.equal(headersWithComma); + expect(callArgs[3]).to.deep.equal(rowsWithComma); + }); + + test("should call serializeData with correct parameters for JSON", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.json"); + showSaveDialogStub.resolves(mockUri); + const headers = ["name", "nickname"]; + const rowsWithEmpty = [["John", ""]]; + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "json", + data: { headers: headers, rows: rowsWithEmpty }, + }); + + // Assert + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("json"); + expect(callArgs[2]).to.deep.equal(headers); + expect(callArgs[3]).to.deep.equal(rowsWithEmpty); + }); + + test("should call serializeData with correct parameters for Excel", async () => { + // Arrange + controller.state.tableName = "TestTable"; + const mockUri = vscode.Uri.file("/path/to/export.xlsx"); + showSaveDialogStub.resolves(mockUri); + + // Act + await controller["_reducerHandlers"].get("saveResults")(controller.state, { + format: "excel", + data: { headers: mockHeaders, rows: mockRows }, + }); + + // Assert + expect(mockTableExplorerService.serializeData.calledOnce).to.be.true; + const callArgs = (mockTableExplorerService.serializeData as sinon.SinonStub).firstCall + .args; + expect(callArgs[0]).to.equal(mockUri.fsPath); + expect(callArgs[1]).to.equal("excel"); + expect(callArgs[2]).to.deep.equal(mockHeaders); + expect(callArgs[3]).to.deep.equal(mockRows); + }); + }); }); diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 69f6a15f10..0d8f267680 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1572,6 +1572,9 @@ Export to Excel + + Export to JSON + Export to tab delimited @@ -1675,6 +1678,10 @@ Failed to establish connection with ID "{0}". Please check connection details and network connectivity. {0} is the connection ID + + Failed to export results: {0} + {0} is the error message + Failed to fetch Docker container tags: {0} @@ -3218,6 +3225,10 @@ Results copied to clipboard + + Results exported successfully to {0} + {0} is the file path + Retry