From d68603b58128a6ff986736d854c7a7a681412b86 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Tue, 13 Oct 2026 14:19:51 +0530 Subject: [PATCH 1/8] feat(sdk): support per-action disabledActionReason (SCAL-337048) disabledActionReason accepted only one string, shown on every disabled action, so a host could not explain why each action was disabled. It now also accepts a map keyed by Action, with an optional GENERIC_REASON fallback. A string is sent unchanged. A map is cleaned before it is sent: keys that are not an Action or GENERIC_REASON, and values that are not strings, are dropped with a console warning, so a bad entry never fails the embed. What is left goes out JSON-encoded in the existing disableHint param; an empty result sends nothing, the same as leaving the option unset. GENERIC_REASON and DisabledActionReasonMap are exported from the main and React entry points. --- src/embed/liveboard.spec.ts | 36 ++++++++ src/embed/ts-embed.spec.ts | 37 ++++++++ src/embed/ts-embed.ts | 9 +- src/errors.ts | 6 ++ src/index.ts | 4 + src/react/all-types-export.ts | 2 + src/types.ts | 54 ++++++++++- src/utils/disabled-action-reason.spec.ts | 112 +++++++++++++++++++++++ src/utils/disabled-action-reason.ts | 58 ++++++++++++ 9 files changed, 314 insertions(+), 4 deletions(-) create mode 100644 src/utils/disabled-action-reason.spec.ts create mode 100644 src/utils/disabled-action-reason.ts diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 5f008449..ca8e5536 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -6,6 +6,7 @@ import { AuthType, ContextMenuTriggerOptions, EmbedEvent, + GENERIC_REASON, HostEvent, RuntimeFilterOp, } from '../types'; @@ -143,6 +144,41 @@ describe('Liveboard/viz embed tests', () => { }); }); + test('should send a per-action disabledActionReason map as JSON', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + disabledActions: [Action.Download, Action.Save, Action.Share], + disabledActionReason: { + [Action.Download]: 'Downloads require a Pro license', + [Action.Save]: 'You have read-only access', + [GENERIC_REASON]: 'Not available for your account', + }, + ...defaultViewConfig, + liveboardId, + } as LiveboardViewConfig); + liveboardEmbed.render(); + await executeAfterWait(() => { + const disableHint = new URL(getIFrameSrc()).searchParams.get('disableHint'); + expect(JSON.parse(disableHint)).toEqual({ + [Action.Download]: 'Downloads require a Pro license', + [Action.Save]: 'You have read-only access', + [GENERIC_REASON]: 'Not available for your account', + }); + }); + }); + + test('should not send disableHint for an empty disabledActionReason map', async () => { + const liveboardEmbed = new LiveboardEmbed(getRootEl(), { + disabledActions: [Action.Download], + disabledActionReason: {}, + ...defaultViewConfig, + liveboardId, + } as LiveboardViewConfig); + liveboardEmbed.render(); + await executeAfterWait(() => { + expect(new URL(getIFrameSrc()).searchParams.has('disableHint')).toBe(false); + }); + }); + test('should set hidden actions', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { hiddenActions: [Action.DownloadAsCsv, Action.DownloadAsPdf, Action.DownloadAsXlsx], diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index f9301d5a..13f3fc94 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -33,6 +33,8 @@ import { EmbedErrorCodes, EmbedErrorSeverity, ContextObject, + GENERIC_REASON, + DisabledActionReasonMap, } from '../types'; import { executeAfterWait, @@ -6069,6 +6071,41 @@ describe('ShowPreRender with UpdateEmbedParams', () => { }); }); + test('should carry a disabledActionReason map in UpdateEmbedParams', async () => { + await setupPreRenderTest('params-disabled-reason', { liveboardId: 'original-lb' }); + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + + const embed2 = new LiveboardEmbed('#tsEmbedDiv', { + preRenderId: 'params-disabled-reason', + liveboardId: 'original-lb', + disabledActions: [Action.Download, Action.Save], + disabledActionReason: { + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + notAnAction: 'dropped', + } as DisabledActionReasonMap, + }); + + embed2.showPreRender(); + + await executeAfterWait(() => { + expect(mockProcessTrigger).toHaveBeenCalledWith( + expect.any(Object), + HostEvent.UpdateEmbedParams, + expect.any(String), + expect.objectContaining({ + // Parsed back from JSON, with the unknown key already + // dropped. + [Param.DisableActionReason]: { + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + }, + }), + undefined, + ); + }); + }); + test('should carry the new config filters in UpdateEmbedParams', async () => { await setupPreRenderTest('params-new-filters', { liveboardId: 'original-lb' }); diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 452609eb..35839207 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -49,6 +49,7 @@ import { setParamIfDefined, } from '../utils'; import { getCustomActions } from '../utils/custom-actions'; +import { getDisabledActionReasonParam } from '../utils/disabled-action-reason'; import { getThoughtSpotHost, URL_MAX_LENGTH, @@ -973,9 +974,11 @@ export class TsEmbed { if (disabledActions?.length) { queryParams[Param.DisableActions] = disabledActions; } - if (disabledActionReason) { - queryParams[Param.DisableActionReason] = disabledActionReason; - } + setParamIfDefined( + queryParams, + Param.DisableActionReason, + getDisabledActionReasonParam(disabledActionReason), + ); if (exposeTranslationIDs) { queryParams[Param.ExposeTranslationIDs] = exposeTranslationIDs; } diff --git a/src/errors.ts b/src/errors.ts index 669c95a6..3b5a6960 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -45,3 +45,9 @@ export const CUSTOM_ACTIONS_ERROR_MESSAGE = { INVALID_MENU_INDEX_POSITION: (id: string, position: string) => `Custom Action Validation Error for '${id}': 'menuIndex' is only valid for position: MENU, but got position '${position}'`, DUPLICATE_IDS: (id: string, duplicateNames: string[], keptName: string) => `Duplicate custom action ID '${id}' found. Actions with names '${duplicateNames.join("', '")}' will be ignored. Keeping '${keptName}'.`, }; + +export const DISABLED_ACTION_REASON_WARNING = { + INVALID_TYPE: (type: string) => `disabledActionReason must be a string or an object keyed by Action. The ${type} value is ignored.`, + UNKNOWN_KEY: (key: string) => `disabledActionReason key '${key}' is not an Action or GENERIC_REASON and is ignored.`, + NON_STRING_REASON: (key: string) => `disabledActionReason value for '${key}' must be a string and is ignored.`, +}; diff --git a/src/index.ts b/src/index.ts index d3d379e4..a1344689 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,6 +53,8 @@ import { HostEvent, DataSourceVisualMode, Action, + GENERIC_REASON, + DisabledActionReasonMap, EmbedConfig, PrefetchFeatures, FrameParams, @@ -270,6 +272,8 @@ export { ContextType, DataSourceVisualMode, Action, + GENERIC_REASON, + DisabledActionReasonMap, ContextMenuTriggerOptions, EmbedConfig, SearchViewConfig, diff --git a/src/react/all-types-export.ts b/src/react/all-types-export.ts index 91d600a1..6d549230 100644 --- a/src/react/all-types-export.ts +++ b/src/react/all-types-export.ts @@ -39,6 +39,8 @@ export { HostEvent, DataSourceVisualMode, Action, + GENERIC_REASON, + DisabledActionReasonMap, EmbedConfig, SearchViewConfig, SearchBarViewConfig, diff --git a/src/types.ts b/src/types.ts index c860625a..53b7c278 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1061,6 +1061,16 @@ export interface BaseViewConfig extends ApiInterceptFlags { /** * The tooltip to display for disabled actions. * + * Pass a string to show the same reason on every action in + * {@link disabledActions}. + * + * Pass a {@link DisabledActionReasonMap} to give each action its own reason. + * An action without an entry shows the {@link GENERIC_REASON} value if one + * is set, and no tooltip otherwise. Keys that are not an {@link Action} or + * {@link GENERIC_REASON} are ignored with a console warning. + * The object form requires SDK 1.53.1 and ThoughtSpot Cloud 26.12.0.cl or + * later. + * * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` * @version SDK: 1.6.0 | ThoughtSpot: ts8.nov.cl, 8.4.1.sw * @example @@ -1072,8 +1082,23 @@ export interface BaseViewConfig extends ApiInterceptFlags { * disabledActionReason: "Reason for disabling", * }); * ``` + * @example + * ```js + * import { Action, GENERIC_REASON } from '@thoughtspot/visual-embed-sdk'; + * + * const embed = new ('#tsEmbed', { + * ... // other embed view config + * disabledActions: [Action.Download, Action.Save, Action.Share], + * disabledActionReason: { + * [Action.Download]: 'Downloads require a Pro license', + * [Action.Save]: 'You have read-only access to this Liveboard', + * // Optional. Shown on Action.Share, which has no entry of its own. + * [GENERIC_REASON]: 'This action is not available for your account', + * }, + * }); + * ``` */ - disabledActionReason?: string; + disabledActionReason?: string | DisabledActionReasonMap; /** * The list of actions to completely remove from the embedded view. * Hidden actions are not visible to the user at all (fully removed from the UI). @@ -9895,6 +9920,33 @@ export enum Action { */ Monitor = 'monitor', } + +/** + * Key for the optional catch-all reason in a {@link DisabledActionReasonMap}. + * A disabled action that has no entry of its own shows this reason. + * @version SDK: 1.53.1 | ThoughtSpot Cloud: 26.12.0.cl + * @example + * ```js + * const embed = new LiveboardEmbed('#tsEmbed', { + * ... // other embed view config + * disabledActions: [Action.Download, Action.Share], + * disabledActionReason: { + * [Action.Download]: 'Downloads require a Pro license', + * [GENERIC_REASON]: 'This action is not available for your account', + * }, + * }); + * ``` + */ +export const GENERIC_REASON = 'GENERIC_REASON' as const; + +/** + * Tooltip text for disabled actions, keyed by {@link Action}, with an optional + * {@link GENERIC_REASON} fallback. Every key is optional. A disabled action with + * no entry and no {@link GENERIC_REASON} shows no tooltip. + * @version SDK: 1.53.1 | ThoughtSpot Cloud: 26.12.0.cl + */ +export type DisabledActionReasonMap = Partial>; + export interface AnswerServiceType { getAnswer?: (offset: number, batchSize: number) => any; } diff --git a/src/utils/disabled-action-reason.spec.ts b/src/utils/disabled-action-reason.spec.ts new file mode 100644 index 00000000..5a1f7a56 --- /dev/null +++ b/src/utils/disabled-action-reason.spec.ts @@ -0,0 +1,112 @@ +import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; +import { DISABLED_ACTION_REASON_WARNING } from '../errors'; +import { getDisabledActionReasonParam } from './disabled-action-reason'; +import { logger } from './logger'; + +describe('getDisabledActionReasonParam', () => { + let warnSpy: jest.SpyInstance; + + beforeEach(() => { + warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); + }); + + afterEach(() => { + warnSpy.mockRestore(); + }); + + test('returns a string reason unchanged', () => { + expect(getDisabledActionReasonParam('Action denied')).toBe('Action denied'); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('returns undefined when the reason is unset or an empty string', () => { + expect(getDisabledActionReasonParam(undefined)).toBeUndefined(); + expect(getDisabledActionReasonParam('')).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('JSON-encodes a full map', () => { + const reason = { + [Action.Download]: 'Downloads require a Pro license', + [Action.Save]: 'You have read-only access', + }; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('keeps GENERIC_REASON alongside a partial map', () => { + const reason = { + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + }; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + }); + + test('sends a partial map without GENERIC_REASON as is', () => { + const reason = { [Action.Download]: 'Downloads require a Pro license' }; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('sends a map with only GENERIC_REASON', () => { + const reason = { [GENERIC_REASON]: 'Not available for your account' }; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + }); + + test('treats an empty map the same as an unset reason', () => { + expect(getDisabledActionReasonParam({})).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test('ignores unknown keys with a warning and keeps the valid ones', () => { + const reason = { + [Action.Save]: 'You have read-only access', + notAnAction: 'dropped', + } as DisabledActionReasonMap; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual({ + [Action.Save]: 'You have read-only access', + }); + expect(warnSpy).toHaveBeenCalledWith( + DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY('notAnAction'), + ); + }); + + test('does not accept prototype keys', () => { + const reason = JSON.parse('{"__proto__":"x","constructor":"y"}'); + expect(getDisabledActionReasonParam(reason)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith( + DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY('__proto__'), + ); + expect(warnSpy).toHaveBeenCalledWith( + DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY('constructor'), + ); + }); + + test('ignores non-string values with a warning', () => { + const reason = { + [Action.Save]: 42, + [Action.Download]: 'Downloads require a Pro license', + } as unknown as DisabledActionReasonMap; + expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual({ + [Action.Download]: 'Downloads require a Pro license', + }); + expect(warnSpy).toHaveBeenCalledWith( + DISABLED_ACTION_REASON_WARNING.NON_STRING_REASON(Action.Save), + ); + }); + + test('drops empty-string values without a warning', () => { + const reason = { [Action.Save]: '' }; + expect(getDisabledActionReasonParam(reason)).toBeUndefined(); + expect(warnSpy).not.toHaveBeenCalled(); + }); + + test.each([ + ['array', ['Action denied']], + ['number', 42], + ['boolean', true], + ])('ignores a %s with a warning', (type, value) => { + expect(getDisabledActionReasonParam(value as any)).toBeUndefined(); + expect(warnSpy).toHaveBeenCalledWith(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(type)); + }); +}); diff --git a/src/utils/disabled-action-reason.ts b/src/utils/disabled-action-reason.ts new file mode 100644 index 00000000..659a3dad --- /dev/null +++ b/src/utils/disabled-action-reason.ts @@ -0,0 +1,58 @@ +import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; +import { DISABLED_ACTION_REASON_WARNING } from '../errors'; +import { logger } from './logger'; + +const VALID_REASON_KEYS: ReadonlySet = new Set([ + ...Object.values(Action), + GENERIC_REASON, +]); + +const isPlainObject = (value: unknown): value is Record => ( + typeof value === 'object' && value !== null && !Array.isArray(value) +); + +/** + * Builds the `disableHint` query param value from `disabledActionReason`. + * + * A string is returned unchanged. A map keeps only the keys that are an + * {@link Action} or {@link GENERIC_REASON} and have a string value, and is + * returned JSON-encoded, the same way `disableAction` carries its array. Anything + * ignored is logged as a warning and never fails the embed. + * @param reason - The `disabledActionReason` view config value + * @returns The param value, or `undefined` when there is nothing to send, which + * is the same as leaving `disabledActionReason` unset. + * @hidden + */ +export const getDisabledActionReasonParam = ( + reason: string | DisabledActionReasonMap | undefined, +): string | undefined => { + if (!reason) { + return undefined; + } + if (typeof reason === 'string') { + return reason; + } + if (!isPlainObject(reason)) { + const type = Array.isArray(reason) ? 'array' : typeof reason; + logger.warn(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(type)); + return undefined; + } + + const validReasons: Record = {}; + Object.entries(reason).forEach(([key, value]) => { + if (!VALID_REASON_KEYS.has(key)) { + logger.warn(DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY(key)); + return; + } + if (typeof value !== 'string') { + logger.warn(DISABLED_ACTION_REASON_WARNING.NON_STRING_REASON(key)); + return; + } + // An empty reason sends nothing, the same as the string form. + if (value) { + validReasons[key] = value; + } + }); + + return Object.keys(validReasons).length ? JSON.stringify(validReasons) : undefined; +}; From a2b4553f91a404b6150f5b1790004c405a1af397 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Thu, 15 Oct 2026 12:22:24 +0530 Subject: [PATCH 2/8] test(sdk): cover disabledActionReason with excludeConfigFromURL (SCAL-337048) With excludeConfigFromURL, disableHint is held back from the iframe URL and sent in the APP_INIT embedParams instead. Add tests that a map arrives there as the cleaned object, without the unknown key, and that a string arrives unchanged. Also detect a plain object by its built-in type, so a Map, Date or other built-in gets the invalid-type warning, named by its real type, instead of being treated as an empty map. --- src/embed/ts-embed.spec.ts | 32 ++++++++++++++++++++++++ src/utils/disabled-action-reason.spec.ts | 2 ++ src/utils/disabled-action-reason.ts | 11 +++++--- 3 files changed, 42 insertions(+), 3 deletions(-) diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 13f3fc94..f03a3d3e 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -7196,6 +7196,38 @@ describe('excludeConfigFromURL', () => { ); }); + test('sends a disabledActionReason map in the APP_INIT payload, not the URL', async () => { + jest.spyOn(logger, 'warn').mockImplementation(() => {}); + await renderEmbed(LiveboardEmbed, { + liveboardId, + disabledActions: [Action.Download, Action.Share], + disabledActionReason: { + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + notAnAction: 'dropped', + } as DisabledActionReasonMap, + }); + expect(getIFrameSrc()).not.toContain(`${Param.DisableActionReason}=`); + + const { embedParams } = await requestAppInit(); + + // Parsed back to an object, with the unknown key already dropped. + expect(embedParams[Param.DisableActionReason]).toEqual({ + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + }); + }); + + test('sends a string disabledActionReason unchanged in the APP_INIT payload', async () => { + const { embedParams } = await getAppInitFor(LiveboardEmbed, { + liveboardId, + disabledActions: [Action.Download], + disabledActionReason: 'Action denied', + }); + + expect(embedParams[Param.DisableActionReason]).toBe('Action denied'); + }); + test('sends the Liveboard layout flags', async () => { const { embedParams } = await getAppInitFor(LiveboardEmbed, { liveboardId, diff --git a/src/utils/disabled-action-reason.spec.ts b/src/utils/disabled-action-reason.spec.ts index 5a1f7a56..460eec49 100644 --- a/src/utils/disabled-action-reason.spec.ts +++ b/src/utils/disabled-action-reason.spec.ts @@ -105,6 +105,8 @@ describe('getDisabledActionReasonParam', () => { ['array', ['Action denied']], ['number', 42], ['boolean', true], + ['map', new Map([[Action.Save, 'Read-only']])], + ['date', new Date()], ])('ignores a %s with a warning', (type, value) => { expect(getDisabledActionReasonParam(value as any)).toBeUndefined(); expect(warnSpy).toHaveBeenCalledWith(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(type)); diff --git a/src/utils/disabled-action-reason.ts b/src/utils/disabled-action-reason.ts index 659a3dad..3278da2d 100644 --- a/src/utils/disabled-action-reason.ts +++ b/src/utils/disabled-action-reason.ts @@ -7,8 +7,14 @@ const VALID_REASON_KEYS: ReadonlySet = new Set([ GENERIC_REASON, ]); +// Built-in type of a value, lower-cased: 'object', 'array', 'map', 'date', ... +const getTypeName = (value: unknown): string => ( + Object.prototype.toString.call(value).slice(8, -1).toLowerCase() +); + +// Only `{ ... }` objects; arrays, Maps, Dates and other built-ins are not. const isPlainObject = (value: unknown): value is Record => ( - typeof value === 'object' && value !== null && !Array.isArray(value) + getTypeName(value) === 'object' ); /** @@ -33,8 +39,7 @@ export const getDisabledActionReasonParam = ( return reason; } if (!isPlainObject(reason)) { - const type = Array.isArray(reason) ? 'array' : typeof reason; - logger.warn(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(type)); + logger.warn(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(getTypeName(reason))); return undefined; } From 37571924df157602d64304668e11a1bd095f91f9 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Fri, 23 Oct 2026 12:27:45 +0530 Subject: [PATCH 3/8] refactor(sdk): use lodash for disabledActionReason type checks (SCAL-337048) Use lodash isPlainObject and isString, which the SDK already uses, instead of a hand-written type check. isPlainObject is false for arrays, Maps, Dates and other built-ins, so they still get the invalid-type warning. The warning no longer names the value's type. --- src/errors.ts | 2 +- src/utils/disabled-action-reason.spec.ts | 4 ++-- src/utils/disabled-action-reason.ts | 19 ++++++------------- 3 files changed, 9 insertions(+), 16 deletions(-) diff --git a/src/errors.ts b/src/errors.ts index 3b5a6960..3807aa17 100644 --- a/src/errors.ts +++ b/src/errors.ts @@ -47,7 +47,7 @@ export const CUSTOM_ACTIONS_ERROR_MESSAGE = { }; export const DISABLED_ACTION_REASON_WARNING = { - INVALID_TYPE: (type: string) => `disabledActionReason must be a string or an object keyed by Action. The ${type} value is ignored.`, + INVALID_TYPE: 'disabledActionReason must be a string or an object keyed by Action. The value is ignored.', UNKNOWN_KEY: (key: string) => `disabledActionReason key '${key}' is not an Action or GENERIC_REASON and is ignored.`, NON_STRING_REASON: (key: string) => `disabledActionReason value for '${key}' must be a string and is ignored.`, }; diff --git a/src/utils/disabled-action-reason.spec.ts b/src/utils/disabled-action-reason.spec.ts index 460eec49..862f4b5b 100644 --- a/src/utils/disabled-action-reason.spec.ts +++ b/src/utils/disabled-action-reason.spec.ts @@ -107,8 +107,8 @@ describe('getDisabledActionReasonParam', () => { ['boolean', true], ['map', new Map([[Action.Save, 'Read-only']])], ['date', new Date()], - ])('ignores a %s with a warning', (type, value) => { + ])('ignores a %s with a warning', (_type, value) => { expect(getDisabledActionReasonParam(value as any)).toBeUndefined(); - expect(warnSpy).toHaveBeenCalledWith(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(type)); + expect(warnSpy).toHaveBeenCalledWith(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE); }); }); diff --git a/src/utils/disabled-action-reason.ts b/src/utils/disabled-action-reason.ts index 3278da2d..3c5e62b1 100644 --- a/src/utils/disabled-action-reason.ts +++ b/src/utils/disabled-action-reason.ts @@ -1,3 +1,5 @@ +import isPlainObject from 'lodash/isPlainObject'; +import isString from 'lodash/isString'; import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; import { DISABLED_ACTION_REASON_WARNING } from '../errors'; import { logger } from './logger'; @@ -7,16 +9,6 @@ const VALID_REASON_KEYS: ReadonlySet = new Set([ GENERIC_REASON, ]); -// Built-in type of a value, lower-cased: 'object', 'array', 'map', 'date', ... -const getTypeName = (value: unknown): string => ( - Object.prototype.toString.call(value).slice(8, -1).toLowerCase() -); - -// Only `{ ... }` objects; arrays, Maps, Dates and other built-ins are not. -const isPlainObject = (value: unknown): value is Record => ( - getTypeName(value) === 'object' -); - /** * Builds the `disableHint` query param value from `disabledActionReason`. * @@ -35,11 +27,12 @@ export const getDisabledActionReasonParam = ( if (!reason) { return undefined; } - if (typeof reason === 'string') { + if (isString(reason)) { return reason; } + // False for arrays, Maps, Dates and other built-ins. if (!isPlainObject(reason)) { - logger.warn(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE(getTypeName(reason))); + logger.warn(DISABLED_ACTION_REASON_WARNING.INVALID_TYPE); return undefined; } @@ -49,7 +42,7 @@ export const getDisabledActionReasonParam = ( logger.warn(DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY(key)); return; } - if (typeof value !== 'string') { + if (!isString(value)) { logger.warn(DISABLED_ACTION_REASON_WARNING.NON_STRING_REASON(key)); return; } From 172f1b666883d169a79ce0bff83b7c02c6e60f18 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Fri, 23 Oct 2026 12:28:26 +0530 Subject: [PATCH 4/8] docs(sdk): use the SDK version for disabledActionReason map (SCAL-337048) 1.53.1 is already released, so the map form ships in the next release. Use the placeholder, which the release bump fills in, and point the disabledActionReason doc at DisabledActionReasonMap for the SDK version instead of repeating it. --- src/types.ts | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/src/types.ts b/src/types.ts index 53b7c278..6d445550 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1068,8 +1068,8 @@ export interface BaseViewConfig extends ApiInterceptFlags { * An action without an entry shows the {@link GENERIC_REASON} value if one * is set, and no tooltip otherwise. Keys that are not an {@link Action} or * {@link GENERIC_REASON} are ignored with a console warning. - * The object form requires SDK 1.53.1 and ThoughtSpot Cloud 26.12.0.cl or - * later. + * The object form requires ThoughtSpot Cloud 26.12.0.cl or later; see + * {@link DisabledActionReasonMap} for the SDK version. * * Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed` * @version SDK: 1.6.0 | ThoughtSpot: ts8.nov.cl, 8.4.1.sw @@ -2829,7 +2829,8 @@ export enum EmbedEvent { * } * }) * ``` - * Payload: {@link CustomActionPayload} (on() enriches it to {@link CustomActionEventPayload}). + * Payload: {@link CustomActionPayload} (on() enriches it to {@link + * CustomActionEventPayload}). */ CustomAction = 'customAction', /** @@ -9924,7 +9925,7 @@ export enum Action { /** * Key for the optional catch-all reason in a {@link DisabledActionReasonMap}. * A disabled action that has no entry of its own shows this reason. - * @version SDK: 1.53.1 | ThoughtSpot Cloud: 26.12.0.cl + * @version SDK: | ThoughtSpot Cloud: 26.12.0.cl * @example * ```js * const embed = new LiveboardEmbed('#tsEmbed', { @@ -9943,7 +9944,7 @@ export const GENERIC_REASON = 'GENERIC_REASON' as const; * Tooltip text for disabled actions, keyed by {@link Action}, with an optional * {@link GENERIC_REASON} fallback. Every key is optional. A disabled action with * no entry and no {@link GENERIC_REASON} shows no tooltip. - * @version SDK: 1.53.1 | ThoughtSpot Cloud: 26.12.0.cl + * @version SDK: | ThoughtSpot Cloud: 26.12.0.cl */ export type DisabledActionReasonMap = Partial>; From 176c426f25a41ec0551ed0b539586f4ef4fd54b9 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Fri, 23 Oct 2026 12:29:23 +0530 Subject: [PATCH 5/8] refactor(sdk): rename disabled-action-reason util to actions (SCAL-337048) A general name, so other action helpers can live in the same file. --- src/embed/ts-embed.ts | 2 +- src/utils/{disabled-action-reason.spec.ts => actions.spec.ts} | 2 +- src/utils/{disabled-action-reason.ts => actions.ts} | 0 3 files changed, 2 insertions(+), 2 deletions(-) rename src/utils/{disabled-action-reason.spec.ts => actions.spec.ts} (98%) rename src/utils/{disabled-action-reason.ts => actions.ts} (100%) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 35839207..5b48a89a 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -49,7 +49,7 @@ import { setParamIfDefined, } from '../utils'; import { getCustomActions } from '../utils/custom-actions'; -import { getDisabledActionReasonParam } from '../utils/disabled-action-reason'; +import { getDisabledActionReasonParam } from '../utils/actions'; import { getThoughtSpotHost, URL_MAX_LENGTH, diff --git a/src/utils/disabled-action-reason.spec.ts b/src/utils/actions.spec.ts similarity index 98% rename from src/utils/disabled-action-reason.spec.ts rename to src/utils/actions.spec.ts index 862f4b5b..7dbd99d2 100644 --- a/src/utils/disabled-action-reason.spec.ts +++ b/src/utils/actions.spec.ts @@ -1,6 +1,6 @@ import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; import { DISABLED_ACTION_REASON_WARNING } from '../errors'; -import { getDisabledActionReasonParam } from './disabled-action-reason'; +import { getDisabledActionReasonParam } from './actions'; import { logger } from './logger'; describe('getDisabledActionReasonParam', () => { diff --git a/src/utils/disabled-action-reason.ts b/src/utils/actions.ts similarity index 100% rename from src/utils/disabled-action-reason.ts rename to src/utils/actions.ts From 6784e5ad55c1e79709bcbb95774e4b5dcd5b29b9 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Wed, 28 Oct 2026 00:50:57 +0530 Subject: [PATCH 6/8] feat(sdk): send the disabledActionReason map in APP_INIT, not the URL (SCAL-337048) A per-action map can hold a reason for every action, which is too long for the iframe URL. Send the map in the APP_INIT payload's embedParams on every embed, the same place excludeConfigFromURL already sends it; the app merges those embedParams over the URL params, so it needs no change. The string form stays on the URL, so it keeps working on every cluster. Prerender and UpdateEmbedParams already carry the map as an object. Filter the map with lodash pickBy and walk the query params with lodash forEach, matching the lodash helpers the SDK already uses. --- src/embed/liveboard.spec.ts | 10 +++------- src/embed/ts-embed.spec.ts | 39 +++++++++++++++++++++++++++++++++++++ src/embed/ts-embed.ts | 27 +++++++++++++++++-------- src/utils/actions.spec.ts | 14 ++++++------- src/utils/actions.ts | 30 ++++++++++++++-------------- 5 files changed, 83 insertions(+), 37 deletions(-) diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index ca8e5536..5c6b02aa 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -144,7 +144,7 @@ describe('Liveboard/viz embed tests', () => { }); }); - test('should send a per-action disabledActionReason map as JSON', async () => { + test('should keep a per-action disabledActionReason map off the iframe URL', async () => { const liveboardEmbed = new LiveboardEmbed(getRootEl(), { disabledActions: [Action.Download, Action.Save, Action.Share], disabledActionReason: { @@ -157,12 +157,8 @@ describe('Liveboard/viz embed tests', () => { } as LiveboardViewConfig); liveboardEmbed.render(); await executeAfterWait(() => { - const disableHint = new URL(getIFrameSrc()).searchParams.get('disableHint'); - expect(JSON.parse(disableHint)).toEqual({ - [Action.Download]: 'Downloads require a Pro license', - [Action.Save]: 'You have read-only access', - [GENERIC_REASON]: 'Not available for your account', - }); + // The map is sent in the APP_INIT payload; see ts-embed.spec.ts. + expect(new URL(getIFrameSrc()).searchParams.has('disableHint')).toBe(false); }); }); diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index f03a3d3e..23df43e8 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -7218,6 +7218,45 @@ describe('excludeConfigFromURL', () => { }); }); + test('sends a disabledActionReason map in the APP_INIT payload without excludeConfigFromURL', async () => { + const reason = { + [Action.Download]: 'Downloads require a Pro license', + [GENERIC_REASON]: 'Not available for your account', + }; + await renderEmbed( + LiveboardEmbed, + { liveboardId, disabledActions: [Action.Download], disabledActionReason: reason }, + false, + ); + const iframeSrc = new URL(getIFrameSrc()); + // Only the map moves; the rest of the config stays on the URL. + expect(iframeSrc.searchParams.has(Param.DisableActionReason)).toBe(false); + expect(iframeSrc.searchParams.has(Param.DisableActions)).toBe(true); + + const { embedParams } = await requestAppInit(); + + expect(embedParams[Param.DisableActionReason]).toEqual(reason); + expect(embedParams[Param.DisableActions]).toBeUndefined(); + }); + + test('keeps a string disabledActionReason on the URL without excludeConfigFromURL', async () => { + await renderEmbed( + LiveboardEmbed, + { + liveboardId, + disabledActions: [Action.Download], + disabledActionReason: 'Action denied', + }, + false, + ); + expect(new URL(getIFrameSrc()).searchParams.get(Param.DisableActionReason)) + .toBe('Action denied'); + + const { embedParams } = await requestAppInit(); + + expect(embedParams?.[Param.DisableActionReason]).toBeUndefined(); + }); + test('sends a string disabledActionReason unchanged in the APP_INIT payload', async () => { const { embedParams } = await getAppInitFor(LiveboardEmbed, { liveboardId, diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 5b48a89a..718a51c0 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -9,6 +9,8 @@ import isEqual from 'lodash/isEqual'; import isEmpty from 'lodash/isEmpty'; import isObject from 'lodash/isObject'; +import isPlainObject from 'lodash/isPlainObject'; +import forEach from 'lodash/forEach'; import { UIPassthroughArrayResponse, UIPassthroughEvent, @@ -224,6 +226,17 @@ export const BOOTSTRAP_URL_PARAMS: ReadonlySet = new Set([ Param.searchTokenString, ]); +/** + * Params never put on the iframe `src` when they hold an object. They are sent in + * the APP_INIT payload instead, even without `excludeConfigFromURL`: a per-action + * `disabledActionReason` map can hold a reason for every action, which is too long + * for a URL. The string form of a param stays on the `src`. + * @internal + */ +export const APP_INIT_OBJECT_PARAMS: ReadonlySet = new Set([ + Param.DisableActionReason, +]); + /** * The event id map from v2 event names to v1 event id * v1 events are the classic embed events implemented in Blink v1 @@ -1086,17 +1099,15 @@ export class TsEmbed { protected getUrlQueryParamsObject(): Record { const queryParams = this.getEmbedParamsObject(); - if (!this.isConfigExcludedFromUrl()) { - this.initDataParams = {}; - return queryParams; - } + const isConfigExcluded = this.isConfigExcludedFromUrl(); const urlParams: Record = {}; const excludedParams: Partial> & Record = {}; - Object.entries(queryParams).forEach(([key, value]) => { - if (BOOTSTRAP_URL_PARAMS.has(key)) { - urlParams[key] = value; - } else { + forEach(queryParams, (value, key) => { + const isAppInitObject = APP_INIT_OBJECT_PARAMS.has(key) && isPlainObject(value); + if (isAppInitObject || (isConfigExcluded && !BOOTSTRAP_URL_PARAMS.has(key))) { excludedParams[key] = deserializeParam(value); + } else { + urlParams[key] = value; } }); this.initDataParams = excludedParams; diff --git a/src/utils/actions.spec.ts b/src/utils/actions.spec.ts index 7dbd99d2..b489fdf8 100644 --- a/src/utils/actions.spec.ts +++ b/src/utils/actions.spec.ts @@ -25,12 +25,12 @@ describe('getDisabledActionReasonParam', () => { expect(warnSpy).not.toHaveBeenCalled(); }); - test('JSON-encodes a full map', () => { + test('returns a full map as an object', () => { const reason = { [Action.Download]: 'Downloads require a Pro license', [Action.Save]: 'You have read-only access', }; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(getDisabledActionReasonParam(reason)).toEqual(reason); expect(warnSpy).not.toHaveBeenCalled(); }); @@ -39,18 +39,18 @@ describe('getDisabledActionReasonParam', () => { [Action.Download]: 'Downloads require a Pro license', [GENERIC_REASON]: 'Not available for your account', }; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(getDisabledActionReasonParam(reason)).toEqual(reason); }); test('sends a partial map without GENERIC_REASON as is', () => { const reason = { [Action.Download]: 'Downloads require a Pro license' }; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(getDisabledActionReasonParam(reason)).toEqual(reason); expect(warnSpy).not.toHaveBeenCalled(); }); test('sends a map with only GENERIC_REASON', () => { const reason = { [GENERIC_REASON]: 'Not available for your account' }; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual(reason); + expect(getDisabledActionReasonParam(reason)).toEqual(reason); }); test('treats an empty map the same as an unset reason', () => { @@ -63,7 +63,7 @@ describe('getDisabledActionReasonParam', () => { [Action.Save]: 'You have read-only access', notAnAction: 'dropped', } as DisabledActionReasonMap; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual({ + expect(getDisabledActionReasonParam(reason)).toEqual({ [Action.Save]: 'You have read-only access', }); expect(warnSpy).toHaveBeenCalledWith( @@ -87,7 +87,7 @@ describe('getDisabledActionReasonParam', () => { [Action.Save]: 42, [Action.Download]: 'Downloads require a Pro license', } as unknown as DisabledActionReasonMap; - expect(JSON.parse(getDisabledActionReasonParam(reason))).toEqual({ + expect(getDisabledActionReasonParam(reason)).toEqual({ [Action.Download]: 'Downloads require a Pro license', }); expect(warnSpy).toHaveBeenCalledWith( diff --git a/src/utils/actions.ts b/src/utils/actions.ts index 3c5e62b1..53e4e286 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -1,5 +1,7 @@ import isPlainObject from 'lodash/isPlainObject'; import isString from 'lodash/isString'; +import isEmpty from 'lodash/isEmpty'; +import pickBy from 'lodash/pickBy'; import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; import { DISABLED_ACTION_REASON_WARNING } from '../errors'; import { logger } from './logger'; @@ -10,12 +12,13 @@ const VALID_REASON_KEYS: ReadonlySet = new Set([ ]); /** - * Builds the `disableHint` query param value from `disabledActionReason`. + * Builds the `disableHint` param value from `disabledActionReason`. * - * A string is returned unchanged. A map keeps only the keys that are an - * {@link Action} or {@link GENERIC_REASON} and have a string value, and is - * returned JSON-encoded, the same way `disableAction` carries its array. Anything - * ignored is logged as a warning and never fails the embed. + * A string is returned unchanged and goes on the iframe URL. A map keeps only the + * keys that are an {@link Action} or {@link GENERIC_REASON} and have a string + * value, and is returned as an object, which is sent in the APP_INIT payload + * rather than the URL. Anything ignored is logged as a warning and never fails the + * embed. * @param reason - The `disabledActionReason` view config value * @returns The param value, or `undefined` when there is nothing to send, which * is the same as leaving `disabledActionReason` unset. @@ -23,7 +26,7 @@ const VALID_REASON_KEYS: ReadonlySet = new Set([ */ export const getDisabledActionReasonParam = ( reason: string | DisabledActionReasonMap | undefined, -): string | undefined => { +): string | Record | undefined => { if (!reason) { return undefined; } @@ -36,21 +39,18 @@ export const getDisabledActionReasonParam = ( return undefined; } - const validReasons: Record = {}; - Object.entries(reason).forEach(([key, value]) => { + const validReasons = pickBy(reason, (value, key) => { if (!VALID_REASON_KEYS.has(key)) { logger.warn(DISABLED_ACTION_REASON_WARNING.UNKNOWN_KEY(key)); - return; + return false; } if (!isString(value)) { logger.warn(DISABLED_ACTION_REASON_WARNING.NON_STRING_REASON(key)); - return; + return false; } // An empty reason sends nothing, the same as the string form. - if (value) { - validReasons[key] = value; - } - }); + return value !== ''; + }) as Record; - return Object.keys(validReasons).length ? JSON.stringify(validReasons) : undefined; + return isEmpty(validReasons) ? undefined : validReasons; }; From 3d65b63c8784bf08a8126bbd53b3fc2c27494b2c Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Wed, 28 Oct 2026 11:51:42 +0530 Subject: [PATCH 7/8] refactor(sdk): generalise APP_INIT_OBJECT_PARAMS into APP_INIT_PARAMS (SCAL-337048) Map each param to a check on its value instead of hard-coding the object check in the loop, so a future param can be kept off the URL by adding one entry with its own condition. No behaviour change. --- src/embed/ts-embed.ts | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/embed/ts-embed.ts b/src/embed/ts-embed.ts index 718a51c0..19e019f5 100644 --- a/src/embed/ts-embed.ts +++ b/src/embed/ts-embed.ts @@ -227,14 +227,19 @@ export const BOOTSTRAP_URL_PARAMS: ReadonlySet = new Set([ ]); /** - * Params never put on the iframe `src` when they hold an object. They are sent in - * the APP_INIT payload instead, even without `excludeConfigFromURL`: a per-action - * `disabledActionReason` map can hold a reason for every action, which is too long - * for a URL. The string form of a param stays on the `src`. + * Params sent in the APP_INIT payload instead of the iframe `src`, even without + * `excludeConfigFromURL`. Each param maps to a check on its value: when the check + * passes, the value goes in APP_INIT; otherwise it stays on the `src`. Add a param + * here with its own check to keep it off the URL. * @internal */ -export const APP_INIT_OBJECT_PARAMS: ReadonlySet = new Set([ - Param.DisableActionReason, +export const APP_INIT_PARAMS: ReadonlyMap boolean> = new Map< + string, + (value: unknown) => boolean +>([ + // A per-action map can hold a reason for every action, which is + // too long for a URL. The string form stays on the `src`. + [Param.DisableActionReason, isPlainObject], ]); /** @@ -1103,8 +1108,8 @@ export class TsEmbed { const urlParams: Record = {}; const excludedParams: Partial> & Record = {}; forEach(queryParams, (value, key) => { - const isAppInitObject = APP_INIT_OBJECT_PARAMS.has(key) && isPlainObject(value); - if (isAppInitObject || (isConfigExcluded && !BOOTSTRAP_URL_PARAMS.has(key))) { + const isAppInitParam = APP_INIT_PARAMS.get(key)?.(value) ?? false; + if (isAppInitParam || (isConfigExcluded && !BOOTSTRAP_URL_PARAMS.has(key))) { excludedParams[key] = deserializeParam(value); } else { urlParams[key] = value; From 34c10631e615f1c7b17cce70e4e716e71e653f31 Mon Sep 17 00:00:00 2001 From: Jitendra Bhati Date: Wed, 28 Oct 2026 13:59:28 +0530 Subject: [PATCH 8/8] refactor(sdk): drop the GENERIC_REASON constant, type the key directly (SCAL-337048) GENERIC_REASON was the only const in types.ts. Type the map key as Action | 'GENERIC_REASON' instead, so customers write GENERIC_REASON as a plain key and TypeScript still checks it. The key on the wire is unchanged, so the merged app side needs nothing. The constant was never released. --- src/embed/liveboard.spec.ts | 3 +-- src/embed/ts-embed.spec.ts | 11 +++++------ src/index.ts | 2 -- src/react/all-types-export.ts | 1 - src/types.ts | 26 ++++++++++---------------- src/utils/actions.spec.ts | 6 +++--- src/utils/actions.ts | 6 +++--- 7 files changed, 22 insertions(+), 33 deletions(-) diff --git a/src/embed/liveboard.spec.ts b/src/embed/liveboard.spec.ts index 5c6b02aa..e5605be0 100644 --- a/src/embed/liveboard.spec.ts +++ b/src/embed/liveboard.spec.ts @@ -6,7 +6,6 @@ import { AuthType, ContextMenuTriggerOptions, EmbedEvent, - GENERIC_REASON, HostEvent, RuntimeFilterOp, } from '../types'; @@ -150,7 +149,7 @@ describe('Liveboard/viz embed tests', () => { disabledActionReason: { [Action.Download]: 'Downloads require a Pro license', [Action.Save]: 'You have read-only access', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', }, ...defaultViewConfig, liveboardId, diff --git a/src/embed/ts-embed.spec.ts b/src/embed/ts-embed.spec.ts index 23df43e8..6dd78f59 100644 --- a/src/embed/ts-embed.spec.ts +++ b/src/embed/ts-embed.spec.ts @@ -33,7 +33,6 @@ import { EmbedErrorCodes, EmbedErrorSeverity, ContextObject, - GENERIC_REASON, DisabledActionReasonMap, } from '../types'; import { @@ -6081,7 +6080,7 @@ describe('ShowPreRender with UpdateEmbedParams', () => { disabledActions: [Action.Download, Action.Save], disabledActionReason: { [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', notAnAction: 'dropped', } as DisabledActionReasonMap, }); @@ -6098,7 +6097,7 @@ describe('ShowPreRender with UpdateEmbedParams', () => { // dropped. [Param.DisableActionReason]: { [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', }, }), undefined, @@ -7203,7 +7202,7 @@ describe('excludeConfigFromURL', () => { disabledActions: [Action.Download, Action.Share], disabledActionReason: { [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', notAnAction: 'dropped', } as DisabledActionReasonMap, }); @@ -7214,14 +7213,14 @@ describe('excludeConfigFromURL', () => { // Parsed back to an object, with the unknown key already dropped. expect(embedParams[Param.DisableActionReason]).toEqual({ [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', }); }); test('sends a disabledActionReason map in the APP_INIT payload without excludeConfigFromURL', async () => { const reason = { [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', }; await renderEmbed( LiveboardEmbed, diff --git a/src/index.ts b/src/index.ts index a1344689..ab6457a0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -53,7 +53,6 @@ import { HostEvent, DataSourceVisualMode, Action, - GENERIC_REASON, DisabledActionReasonMap, EmbedConfig, PrefetchFeatures, @@ -272,7 +271,6 @@ export { ContextType, DataSourceVisualMode, Action, - GENERIC_REASON, DisabledActionReasonMap, ContextMenuTriggerOptions, EmbedConfig, diff --git a/src/react/all-types-export.ts b/src/react/all-types-export.ts index 6d549230..385afdb4 100644 --- a/src/react/all-types-export.ts +++ b/src/react/all-types-export.ts @@ -39,7 +39,6 @@ export { HostEvent, DataSourceVisualMode, Action, - GENERIC_REASON, DisabledActionReasonMap, EmbedConfig, SearchViewConfig, diff --git a/src/types.ts b/src/types.ts index 6d445550..c69a295c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1065,9 +1065,9 @@ export interface BaseViewConfig extends ApiInterceptFlags { * {@link disabledActions}. * * Pass a {@link DisabledActionReasonMap} to give each action its own reason. - * An action without an entry shows the {@link GENERIC_REASON} value if one + * An action without an entry shows the `GENERIC_REASON` value if one * is set, and no tooltip otherwise. Keys that are not an {@link Action} or - * {@link GENERIC_REASON} are ignored with a console warning. + * `GENERIC_REASON` are ignored with a console warning. * The object form requires ThoughtSpot Cloud 26.12.0.cl or later; see * {@link DisabledActionReasonMap} for the SDK version. * @@ -1084,7 +1084,7 @@ export interface BaseViewConfig extends ApiInterceptFlags { * ``` * @example * ```js - * import { Action, GENERIC_REASON } from '@thoughtspot/visual-embed-sdk'; + * import { Action } from '@thoughtspot/visual-embed-sdk'; * * const embed = new ('#tsEmbed', { * ... // other embed view config @@ -1093,7 +1093,7 @@ export interface BaseViewConfig extends ApiInterceptFlags { * [Action.Download]: 'Downloads require a Pro license', * [Action.Save]: 'You have read-only access to this Liveboard', * // Optional. Shown on Action.Share, which has no entry of its own. - * [GENERIC_REASON]: 'This action is not available for your account', + * GENERIC_REASON: 'This action is not available for your account', * }, * }); * ``` @@ -9923,8 +9923,10 @@ export enum Action { } /** - * Key for the optional catch-all reason in a {@link DisabledActionReasonMap}. - * A disabled action that has no entry of its own shows this reason. + * Tooltip text for disabled actions, keyed by {@link Action}, with an optional + * `GENERIC_REASON` fallback. Every key is optional. A disabled action with no + * entry of its own shows the `GENERIC_REASON` value, or no tooltip when there is + * none. * @version SDK: | ThoughtSpot Cloud: 26.12.0.cl * @example * ```js @@ -9933,20 +9935,12 @@ export enum Action { * disabledActions: [Action.Download, Action.Share], * disabledActionReason: { * [Action.Download]: 'Downloads require a Pro license', - * [GENERIC_REASON]: 'This action is not available for your account', + * GENERIC_REASON: 'This action is not available for your account', * }, * }); * ``` */ -export const GENERIC_REASON = 'GENERIC_REASON' as const; - -/** - * Tooltip text for disabled actions, keyed by {@link Action}, with an optional - * {@link GENERIC_REASON} fallback. Every key is optional. A disabled action with - * no entry and no {@link GENERIC_REASON} shows no tooltip. - * @version SDK: | ThoughtSpot Cloud: 26.12.0.cl - */ -export type DisabledActionReasonMap = Partial>; +export type DisabledActionReasonMap = Partial>; export interface AnswerServiceType { getAnswer?: (offset: number, batchSize: number) => any; diff --git a/src/utils/actions.spec.ts b/src/utils/actions.spec.ts index b489fdf8..72c85f19 100644 --- a/src/utils/actions.spec.ts +++ b/src/utils/actions.spec.ts @@ -1,4 +1,4 @@ -import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; +import { Action, DisabledActionReasonMap } from '../types'; import { DISABLED_ACTION_REASON_WARNING } from '../errors'; import { getDisabledActionReasonParam } from './actions'; import { logger } from './logger'; @@ -37,7 +37,7 @@ describe('getDisabledActionReasonParam', () => { test('keeps GENERIC_REASON alongside a partial map', () => { const reason = { [Action.Download]: 'Downloads require a Pro license', - [GENERIC_REASON]: 'Not available for your account', + GENERIC_REASON: 'Not available for your account', }; expect(getDisabledActionReasonParam(reason)).toEqual(reason); }); @@ -49,7 +49,7 @@ describe('getDisabledActionReasonParam', () => { }); test('sends a map with only GENERIC_REASON', () => { - const reason = { [GENERIC_REASON]: 'Not available for your account' }; + const reason = { GENERIC_REASON: 'Not available for your account' }; expect(getDisabledActionReasonParam(reason)).toEqual(reason); }); diff --git a/src/utils/actions.ts b/src/utils/actions.ts index 53e4e286..e7f23f6a 100644 --- a/src/utils/actions.ts +++ b/src/utils/actions.ts @@ -2,20 +2,20 @@ import isPlainObject from 'lodash/isPlainObject'; import isString from 'lodash/isString'; import isEmpty from 'lodash/isEmpty'; import pickBy from 'lodash/pickBy'; -import { Action, DisabledActionReasonMap, GENERIC_REASON } from '../types'; +import { Action, DisabledActionReasonMap } from '../types'; import { DISABLED_ACTION_REASON_WARNING } from '../errors'; import { logger } from './logger'; const VALID_REASON_KEYS: ReadonlySet = new Set([ ...Object.values(Action), - GENERIC_REASON, + 'GENERIC_REASON', ]); /** * Builds the `disableHint` param value from `disabledActionReason`. * * A string is returned unchanged and goes on the iframe URL. A map keeps only the - * keys that are an {@link Action} or {@link GENERIC_REASON} and have a string + * keys that are an {@link Action} or `GENERIC_REASON` and have a string * value, and is returned as an object, which is sent in the APP_INIT payload * rather than the URL. Anything ignored is logged as a warning and never fails the * embed.