diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index 16566604e..25aad07cf 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -590,6 +590,7 @@ export class KnockGuideClient { ...state, debug: { skipEngagementTracking: true, + ignoreDisplayInterval: true, ...debugOpts, debugging: true, }, diff --git a/packages/client/src/clients/guide/helpers.ts b/packages/client/src/clients/guide/helpers.ts index 63294f218..7e163dcd2 100644 --- a/packages/client/src/clients/guide/helpers.ts +++ b/packages/client/src/clients/guide/helpers.ts @@ -64,6 +64,10 @@ export const findDefaultGroup = (guideGroups: GuideGroupData[]) => ); export const checkStateIfThrottled = (state: StoreState) => { + if (state.debug?.ignoreDisplayInterval) { + return false; + } + const defaultGroup = findDefaultGroup(state.guideGroups); const throttleWindowStartedAt = state.guideGroupDisplayLogs[DEFAULT_GROUP_KEY]; diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index 725871ce4..9e09efb93 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -234,6 +234,7 @@ export type DebugState = { forcedGuideKey?: string | null; previewSessionId?: string | null; skipEngagementTracking?: boolean; + ignoreDisplayInterval?: boolean; }; export type StoreState = { diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 8c0f15134..4bae5b050 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -2363,6 +2363,71 @@ describe("KnockGuideClient", () => { expect(result2!.type).toBe("banner"); }); + test("returns a guide inside a throttle window when ignoreDisplayInterval is true", () => { + const stateWithGuides = { + guideGroups: [ + { + ...mockDefaultGroup, + display_interval: 5 * 60, // 5 minutes + }, + ], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + ignoreDisplayInterval: true, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithGuides); + + // Even though we are inside the configured throttle window, + // ignoreDisplayInterval bypasses it. + expect(result).toBeDefined(); + expect(result!.key).toBe("feature_tour"); + }); + + test("does not return a guide inside a throttle window when ignoreDisplayInterval is false", () => { + const stateWithGuides = { + guideGroups: [ + { + ...mockDefaultGroup, + display_interval: 5 * 60, // 5 minutes + }, + ], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { + debugging: true, + ignoreDisplayInterval: false, + forcedGuideKey: null, + previewSessionId: null, + }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + const result = client["_selectGuide"](stateWithGuides); + + expect(result).toBeUndefined(); + }); + test("skips ineligible guides during selection", () => { const stateWithGuides = { guideGroups: [mockDefaultGroup], @@ -4234,6 +4299,34 @@ describe("KnockGuideClient", () => { expect(client.store.state.debug!.skipEngagementTracking).toBe(false); }); + + test("defaults ignoreDisplayInterval to true", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + vi.spyOn(client, "fetch").mockImplementation(() => + Promise.resolve({ status: "ok" }), + ); + vi.spyOn(client, "subscribe").mockImplementation(() => {}); + + client.setDebug(); + + expect(client.store.state.debug!.ignoreDisplayInterval).toBe(true); + }); + + test("allows overriding ignoreDisplayInterval to false", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + vi.spyOn(client, "fetch").mockImplementation(() => + Promise.resolve({ status: "ok" }), + ); + vi.spyOn(client, "subscribe").mockImplementation(() => {}); + + client.setDebug({ ignoreDisplayInterval: false }); + + expect(client.store.state.debug!.ignoreDisplayInterval).toBe(false); + }); }); describe("unsetDebug", () => { diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx index a02e4001d..21a0f728f 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx @@ -16,6 +16,7 @@ export const GuideContextDetails = () => { defaultGroup: state.guideGroups[0], debugSettings: { skipEngagementTracking: !!state.debug?.skipEngagementTracking, + ignoreDisplayInterval: !!state.debug?.ignoreDisplayInterval, }, }; }); @@ -70,13 +71,40 @@ export const GuideContextDetails = () => { - - - Throttle - - - {displayInterval === null ? "-" : `Every ${displayInterval}s`} - + + + + + Suspend throttling + + + + + + + + + + Throttle:{" "} + {debugSettings.ignoreDisplayInterval + ? "(ignored)" + : displayInterval === null + ? "(none)" + : `Every ${displayInterval}s`} + +