diff --git a/.changeset/pretty-facts-tickle.md b/.changeset/pretty-facts-tickle.md new file mode 100644 index 000000000..dfb29ddcb --- /dev/null +++ b/.changeset/pretty-facts-tickle.md @@ -0,0 +1,6 @@ +--- +"@knocklabs/client": patch +"@knocklabs/react": patch +--- + +[Guides] Add reset engagement button in guide toolbar v2 diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index cb468ec93..5b0f35e9f 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -1101,6 +1101,42 @@ export class KnockGuideClient { return updatedStep; } + async resetEngagement(guide: GuideData) { + const target = this.store.state.guides[guide.key]; + if (!target) return; + + this.knock.log(`[Guide] Resetting engagement (Guide key: ${guide.key})`); + + // Note: Bypasse the skipEngagementTracking debug setting, so that the user + // can reset engagement from the toolbar while debugging. + const response = await this.knock.user.resetGuideEngagement({ + guide_key: guide.key, + tenant: this.targetParams.tenant, + }); + + if (response.status !== "ok") return; + + // Nullify all step message state fields for this guide in the local store. + this.store.setState((state) => { + const steps = target.steps.map((step) => ({ + ...step, + message: { + seen_at: null, + read_at: null, + interacted_at: null, + archived_at: null, + link_clicked_at: null, + } as StepMessageState, + })); + + return { + ...state, + guides: { ...state.guides, [guide.key]: { ...target, steps } }, + counter: state.counter + 1, + }; + }); + } + private shouldSkipEngagementApi(): boolean { return !!this.store.state.debug?.skipEngagementTracking; } diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index f1c834a6b..0eaf8b490 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -143,6 +143,15 @@ export type MarkGuideAsResponse = { status: "ok"; }; +export type ResetGuideEngagementParams = { + guide_key: string; + tenant?: string; +}; + +export type ResetGuideEngagementResponse = { + status: "ok"; +}; + // // Socket events // diff --git a/packages/client/src/clients/users/index.ts b/packages/client/src/clients/users/index.ts index 9b6d9ca0d..0cdab95f2 100644 --- a/packages/client/src/clients/users/index.ts +++ b/packages/client/src/clients/users/index.ts @@ -4,7 +4,11 @@ import { ApiResponse } from "../../api"; import { ChannelData, User } from "../../interfaces"; import Knock from "../../knock"; import { guidesApiRootPath } from "../guide/client"; -import { GuideEngagementEventBaseParams } from "../guide/types"; +import { + GuideEngagementEventBaseParams, + ResetGuideEngagementParams, + ResetGuideEngagementResponse, +} from "../guide/types"; import { GetPreferencesOptions, PreferenceOptions, @@ -137,6 +141,16 @@ class UserClient { return this.handleResponse(result); } + async resetGuideEngagement(params: ResetGuideEngagementParams) { + const result = await this.instance.client().makeRequest({ + method: "PUT", + url: `${guidesApiRootPath(this.instance.userId)}/engagements/reset`, + data: params, + }); + + return this.handleResponse(result); + } + private handleResponse(response: ApiResponse) { if (response.statusCode === "error") { throw new Error(response.error || response.body); diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 465f59dfe..9d8a68848 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -135,6 +135,7 @@ describe("KnockGuideClient", () => { .fn() .mockImplementation(() => Promise.resolve({ entries: [] })), markGuideStepAs: vi.fn().mockResolvedValue({ status: "ok" }), + resetGuideEngagement: vi.fn().mockResolvedValue({ status: "ok" }), }, } as unknown as Knock; }); @@ -1283,6 +1284,107 @@ describe("KnockGuideClient", () => { expect.any(Object), ); }); + + test("resetEngagement calls API and nullifies step message state", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const engagedStep = { + ...mockStep, + message: { + id: "msg_123", + seen_at: "2026-01-01T00:00:00.000Z", + read_at: "2026-01-01T00:00:00.000Z", + interacted_at: "2026-01-01T00:00:00.000Z", + archived_at: "2026-01-01T00:00:00.000Z", + link_clicked_at: "2026-01-01T00:00:00.000Z", + }, + } as unknown as KnockGuideStep; + + const engagedGuide = { + ...mockGuide, + steps: [engagedStep], + } as unknown as KnockGuide; + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [engagedGuide.key]: engagedGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + await client.resetEngagement(engagedGuide); + + expect(mockKnock.user.resetGuideEngagement).toHaveBeenCalledWith({ + guide_key: "test_guide", + tenant: undefined, + }); + + // Verify the store state was updated with nullified message fields + const updatedGuide = mockStore.state.guides[engagedGuide.key]!; + for (const step of updatedGuide.steps) { + expect(step.message.seen_at).toBeNull(); + expect(step.message.read_at).toBeNull(); + expect(step.message.interacted_at).toBeNull(); + expect(step.message.archived_at).toBeNull(); + expect(step.message.link_clicked_at).toBeNull(); + } + }); + + test("resetEngagement noops when guide is not in the store", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + // Store has no guides + mockStore.state = { + guideGroups: [], + guideGroupDisplayLogs: {}, + guides: {}, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + + await client.resetEngagement(mockGuide); + + expect(mockKnock.user.resetGuideEngagement).not.toHaveBeenCalled(); + expect(mockStore.setState).not.toHaveBeenCalled(); + }); + + test("resetEngagement does not update store when API response is not ok", async () => { + const client = new KnockGuideClient(mockKnock, channelId); + + const stateWithGuides = { + guideGroups: [mockDefaultGroup], + guideGroupDisplayLogs: {}, + guides: { [mockGuide.key]: mockGuide }, + ineligibleGuides: {}, + previewGuides: {}, + queries: {}, + location: undefined, + counter: 0, + debug: { forcedGuideKey: null, previewSessionId: null }, + }; + mockStore.state = stateWithGuides; + mockStore.getState.mockReturnValue(stateWithGuides); + + (mockKnock.user.resetGuideEngagement as ReturnType).mockResolvedValueOnce({ + status: "error", + }); + + await client.resetEngagement(mockGuide); + + expect(mockKnock.user.resetGuideEngagement).toHaveBeenCalled(); + expect(mockStore.setState).not.toHaveBeenCalled(); + }); }); describe("cleanup", () => { @@ -3072,6 +3174,7 @@ describe("KnockGuideClient", () => { activation_url_patterns: [], activation_url_rules: [], bypass_global_group_limit: false, + dashboard_url: null, inserted_at: new Date().toISOString(), updated_at: new Date().toISOString(), }; @@ -3105,6 +3208,7 @@ describe("KnockGuideClient", () => { activation_url_patterns: [], activation_url_rules: [], bypass_global_group_limit: false, + dashboard_url: null, inserted_at: new Date().toISOString(), updated_at: new Date().toISOString(), getStep() { @@ -3160,6 +3264,7 @@ describe("KnockGuideClient", () => { activation_url_patterns: [], activation_url_rules: [], bypass_global_group_limit: false, + dashboard_url: null, inserted_at: new Date().toISOString(), updated_at: new Date().toISOString(), getStep() { @@ -3207,6 +3312,7 @@ describe("KnockGuideClient", () => { activation_url_rules: [], activation_url_patterns: [], bypass_global_group_limit: false, + dashboard_url: null, inserted_at: new Date().toISOString(), updated_at: new Date().toISOString(), getStep() { diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRowDetails.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRowDetails.tsx index aa3ba0778..331800c8d 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/GuideRowDetails.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/GuideRowDetails.tsx @@ -3,6 +3,8 @@ import { Box, Stack } from "@telegraph/layout"; import { Tooltip } from "@telegraph/tooltip"; import { Text } from "@telegraph/typography"; +import { useGuideContext } from "@knocklabs/react-core"; + import { StatusColor, GuideAnnotatedStatusDot as StatusDot, @@ -120,6 +122,8 @@ export const GuideRowDetails = ({ }: { guide: AnnotatedGuide | UncommittedGuide; }) => { + const { client } = useGuideContext(); + if (isUncommittedGuide(guide)) { return ( @@ -191,6 +195,13 @@ export const GuideRowDetails = ({ gap="1" style={{ alignSelf: "stretch" }} > + {dashboardUrl && (