Skip to content

Commit 13206a0

Browse files
authored
feat: add focusedGuideKeys debug setting to pin a guide (#879)
1 parent 68d94b7 commit 13206a0

7 files changed

Lines changed: 368 additions & 8 deletions

File tree

packages/client/src/clients/guide/client.ts

Lines changed: 34 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,12 @@ const predicate = (
211211
// If in debug mode with a forced guide key, bypass other filtering and always
212212
// return true for that guide only. This should always run AFTER checking the
213213
// filters but BEFORE checking archived status and location rules.
214+
if (
215+
debug.focusedGuideKeys &&
216+
Object.keys(debug.focusedGuideKeys).length > 0
217+
) {
218+
return !!debug.focusedGuideKeys[guide.key];
219+
}
214220
if (debug.forcedGuideKey) {
215221
return debug.forcedGuideKey === guide.key;
216222
}
@@ -563,7 +569,11 @@ export class KnockGuideClient {
563569
// Clear debug state from store
564570
this.store.setState((state) => ({
565571
...state,
566-
debug: { forcedGuideKey: null, previewSessionId: null },
572+
debug: {
573+
forcedGuideKey: null,
574+
previewSessionId: null,
575+
focusedGuideKeys: {},
576+
},
567577
previewGuides: {}, // Clear preview guides when exiting debug mode
568578
}));
569579

@@ -591,6 +601,7 @@ export class KnockGuideClient {
591601
debug: {
592602
skipEngagementTracking: true,
593603
ignoreDisplayInterval: true,
604+
focusedGuideKeys: {},
594605
...debugOpts,
595606
debugging: true,
596607
},
@@ -754,6 +765,10 @@ export class KnockGuideClient {
754765
return guide;
755766
}
756767

768+
// If focused while in debug mode, then we want to ignore the guide order
769+
// and throttle settings and force render this guide.
770+
const focusedInDebug = state.debug?.focusedGuideKeys?.[guide.key];
771+
757772
const throttled = !opts.includeThrottled && checkStateIfThrottled(state);
758773

759774
switch (this.stage.status) {
@@ -770,6 +785,13 @@ export class KnockGuideClient {
770785
// we can re-resolve when the group stage closes.
771786
this.stage.ordered[index] = guide.key;
772787

788+
if (focusedInDebug) {
789+
this.knock.log(
790+
`[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`,
791+
);
792+
return guide;
793+
}
794+
773795
if (throttled) {
774796
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
775797
return undefined;
@@ -783,6 +805,13 @@ export class KnockGuideClient {
783805
}
784806

785807
case "closed": {
808+
if (focusedInDebug) {
809+
this.knock.log(
810+
`[Guide] Focused to return \`${guide.key}\` (stage: ${formatGroupStage(this.stage)})`,
811+
);
812+
return guide;
813+
}
814+
786815
if (throttled) {
787816
this.knock.log(`[Guide] Throttling the selected guide: ${guide.key}`);
788817
return undefined;
@@ -1082,7 +1111,10 @@ export class KnockGuideClient {
10821111
// Get the next unarchived step.
10831112
getStep() {
10841113
// If debugging this guide, return the first step regardless of archive status
1085-
if (self.store.state.debug?.forcedGuideKey === this.key) {
1114+
if (
1115+
self.store.state.debug?.forcedGuideKey === this.key ||
1116+
self.store.state.debug?.focusedGuideKeys?.[this.key]
1117+
) {
10861118
return this.steps[0];
10871119
}
10881120

packages/client/src/clients/guide/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,7 @@ export type QueryStatus = {
232232
export type DebugState = {
233233
debugging?: boolean;
234234
forcedGuideKey?: string | null;
235+
focusedGuideKeys?: Record<KnockGuide["key"], true>;
235236
previewSessionId?: string | null;
236237
skipEngagementTracking?: boolean;
237238
ignoreDisplayInterval?: boolean;

packages/client/test/clients/guide/guide.test.ts

Lines changed: 256 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2260,6 +2260,116 @@ describe("KnockGuideClient", () => {
22602260
expect(result).toBeUndefined();
22612261
});
22622262

2263+
test("returns an archived guide when focusedGuideKeys includes it", () => {
2264+
const archivedGuide = {
2265+
...mockGuideThree,
2266+
steps: [
2267+
{
2268+
...mockStep,
2269+
message: {
2270+
...mockStep.message,
2271+
archived_at: new Date().toISOString(),
2272+
},
2273+
},
2274+
],
2275+
};
2276+
2277+
const stateWithArchivedGuide = {
2278+
guideGroups: [mockDefaultGroup],
2279+
guideGroupDisplayLogs: {},
2280+
guides: {
2281+
...mockGuides,
2282+
[mockGuideThree.key]: archivedGuide,
2283+
},
2284+
ineligibleGuides: {},
2285+
previewGuides: {},
2286+
queries: {},
2287+
location: undefined,
2288+
counter: 0,
2289+
debug: {
2290+
focusedGuideKeys: { [mockGuideThree.key]: true as const },
2291+
},
2292+
};
2293+
2294+
const client = new KnockGuideClient(mockKnock, channelId);
2295+
const result = client["_selectGuide"](stateWithArchivedGuide, {
2296+
key: mockGuideThree.key,
2297+
});
2298+
2299+
// Should return the focused guide even though it's archived
2300+
expect(result!.key).toBe("system_status");
2301+
expect(result!.steps[0]!.message.archived_at).toBeTruthy();
2302+
});
2303+
2304+
test("returns only focused guides when focusedGuideKeys is set", () => {
2305+
const stateWithGuides = {
2306+
guideGroups: [mockDefaultGroup],
2307+
guideGroupDisplayLogs: {},
2308+
guides: mockGuides,
2309+
ineligibleGuides: {},
2310+
previewGuides: {},
2311+
queries: {},
2312+
location: undefined,
2313+
counter: 0,
2314+
debug: {
2315+
focusedGuideKeys: { [mockGuideTwo.key]: true as const },
2316+
},
2317+
};
2318+
2319+
const client = new KnockGuideClient(mockKnock, channelId);
2320+
const result = client["_selectGuide"](stateWithGuides);
2321+
2322+
// Should return the focused guide
2323+
expect(result!.key).toBe("feature_tour");
2324+
});
2325+
2326+
test("doesn't return a guide not in focusedGuideKeys", () => {
2327+
const stateWithGuides = {
2328+
guideGroups: [mockDefaultGroup],
2329+
guideGroupDisplayLogs: {},
2330+
guides: mockGuides,
2331+
ineligibleGuides: {},
2332+
previewGuides: {},
2333+
queries: {},
2334+
location: undefined,
2335+
counter: 0,
2336+
debug: {
2337+
focusedGuideKeys: { [mockGuideTwo.key]: true as const },
2338+
},
2339+
};
2340+
2341+
const client = new KnockGuideClient(mockKnock, channelId);
2342+
const result = client["_selectGuide"](stateWithGuides, {
2343+
key: mockGuideOne.key,
2344+
});
2345+
2346+
// Guide one is not in focusedGuideKeys, so should not be returned
2347+
expect(result).toBeUndefined();
2348+
});
2349+
2350+
test("falls through to normal filtering when focusedGuideKeys is empty object", () => {
2351+
const stateWithGuides = {
2352+
guideGroups: [mockDefaultGroup],
2353+
guideGroupDisplayLogs: {},
2354+
guides: mockGuides,
2355+
ineligibleGuides: {},
2356+
previewGuides: {},
2357+
queries: {},
2358+
location: undefined,
2359+
counter: 0,
2360+
debug: {
2361+
focusedGuideKeys: {},
2362+
},
2363+
};
2364+
2365+
const client = new KnockGuideClient(mockKnock, channelId);
2366+
const result = client["_selectGuide"](stateWithGuides);
2367+
2368+
// Empty focusedGuideKeys should not filter — normal selection applies
2369+
expect(result).toBeDefined();
2370+
expect(result!.key).toBe("feature_tour");
2371+
});
2372+
22632373
test("does not return a guide inside a throttle window ", () => {
22642374
const stateWithGuides = {
22652375
guideGroups: [
@@ -4217,6 +4327,152 @@ describe("KnockGuideClient", () => {
42174327
expect(result).toBeDefined();
42184328
expect(result!.key).toBe("onboarding");
42194329
});
4330+
4331+
test("returns focused guide in closed stage even when throttled", () => {
4332+
const stateWithGuides = {
4333+
guideGroups: [throttleDefaultGroup],
4334+
guideGroupDisplayLogs: {
4335+
default: new Date().toISOString(),
4336+
},
4337+
guides: { onboarding: mockGuide },
4338+
ineligibleGuides: {},
4339+
previewGuides: {},
4340+
queries: {},
4341+
location: undefined,
4342+
counter: 0,
4343+
debug: {
4344+
focusedGuideKeys: { onboarding: true as const },
4345+
},
4346+
};
4347+
4348+
const client = new KnockGuideClient(mockKnock, channelId);
4349+
4350+
// Set up a closed stage with the guide resolved
4351+
client["stage"] = {
4352+
status: "closed",
4353+
ordered: ["onboarding"],
4354+
resolved: "onboarding",
4355+
results: {},
4356+
timeoutId: null,
4357+
};
4358+
4359+
// Focused guides bypass throttle in closed stage
4360+
const result = client.selectGuide(stateWithGuides, {
4361+
key: "onboarding",
4362+
});
4363+
expect(result).toBeDefined();
4364+
expect(result!.key).toBe("onboarding");
4365+
});
4366+
4367+
test("returns focused guide in patch stage even when throttled", () => {
4368+
const stateWithGuides = {
4369+
guideGroups: [throttleDefaultGroup],
4370+
guideGroupDisplayLogs: {
4371+
default: new Date().toISOString(),
4372+
},
4373+
guides: { onboarding: mockGuide },
4374+
ineligibleGuides: {},
4375+
previewGuides: {},
4376+
queries: {},
4377+
location: undefined,
4378+
counter: 0,
4379+
debug: {
4380+
focusedGuideKeys: { onboarding: true as const },
4381+
},
4382+
};
4383+
4384+
const client = new KnockGuideClient(mockKnock, channelId);
4385+
4386+
// Set up a closed stage then patch it
4387+
client["stage"] = {
4388+
status: "closed",
4389+
ordered: ["onboarding"],
4390+
resolved: "onboarding",
4391+
results: {},
4392+
timeoutId: null,
4393+
};
4394+
client["patchClosedGroupStage"]();
4395+
4396+
expect(client.getStage()!.status).toBe("patch");
4397+
4398+
// Focused guides bypass throttle in patch stage
4399+
const result = client.selectGuide(stateWithGuides, {
4400+
key: "onboarding",
4401+
});
4402+
expect(result).toBeDefined();
4403+
expect(result!.key).toBe("onboarding");
4404+
});
4405+
4406+
test("returns focused guide in patch stage even when not the resolved guide", () => {
4407+
const stateWithGuides = {
4408+
guideGroups: [throttleDefaultGroup],
4409+
guideGroupDisplayLogs: {},
4410+
guides: { onboarding: mockGuide },
4411+
ineligibleGuides: {},
4412+
previewGuides: {},
4413+
queries: {},
4414+
location: undefined,
4415+
counter: 0,
4416+
debug: {
4417+
focusedGuideKeys: { onboarding: true as const },
4418+
},
4419+
};
4420+
4421+
const client = new KnockGuideClient(mockKnock, channelId);
4422+
4423+
// Set up a closed stage where a DIFFERENT guide was resolved
4424+
client["stage"] = {
4425+
status: "closed",
4426+
ordered: ["onboarding"],
4427+
resolved: "some_other_guide",
4428+
results: {},
4429+
timeoutId: null,
4430+
};
4431+
client["patchClosedGroupStage"]();
4432+
4433+
expect(client.getStage()!.status).toBe("patch");
4434+
4435+
// Focused guides bypass the resolved check in patch stage
4436+
const result = client.selectGuide(stateWithGuides, {
4437+
key: "onboarding",
4438+
});
4439+
expect(result).toBeDefined();
4440+
expect(result!.key).toBe("onboarding");
4441+
});
4442+
4443+
test("returns focused guide in closed stage even when not the resolved guide", () => {
4444+
const stateWithGuides = {
4445+
guideGroups: [throttleDefaultGroup],
4446+
guideGroupDisplayLogs: {},
4447+
guides: { onboarding: mockGuide },
4448+
ineligibleGuides: {},
4449+
previewGuides: {},
4450+
queries: {},
4451+
location: undefined,
4452+
counter: 0,
4453+
debug: {
4454+
focusedGuideKeys: { onboarding: true as const },
4455+
},
4456+
};
4457+
4458+
const client = new KnockGuideClient(mockKnock, channelId);
4459+
4460+
// Set up a closed stage where a DIFFERENT guide was resolved
4461+
client["stage"] = {
4462+
status: "closed",
4463+
ordered: ["onboarding"],
4464+
resolved: "some_other_guide",
4465+
results: {},
4466+
timeoutId: null,
4467+
};
4468+
4469+
// Focused guides bypass the resolved check in closed stage
4470+
const result = client.selectGuide(stateWithGuides, {
4471+
key: "onboarding",
4472+
});
4473+
expect(result).toBeDefined();
4474+
expect(result!.key).toBe("onboarding");
4475+
});
42204476
});
42214477

42224478
describe("setDebug", () => {

packages/react/src/modules/guide/components/Toolbar/V2/GuideContextDetails.tsx

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,7 @@ export const GuideContextDetails = () => {
1414
const { defaultGroup, debugSettings } = useStore(client.store, (state) => {
1515
return {
1616
defaultGroup: state.guideGroups[0],
17-
debugSettings: {
18-
skipEngagementTracking: !!state.debug?.skipEngagementTracking,
19-
ignoreDisplayInterval: !!state.debug?.ignoreDisplayInterval,
20-
},
17+
debugSettings: state.debug || {},
2118
};
2219
});
2320
const displayInterval = defaultGroup?.display_interval ?? null;

0 commit comments

Comments
 (0)