Skip to content

Commit

Permalink
Copy results supports copy with headers (#386)
Browse files Browse the repository at this point in the history
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
  • Loading branch information
kevcunnane authored Nov 22, 2016
1 parent c2bb0e5 commit f1dcce6
Show file tree
Hide file tree
Showing 14 changed files with 273 additions and 29 deletions.
3 changes: 2 additions & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
6 changes: 6 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@
"event.prevGrid": "ctrl+up",
"event.nextGrid": "ctrl+down",
"event.copySelection": "ctrl+c",
"event.copyWithHeaders": "",
"event.maximizeGrid": "",
"event.selectAll": "",
"event.saveAsJSON": "",
Expand All @@ -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
}
}
}
Expand Down
37 changes: 36 additions & 1 deletion src/controllers/QueryRunner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion src/models/SqlOutputContentProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
Expand Down
1 change: 1 addition & 0 deletions src/models/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down
4 changes: 4 additions & 0 deletions src/views/htmlcontent/src/html/contextmenu.component.html
Original file line number Diff line number Diff line change
Expand Up @@ -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>
13 changes: 13 additions & 0 deletions src/views/htmlcontent/src/js/components/app.component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
},
Expand Down Expand Up @@ -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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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>
`;

Expand Down Expand Up @@ -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) {
Expand Down
2 changes: 2 additions & 0 deletions src/views/htmlcontent/src/js/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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...';
Expand Down
6 changes: 5 additions & 1 deletion src/views/htmlcontent/src/js/services/data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
}

Expand Down
17 changes: 17 additions & 0 deletions src/views/htmlcontent/test/app.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions src/views/htmlcontent/test/contextmenu.component.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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]);
Expand Down Expand Up @@ -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', () => {
Expand Down
20 changes: 20 additions & 0 deletions src/views/htmlcontent/test/data.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([]);
Expand All @@ -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) => {
Expand Down
Loading

0 comments on commit f1dcce6

Please sign in to comment.