diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index e458b3d8c..acb4027ce 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -7,7 +7,6 @@ import Knock from "../../knock"; import { DEFAULT_GROUP_KEY, - SelectionResult, byKey, checkStateIfThrottled, findDefaultGroup, @@ -46,6 +45,8 @@ import { SelectFilterParams, SelectGuideOpts, SelectGuidesOpts, + SelectQueryLimit, + SelectionResult, StepMessageState, StoreState, TargetParams, @@ -150,7 +151,16 @@ const safeJsonParseDebugParams = (value: string): DebugState => { } }; -const select = (state: StoreState, filters: SelectFilterParams = {}) => { +type SelectQueryMetadata = { + limit: SelectQueryLimit; + opts: SelectGuideOpts; +}; + +const select = ( + state: StoreState, + filters: SelectFilterParams, + metadata: SelectQueryMetadata, +) => { // A map of selected guides as values, with its order index as keys. const result = new SelectionResult(); @@ -175,7 +185,8 @@ const select = (state: StoreState, filters: SelectFilterParams = {}) => { result.set(index, guide); } - result.metadata = { guideGroup: defaultGroup }; + result.metadata = { guideGroup: defaultGroup, filters, ...metadata }; + return result; }; @@ -617,14 +628,35 @@ export class KnockGuideClient { `[Guide] .selectGuides (filters: ${formatFilters(filters)}; state: ${formatState(state)})`, ); - const selectedGuide = this.selectGuide(state, filters, opts); - if (!selectedGuide) { + // 1. First, call selectGuide() using the same filters to ensure we have a + // group stage open and respect throttling. This isn't the real query, but + // rather it's a shortcut ahead of handling the actual query result below. + const selectedGuide = this.selectGuide(state, filters, { + ...opts, + // Don't record this result, not the actual query result we need. + recordSelectQuery: false, + }); + + // 2. Now make the actual select query with the provided filters and opts, + // and record the result (as needed). By default, we only record the result + // while in debugging. + const { recordSelectQuery = !!state.debug?.debugging } = opts; + const metadata: SelectQueryMetadata = { + limit: "all", + opts: { ...opts, recordSelectQuery }, + }; + const result = select(state, filters, metadata); + this.maybeRecordSelectResult(result); + + // 3. Stop if there is not at least one guide to return. + if (!selectedGuide && !opts.includeThrottled) { return []; } // There should be at least one guide to return here now. - const guides = [...select(state, filters).values()]; + const guides = [...result.values()]; + // 4. If throttled, filter out any throttled guides. if (!opts.includeThrottled && checkStateIfThrottled(state)) { const unthrottledGuides = guides.filter( (g) => g.bypass_global_group_limit, @@ -657,32 +689,6 @@ export class KnockGuideClient { return undefined; } - const result = select(state, filters); - - if (result.size === 0) { - this.knock.log("[Guide] Selection found zero result"); - return undefined; - } - - const [index, guide] = [...result][0]!; - this.knock.log( - `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`, - ); - - // If a guide ignores the group limit, then return immediately to render - // always. - if (guide.bypass_global_group_limit) { - this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`); - return guide; - } - - // Check if inside the throttle window (i.e. throttled) and if so stop and - // return undefined unless explicitly given the option to include throttled. - if (!opts.includeThrottled && checkStateIfThrottled(state)) { - this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); - return undefined; - } - // Starting here to the end of this method represents the core logic of how // "group stage" works. It provides a mechanism for 1) figuring out which // guide components are about to render on a page, 2) determining which @@ -716,6 +722,35 @@ export class KnockGuideClient { this.stage = this.openGroupStage(); // Assign here to make tsc happy } + // Must come AFTER we ensure a group stage exists above, so we can record + // select queries. By default, we only record the result while in debugging. + const { recordSelectQuery = !!state.debug?.debugging } = opts; + const metadata: SelectQueryMetadata = { + limit: "one", + opts: { ...opts, recordSelectQuery }, + }; + const result = select(state, filters, metadata); + this.maybeRecordSelectResult(result); + + if (result.size === 0) { + this.knock.log("[Guide] Selection found zero result"); + return undefined; + } + + const [index, guide] = [...result][0]!; + this.knock.log( + `[Guide] Selection found: \`${guide.key}\` (total: ${result.size})`, + ); + + // If a guide ignores the group limit, then return immediately to render + // always. + if (guide.bypass_global_group_limit) { + this.knock.log(`[Guide] Returning the unthrottled guide: ${guide.key}`); + return guide; + } + + const throttled = !opts.includeThrottled && checkStateIfThrottled(state); + switch (this.stage.status) { case "open": { this.knock.log(`[Guide] Adding to the group stage: ${guide.key}`); @@ -725,8 +760,16 @@ export class KnockGuideClient { case "patch": { this.knock.log(`[Guide] Patching the group stage: ${guide.key}`); + // Refresh the ordered queue in the group stage while continuing to + // render the currently resolved guide while in patch window, so that + // we can re-resolve when the group stage closes. this.stage.ordered[index] = guide.key; + if (throttled) { + this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); + return undefined; + } + const ret = this.stage.resolved === guide.key ? guide : undefined; this.knock.log( `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`, @@ -735,6 +778,11 @@ export class KnockGuideClient { } case "closed": { + if (throttled) { + this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`); + return undefined; + } + const ret = this.stage.resolved === guide.key ? guide : undefined; this.knock.log( `[Guide] Returning \`${ret?.key}\` (stage: ${formatGroupStage(this.stage)})`, @@ -744,6 +792,42 @@ export class KnockGuideClient { } } + // Record select query results by accumulating them by 1) key or type first, + // and then 2) "one" or "all". + private maybeRecordSelectResult(result: SelectionResult) { + if (!result.metadata) return; + + const { opts, filters, limit } = result.metadata; + if (!opts.recordSelectQuery) return; + if (!filters.key && !filters.type) return; + if (!this.stage || this.stage.status === "closed") return; + + // Deep merge to accumulate the results. + const queriedByKey = this.stage.results.key || {}; + if (filters.key) { + queriedByKey[filters.key] = { + ...(queriedByKey[filters.key] || {}), + ...{ [limit]: result }, + }; + } + const queriedByType = this.stage.results.type || {}; + if (filters.type) { + queriedByType[filters.type] = { + ...(queriedByType[filters.type] || {}), + ...{ [limit]: result }, + }; + } + + this.stage = { + ...this.stage, + results: { key: queriedByKey, type: queriedByType }, + }; + } + + getStage() { + return this.stage; + } + private openGroupStage() { this.knock.log("[Guide] Opening a new group stage"); @@ -759,6 +843,7 @@ export class KnockGuideClient { this.stage = { status: "open", ordered: [], + results: {}, timeoutId, }; diff --git a/packages/client/src/clients/guide/helpers.ts b/packages/client/src/clients/guide/helpers.ts index 771bb7490..63294f218 100644 --- a/packages/client/src/clients/guide/helpers.ts +++ b/packages/client/src/clients/guide/helpers.ts @@ -3,23 +3,11 @@ import { GuideActivationUrlRuleData, GuideData, GuideGroupData, - KnockGuide, KnockGuideActivationUrlPattern, SelectFilterParams, StoreState, } from "./types"; -// Extends the map class to allow having metadata on it, which is used to record -// the guide group context for the selection result (though currently only a -// default global group is supported). -export class SelectionResult extends Map { - metadata: { guideGroup: GuideGroupData } | undefined; - - constructor() { - super(); - } -} - export const formatGroupStage = (stage: GroupStage) => { return `status=${stage.status}, resolved=${stage.resolved}`; }; diff --git a/packages/client/src/clients/guide/index.ts b/packages/client/src/clients/guide/index.ts index 7d5519160..5741773c0 100644 --- a/packages/client/src/clients/guide/index.ts +++ b/packages/client/src/clients/guide/index.ts @@ -3,6 +3,7 @@ export { DEBUG_QUERY_PARAMS, checkActivatable, } from "./client"; +export { checkStateIfThrottled } from "./helpers"; export type { KnockGuide, KnockGuideStep, @@ -12,4 +13,6 @@ export type { SelectGuideOpts as KnockSelectGuideOpts, SelectGuidesOpts as KnockSelectGuidesOpts, StoreState as KnockGuideClientStoreState, + GroupStage as KnockGuideClientGroupStage, + SelectionResult as KnockGuideSelectionResult, } from "./types"; diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index af3bdab4d..96903b1b3 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -1,5 +1,27 @@ import { GenericData } from "@knocklabs/types"; +// i.e. useGuide vs useGuides +export type SelectQueryLimit = "one" | "all"; + +type SelectionResultMetadata = { + guideGroup: GuideGroupData; + // Additional info about the underlying select query behind the result. + filters: SelectFilterParams; + limit: SelectQueryLimit; + opts: SelectGuideOpts; +}; + +// Extends the map class to allow having metadata on it, which is used to record +// the guide group context for the selection result (though currently only a +// default global group is supported). +export class SelectionResult extends Map { + metadata: SelectionResultMetadata | undefined; + + constructor() { + super(); + } +} + // // Fetch guides API // @@ -237,6 +259,7 @@ export type SelectFilterParams = { export type SelectGuideOpts = { includeThrottled?: boolean; + recordSelectQuery?: boolean; }; export type SelectGuidesOpts = SelectGuideOpts; @@ -253,9 +276,20 @@ export type ConstructorOpts = { throttleCheckInterval?: number; }; +type SelectionResultByLimit = { + one?: SelectionResult; + all?: SelectionResult; +}; + +type RecordedSelectionResults = { + key?: Record; + type?: Record; +}; + export type GroupStage = { status: "open" | "closed" | "patch"; ordered: Array; resolved?: KnockGuide["key"]; timeoutId: ReturnType | null; + results: RecordedSelectionResults; }; diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index e68a710fd..88d5a7785 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -183,7 +183,7 @@ describe("KnockGuideClient", () => { // Mock window to simulate browser environment vi.stubGlobal("window", mockWindow); - const _client = new KnockGuideClient(mockKnock, channelId); + new KnockGuideClient(mockKnock, channelId); expect(Store).toHaveBeenCalledWith({ guideGroups: [], @@ -1596,6 +1596,7 @@ describe("KnockGuideClient", () => { client["stage"] = { status: "open", ordered: ["feature_tour", "onboarding", "system_status"], + results: {}, timeoutId: 123, }; @@ -1625,6 +1626,7 @@ describe("KnockGuideClient", () => { status: "closed", ordered: ["feature_tour", "onboarding", "system_status"], resolved: "feature_tour", + results: {}, timeoutId: 123, }; @@ -2037,7 +2039,7 @@ describe("KnockGuideClient", () => { feature_tour: { __typename: "GuideIneligibilityMarker" as const, key: "feature_tour", - reason: "target_conditions_not_met", + reason: "target_conditions_not_met" as const, message: "User does not match the targeting conditions", }, }, @@ -2065,19 +2067,19 @@ describe("KnockGuideClient", () => { feature_tour: { __typename: "GuideIneligibilityMarker" as const, key: "feature_tour", - reason: "marked_as_archived", + reason: "marked_as_archived" as const, message: "User has archived this guide already", }, onboarding: { __typename: "GuideIneligibilityMarker" as const, key: "onboarding", - reason: "marked_as_archived", + reason: "marked_as_archived" as const, message: "User has archived this guide already", }, system_status: { __typename: "GuideIneligibilityMarker" as const, key: "system_status", - reason: "marked_as_archived", + reason: "marked_as_archived" as const, message: "User has archived this guide already", }, }, @@ -2483,6 +2485,67 @@ describe("KnockGuideClient", () => { const result = client["_selectGuides"](stateOutsideThrottleWindow); expect(result).toHaveLength(3); }); + + test("returns matched guides with includeThrottled during open stage", () => { + const client = new KnockGuideClient(mockKnock, channelId); + + // Call selectGuides directly (not _selectGuides which handles the full + // open/close cycle). The internal selectGuide call opens a new stage and + // returns undefined because the stage is open. With includeThrottled, + // selectGuides should still return the matched guides instead of []. + const result = client.selectGuides( + stateWithGuides, + { type: "card" }, + { includeThrottled: true }, + ); + + expect(result).toHaveLength(2); + expect(result.map((g) => g.key)).toEqual(["changelog", "onboarding"]); + }); + + test("returns empty array without includeThrottled during open stage", () => { + const client = new KnockGuideClient(mockKnock, channelId); + + // Without includeThrottled, selectGuides returns [] during the open stage + // because the internal selectGuide call returns undefined. + const result = client.selectGuides(stateWithGuides, { type: "card" }); + + expect(result).toEqual([]); + }); + + test("returns matched guides with includeThrottled during open stage even when throttled", () => { + const stateInsideThrottleWindow = { + guideGroups: [ + { + ...mockDefaultGroup, + display_interval: 5 * 60, // 5 minutes + }, + ], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: mockGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Even when throttled and in the open stage, includeThrottled should + // return all matched guides instead of []. + const result = client.selectGuides( + stateInsideThrottleWindow, + { type: "card" }, + { includeThrottled: true }, + ); + + expect(result).toHaveLength(2); + expect(result.map((g) => g.key)).toEqual(["changelog", "onboarding"]); + }); }); describe("guide socket event handling", () => { @@ -2858,7 +2921,7 @@ describe("KnockGuideClient", () => { }, }); - const _client = new KnockGuideClient( + new KnockGuideClient( mockKnock, channelId, {}, @@ -3272,6 +3335,492 @@ describe("KnockGuideClient", () => { }); }); + describe("maybeRecordSelectResult and select query recording", () => { + const mockStep = { + ref: "step_1", + schema_key: "foo", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const mockGuideOne = { + __typename: "Guide", + channel_id: channelId, + id: "guide_1", + key: "onboarding", + type: "card", + semver: "1.0.0", + active: true, + steps: [mockStep], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + getStep: vi.fn().mockReturnValue(mockStep), + } as unknown as KnockGuide; + + const mockGuideTwo = { + __typename: "Guide", + channel_id: channelId, + id: "guide_2", + key: "changelog", + type: "card", + semver: "1.0.0", + active: true, + steps: [mockStep], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + getStep: vi.fn().mockReturnValue(mockStep), + } as unknown as KnockGuide; + + const mockGuideThree = { + __typename: "Guide", + channel_id: channelId, + id: "guide_3", + key: "system_status", + type: "banner", + semver: "1.0.0", + active: true, + steps: [mockStep], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + getStep: vi.fn().mockReturnValue(mockStep), + } as unknown as KnockGuide; + + const recordingGuides = { + [mockGuideOne.key]: mockGuideOne, + [mockGuideTwo.key]: mockGuideTwo, + [mockGuideThree.key]: mockGuideThree, + }; + + const recordingDefaultGroup = { + __typename: "GuideGroup", + key: "default", + display_sequence: ["changelog", "onboarding", "system_status"], + display_interval: null, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as unknown as GuideGroupData; + + test("records select result by key when debugging and stage is open", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // First call opens stage and records result because debugging is true + client.selectGuide(stateWithGuides, { key: "onboarding" }); + + const stage = client.getStage(); + expect(stage).toBeDefined(); + expect(stage!.status).toBe("open"); + expect(stage!.results.key).toBeDefined(); + expect(stage!.results.key!["onboarding"]).toBeDefined(); + expect(stage!.results.key!["onboarding"]!.one).toBeDefined(); + expect(stage!.results.key!["onboarding"]!.one!.metadata).toBeDefined(); + expect(stage!.results.key!["onboarding"]!.one!.metadata!.limit).toBe( + "one", + ); + }); + + test("records select result by type when debugging and stage is open", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Call with type filter triggers recording by type + client.selectGuide(stateWithGuides, { type: "card" }); + + const stage = client.getStage(); + expect(stage).toBeDefined(); + expect(stage!.results.type).toBeDefined(); + expect(stage!.results.type!["card"]).toBeDefined(); + expect(stage!.results.type!["card"]!.one).toBeDefined(); + }); + + test("does not record when not debugging", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: undefined, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + client.selectGuide(stateWithGuides, { key: "onboarding" }); + + const stage = client.getStage(); + expect(stage!.results).toEqual({}); + }); + + test("does not record without key or type filters", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + // No filters + client.selectGuide(stateWithGuides); + + const stage = client.getStage(); + expect(stage!.results).toEqual({}); + }); + + test("accumulates results from multiple selectGuide calls", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // First call opens stage and records by key + client.selectGuide(stateWithGuides, { key: "onboarding" }); + // Second call records by type (same open stage) + client.selectGuide(stateWithGuides, { type: "banner" }); + + const stage = client.getStage(); + expect(stage!.results.key!["onboarding"].one).toBeDefined(); + expect(stage!.results.type!["banner"].one).toBeDefined(); + }); + + test("selectGuides records result with 'all' limit", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // selectGuides opens stage via internal selectGuide, then records its + // own select result with limit "all". + client.selectGuides(stateWithGuides, { type: "card" }); + + const stage = client.getStage(); + expect(stage!.results.type).toBeDefined(); + expect(stage!.results.type!["card"]).toBeDefined(); + expect(stage!.results.type!["card"]!.all).toBeDefined(); + expect(stage!.results.type!["card"]!.all!.metadata!.limit).toBe("all"); + + // selectGuides calls selectGuide internally with recordSelectQuery: false, + // so the "one" limit should NOT be recorded for the same type filter. + expect(stage!.results.type!["card"]!.one).toBeUndefined(); + }); + + test("does not record when stage is closed", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage + client["stage"] = { + status: "closed", + ordered: ["changelog"], + resolved: "changelog", + results: {}, + timeoutId: null, + }; + + // Call selectGuide on the closed stage; should NOT record + client.selectGuide(stateWithGuides, { key: "onboarding" }); + + const stage = client.getStage(); + expect(stage!.results).toEqual({}); + }); + + test("records during patch stage status", () => { + const stateWithGuides = { + guideGroups: [recordingDefaultGroup], + guideGroupDisplayLogs: {}, + guides: recordingGuides, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { debugging: true }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage, then patch it + client["stage"] = { + status: "closed", + ordered: ["changelog"], + resolved: "changelog", + results: {}, + timeoutId: null, + }; + client["patchClosedGroupStage"](); + + expect(client.getStage()!.status).toBe("patch"); + + // Call selectGuide on the patched stage; SHOULD record + client.selectGuide(stateWithGuides, { key: "onboarding" }); + + const stage = client.getStage(); + expect(stage!.results.key).toBeDefined(); + expect(stage!.results.key!["onboarding"]).toBeDefined(); + }); + }); + + describe("throttle behavior in group stage", () => { + const mockStep = { + ref: "step_1", + schema_key: "foo", + schema_semver: "1.0.0", + schema_variant_key: "default", + message: { + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + }, + content: {}, + markAsSeen: vi.fn(), + markAsInteracted: vi.fn(), + markAsArchived: vi.fn(), + } as unknown as KnockGuideStep; + + const mockGuide = { + __typename: "Guide", + channel_id: channelId, + id: "guide_1", + key: "onboarding", + type: "card", + semver: "1.0.0", + active: true, + steps: [mockStep], + activation_url_rules: [], + activation_url_patterns: [], + bypass_global_group_limit: false, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + getStep: vi.fn().mockReturnValue(mockStep), + } as unknown as KnockGuide; + + const throttleDefaultGroup = { + __typename: "GuideGroup", + key: "default", + display_sequence: ["onboarding"], + display_interval: 24 * 60 * 60, + inserted_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as unknown as GuideGroupData; + + test("returns undefined for throttled guide in closed stage", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: undefined, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage with the guide resolved + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + + const result = client.selectGuide(stateWithGuides, { key: "onboarding" }); + expect(result).toBeUndefined(); + }); + + test("returns undefined for throttled guide in patch stage", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage then patch it + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + client["patchClosedGroupStage"](); + + expect(client.getStage()!.status).toBe("patch"); + + const result = client.selectGuide(stateWithGuides, { key: "onboarding" }); + expect(result).toBeUndefined(); + }); + + test("returns unthrottled guide in closed stage even when throttled", () => { + const unthrottledGuide = { + ...mockGuide, + bypass_global_group_limit: true, + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: unthrottledGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage with the guide resolved + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + + // Unthrottled guides bypass the throttle check entirely + const result = client.selectGuide(stateWithGuides, { key: "onboarding" }); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); + + test("returns throttled guide with includeThrottled option in patch stage", () => { + const stateWithGuides = { + guideGroups: [throttleDefaultGroup], + guideGroupDisplayLogs: { + default: new Date().toISOString(), + }, + guides: { onboarding: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + + const client = new KnockGuideClient(mockKnock, channelId); + + // Set up a closed stage then patch it + client["stage"] = { + status: "closed", + ordered: ["onboarding"], + resolved: "onboarding", + results: {}, + timeoutId: null, + }; + client["patchClosedGroupStage"](); + + // With includeThrottled, should return the resolved guide + const result = client.selectGuide( + stateWithGuides, + { key: "onboarding" }, + { includeThrottled: true }, + ); + expect(result).toBeDefined(); + expect(result!.key).toBe("onboarding"); + }); + }); + describe("setDebug", () => { test("sets debug state with debugging: true", () => { const client = new KnockGuideClient(mockKnock, channelId); diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx index e260425ad..c29a9568c 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRow.tsx @@ -6,6 +6,7 @@ import { Text } from "@telegraph/typography"; import { CheckCircle2, CircleDashed, + Code2, Eye, LocateFixed, UserCircle2, @@ -52,6 +53,33 @@ export const GuideRow = ({ guide, orderIndex }: Props) => { {!isUnknownGuide(guide) && ( <> + +