• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

thoughtspot / visual-embed-sdk / #1510

27 Jan 2025 03:21PM UTC coverage: 93.715% (-0.2%) from 93.883%
#1510

Pull #105

ruchI9897
Added missing comma
Pull Request #105: [SCAL-227749] Doc Fix

950 of 1089 branches covered (87.24%)

Branch coverage included in aggregate %.

2390 of 2475 relevant lines covered (96.57%)

59.26 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

86.62
/src/utils/graphql/answerService/answerService.ts
1
// import YAML from 'yaml';
2
import { tokenizedFetch } from '../../../tokenizedFetch';
17✔
3
import type {
4
    ColumnValue, RuntimeFilter, RuntimeFilterOp, VizPoint,
5
} from '../../../types';
6
import { deepMerge, getTypeFromValue, removeTypename } from '../../../utils';
17✔
7
import { graphqlQuery } from '../graphql-request';
17✔
8
import { getSourceDetail } from '../sourceService';
17✔
9
import * as queries from './answer-queries';
17✔
10

11
export interface SessionInterface {
12
    sessionId: string;
13
    genNo: number;
14
    acSession: { sessionId: string; genNo: number };
15
}
16

17
// eslint-disable-next-line no-shadow
18
export enum OperationType {
17✔
19
    GetChartWithData = 'GetChartWithData',
17✔
20
    GetTableWithHeadlineData = 'GetTableWithHeadlineData',
17✔
21
}
22

23
export interface UnderlyingDataPoint {
24
    columnId: string;
25
    dataValue: any;
26
}
27

28
/**
29
 * Class representing the answer service provided with the
30
 * custom action payload. This service could be used to run
31
 * graphql queries in the context of the answer on which the
32
 * custom action was triggered.
33
 * @example
34
 * ```js
35
 *  embed.on(EmbedEvent.CustomAction, e => {
36
 *     const underlying = await e.answerService.getUnderlyingDataForPoint([
37
 *       'col name 1'
38
 *     ]);
39
 *     const data = await underlying.fetchData(0, 100);
40
 *  })
41
 * ```
42
 * @example
43
 * ```js
44
 * embed.on(EmbedEvent.Data, async (e) => {
45
 *     const service = await embed.getAnswerService();
46
 *     await service.addColumns([
47
 *         "<column guid>"
48
 *     ]);
49
 *     console.log(await service.fetchData());
50
 * });
51
 * ```
52
 * @version SDK: 1.25.0| ThoughtSpot: 9.10.0.cl
53
 * @group Events
54
 */
55
export class AnswerService {
17✔
56
    private answer: Promise<any>;
57

58
    private tmlOverride = {};
22✔
59

60
    /**
61
     * Should not need to be called directly.
62
     * @param session
63
     * @param answer
64
     * @param thoughtSpotHost
65
     * @param selectedPoints
66
     */
67
    constructor(
68
        private session: SessionInterface,
22✔
69
        answer: any,
70
        private thoughtSpotHost: string,
22✔
71
        private selectedPoints?: VizPoint[],
22✔
72
    ) {
73
        this.session = removeTypename(session);
22✔
74
        this.answer = answer;
22✔
75
    }
76

77
    /**
78
     * Get the details about the source used in the answer.
79
     * This can be used to get the list of all columns in the data source for example.
80
     */
81
    public async getSourceDetail() {
82
        const sourceId = (await this.getAnswer()).sources[0].header.guid;
3✔
83
        return getSourceDetail(
3✔
84
            this.thoughtSpotHost,
85
            sourceId,
86
        );
87
    }
88

89
    /**
90
     * Remove columnIds and return updated answer session.
91
     * @param columnIds
92
     * @returns
93
     */
94
    public async removeColumns(columnIds: string[]) {
95
        return this.executeQuery(
1✔
96
            queries.removeColumns,
97
            {
98
                logicalColumnIds: columnIds,
99
            },
100
        );
101
    }
102

103
    /**
104
     * Add columnIds and return updated answer session.
105
     * @param columnIds
106
     * @returns
107
     */
108
    public async addColumns(columnIds: string[]) {
109
        return this.executeQuery(
3✔
110
            queries.addColumns,
111
            {
112
                columns: columnIds.map((colId) => ({ logicalColumnId: colId })),
4✔
113
            },
114
        );
115
    }
116

117
    /**
118
     * Add columns by names and return updated answer session.
119
     * @param columnNames
120
     * @returns
121
     * @example
122
     * ```js
123
     * embed.on(EmbedEvent.Data, async (e) => {
124
     *    const service = await embed.getAnswerService();
125
     *    await service.addColumnsByName([
126
     *      "col name 1",
127
     *      "col name 2"
128
     *    ]);
129
     *    console.log(await service.fetchData());
130
     * });
131
     */
132
    public async addColumnsByName(columnNames: string[]) {
133
        const sourceDetail = await this.getSourceDetail();
1✔
134
        const columnGuids = getGuidsFromColumnNames(sourceDetail, columnNames);
1✔
135
        return this.addColumns([...columnGuids]);
1✔
136
    }
137

138
    /**
139
     * Add a filter to the answer.
140
     * @param columnName
141
     * @param operator
142
     * @param values
143
     * @returns
144
     */
145
    public async addFilter(columnName: string, operator: RuntimeFilterOp, values: RuntimeFilter['values']) {
146
        const sourceDetail = await this.getSourceDetail();
1✔
147
        const columnGuids = getGuidsFromColumnNames(sourceDetail, [columnName]);
1✔
148
        return this.executeQuery(
1✔
149
            queries.addFilter,
150
            {
151
                params: {
152
                    filterContent: [{
153
                        filterType: operator,
154
                        value: values.map(
155
                            (v) => {
156
                                const [type, prefix] = getTypeFromValue(v);
1✔
157
                                return {
1✔
158
                                    type: type.toUpperCase(),
159
                                    [`${prefix}Value`]: v,
160
                                };
161
                            },
162
                        ),
163
                    }],
164
                    filterGroupId: {
165
                        logicalColumnId: columnGuids.values().next().value,
166
                    },
167
                },
168
            },
169
        );
170
    }
171

172
    public async getSQLQuery(): Promise<string> {
173
        const { sql } = await this.executeQuery(
1✔
174
            queries.getSQLQuery,
175
            {},
176
        );
177
        return sql;
1✔
178
    }
179

180
    /**
181
     * Fetch data from the answer.
182
     * @param offset
183
     * @param size
184
     * @returns
185
     */
186
    public async fetchData(offset = 0, size = 1000) {
×
187
        const { answer } = await this.executeQuery(
1✔
188
            queries.getAnswerData,
189
            {
190
                deadline: 0,
191
                dataPaginationParams: {
192
                    isClientPaginated: true,
193
                    offset,
194
                    size,
195
                },
196
            },
197
        );
198
        const { columns, data } = answer.visualizations.find(
1!
199
            (viz: any) => !!viz.data,
2✔
200
        ) || {};
201
        return {
1✔
202
            columns,
203
            data,
204
        };
205
    }
206

207
    /**
208
     * Fetch the data for the answer as a CSV blob. This might be
209
     * quicker for larger data.
210
     * @param userLocale
211
     * @param includeInfo Include the CSV header in the output
212
     * @returns Response
213
     */
214
    public async fetchCSVBlob(userLocale = 'en-us', includeInfo = false): Promise<Response> {
2!
215
        const fetchUrl = this.getFetchCSVBlobUrl(userLocale, includeInfo);
3✔
216
        return tokenizedFetch(fetchUrl, {
3✔
217
            credentials: 'include',
218
        });
219
    }
220

221
    /**
222
     * Fetch the data for the answer as a PNG blob. This might be
223
     * quicker for larger data.
224
     * @param userLocale
225
     * @param includeInfo
226
     * @param omitBackground Omit the background in the PNG
227
     * @param deviceScaleFactor The scale factor for the PNG
228
     * @return Response
229
     */
230
    public async fetchPNGBlob(userLocale = 'en-us', omitBackground = false, deviceScaleFactor = 2): Promise<Response> {
6!
231
        const fetchUrl = this.getFetchPNGBlobUrl(
4✔
232
            userLocale,
233
            omitBackground,
234
            deviceScaleFactor,
235
        );
236
        return tokenizedFetch(fetchUrl, {
4✔
237
            credentials: 'include',
238
        });
239
    }
240

241
    /**
242
     * Just get the internal URL for this answer's data
243
     * as a CSV blob.
244
     * @param userLocale
245
     * @param includeInfo
246
     * @returns
247
     */
248
    public getFetchCSVBlobUrl(userLocale = 'en-us', includeInfo = false): string {
×
249
        return `${this.thoughtSpotHost}/prism/download/answer/csv?sessionId=${this.session.sessionId}&genNo=${this.session.genNo}&userLocale=${userLocale}&exportFileName=data&hideCsvHeader=${!includeInfo}`;
3✔
250
    }
251

252
    /**
253
     * Just get the internal URL for this answer's data
254
     * as a PNG blob.
255
     * @param userLocale
256
     * @param omitBackground
257
     * @param deviceScaleFactor
258
     */
259
    public getFetchPNGBlobUrl(userLocale = 'en-us', omitBackground = false, deviceScaleFactor = 2): string {
×
260
        return `${this.thoughtSpotHost}/prism/download/answer/png?sessionId=${this.session.sessionId}&deviceScaleFactor=${deviceScaleFactor}&omitBackground=${omitBackground}&genNo=${this.session.genNo}&userLocale=${userLocale}&exportFileName=data`;
4✔
261
    }
262

263
    /**
264
     * Get underlying data given a point and the output column names.
265
     * In case of a context menu action, the selectedPoints are
266
     * automatically passed.
267
     * @param outputColumnNames
268
     * @param selectedPoints
269
     * @example
270
     * ```js
271
     *  embed.on(EmbedEvent.CustomAction, e => {
272
     *     const underlying = await e.answerService.getUnderlyingDataForPoint([
273
     *       'col name 1' // The column should exist in the data source.
274
     *     ]);
275
     *     const data = await underlying.fetchData(0, 100);
276
     *  })
277
     * ```
278
     * @version SDK: 1.25.0| ThoughtSpot: 9.10.0.cl
279
     */
280
    public async getUnderlyingDataForPoint(
281
        outputColumnNames: string[],
282
        selectedPoints?: UnderlyingDataPoint[],
283
    ): Promise<AnswerService> {
284
        if (!selectedPoints && !this.selectedPoints) {
2✔
285
            throw new Error('Needs to be triggered in context of a point');
1✔
286
        }
287

288
        if (!selectedPoints) {
1✔
289
            selectedPoints = getSelectedPointsForUnderlyingDataQuery(
1✔
290
                this.selectedPoints,
291
            );
292
        }
293

294
        const sourceDetail = await this.getSourceDetail();
1✔
295
        const ouputColumnGuids = getGuidsFromColumnNames(sourceDetail, outputColumnNames);
1✔
296
        const unAggAnswer = await graphqlQuery({
1✔
297
            query: queries.getUnaggregatedAnswerSession,
298
            variables: {
299
                session: this.session,
300
                columns: selectedPoints,
301
            },
302
            thoughtSpotHost: this.thoughtSpotHost,
303
        });
304
        const unaggAnswerSession = new AnswerService(
1✔
305
            unAggAnswer.id,
306
            unAggAnswer.answer,
307
            this.thoughtSpotHost,
308
        );
309
        const currentColumns: Set<string> = new Set(
1✔
310
            unAggAnswer.answer.visualizations[0].columns
311
                .map(
312
                    (c: any) => c.column.referencedColumns[0].guid,
1✔
313
                ),
314
        );
315

316
        const columnsToAdd = [...ouputColumnGuids].filter((col) => !currentColumns.has(col));
1✔
317
        if (columnsToAdd.length) {
1✔
318
            await unaggAnswerSession.addColumns(columnsToAdd);
1✔
319
        }
320

321
        const columnsToRemove = [...currentColumns].filter((col) => !ouputColumnGuids.has(col));
1✔
322
        if (columnsToRemove.length) {
1✔
323
            await unaggAnswerSession.removeColumns(columnsToRemove);
1✔
324
        }
325

326
        return unaggAnswerSession;
1✔
327
    }
328

329
    /**
330
     * Execute a custom graphql query in the context of the answer.
331
     * @param query graphql query
332
     * @param variables graphql variables
333
     * @returns
334
     */
335
    public async executeQuery(query: string, variables: any): Promise<any> {
336
        const data = await graphqlQuery({
11✔
337
            query,
338
            variables: {
339
                session: this.session,
340
                ...variables,
341
            },
342
            thoughtSpotHost: this.thoughtSpotHost,
343
            isCompositeQuery: false,
344
        });
345

346
        this.session = deepMerge(this.session, data?.id || {}) as unknown as SessionInterface;
11!
347
        return data;
11✔
348
    }
349

350
    /**
351
     * Get the internal session details for the answer.
352
     * @returns
353
     */
354
    public getSession() {
355
        return this.session;
2✔
356
    }
357

358
    public async getAnswer() {
359
        if (this.answer) {
6✔
360
            return this.answer;
5✔
361
        }
362
        this.answer = this.executeQuery(
1✔
363
            queries.getAnswer,
364
            {},
365
        ).then((data) => data?.answer);
1!
366
        return this.answer;
1✔
367
    }
368

369
    public async getTML(): Promise<any> {
370
        const { object } = await this.executeQuery(
×
371
            queries.getAnswerTML,
372
            {},
373
        );
374
        const edoc = object[0].edoc;
×
375
        const YAML = await import('yaml');
×
376
        const parsedDoc = YAML.parse(edoc);
×
377
        return {
×
378
            answer: {
379
                ...parsedDoc.answer,
380
                ...this.tmlOverride,
381
            },
382
        };
383
    }
384

385
    public async addDisplayedVizToLiveboard(liveboardId: string) {
386
        const { displayMode, visualizations } = await this.getAnswer();
2✔
387
        const viz = getDisplayedViz(visualizations, displayMode);
2✔
388
        return this.executeQuery(
2✔
389
            queries.addVizToLiveboard,
390
            {
391
                liveboardId,
392
                vizId: viz.id,
393
            },
394
        );
395
    }
396

397
    public setTMLOverride(override: any) {
398
        this.tmlOverride = override;
×
399
    }
400
}
401

402
/**
403
 *
404
 * @param sourceDetail
405
 * @param colNames
406
 */
407
function getGuidsFromColumnNames(sourceDetail: any, colNames: string[]) {
408
    const cols = sourceDetail.columns.reduce((colSet: any, col: any) => {
3✔
409
        colSet[col.name.toLowerCase()] = col;
9✔
410
        return colSet;
9✔
411
    }, {});
412

413
    return new Set(colNames.map((colName) => {
3✔
414
        const col = cols[colName.toLowerCase()];
3✔
415
        return col.id;
3✔
416
    }));
417
}
418

419
/**
420
 *
421
 * @param selectedPoints
422
 */
423
function getSelectedPointsForUnderlyingDataQuery(
424
    selectedPoints: VizPoint[],
425
): UnderlyingDataPoint[] {
426
    const underlyingDataPoint: UnderlyingDataPoint[] = [];
1✔
427
    /**
428
     *
429
     * @param colVal
430
     */
431
    function addPointFromColVal(colVal: ColumnValue) {
432
        const dataType = colVal.column.dataType;
3✔
433
        const id = colVal.column.id;
3✔
434
        let dataValue;
435
        if (dataType === 'DATE') {
3✔
436
            if (Number.isFinite(colVal.value)) {
2✔
437
                dataValue = [{
1✔
438
                    epochRange: {
439
                        startEpoch: colVal.value,
440
                    },
441
                }];
442
                // Case for custom calendar.
443
            } else if ((colVal.value as any)?.v) {
1!
444
                dataValue = [{
1✔
445
                    epochRange: {
446
                        startEpoch: (colVal.value as any).v.s,
447
                        endEpoch: (colVal.value as any).v.e,
448
                    },
449
                }];
450
            }
451
        } else {
452
            dataValue = [{ value: colVal.value }];
1✔
453
        }
454
        underlyingDataPoint.push({
3✔
455
            columnId: colVal.column.id,
456
            dataValue,
457
        });
458
    }
459

460
    selectedPoints.forEach((p) => {
1✔
461
        p.selectedAttributes.forEach(addPointFromColVal);
1✔
462
    });
463
    return underlyingDataPoint;
1✔
464
}
465

466
/**
467
 *
468
 * @param visualizations
469
 * @param displayMode
470
 */
471
function getDisplayedViz(visualizations: any[], displayMode: string) {
472
    if (displayMode === 'CHART_MODE') {
2✔
473
        return visualizations.find(
1✔
474
            // eslint-disable-next-line no-underscore-dangle
475
            (viz: any) => viz.__typename === 'ChartViz',
2✔
476
        );
477
    }
478
    return visualizations.find(
1✔
479
        // eslint-disable-next-line no-underscore-dangle
480
        (viz: any) => viz.__typename === 'TableViz',
1✔
481
    );
482
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc