Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions src/embed/liveboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,37 @@ describe('Liveboard/viz embed tests', () => {
});
});

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: {
[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(() => {
// The map is sent in the APP_INIT payload; see ts-embed.spec.ts.
expect(new URL(getIFrameSrc()).searchParams.has('disableHint')).toBe(false);
});
});

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],
Expand Down
107 changes: 107 additions & 0 deletions src/embed/ts-embed.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
EmbedErrorCodes,
EmbedErrorSeverity,
ContextObject,
DisabledActionReasonMap,
} from '../types';
import {
executeAfterWait,
Expand Down Expand Up @@ -6069,6 +6070,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' });

Expand Down Expand Up @@ -7159,6 +7195,77 @@ 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 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,
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,
Expand Down
41 changes: 30 additions & 11 deletions src/embed/ts-embed.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -49,6 +51,7 @@ import {
setParamIfDefined,
} from '../utils';
import { getCustomActions } from '../utils/custom-actions';
import { getDisabledActionReasonParam } from '../utils/actions';
import {
getThoughtSpotHost,
URL_MAX_LENGTH,
Expand Down Expand Up @@ -223,6 +226,22 @@ export const BOOTSTRAP_URL_PARAMS: ReadonlySet<string> = new Set<string>([
Param.searchTokenString,
]);

/**
* 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_PARAMS: ReadonlyMap<string, (value: unknown) => 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],
]);

/**
* The event id map from v2 event names to v1 event id
* v1 events are the classic embed events implemented in Blink v1
Expand Down Expand Up @@ -973,9 +992,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;
}
Expand Down Expand Up @@ -1083,17 +1104,15 @@ export class TsEmbed {

protected getUrlQueryParamsObject(): Record<any, any> {
const queryParams = this.getEmbedParamsObject();
if (!this.isConfigExcludedFromUrl()) {
this.initDataParams = {};
return queryParams;
}
const isConfigExcluded = this.isConfigExcludedFromUrl();
const urlParams: Record<any, any> = {};
const excludedParams: Partial<Record<Param, any>> & Record<string, any> = {};
Object.entries(queryParams).forEach(([key, value]) => {
if (BOOTSTRAP_URL_PARAMS.has(key)) {
urlParams[key] = value;
} else {
forEach(queryParams, (value, 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;
}
});
this.initDataParams = excludedParams;
Expand Down
6 changes: 6 additions & 0 deletions src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: '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.`,
};
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ import {
HostEvent,
DataSourceVisualMode,
Action,
DisabledActionReasonMap,
EmbedConfig,
PrefetchFeatures,
FrameParams,
Expand Down Expand Up @@ -270,6 +271,7 @@ export {
ContextType,
DataSourceVisualMode,
Action,
DisabledActionReasonMap,
ContextMenuTriggerOptions,
EmbedConfig,
SearchViewConfig,
Expand Down
1 change: 1 addition & 0 deletions src/react/all-types-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ export {
HostEvent,
DataSourceVisualMode,
Action,
DisabledActionReasonMap,
EmbedConfig,
SearchViewConfig,
SearchBarViewConfig,
Expand Down
51 changes: 49 additions & 2 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `GENERIC_REASON` value if one
* is set, and no tooltip otherwise. Keys that are not an {@link Action} or
* `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.
*
* Supported embed types: `AppEmbed`, `LiveboardEmbed`, `SearchEmbed`, `SpotterAgentEmbed`, `SpotterEmbed`, `SearchBarEmbed`
* @version SDK: 1.6.0 | ThoughtSpot: ts8.nov.cl, 8.4.1.sw
* @example
Expand All @@ -1072,8 +1082,23 @@ export interface BaseViewConfig extends ApiInterceptFlags {
* disabledActionReason: "Reason for disabling",
* });
* ```
* @example
* ```js
* import { Action } from '@thoughtspot/visual-embed-sdk';
*
* const embed = new <EmbedComponent>('#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).
Expand Down Expand Up @@ -2804,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',
/**
Expand Down Expand Up @@ -9895,6 +9921,27 @@ export enum Action {
*/
Monitor = 'monitor',
}

/**
* 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: <TBD> | 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 type DisabledActionReasonMap = Partial<Record<Action | 'GENERIC_REASON', string>>;

export interface AnswerServiceType {
getAnswer?: (offset: number, batchSize: number) => any;
}
Expand Down
Loading
Loading