diff --git a/src/embed/hostEventClient/contracts.ts b/src/embed/hostEventClient/contracts.ts index 290ef2e60..1ab822dfa 100644 --- a/src/embed/hostEventClient/contracts.ts +++ b/src/embed/hostEventClient/contracts.ts @@ -33,9 +33,29 @@ export interface Applicability { } export interface FilterUpdate { - column: string; - oper: string; - values: string[]; + /** + * Name of the column to filter on. + * @deprecated Use `columnName`, which matches {@link RuntimeFilter} and the + * payload emitted by `EmbedEvent.FilterChanged`. Still accepted, and still + * what is sent on the wire. + */ + column?: string; + /** + * Name of the column to filter on. Preferred over `column`. + */ + columnName?: string; + /** + * Filter operator, for example EQ, IN, CONTAINS. + * @deprecated Use `operator`, which matches {@link RuntimeFilter} and the + * payload emitted by `EmbedEvent.FilterChanged`. Still accepted, and still + * what is sent on the wire. + */ + oper?: string; + /** + * Filter operator, for example EQ, IN, CONTAINS. Preferred over `oper`. + */ + operator?: string; + values: (string | number | boolean | bigint)[]; type?: string; applicability?: Applicability; } diff --git a/src/embed/hostEventClient/host-event-client.ts b/src/embed/hostEventClient/host-event-client.ts index 46e0f8cb0..52231a3ad 100644 --- a/src/embed/hostEventClient/host-event-client.ts +++ b/src/embed/hostEventClient/host-event-client.ts @@ -3,6 +3,7 @@ import { processTrigger as processTriggerService } from '../../utils/processTrig import { getEmbedConfig } from '../embedConfig'; import { isValidUpdateFiltersPayload, + resolveUpdateFiltersAliases, isValidUpdateParametersPayload, isValidDrillDownPayload, throwUpdateFiltersValidationError, @@ -244,7 +245,12 @@ export class HostEventClient { throwUpdateFiltersValidationError(); } - return this.handleHostEventWithParam(UIPassthroughEvent.UpdateFilters, payload, context as ContextType); + // The payload is forwarded to the embedded app as-is, so swap the + // columnName/operator aliases for column/oper first - otherwise a payload + // using the alias passes validation and is then ignored downstream. + const resolvedPayload = resolveUpdateFiltersAliases(payload); + + return this.handleHostEventWithParam(UIPassthroughEvent.UpdateFilters, resolvedPayload, context as ContextType); } protected handleUpdateParametersEvent( diff --git a/src/embed/hostEventClient/utils.spec.ts b/src/embed/hostEventClient/utils.spec.ts index b1b38f4a4..80816d38c 100644 --- a/src/embed/hostEventClient/utils.spec.ts +++ b/src/embed/hostEventClient/utils.spec.ts @@ -1,5 +1,6 @@ import { isValidUpdateFiltersPayload, + resolveUpdateFiltersAliases, isValidUpdateParametersPayload, isValidDrillDownPayload, createValidationError, @@ -426,4 +427,84 @@ describe('hostEventClient utils', () => { .toThrow(ERROR_MESSAGE.DRILLDOWN_INVALID_PAYLOAD); }); }); -}); \ No newline at end of file + describe('resolveUpdateFiltersAliases', () => { + // The payload is forwarded to the embedded app verbatim, and only + // column/oper are known to be understood there. Without this step a + // payload using the columnName/operator aliases passes validation and + // is then silently ignored downstream. + it('rewrites columnName/operator to column/oper on a single filter', () => { + const out = resolveUpdateFiltersAliases({ + filter: { columnName: 'region', operator: 'EQ', values: ['west'] }, + } as any); + + expect(out.filter).toEqual({ column: 'region', oper: 'EQ', values: ['west'] }); + expect(out.filter).not.toHaveProperty('columnName'); + expect(out.filter).not.toHaveProperty('operator'); + }); + + it('rewrites every entry in a filters array', () => { + const out = resolveUpdateFiltersAliases({ + filters: [ + { columnName: 'region', operator: 'EQ', values: ['west'] }, + { columnName: 'item type', operator: 'IN', values: ['bags'] }, + ], + } as any); + + expect(out.filters).toEqual([ + { column: 'region', oper: 'EQ', values: ['west'] }, + { column: 'item type', oper: 'IN', values: ['bags'] }, + ]); + }); + + it('leaves a payload that already uses column/oper untouched', () => { + const payload = { filter: { column: 'region', oper: 'EQ', values: ['west'] } } as any; + + expect(resolveUpdateFiltersAliases(payload)).toEqual(payload); + }); + + it('prefers column/oper when both spellings are present', () => { + const out = resolveUpdateFiltersAliases({ + filter: { + column: 'from-column', columnName: 'from-columnName', + oper: 'EQ', operator: 'IN', + values: ['x'], + }, + } as any); + + expect(out.filter).toEqual({ column: 'from-column', oper: 'EQ', values: ['x'] }); + }); + + it('preserves the other filter fields, including applicability', () => { + const out = resolveUpdateFiltersAliases({ + filter: { + columnName: 'date', + operator: 'EQ', + values: [1700000000], + type: 'EXACT_DATE', + applicability: { level: 'TAB', targetId: 'tab-guid' }, + }, + } as any); + + expect(out.filter).toEqual({ + column: 'date', + oper: 'EQ', + values: [1700000000], + type: 'EXACT_DATE', + applicability: { level: 'TAB', targetId: 'tab-guid' }, + }); + }); + + it('produces a payload that still passes validation', () => { + const out = resolveUpdateFiltersAliases({ + filters: [{ columnName: 'region', operator: 'EQ', values: ['west'] }], + } as any); + + expect(isValidUpdateFiltersPayload(out as any)).toBe(true); + }); + + it('is a no-op on a non-object payload', () => { + expect(resolveUpdateFiltersAliases(undefined as any)).toBeUndefined(); + expect(resolveUpdateFiltersAliases(null as any)).toBeNull(); + }); + }); +}); diff --git a/src/embed/hostEventClient/utils.ts b/src/embed/hostEventClient/utils.ts index 91e6a997b..ceeb28fae 100644 --- a/src/embed/hostEventClient/utils.ts +++ b/src/embed/hostEventClient/utils.ts @@ -43,6 +43,48 @@ export function isValidUpdateFiltersPayload( return !!(hasValidFilter || hasValidFilters); } +/** + * Rewrites one filter's `columnName`/`operator` to `column`/`oper`. + * + * Callers may use either spelling: `columnName`/`operator` match + * {@link RuntimeFilter} and the `EmbedEvent.FilterChanged` payload, so a + * converted payload can be replayed as-is. But the payload is forwarded to the + * embedded app unchanged, and only `column`/`oper` are known to be understood + * there - so the alias must be swapped here, or it would pass validation and + * then be silently ignored. + * @param filter One entry from the UpdateFilters payload. + */ +function resolveFilterAliases( + filter: T, +): T { + if (!isPlainObject(filter)) return filter; + + const column = filter.column ?? filter.columnName; + const oper = filter.oper ?? filter.operator; + const { columnName, operator, ...rest } = filter; + + return { + ...rest, + ...(isUndefined(column) ? {} : { column }), + ...(isUndefined(oper) ? {} : { oper }), + } as T; +} + +/** + * Rewrites every filter in an UpdateFilters payload to use `column`/`oper`, + * whichever spelling the caller supplied. Call after validation. + * @param payload The UpdateFilters host event payload. + */ +export function resolveUpdateFiltersAliases(payload: T): T { + if (!isPlainObject(payload)) return payload; + + return { + ...payload, + ...(payload.filter ? { filter: resolveFilterAliases(payload.filter) } : {}), + ...(Array.isArray(payload.filters) ? { filters: payload.filters.map(resolveFilterAliases) } : {}), + }; +} + export function isValidUpdateParametersPayload(payload: unknown): boolean { // Only validates the applicability of each parameter (null treated as absent); the rest is forwarded as-is for backward compatibility. if (!Array.isArray(payload)) return true; diff --git a/src/index.ts b/src/index.ts index 47f5ecf01..81d491a59 100644 --- a/src/index.ts +++ b/src/index.ts @@ -106,6 +106,12 @@ import { LiveboardFilter, LiveboardParameter, } from './embed/hostEventClient/contracts'; +import { + convertFilterChangedToUpdateFiltersPayload, + FilterChangedPayload, + UpdateFiltersFilterParam, + UpdateFiltersPayload, +} from './utils/filterConverter'; export { init, @@ -228,6 +234,10 @@ export { DataLabelFilterOperator, TableTheme, TableContentDensity, + convertFilterChangedToUpdateFiltersPayload, + FilterChangedPayload, + UpdateFiltersFilterParam, + UpdateFiltersPayload, }; export { resetCachedAuthToken } from './authToken'; diff --git a/src/react/all-types-export.ts b/src/react/all-types-export.ts index f42d20df5..fe55e4429 100644 --- a/src/react/all-types-export.ts +++ b/src/react/all-types-export.ts @@ -82,4 +82,8 @@ export { StarterPromptCategory, StarterPreviewDataCategory, StarterPromptQuestion, + convertFilterChangedToUpdateFiltersPayload, + FilterChangedPayload, + UpdateFiltersFilterParam, + UpdateFiltersPayload, } from '../index'; diff --git a/src/types.ts b/src/types.ts index 02be92cd8..d762ce2d6 100644 --- a/src/types.ts +++ b/src/types.ts @@ -5875,6 +5875,10 @@ export enum HostEvent { * } * }); * ``` + * `columnName` and `operator` are also accepted as aliases for `column` + * and `oper` respectively. To reapply the filter state captured from + * {@link EmbedEvent.FilterChanged}, use `convertFilterChangedToUpdateFiltersPayload` + * to convert its payload into the shape expected here. * @version SDK: 1.23.0 | ThoughtSpot: 9.4.0.cl */ UpdateFilters = 'updateFilters', diff --git a/src/utils/filterConverter.spec.ts b/src/utils/filterConverter.spec.ts new file mode 100644 index 000000000..5f172194b --- /dev/null +++ b/src/utils/filterConverter.spec.ts @@ -0,0 +1,801 @@ +import { RuntimeFilterOp } from '../types'; +import { isValidUpdateFiltersPayload } from '../embed/hostEventClient/utils'; +import { convertFilterChangedToUpdateFiltersPayload, FilterChangedPayload } from './filterConverter'; + +describe('convertFilterChangedToUpdateFiltersPayload', () => { + test('returns an empty filters array for an empty payload', () => { + expect(convertFilterChangedToUpdateFiltersPayload({})).toEqual({ filters: [] }); + }); + + test('returns an empty filters array for null/undefined input', () => { + expect(convertFilterChangedToUpdateFiltersPayload(null as any)).toEqual({ filters: [] }); + expect(convertFilterChangedToUpdateFiltersPayload(undefined as any)).toEqual({ filters: [] }); + }); + + test('converts runtime filters as-is', () => { + const payload: FilterChangedPayload = { + runtimeFilters: [ + { columnName: 'state', operator: RuntimeFilterOp.EQ, values: ['california'] }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'state', operator: RuntimeFilterOp.EQ, values: ['california'] }, + ], + }); + }); + + test('converts a simple attribute filter (filterContent)', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + filters: [ + { + filterContent: [ + { + filterType: 'IN', + value: [{ key: 'bags' }, { key: 'shirts' }], + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'item type', operator: 'IN', values: ['bags', 'shirts'] }, + ], + }); + }); + + test('includes negate when set on a filterContent filter', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'region' }, + filters: [ + { + filterContent: [ + { + filterType: 'EQ', + negate: true, + value: [{ key: 'west' }], + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'region', operator: 'EQ', values: ['west'], negate: true }, + ], + }); + }); + + test('skips a filter group with no columnInfo.name', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + filters: [ + { filterContent: [{ filterType: 'EQ', value: [{ key: 'x' }] }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('skips a filter with neither filterContent nor dateFilterContent', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'region' }, + filters: [{}], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('converts an EXACT_DATE filter using epoch', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { + dateFilter: { type: 'EXACT_DATE', op: 'EQ', epoch: '1690847400' }, + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { + columnName: 'date', operator: 'EQ', values: [1690847400], type: 'EXACT_DATE', + }, + ], + }); + }); + + test('converts an EXACT_DATE_RANGE filter using dateRange epochs', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { + dateFilter: { + type: 'EXACT_DATE_RANGE', + op: 'BW_INC', + dateRange: { lowEpoch: '100', highEpoch: '200' }, + }, + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { + columnName: 'date', operator: 'BW_INC', values: [100, 200], type: 'EXACT_DATE_RANGE', + }, + ], + }); + }); + + test('converts a MONTH_YEAR filter', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { + dateFilter: { + type: 'MONTH_YEAR', op: 'EQ', monthName: 'JULY', yearName: '2023', + }, + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { + columnName: 'date', operator: 'EQ', values: ['JULY', '2023'], type: 'MONTH_YEAR', + }, + ], + }); + }); + + test('converts a QUARTER_YEAR filter', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { + dateFilter: { + type: 'QUARTER_YEAR', op: 'EQ', quarterName: 'Q1', yearName: '2023', + }, + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { + columnName: 'date', operator: 'EQ', values: ['Q1', '2023'], type: 'QUARTER_YEAR', + }, + ], + }); + }); + + test('converts a YEAR_ONLY filter', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { dateFilter: { type: 'YEAR_ONLY', op: 'EQ', yearName: '2023' } }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'date', operator: 'EQ', values: ['2023'], type: 'YEAR_ONLY' }, + ], + }); + }); + + test('converts a LAST_N_PERIOD filter with datePeriod and includeCurrentPeriod', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { + negate: true, + dateFilter: { + type: 'LAST_N_PERIOD', + op: 'EQ', + number: 3, + datePeriod: 'MONTH', + includeCurrentPeriod: true, + }, + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { + columnName: 'date', + operator: 'EQ', + values: [3], + type: 'LAST_N_PERIOD', + datePeriod: 'MONTH', + includeCurrentPeriod: true, + negate: true, + }, + ], + }); + }); + + test('converts a period-only date filter (e.g. TODAY) with empty values', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { dateFilter: { type: 'TODAY', op: 'EQ' } }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'date', operator: 'EQ', values: [], type: 'TODAY' }, + ], + }); + }); + + test('skips a dateFilterContent entry with no type', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { dateFilterContent: [{ dateFilter: {} }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('defaults operator to EQ when dateFilter.op is missing', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { dateFilterContent: [{ dateFilter: { type: 'TODAY' } }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'date', operator: RuntimeFilterOp.EQ, values: [], type: 'TODAY' }, + ], + }); + }); + + test('converts every filterContent entry on a filter, not just the first', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'quantity' }, + filters: [ + { + filterContent: [ + { filterType: 'GE', value: [{ key: 5 }] }, + { filterType: 'LE', value: [{ key: 10 }] }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'quantity', operator: 'GE', values: [5] }, + { columnName: 'quantity', operator: 'LE', values: [10] }, + ], + }); + }); + + test('converts every Filter entry in a filter group, not just the first', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'region' }, + filters: [ + { filterContent: [{ filterType: 'EQ', value: [{ key: 'west' }] }] }, + { filterContent: [{ filterType: 'EQ', value: [{ key: 'east' }] }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'region', operator: 'EQ', values: ['west'] }, + { columnName: 'region', operator: 'EQ', values: ['east'] }, + ], + }); + }); + + test('keeps falsy-but-defined filter values such as 0 and false', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'flag' }, + filters: [ + { + filterContent: [ + { filterType: 'IN', value: [{ key: 0 }, { key: false }] }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'flag', operator: 'IN', values: [0, false] }, + ], + }); + }); + + test('skips an EXACT_DATE filter missing its epoch instead of emitting empty values', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { dateFilterContent: [{ dateFilter: { type: 'EXACT_DATE', op: 'EQ' } }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('skips a MONTH_YEAR filter missing yearName instead of emitting empty values', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { + dateFilterContent: [ + { dateFilter: { type: 'MONTH_YEAR', op: 'EQ', monthName: 'JULY' } }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('skips an unrecognized date filter type rather than guessing', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [ + { dateFilterContent: [{ dateFilter: { type: 'SOME_FUTURE_TYPE', op: 'EQ' } }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('output satisfies isValidUpdateFiltersPayload', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + filters: [ + { filterContent: [{ filterType: 'IN', value: [{ key: 'bags' }] }] }, + ], + }, + ], + runtimeFilters: [ + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ], + }; + + const converted = convertFilterChangedToUpdateFiltersPayload(payload); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('combines multiple liveboard filters and runtime filters', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + filters: [ + { filterContent: [{ filterType: 'IN', value: [{ key: 'bags' }] }] }, + ], + }, + ], + runtimeFilters: [ + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [ + { columnName: 'item type', operator: 'IN', values: ['bags'] }, + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ], + }); + }); + // The payload arrives as JSON from the embedded app, so an absent field can + // be `null` rather than omitted. `Number(null)` is 0, so a naive coercion + // would turn a missing epoch into 1970 and a missing period count into 0. + describe('null and non-numeric values in the payload', () => { + const dateFilterPayload = (dateFilter: Record): FilterChangedPayload => ({ + liveboardFilters: [ + { + columnInfo: { name: 'date' }, + filters: [{ dateFilterContent: [{ dateFilter: dateFilter as any }] }], + }, + ], + }); + + test.each([null, '', 'not-a-date'])( + 'skips an EXACT_DATE filter whose epoch is %p instead of emitting epoch 0', + (epoch) => { + const payload = dateFilterPayload({ type: 'EXACT_DATE', op: 'EQ', epoch }); + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }, + ); + + test('skips an EXACT_DATE_RANGE filter when either bound is null', () => { + const missingHigh = dateFilterPayload({ + type: 'EXACT_DATE_RANGE', op: 'BW', dateRange: { lowEpoch: 1000, highEpoch: null }, + }); + const missingLow = dateFilterPayload({ + type: 'EXACT_DATE_RANGE', op: 'BW', dateRange: { lowEpoch: null, highEpoch: 2000 }, + }); + + expect(convertFilterChangedToUpdateFiltersPayload(missingHigh)).toEqual({ filters: [] }); + expect(convertFilterChangedToUpdateFiltersPayload(missingLow)).toEqual({ filters: [] }); + }); + + test.each(['LAST_N_PERIOD', 'NEXT_N_PERIOD'])( + 'skips a %s filter whose number is null instead of emitting a 0-length period', + (type) => { + const payload = dateFilterPayload({ type, op: 'EQ', number: null, datePeriod: 'MONTH' }); + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }, + ); + + test('accepts an epoch of 0 as a real value', () => { + const payload = dateFilterPayload({ type: 'EXACT_DATE', op: 'EQ', epoch: 0 }); + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [{ + columnName: 'date', operator: 'EQ', values: [0], type: 'EXACT_DATE', + }], + }); + }); + + test('accepts a numeric epoch sent as a string', () => { + const payload = dateFilterPayload({ type: 'EXACT_DATE', op: 'EQ', epoch: '1700000000' }); + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [{ + columnName: 'date', operator: 'EQ', values: [1700000000], type: 'EXACT_DATE', + }], + }); + }); + + test('omits includeCurrentPeriod when it is null', () => { + const payload = dateFilterPayload({ + type: 'LAST_N_PERIOD', op: 'EQ', number: 3, datePeriod: 'MONTH', includeCurrentPeriod: null, + }); + const [filter] = convertFilterChangedToUpdateFiltersPayload(payload).filters; + + expect(filter).not.toHaveProperty('includeCurrentPeriod'); + }); + + test('keeps includeCurrentPeriod when it is explicitly false', () => { + const payload = dateFilterPayload({ + type: 'LAST_N_PERIOD', op: 'EQ', number: 3, datePeriod: 'MONTH', includeCurrentPeriod: false, + }); + const [filter] = convertFilterChangedToUpdateFiltersPayload(payload).filters; + + expect(filter.includeCurrentPeriod).toBe(false); + }); + + test('drops null keys from an attribute filter but keeps the rest', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + filters: [ + { + filterContent: [ + { + filterType: 'IN', + value: [{ key: 'bags' }, { key: null }, { key: 'shirts' }], + }, + ], + }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [{ columnName: 'item type', operator: 'IN', values: ['bags', 'shirts'] }], + }); + }); + + test('keeps a falsy-but-real key such as 0 or false', () => { + const payload: FilterChangedPayload = { + liveboardFilters: [ + { + columnInfo: { name: 'quantity' }, + filters: [ + { filterContent: [{ filterType: 'IN', value: [{ key: 0 }, { key: false }] }] }, + ], + }, + ], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [{ columnName: 'quantity', operator: 'IN', values: [0, false] }], + }); + }); + }); + // A runtime filter needs no reshaping, but it still arrives as JSON. One + // malformed entry fails isValidUpdateFiltersPayload for the WHOLE filters + // array, which would take every other converted filter down with it. + describe('malformed runtime filters', () => { + const malformed: Record[] = [ + { columnName: null, operator: RuntimeFilterOp.EQ, values: ['west'] }, + { columnName: '', operator: RuntimeFilterOp.EQ, values: ['west'] }, + { operator: RuntimeFilterOp.EQ, values: ['west'] }, + { columnName: 'region', operator: null, values: ['west'] }, + { columnName: 'region', values: ['west'] }, + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: null }, + { columnName: 'region', operator: RuntimeFilterOp.EQ }, + ]; + + test.each(malformed)('skips the malformed runtime filter %p', (runtimeFilter) => { + const payload = { runtimeFilters: [runtimeFilter] } as any as FilterChangedPayload; + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ filters: [] }); + }); + + test('a bad runtime filter does not discard the good filters alongside it', () => { + const payload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + filters: [{ filterContent: [{ filterType: 'IN', value: [{ key: 'bags' }] }] }], + }, + ], + runtimeFilters: [ + { columnName: null, operator: RuntimeFilterOp.EQ, values: ['west'] }, + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ], + } as any as FilterChangedPayload; + + const converted = convertFilterChangedToUpdateFiltersPayload(payload); + + expect(converted).toEqual({ + filters: [ + { columnName: 'item type', operator: 'IN', values: ['bags'] }, + { columnName: 'region', operator: RuntimeFilterOp.EQ, values: ['west'] }, + ], + }); + // The whole point: the surviving payload is still triggerable. + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('keeps a runtime filter with an empty values array', () => { + const payload: FilterChangedPayload = { + runtimeFilters: [{ columnName: 'region', operator: RuntimeFilterOp.EQ, values: [] }], + }; + + expect(convertFilterChangedToUpdateFiltersPayload(payload)).toEqual({ + filters: [{ columnName: 'region', operator: RuntimeFilterOp.EQ, values: [] }], + }); + }); + }); + + // liveboardFilters entries carry an optional `applicability` + // ({level, targetId}) from SDK 1.53.0 / 26.10.0.cl, and UpdateFilters + // accepts it per filter. Dropping it would replay a tab-scoped filter + // across the whole Liveboard. + describe('applicability (filter scope)', () => { + const TAB_ID = 'e0836cad-4fdf-42d4-bd97-567a6b2a6058'; + + const groupWith = (applicability?: unknown) => ({ + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + applicability, + filters: [{ filterContent: [{ filterType: 'IN', value: [{ key: 'bags' }] }] }], + }, + ], + } as any as FilterChangedPayload); + + test('carries TAB scope through to the converted filter', () => { + const converted = convertFilterChangedToUpdateFiltersPayload( + groupWith({ level: 'TAB', targetId: TAB_ID }), + ); + + expect(converted).toEqual({ + filters: [{ + columnName: 'item type', + operator: 'IN', + values: ['bags'], + applicability: { level: 'TAB', targetId: TAB_ID }, + }], + }); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('carries GROUP scope through to the converted filter', () => { + const converted = convertFilterChangedToUpdateFiltersPayload( + groupWith({ level: 'GROUP', targetId: TAB_ID }), + ); + + expect(converted.filters[0].applicability).toEqual({ level: 'GROUP', targetId: TAB_ID }); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('carries LIVEBOARD scope, which needs no targetId', () => { + const converted = convertFilterChangedToUpdateFiltersPayload( + groupWith({ level: 'LIVEBOARD' }), + ); + + expect(converted.filters[0].applicability).toEqual({ level: 'LIVEBOARD' }); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('omits applicability entirely when the payload has none', () => { + const converted = convertFilterChangedToUpdateFiltersPayload(groupWith(undefined)); + + expect(converted.filters[0]).not.toHaveProperty('applicability'); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + + test('applies the group scope to every filter the group produces', () => { + const payload = { + liveboardFilters: [ + { + columnInfo: { name: 'quantity' }, + applicability: { level: 'TAB', targetId: TAB_ID }, + filters: [ + { + filterContent: [ + { filterType: 'GE', value: [{ key: 5 }] }, + { filterType: 'LE', value: [{ key: 10 }] }, + ], + }, + ], + }, + ], + } as any as FilterChangedPayload; + + const converted = convertFilterChangedToUpdateFiltersPayload(payload); + + expect(converted.filters).toHaveLength(2); + converted.filters.forEach((filter) => { + expect(filter.applicability).toEqual({ level: 'TAB', targetId: TAB_ID }); + }); + }); + + test.each([ + [{ level: 'TAB' }, 'TAB with no targetId'], + [{ level: 'GROUP', targetId: ' ' }, 'GROUP with a blank targetId'], + [{ level: 'SOMETHING_ELSE', targetId: TAB_ID }, 'an unknown level'], + [{ targetId: TAB_ID }, 'no level at all'], + ])('skips a filter scoped with %p (%s) rather than widening it', (applicability, _label) => { + expect(convertFilterChangedToUpdateFiltersPayload(groupWith(applicability))) + .toEqual({ filters: [] }); + }); + + test('a malformed scope on one column does not invalidate the others', () => { + const payload = { + liveboardFilters: [ + { + columnInfo: { name: 'item type' }, + applicability: { level: 'TAB' }, + filters: [{ filterContent: [{ filterType: 'IN', value: [{ key: 'bags' }] }] }], + }, + { + columnInfo: { name: 'region' }, + applicability: { level: 'TAB', targetId: TAB_ID }, + filters: [{ filterContent: [{ filterType: 'IN', value: [{ key: 'west' }] }] }], + }, + ], + } as any as FilterChangedPayload; + + const converted = convertFilterChangedToUpdateFiltersPayload(payload); + + expect(converted).toEqual({ + filters: [{ + columnName: 'region', + operator: 'IN', + values: ['west'], + applicability: { level: 'TAB', targetId: TAB_ID }, + }], + }); + expect(isValidUpdateFiltersPayload(converted as any)).toBe(true); + }); + }); +}); diff --git a/src/utils/filterConverter.ts b/src/utils/filterConverter.ts new file mode 100644 index 000000000..2b7a8bdb4 --- /dev/null +++ b/src/utils/filterConverter.ts @@ -0,0 +1,301 @@ +/** + * Copyright (c) 2026 + * + * Utility to convert the payload emitted by `EmbedEvent.FilterChanged` + * into the payload shape expected by `HostEvent.UpdateFilters`, so that + * the current filter state of a Liveboard can be captured and re-applied + * later without having to hand-parse the event payload. + * @summary Filter payload converter + */ + +import isNil from 'lodash/isNil'; +import { RuntimeFilter, RuntimeFilterOp } from '../types'; +import { Applicability, ApplicabilityLevel } from '../embed/hostEventClient/contracts'; + +export interface FilterChangedFilterContentValue { + key?: string | number | boolean | null; +} + +export interface FilterChangedFilterContent { + filterType?: string; + negate?: boolean; + value?: FilterChangedFilterContentValue[]; +} + +export interface FilterChangedDateFilterValue { + type?: string; + op?: string; + // The payload is JSON from the embedded app, so absent fields can arrive + // as `null` rather than being omitted. + epoch?: string | number | null; + dateRange?: { + lowEpoch?: string | number | null; + highEpoch?: string | number | null; + }; + monthName?: string; + quarterName?: string; + yearName?: string; + number?: number | null; + datePeriod?: string; + includeCurrentPeriod?: boolean | null; +} + +export interface FilterChangedDateFilterContent { + negate?: boolean; + dateFilter?: FilterChangedDateFilterValue; +} + +export interface FilterChangedFilter { + filterContent?: FilterChangedFilterContent[]; + dateFilterContent?: FilterChangedDateFilterContent[]; +} + +export interface FilterChangedFilterGroup { + columnInfo?: { + name?: string; + }; + filters?: FilterChangedFilter[]; + /** + * Scope of the filter - `LIVEBOARD`, `TAB` or `GROUP`. Present from + * SDK 1.53.0 / ThoughtSpot 26.10.0.cl. Omitted means Liveboard level. + */ + applicability?: Applicability; +} + +/** + * Shape of the payload received via `LiveboardEmbed.on(EmbedEvent.FilterChanged, ...)`. + */ +export interface FilterChangedPayload { + liveboardFilters?: FilterChangedFilterGroup[]; + runtimeFilters?: RuntimeFilter[]; +} + +export interface UpdateFiltersFilterParam { + columnName: string; + operator: string; + values: (string | number | boolean | bigint)[]; + type?: string; + datePeriod?: string; + negate?: boolean; + includeCurrentPeriod?: boolean; + applicability?: Applicability; +} + +/** + * Shape expected by `liveboardEmbed.trigger(HostEvent.UpdateFilters, ...)`. + */ +export interface UpdateFiltersPayload { + filters: UpdateFiltersFilterParam[]; +} + +type DateFilterValueExtractor = (dateFilter: FilterChangedDateFilterValue) => (string | number)[]; + +/** + * Coerces an epoch/count field to a number, returning `null` when the value is + * absent or not a finite number. The payload arrives as JSON from the iframe, + * so a missing field can be `null` as well as `undefined`, and `Number(null)` + * or `Number('')` would otherwise silently become a valid-looking `0` + * (i.e. the Unix epoch, or a zero-length period). + * @param value Raw value from the date filter payload. + */ +function toFiniteNumber(value: string | number | undefined | null): number | null { + if (isNil(value) || value === '') return null; + const numericValue = Number(value); + return Number.isFinite(numericValue) ? numericValue : null; +} + +const DATE_FILTER_VALUE_EXTRACTORS: Record = { + EXACT_DATE: (dateFilter) => { + const epoch = toFiniteNumber(dateFilter.epoch); + return epoch === null ? [] : [epoch]; + }, + EXACT_DATE_RANGE: (dateFilter) => { + const { lowEpoch, highEpoch } = dateFilter.dateRange ?? {}; + const low = toFiniteNumber(lowEpoch); + const high = toFiniteNumber(highEpoch); + return low === null || high === null ? [] : [low, high]; + }, + MONTH_YEAR: (dateFilter) => ( + dateFilter.monthName && dateFilter.yearName + ? [dateFilter.monthName, dateFilter.yearName] + : [] + ), + QUARTER_YEAR: (dateFilter) => ( + dateFilter.quarterName && dateFilter.yearName + ? [dateFilter.quarterName, dateFilter.yearName] + : [] + ), + YEAR_ONLY: (dateFilter) => (dateFilter.yearName ? [dateFilter.yearName] : []), + LAST_N_PERIOD: (dateFilter) => { + const count = toFiniteNumber(dateFilter.number); + return count === null ? [] : [count]; + }, + NEXT_N_PERIOD: (dateFilter) => { + const count = toFiniteNumber(dateFilter.number); + return count === null ? [] : [count]; + }, +}; + +// Date filter types with no `values` (e.g. TODAY needs no operand). +const PERIOD_ONLY_DATE_FILTER_TYPES = new Set([ + 'THIS_PERIOD', 'PERIOD_TO_DATE', 'TODAY', 'YESTERDAY', 'TOMORROW', +]); + +function convertDateFilterToParam( + columnName: string, + dateFilterContent: FilterChangedDateFilterContent, +): UpdateFiltersFilterParam | null { + const dateFilter = dateFilterContent?.dateFilter; + if (!dateFilter?.type) return null; + + let values: (string | number)[]; + const getValues = DATE_FILTER_VALUE_EXTRACTORS[dateFilter.type]; + if (getValues) { + values = getValues(dateFilter); + // Required fields (e.g. epoch, yearName) are missing - the source data + // can't be reconstructed faithfully, so skip rather than emit a filter + // that would clear/corrupt this column when re-applied. + if (values.length === 0) return null; + } else if (PERIOD_ONLY_DATE_FILTER_TYPES.has(dateFilter.type)) { + values = []; + } else { + // Unrecognized date filter type - skip rather than guess. + return null; + } + + const param: UpdateFiltersFilterParam = { + columnName, + operator: dateFilter.op ?? RuntimeFilterOp.EQ, + values, + type: dateFilter.type, + }; + if (dateFilter.datePeriod) { + param.datePeriod = dateFilter.datePeriod; + } + if (!isNil(dateFilter.includeCurrentPeriod)) { + param.includeCurrentPeriod = dateFilter.includeCurrentPeriod; + } + if (dateFilterContent.negate) { + param.negate = true; + } + + return param; +} + +function convertFilterContentToParam( + columnName: string, + filterContent: FilterChangedFilterContent, +): UpdateFiltersFilterParam | null { + if (!filterContent?.filterType) return null; + + const values = (filterContent.value ?? []) + .map((value) => value?.key) + .filter((value): value is string | number | boolean => !isNil(value)); + + const param: UpdateFiltersFilterParam = { + columnName, + operator: filterContent.filterType, + values, + }; + if (filterContent.negate) { + param.negate = true; + } + + return param; +} + +function convertFilterToParams(columnName: string, filter: FilterChangedFilter): UpdateFiltersFilterParam[] { + const dateParams = (filter?.dateFilterContent ?? []) + .map((dateFilterContent) => convertDateFilterToParam(columnName, dateFilterContent)); + const contentParams = (filter?.filterContent ?? []) + .map((filterContent) => convertFilterContentToParam(columnName, filterContent)); + + return [...dateParams, ...contentParams] + .filter((param): param is UpdateFiltersFilterParam => param !== null); +} + +/** + * Runtime filters need no reshaping, but they still come off the wire as JSON, + * so a malformed entry is possible. Skip it rather than pass it through: an + * entry missing a column, operator or values array fails + * `isValidUpdateFiltersPayload`, and that rejects the *whole* `filters` array, + * so one bad runtime filter would drop every other converted filter with it. + * @param runtimeFilter A runtime filter from the `FilterChanged` payload. + */ +function convertRuntimeFilterToParam(runtimeFilter: RuntimeFilter): UpdateFiltersFilterParam | null { + const { columnName, operator, values } = runtimeFilter ?? {}; + if (typeof columnName !== 'string' || !columnName) return null; + if (typeof operator !== 'string' || !operator) return null; + if (!Array.isArray(values)) return null; + + return { columnName, operator, values }; +} + +/** + * Mirrors `isValidApplicability` in `hostEventClient/utils`: a `TAB` or `GROUP` + * scope is meaningless without a `targetId`, and `UpdateFilters` rejects the + * whole `filters` array when any entry carries a malformed `applicability`. + * @param applicability Scope taken from the `FilterChanged` payload. + */ +function isUsableApplicability(applicability: Applicability): boolean { + if (!Object.values(ApplicabilityLevel).includes(applicability.level)) return false; + if (applicability.level === ApplicabilityLevel.Liveboard) return true; + return typeof applicability.targetId === 'string' && applicability.targetId.trim().length > 0; +} + +function convertFilterGroupToParams(filterGroup: FilterChangedFilterGroup): UpdateFiltersFilterParam[] { + const columnName = filterGroup?.columnInfo?.name; + if (!columnName) return []; + + const { applicability } = filterGroup; + // A filter scoped to a tab or group has to keep that scope. Replaying it + // without `applicability` would silently widen it to the whole Liveboard, + // changing what every other tab shows. If a scope is present but unusable, + // skip the filter: widening is worse than dropping, and a malformed + // applicability would fail validation for every other filter alongside it. + if (!isNil(applicability) && !isUsableApplicability(applicability)) return []; + + const params = (filterGroup.filters ?? []) + .flatMap((filter) => convertFilterToParams(columnName, filter)); + + return isNil(applicability) ? params : params.map((param) => ({ ...param, applicability })); +} + +/** + * Converts the payload emitted by {@link EmbedEvent.FilterChanged} into the + * payload shape expected by {@link HostEvent.UpdateFilters}, so the same + * filter state can be captured and re-applied later, for example on a + * subsequent page load. + * + * Both the Liveboard filters and the runtime filters present in the + * `FilterChanged` payload are included in the returned `filters` array. + * + * Note: a column with multiple date-filter conditions (e.g. an OR of two + * date ranges) is not guaranteed to round-trip losslessly - `HostEvent.UpdateFilters` + * treats date filters as replace-not-merge per column, so only the last + * converted entry for such a column will apply. + * @param filterChangedPayload The payload received in the + * `EmbedEvent.FilterChanged` callback. + * @example + * ```js + * let savedFilters; + * liveboardEmbed.on(EmbedEvent.FilterChanged, (payload) => { + * savedFilters = convertFilterChangedToUpdateFiltersPayload(payload); + * }); + * + * // later, e.g. after a fresh page load + * liveboardEmbed.trigger(HostEvent.UpdateFilters, savedFilters); + * ``` + */ +export function convertFilterChangedToUpdateFiltersPayload( + filterChangedPayload: FilterChangedPayload, +): UpdateFiltersPayload { + const liveboardFilterParams = (filterChangedPayload?.liveboardFilters ?? []) + .flatMap(convertFilterGroupToParams); + + const runtimeFilterParams = (filterChangedPayload?.runtimeFilters ?? []) + .map(convertRuntimeFilterToParam) + .filter((param): param is UpdateFiltersFilterParam => param !== null); + + return { filters: [...liveboardFilterParams, ...runtimeFilterParams] }; +}