Copy results supports copy with headers (#386)

Fixes #368.
- Added a config option to support copy with column name header. Defaults to false to match SSMS behavior
- Support this during the copy event
- Context menu support for both Copy and Copy with Headers
- Unit tests will be added once the main unit test PR for the ResultsView is merged, since this has the necessary hooks and files.
- includes tests to cover all inputs to copyResults
This commit is contained in:
Kevin Cunnane 2016-11-22 13:57:09 -08:00 коммит произвёл GitHub
Родитель c2bb0e5320
Коммит f1dcce68d4
14 изменённых файлов: 273 добавлений и 29 удалений

3
.vscode/settings.json поставляемый
Просмотреть файл

@ -6,7 +6,8 @@
"out": false // set this to true to hide the "out" folder with the compiled JS files
},
"search.exclude": {
"out": true // set this to false to include "out" folder in search results
"out": true, // set this to false to include "out" folder in search results
"coverage": true
},
"typescript.tsdk": "./node_modules/typescript/lib", // we want to use the TS server from our node_modules folder to control its version
"files.watcherExclude": {

Просмотреть файл

@ -381,6 +381,7 @@
"event.prevGrid": "ctrl+up",
"event.nextGrid": "ctrl+down",
"event.copySelection": "ctrl+c",
"event.copyWithHeaders": "",
"event.maximizeGrid": "",
"event.selectAll": "",
"event.saveAsJSON": "",
@ -400,6 +401,11 @@
"default": true,
"description": "[Optional] When true, column headers are included in CSV"
}
},
"mssql.copyIncludeHeaders": {
"type": "boolean",
"description": "[Optional] Configuration options for copying results from the Results View",
"default": false
}
}
}

Просмотреть файл

@ -233,20 +233,44 @@ export default class QueryRunner {
});
}
private getColumnHeaders(batchId: number, resultId: number, range: ISlickRange): string[] {
let headers: string[] = undefined;
let batchSummary: BatchSummary = this.batchSets[batchId];
if (batchSummary !== undefined) {
let resultSetSummary = batchSummary.resultSetSummaries[resultId];
headers = resultSetSummary.columnInfo.slice(range.fromCell, range.toCell + 1).map((info, i) => {
return info.columnName;
});
}
return headers;
}
/**
* Copy the result range to the system clip-board
* @param selection The selection range array to copy
* @param batchId The id of the batch to copy from
* @param resultId The id of the result to copy from
* @param includeHeaders [Optional]: Should column headers be included in the copy selection
*/
public copyResults(selection: ISlickRange[], batchId: number, resultId: number): Promise<void> {
public copyResults(selection: ISlickRange[], batchId: number, resultId: number, includeHeaders?: boolean): Promise<void> {
const self = this;
return new Promise<void>((resolve, reject) => {
let copyString = '';
// create a mapping of the ranges to get promises
let tasks = selection.map((range, i) => {
return () => {
return self.getRows(range.fromRow, range.toRow - range.fromRow + 1, batchId, resultId).then((result) => {
if (self.shouldIncludeHeaders(includeHeaders)) {
let columnHeaders = self.getColumnHeaders(batchId, resultId, range);
if (columnHeaders !== undefined) {
for (let header of columnHeaders) {
copyString += header + '\t';
}
copyString += '\r\n';
}
}
// iterate over the rows to paste into the copy string
for (let row of result.resultSubset.rows) {
// iterate over the cells we want from that row
@ -271,6 +295,17 @@ export default class QueryRunner {
});
}
private shouldIncludeHeaders(includeHeaders: boolean): boolean {
if (includeHeaders !== undefined) {
// Respect the value explicity passed into the method
return includeHeaders;
}
// else get config option from vscode config
let config = this._vscodeWrapper.getConfiguration(Constants.extensionConfigSectionName);
includeHeaders = config[Constants.copyIncludeHeaders];
return !!includeHeaders;
}
/**
* Sets a selection range in the editor for this query
* @param selection The selection range to select

Просмотреть файл

@ -211,8 +211,9 @@ export class SqlOutputContentProvider implements vscode.TextDocumentContentProvi
let uri = req.query.uri;
let resultId = req.query.resultId;
let batchId = req.query.batchId;
let includeHeaders = req.query.includeHeaders;
let selection: Interfaces.ISlickRange[] = req.body;
self._queryResultsMap.get(uri).queryRunner.copyResults(selection, batchId, resultId).then(() => {
self._queryResultsMap.get(uri).queryRunner.copyResults(selection, batchId, resultId, includeHeaders).then(() => {
res.status = 200;
res.send();
});

Просмотреть файл

@ -45,6 +45,7 @@ export const outputServiceLocalhost = 'http://localhost:';
export const msgContentProviderSqlOutputHtml = 'dist/html/sqlOutput.ejs';
export const contentProviderMinFile = 'dist/js/app.min.js';
export const copyIncludeHeaders = 'copyIncludeHeaders';
export const configLogDebugInfo = 'logDebugInfo';
export const configMyConnections = 'connections';
export const configSaveAsCsv = 'saveAsCsv';

Просмотреть файл

@ -5,4 +5,8 @@
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.saveAsJSON']}}</span></li>
<li id="selectall" (click)="handleContextActionClick('selectall')" [class.disabled]="isDisabled"> {{Constants.selectAll}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.selectAll']}}</span></li>
<li id="copy" (click)="handleContextActionClick('copySelection')" [class.disabled]="isDisabled"> {{Constants.copyLabel}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.copySelection']}}</span></li>
<li id="copyWithHeaders" (click)="handleContextActionClick('copyWithHeaders')" [class.disabled]="isDisabled"> {{Constants.copyWithHeadersLabel}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.copyWithHeaders']}}</span></li>
</ul>

Просмотреть файл

@ -165,6 +165,12 @@ export class AppComponent implements OnInit, AfterViewChecked {
let selection = this.slickgrids.toArray()[activeGrid].getSelectedRanges();
this.dataService.copyResults(selection, this.renderedDataSets[activeGrid].batchId, this.renderedDataSets[activeGrid].resultId);
},
'event.copyWithHeaders': () => {
let activeGrid = this.activeGrid;
let selection = this.slickgrids.toArray()[activeGrid].getSelectedRanges();
this.dataService.copyResults(selection, this.renderedDataSets[activeGrid].batchId,
this.renderedDataSets[activeGrid].resultId, true);
},
'event.maximizeGrid': () => {
this.magnify(this.activeGrid);
},
@ -441,6 +447,13 @@ export class AppComponent implements OnInit, AfterViewChecked {
case 'selectall':
this.activeGrid = event.index;
this.shortcutfunc['event.selectAll']();
break;
case 'copySelection':
this.dataService.copyResults(event.selection, event.batchId, event.resultId);
break;
case 'copyWithHeaders':
this.dataService.copyResults(event.selection, event.batchId, event.resultId, true);
break;
default:
break;
}

Просмотреть файл

@ -21,6 +21,10 @@ const template = `
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.saveAsJSON']}}</span></li>
<li id="selectall" (click)="handleContextActionClick('selectall')" [class.disabled]="isDisabled"> {{Constants.selectAll}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.selectAll']}}</span></li>
<li id="copy" (click)="handleContextActionClick('copySelection')" [class.disabled]="isDisabled"> {{Constants.copyLabel}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.copySelection']}}</span></li>
<li id="copyWithHeaders" (click)="handleContextActionClick('copyWithHeaders')" [class.disabled]="isDisabled"> {{Constants.copyWithHeadersLabel}}
<span style="float: right; color: lightgrey; padding-left: 10px">{{keys['event.copyWithHeaders']}}</span></li>
</ul>
`;
@ -48,7 +52,9 @@ export class ContextMenu implements OnInit {
private keys = {
'event.saveAsCSV': '',
'event.saveAsJSON': '',
'event.selectAll': ''
'event.selectAll': '',
'event.copySelection': '',
'event.copyWithHeaders': ''
};
constructor(@Inject(forwardRef(() => ShortcutService)) private shortcuts: ShortcutService) {

Просмотреть файл

@ -5,6 +5,8 @@ export const saveCSVLabel = 'Save as CSV';
export const saveJSONLabel = 'Save as JSON';
export const resultPaneLabel = 'Results';
export const selectAll = 'Select all';
export const copyLabel = 'Copy';
export const copyWithHeadersLabel = 'Copy with Headers';
/** Messages Pane Labels */
export const executeQueryLabel = 'Executing query...';

Просмотреть файл

@ -119,11 +119,15 @@ export class DataService {
* @param selection The selection range to copy
* @param batchId The batch id of the result to copy from
* @param resultId The result id of the result to copy from
* @param includeHeaders [Optional]: Should column headers be included in the copy selection
*/
copyResults(selection: ISlickRange[], batchId: number, resultId: number): void {
copyResults(selection: ISlickRange[], batchId: number, resultId: number, includeHeaders?: boolean): void {
const self = this;
let headers = new Headers();
let url = '/copyResults?' + '&uri=' + self.uri + '&batchId=' + batchId + '&resultId=' + resultId;
if (includeHeaders !== undefined) {
url += '&includeHeaders=' + includeHeaders;
}
self.http.post(url, selection, { headers: headers }).subscribe();
}

Просмотреть файл

@ -481,6 +481,23 @@ describe('AppComponent', function (): void {
}, 100);
});
it('event copy with headers', (done) => {
let dataService = <MockDataService> fixture.componentRef.injector.get(DataService);
let shortcutService = <MockShortcutService> fixture.componentRef.injector.get(ShortcutService);
spyOn(shortcutService, 'buildEventString').and.returnValue('');
spyOn(shortcutService, 'getEvent').and.returnValue(Promise.resolve('event.copyWithHeaders'));
spyOn(dataService, 'copyResults');
dataService.sendWSEvent(batch1);
dataService.sendWSEvent(completeEvent);
fixture.detectChanges();
triggerKeyEvent(40, ele);
setTimeout(() => {
fixture.detectChanges();
expect(dataService.copyResults).toHaveBeenCalledWith([], 0, 0, true);
done();
}, 100);
});
it('event maximize grid', (done) => {
let dataService = <MockDataService> fixture.componentRef.injector.get(DataService);

Просмотреть файл

@ -6,7 +6,9 @@ class MockShortCutService {
private keyToString = {
'event.saveAsCSV': 'ctrl+s',
'event.saveAsJSON': 'ctrl+shift+s',
'event.selectAll': 'ctrl+a'
'event.selectAll': 'ctrl+a',
'event.copySelection': 'ctrl+c',
'event.copyWithHeaders': 'ctrl+shift+c'
};
public stringCodeFor(value: string): Promise<string> {
return Promise.resolve(this.keyToString[value]);
@ -63,7 +65,7 @@ describe('context Menu', () => {
comp.show(0, 0, 0, 0, 0, []);
fixture.detectChanges();
expect(ele.firstElementChild.className.indexOf('hidden')).toEqual(-1);
expect(ele.firstElementChild.childElementCount).toEqual(3);
expect(ele.firstElementChild.childElementCount).toEqual(5, 'expect 5 menu items to be present');
});
it('hides correctly', () => {

Просмотреть файл

@ -110,6 +110,7 @@ describe('data service', () => {
let param = getParamsFromUrl(conn.request.url);
expect(param['batchId']).toEqual('0');
expect(param['resultId']).toEqual('0');
expect(param['includeHeaders']).toEqual(undefined);
let body = JSON.parse(conn.request.getBody());
expect(body).toBeDefined();
expect(body).toEqual([]);
@ -120,6 +121,25 @@ describe('data service', () => {
});
});
describe('copy with headers request', () => {
it('correctly threads through the data', (done) => {
mockbackend.connections.subscribe((conn: MockConnection) => {
let isCopyRequest = urlMatch(conn.request, /\/copyResults/, RequestMethod.Post);
expect(isCopyRequest).toBe(true);
let param = getParamsFromUrl(conn.request.url);
expect(param['batchId']).toEqual('0');
expect(param['resultId']).toEqual('0');
expect(param['includeHeaders']).toEqual('true');
let body = JSON.parse(conn.request.getBody());
expect(body).toBeDefined();
expect(body).toEqual([]);
done();
});
dataservice.copyResults([], 0, 0, true);
});
});
describe('set selection request', () => {
it('correctly threads through the data', (done) => {
mockbackend.connections.subscribe((conn: MockConnection) => {

Просмотреть файл

@ -4,10 +4,12 @@ import QueryRunner from './../src/controllers/QueryRunner';
import { QueryNotificationHandler } from './../src/controllers/QueryNotificationHandler';
import { SqlOutputContentProvider } from './../src/models/SqlOutputContentProvider';
import SqlToolsServerClient from './../src/languageservice/serviceclient';
import { QueryExecuteParams, QueryExecuteCompleteNotificationResult } from './../src/models/contracts/queryExecute';
import { QueryExecuteParams, QueryExecuteCompleteNotificationResult, ResultSetSummary } from './../src/models/contracts/queryExecute';
import VscodeWrapper from './../src/controllers/vscodeWrapper';
import StatusView from './../src/views/statusView';
import * as Constants from '../src/models/constants';
import { ISlickRange } from './../src/models/interfaces';
import * as stubs from './stubs';
const ncp = require('copy-paste');
@ -195,16 +197,28 @@ suite('Query Runner tests', () => {
});
});
test('Correctly copy pastes a selection', () => {
function setupWorkspaceConfig(configResult: {[key: string]: any}): void {
let config = stubs.createWorkspaceConfiguration(configResult);
testVscodeWrapper.setup(x => x.getConfiguration(TypeMoq.It.isAny()))
.returns(x => {
return config;
});
}
suite('Copy Tests', () => {
// ------ Common inputs and setup for copy tests -------
const TAB = '\t';
const CLRF = '\r\n';
const finalString = '1' + TAB + '2' + TAB + CLRF +
const finalStringNoHeader = '1' + TAB + '2' + TAB + CLRF +
'3' + TAB + '4' + TAB + CLRF +
'5' + TAB + '6' + TAB + CLRF +
'7' + TAB + '8' + TAB + CLRF +
'9' + TAB + '10' + TAB + CLRF;
let testuri = 'test';
let testresult = {
const finalStringWithHeader = 'Col1' + TAB + 'Col2' + TAB + CLRF + finalStringNoHeader;
const testuri = 'test';
const testresult = {
message: '',
resultSubset: {
rowCount: 5,
@ -217,24 +231,142 @@ suite('Query Runner tests', () => {
]
}
};
let testRange: ISlickRange[] = [{fromCell: 0, fromRow: 0, toCell: 1, toRow: 4}];
testSqlToolsServerClient.setup(x => x.sendRequest(TypeMoq.It.isAny(),
TypeMoq.It.isAny())).callback(() => {
// testing
}).returns(() => { return Promise.resolve(testresult); });
testStatusView.setup(x => x.executingQuery(TypeMoq.It.isAnyString()));
let queryRunner = new QueryRunner(
testuri,
testuri,
testStatusView.object,
testSqlToolsServerClient.object,
testQueryNotificationHandler.object,
testVscodeWrapper.object
);
queryRunner.uri = testuri;
return queryRunner.copyResults(testRange, 0, 0).then(() => {
let pasteContents = ncp.paste();
assert.equal(pasteContents, finalString);
let result: QueryExecuteCompleteNotificationResult = {
ownerUri: testuri,
message: undefined,
batchSummaries: [{
hasError: false,
id: 0,
selection: {startLine: 0, endLine: 0, startColumn: 3, endColumn: 3},
messages: [{time: '', message: '6 affects rows'}],
resultSetSummaries: <ResultSetSummary[]> [{
id: 0,
rowCount: 5,
columnInfo: [
{ columnName: 'Col1' },
{ columnName: 'Col2' }
]
}],
executionElapsed: undefined,
executionStart: new Date().toISOString(),
executionEnd: new Date().toISOString()
}]
};
setup(() => {
testSqlToolsServerClient.setup(x => x.sendRequest(TypeMoq.It.isAny(),
TypeMoq.It.isAny())).callback(() => {
// testing
}).returns(() => { return Promise.resolve(testresult); });
testStatusView.setup(x => x.executingQuery(TypeMoq.It.isAnyString()));
testStatusView.setup(x => x.executedQuery(TypeMoq.It.isAnyString()));
testVscodeWrapper.setup( x => x.logToOutputChannel(TypeMoq.It.isAnyString()));
});
// ------ Copy tests -------
test('Correctly copy pastes a selection', () => {
let configResult: {[key: string]: any} = {};
configResult[Constants.copyIncludeHeaders] = false;
setupWorkspaceConfig(configResult);
let queryRunner = new QueryRunner(
testuri,
testuri,
testStatusView.object,
testSqlToolsServerClient.object,
testQueryNotificationHandler.object,
testVscodeWrapper.object
);
queryRunner.uri = testuri;
return queryRunner.copyResults(testRange, 0, 0).then(() => {
let pasteContents = ncp.paste();
assert.equal(pasteContents, finalStringNoHeader);
});
});
test('Copies selection with column headers set in user config', () => {
// Set column headers in the user config settings
let configResult: {[key: string]: any} = {};
configResult[Constants.copyIncludeHeaders] = true;
setupWorkspaceConfig(configResult);
let queryRunner = new QueryRunner(
testuri,
testuri,
testStatusView.object,
testSqlToolsServerClient.object,
testQueryNotificationHandler.object,
testVscodeWrapper.object
);
queryRunner.uri = testuri;
queryRunner.dataResolveReject = {resolve: () => {
// Needed to handle the result callback
}};
// Call handleResult to ensure column header info is seeded
queryRunner.handleResult(result);
return queryRunner.copyResults(testRange, 0, 0).then(() => {
let pasteContents = ncp.paste();
assert.equal(pasteContents, finalStringWithHeader);
});
});
test('Copies selection with headers when true passed as parameter', () => {
// Do not set column config in user settings
let configResult: {[key: string]: any} = {};
configResult[Constants.copyIncludeHeaders] = false;
setupWorkspaceConfig(configResult);
let queryRunner = new QueryRunner(
testuri,
testuri,
testStatusView.object,
testSqlToolsServerClient.object,
testQueryNotificationHandler.object,
testVscodeWrapper.object
);
queryRunner.uri = testuri;
queryRunner.dataResolveReject = {resolve: () => {
// Needed to handle the result callback
}};
// Call handleResult to ensure column header info is seeded
queryRunner.handleResult(result);
// call copyResults with additional parameter indicating to include headers
return queryRunner.copyResults(testRange, 0, 0, true).then(() => {
let pasteContents = ncp.paste();
assert.equal(pasteContents, finalStringWithHeader);
});
});
test('Copies selection without headers when false passed as parameter', () => {
// Set column config in user settings
let configResult: {[key: string]: any} = {};
configResult[Constants.copyIncludeHeaders] = true;
setupWorkspaceConfig(configResult);
let queryRunner = new QueryRunner(
testuri,
testuri,
testStatusView.object,
testSqlToolsServerClient.object,
testQueryNotificationHandler.object,
testVscodeWrapper.object
);
queryRunner.uri = testuri;
queryRunner.dataResolveReject = {resolve: () => {
// Needed to handle the result callback
}};
// Call handleResult to ensure column header info is seeded
queryRunner.handleResult(result);
// call copyResults with additional parameter indicating to not include headers
return queryRunner.copyResults(testRange, 0, 0, false).then(() => {
let pasteContents = ncp.paste();
assert.equal(pasteContents, finalStringNoHeader);
});
});
});
});