Skip to content

Commit cf2e62f

Browse files
authored
Add "Save as TSV" option to plot modal (#4285)
1 parent 9b49eb6 commit cf2e62f

File tree

10 files changed

+203
-55
lines changed

10 files changed

+203
-55
lines changed

extension/src/fileSystem/index.test.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import {
1717
findOrCreateDvcYamlFile,
1818
writeJson,
1919
writeCsv,
20+
writeTsv,
2021
isPathInProject
2122
} from '.'
2223
import { dvcDemoPath } from '../test/util'
@@ -88,6 +89,21 @@ describe('writeCsv', () => {
8889
})
8990
})
9091

92+
describe('writeTsv', () => {
93+
it('should write tsv into given file', async () => {
94+
await writeTsv('file-name.tsv', [
95+
{ nested: { string: 'string1' }, value: 3 },
96+
{ nested: { string: 'string2' }, value: 4 },
97+
{ nested: { string: 'string3' }, value: 6 }
98+
])
99+
100+
expect(mockedWriteFileSync).toHaveBeenCalledWith(
101+
'file-name.tsv',
102+
'nested.string\tvalue\nstring1\t3\nstring2\t4\nstring3\t6'
103+
)
104+
})
105+
})
106+
91107
describe('findDvcRootPaths', () => {
92108
it('should find the dvc root if it exists in the given folder', async () => {
93109
const dvcRoots = await findDvcRootPaths(dvcDemoPath)

extension/src/fileSystem/index.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,15 @@ export const writeCsv = async (
243243
return writeFileSync(path, csv)
244244
}
245245

246+
export const writeTsv = async (
247+
path: string,
248+
arr: Array<Record<string, unknown>>
249+
) => {
250+
ensureFileSync(path)
251+
const csv = await json2csv(arr, { delimiter: { field: '\t' } })
252+
return writeFileSync(path, csv)
253+
}
254+
246255
export const getPidFromFile = async (
247256
path: string
248257
): Promise<number | undefined> => {

extension/src/plots/model/index.ts

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,12 @@ import {
5858
} from '../multiSource/collect'
5959
import { isDvcError } from '../../cli/dvc/reader'
6060
import { ErrorsModel } from '../errors/model'
61-
import { openFileInEditor, writeCsv, writeJson } from '../../fileSystem'
61+
import {
62+
openFileInEditor,
63+
writeCsv,
64+
writeJson,
65+
writeTsv
66+
} from '../../fileSystem'
6267
import { Toast } from '../../vscode/toast'
6368

6469
export class PlotsModel extends ModelWithPersistence {
@@ -244,6 +249,10 @@ export class PlotsModel extends ModelWithPersistence {
244249
void this.savePlotData(filePath, plotId, data => writeCsv(filePath, data))
245250
}
246251

252+
public savePlotDataAsTsv(filePath: string, plotId: string) {
253+
void this.savePlotData(filePath, plotId, data => writeTsv(filePath, data))
254+
}
255+
247256
public getTemplatePlots(
248257
order: TemplateOrder | undefined,
249258
selectedRevisions: Revision[]

extension/src/plots/webview/messages.ts

Lines changed: 38 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,8 @@ export class WebviewMessages {
8484
)
8585
case MessageFromWebviewType.EXPORT_PLOT_DATA_AS_CSV:
8686
return this.exportPlotDataAsCsv(message.payload)
87+
case MessageFromWebviewType.EXPORT_PLOT_DATA_AS_TSV:
88+
return this.exportPlotDataAsTsv(message.payload)
8789
case MessageFromWebviewType.EXPORT_PLOT_DATA_AS_JSON:
8890
return this.exportPlotDataAsJson(message.payload)
8991
case MessageFromWebviewType.RESIZE_PLOTS:
@@ -362,35 +364,53 @@ export class WebviewMessages {
362364
return this.plots.getCustomPlots() || null
363365
}
364366

365-
private async exportPlotDataAsJson(plotId: string) {
366-
const file = await showSaveDialog('data.json', 'json')
367+
private async exportPlotData(
368+
extName: string,
369+
plotId: string,
370+
event:
371+
| typeof EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV
372+
| typeof EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON
373+
| typeof EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_TSV,
374+
writeFile: (filePath: string, plotId: string) => void
375+
) {
376+
const file = await showSaveDialog(`data.${extName}`, extName)
367377

368378
if (!file) {
369379
return
370380
}
371381

372-
sendTelemetryEvent(
373-
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON,
374-
undefined,
375-
undefined
376-
)
382+
sendTelemetryEvent(event, undefined, undefined)
377383

378-
void this.plots.savePlotDataAsJson(file.path, plotId)
384+
writeFile(file.path, plotId)
379385
}
380386

381-
private async exportPlotDataAsCsv(plotId: string) {
382-
const file = await showSaveDialog('data.csv', 'csv')
383-
384-
if (!file) {
385-
return
386-
}
387+
private exportPlotDataAsJson(plotId: string) {
388+
void this.exportPlotData(
389+
'json',
390+
plotId,
391+
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON,
392+
(filePath: string, plotId: string) =>
393+
this.plots.savePlotDataAsJson(filePath, plotId)
394+
)
395+
}
387396

388-
sendTelemetryEvent(
397+
private exportPlotDataAsCsv(plotId: string) {
398+
void this.exportPlotData(
399+
'csv',
400+
plotId,
389401
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV,
390-
undefined,
391-
undefined
402+
(filePath: string, plotId: string) =>
403+
this.plots.savePlotDataAsCsv(filePath, plotId)
392404
)
405+
}
393406

394-
void this.plots.savePlotDataAsCsv(file.path, plotId)
407+
private exportPlotDataAsTsv(plotId: string) {
408+
void this.exportPlotData(
409+
'tsv',
410+
plotId,
411+
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_TSV,
412+
(filePath: string, plotId: string) =>
413+
this.plots.savePlotDataAsTsv(filePath, plotId)
414+
)
395415
}
396416
}

extension/src/telemetry/constants.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ export const EventName = Object.assign(
7373
VIEWS_PLOTS_EXPERIMENT_TOGGLE: 'views.plots.toggleExperimentStatus',
7474
VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV: 'views.plots.exportPlotDataAsCsv',
7575
VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON: 'views.plots.exportPlotDataAsJson',
76+
VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_TSV: 'views.plots.exportPlotDataAsTsv',
7677
VIEWS_PLOTS_FOCUS_CHANGED: 'views.plots.focusChanged',
7778
VIEWS_PLOTS_REVISIONS_REORDERED: 'views.plots.revisionsReordered',
7879
VIEWS_PLOTS_SECTION_RESIZED: 'views.plots.sectionResized',
@@ -271,6 +272,7 @@ export interface IEventNamePropertyMapping {
271272
[EventName.VIEWS_PLOTS_EXPERIMENT_TOGGLE]: undefined
272273
[EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV]: undefined
273274
[EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON]: undefined
275+
[EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_TSV]: undefined
274276

275277
[EventName.VIEWS_PLOTS_ZOOM_PLOT]: { isImage: boolean }
276278
[EventName.VIEWS_REORDER_PLOTS_CUSTOM]: undefined

extension/src/test/suite/plots/index.test.ts

Lines changed: 85 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -489,14 +489,13 @@ suite('Plots Test Suite', () => {
489489
const mockWriteJson = stub(FileSystem, 'writeJson')
490490
const mockOpenFile = stub(FileSystem, 'openFileInEditor')
491491
const exportFile = Uri.file('raw-data.json')
492-
const mockShowInformationMessage = stub(window, 'showErrorMessage')
493492

494493
mockShowSaveDialog.resolves(exportFile)
495494

496-
const fileCancelledEvent = new Promise(resolve =>
497-
mockShowSaveDialog.onFirstCall().callsFake(() => {
495+
const openFileEvent = new Promise(resolve =>
496+
mockOpenFile.onFirstCall().callsFake(() => {
498497
resolve(undefined)
499-
return Promise.resolve(undefined)
498+
return Promise.resolve(undefined as unknown as TextDocument)
500499
})
501500
)
502501

@@ -505,27 +504,84 @@ suite('Plots Test Suite', () => {
505504
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_JSON
506505
})
507506

508-
await fileCancelledEvent
507+
await openFileEvent
509508

510-
expect(mockSendTelemetryEvent).not.to.be.called
511-
expect(mockWriteJson).not.to.be.called
509+
expect(mockWriteJson).to.be.calledOnce
510+
expect(mockWriteJson).to.be.calledWithExactly(
511+
exportFile.path,
512+
customPlot.values,
513+
true
514+
)
515+
expect(mockOpenFile).to.calledWithExactly(exportFile.path)
516+
expect(mockSendTelemetryEvent).to.be.calledOnce
517+
expect(mockSendTelemetryEvent).to.be.calledWithExactly(
518+
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON,
519+
undefined,
520+
undefined
521+
)
522+
})
512523

513-
const jsonWriteErrorEvent = new Promise(resolve =>
514-
mockWriteJson.onFirstCall().callsFake(() => {
524+
it('should handle an export plot data as csv message from the webview', async () => {
525+
const { plots } = await buildPlots({
526+
disposer: disposable,
527+
plotsDiff: plotsDiffFixture
528+
})
529+
530+
const webview = await plots.showWebview()
531+
const mockSendTelemetryEvent = stub(Telemetry, 'sendTelemetryEvent')
532+
const mockMessageReceived = getMessageReceivedEmitter(webview)
533+
const mockShowSaveDialog = stub(window, 'showSaveDialog')
534+
const mockWriteCsv = stub(FileSystem, 'writeCsv')
535+
const mockOpenFile = stub(FileSystem, 'openFileInEditor')
536+
const exportFile = Uri.file('raw-data.csv')
537+
const templatePlot = templatePlotsFixture.plots[0].entries[0]
538+
539+
mockShowSaveDialog.resolves(exportFile)
540+
541+
const openFileEvent = new Promise(resolve =>
542+
mockOpenFile.onFirstCall().callsFake(() => {
515543
resolve(undefined)
516-
throw new Error('file failed to write')
544+
return Promise.resolve(undefined as unknown as TextDocument)
517545
})
518546
)
519547

520548
mockMessageReceived.fire({
521-
payload: customPlot.id,
522-
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_JSON
549+
payload: templatePlot.id,
550+
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_CSV
523551
})
524552

525-
await jsonWriteErrorEvent
553+
await openFileEvent
526554

527-
expect(mockOpenFile).not.to.be.called
528-
expect(mockShowInformationMessage).to.be.called
555+
expect(mockWriteCsv).to.be.calledOnce
556+
expect(mockWriteCsv).to.be.calledWithExactly(
557+
exportFile.path,
558+
(templatePlot.content.data as { values: unknown[] }).values
559+
)
560+
expect(mockOpenFile).to.calledWithExactly(exportFile.path)
561+
expect(mockSendTelemetryEvent).to.be.calledOnce
562+
expect(mockSendTelemetryEvent).to.be.calledWithExactly(
563+
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV,
564+
undefined,
565+
undefined
566+
)
567+
})
568+
569+
it('should handle an export plot data as tsv message from the webview', async () => {
570+
const { plots } = await buildPlots({
571+
disposer: disposable,
572+
plotsDiff: plotsDiffFixture
573+
})
574+
575+
const webview = await plots.showWebview()
576+
const mockSendTelemetryEvent = stub(Telemetry, 'sendTelemetryEvent')
577+
const mockMessageReceived = getMessageReceivedEmitter(webview)
578+
const mockShowSaveDialog = stub(window, 'showSaveDialog')
579+
const mockWriteTsv = stub(FileSystem, 'writeTsv')
580+
const mockOpenFile = stub(FileSystem, 'openFileInEditor')
581+
const exportFile = Uri.file('raw-data.tsv')
582+
const customPlot = customPlotsFixture.plots[0]
583+
584+
mockShowSaveDialog.resolves(exportFile)
529585

530586
const openFileEvent = new Promise(resolve =>
531587
mockOpenFile.onFirstCall().callsFake(() => {
@@ -536,25 +592,26 @@ suite('Plots Test Suite', () => {
536592

537593
mockMessageReceived.fire({
538594
payload: customPlot.id,
539-
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_JSON
595+
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_TSV
540596
})
541597

542598
await openFileEvent
543599

544-
expect(mockWriteJson).to.be.calledWithExactly(
600+
expect(mockWriteTsv).to.be.calledOnce
601+
expect(mockWriteTsv).to.be.calledWithExactly(
545602
exportFile.path,
546-
customPlot.values,
547-
true
603+
customPlot.values
548604
)
549605
expect(mockOpenFile).to.calledWithExactly(exportFile.path)
606+
expect(mockSendTelemetryEvent).to.be.calledOnce
550607
expect(mockSendTelemetryEvent).to.be.calledWithExactly(
551-
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_JSON,
608+
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_TSV,
552609
undefined,
553610
undefined
554611
)
555612
})
556613

557-
it('should handle an export plot data as csv message from the webview', async () => {
614+
it('should handle export data messages from the webview when the file is cancelled or errors are thrown during file writing', async () => {
558615
const { plots } = await buildPlots({
559616
disposer: disposable,
560617
plotsDiff: plotsDiffFixture
@@ -589,10 +646,11 @@ suite('Plots Test Suite', () => {
589646
expect(mockWriteCsv).not.to.be.called
590647
expect(mockOpenFile).not.to.be.called
591648

592-
const openFileEvent = new Promise(resolve =>
593-
mockOpenFile.onFirstCall().callsFake(() => {
649+
const mockShowInformationMessage = stub(window, 'showErrorMessage')
650+
const fileWriteErrorEvent = new Promise(resolve =>
651+
mockWriteCsv.onFirstCall().callsFake(() => {
594652
resolve(undefined)
595-
return Promise.resolve(undefined as unknown as TextDocument)
653+
throw new Error('file failed to write')
596654
})
597655
)
598656

@@ -601,18 +659,10 @@ suite('Plots Test Suite', () => {
601659
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_CSV
602660
})
603661

604-
await openFileEvent
662+
await fileWriteErrorEvent
605663

606-
expect(mockWriteCsv).to.be.calledWithExactly(
607-
exportFile.path,
608-
(templatePlot.content.data as { values: unknown[] }).values
609-
)
610-
expect(mockOpenFile).to.calledWithExactly(exportFile.path)
611-
expect(mockSendTelemetryEvent).to.be.calledWithExactly(
612-
EventName.VIEWS_PLOTS_EXPORT_PLOT_DATA_AS_CSV,
613-
undefined,
614-
undefined
615-
)
664+
expect(mockOpenFile).not.to.be.called
665+
expect(mockShowInformationMessage).to.be.called
616666
})
617667

618668
it('should handle a custom plots reordered message from the webview', async () => {

extension/src/webview/contract.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ export enum MessageFromWebviewType {
2020
CREATE_BRANCH_FROM_EXPERIMENT = 'create-branch-from-experiment',
2121
EXPORT_PLOT_DATA_AS_JSON = 'export-plot-data-as-json',
2222
EXPORT_PLOT_DATA_AS_CSV = 'export-plot-data-as-csv',
23+
EXPORT_PLOT_DATA_AS_TSV = 'export-plot-data-as-tsv',
2324
FOCUS_FILTERS_TREE = 'focus-filters-tree',
2425
FOCUS_SORTS_TREE = 'focus-sorts-tree',
2526
OPEN_EXPERIMENTS_WEBVIEW = 'open-experiments-webview',
@@ -108,6 +109,10 @@ export type MessageFromWebview =
108109
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_CSV
109110
payload: string
110111
}
112+
| {
113+
type: MessageFromWebviewType.EXPORT_PLOT_DATA_AS_TSV
114+
payload: string
115+
}
111116
| {
112117
type: MessageFromWebviewType.REORDER_COLUMNS
113118
payload: string[]

0 commit comments

Comments
 (0)