diff --git a/examples/guide-example/src/App.tsx b/examples/guide-example/src/App.tsx index ab59a88ce..9d3204fe8 100644 --- a/examples/guide-example/src/App.tsx +++ b/examples/guide-example/src/App.tsx @@ -65,6 +65,7 @@ function App() { readyToTarget={true} listenForUpdates={true} colorMode={colorMode} + toolbar="v2" >

Knock In-App Guide Example

diff --git a/packages/client/src/clients/guide/client.ts b/packages/client/src/clients/guide/client.ts index a434eaa53..742c073d8 100644 --- a/packages/client/src/clients/guide/client.ts +++ b/packages/client/src/clients/guide/client.ts @@ -270,13 +270,15 @@ export class KnockGuideClient { ) { const { trackLocationFromWindow = true, + // TODO(KNO-11523): Remove once we ship guide toolbar v2, and offload as + // much debugging specific logic and responsibilities to toolbar. + trackDebugParams = false, throttleCheckInterval = DEFAULT_COUNTER_INCREMENT_INTERVAL, } = options; const win = checkForWindow(); const location = trackLocationFromWindow ? win?.location?.href : undefined; - - const debug = detectDebugParams(); + const debug = trackDebugParams ? detectDebugParams() : undefined; this.store = new Store({ guideGroups: [], @@ -412,8 +414,9 @@ export class KnockGuideClient { const params = { ...this.targetParams, user_id: this.knock.userId, - force_all_guides: debugState.forcedGuideKey ? true : undefined, - preview_session_id: debugState.previewSessionId || undefined, + force_all_guides: + debugState?.forcedGuideKey || debugState?.debugging ? true : undefined, + preview_session_id: debugState?.previewSessionId || undefined, }; const newChannel = this.socket.channel(this.socketChannelTopic, params); @@ -561,6 +564,39 @@ export class KnockGuideClient { } } + setDebug(debugOpts?: Omit) { + this.knock.log("[Guide] .setDebug()"); + const shouldRefetch = !this.store.state.debug?.debugging; + + this.store.setState((state) => ({ + ...state, + debug: { ...debugOpts, debugging: true }, + })); + + if (shouldRefetch) { + this.knock.log( + `[Guide] Start debugging, refetching guides and resubscribing to the websocket channel`, + ); + this.fetch(); + this.subscribe(); + } + } + + unsetDebug() { + this.knock.log("[Guide] .unsetDebug()"); + const shouldRefetch = this.store.state.debug?.debugging; + + this.store.setState((state) => ({ ...state, debug: undefined })); + + if (shouldRefetch) { + this.knock.log( + `[Guide] Stop debugging, refetching guides and resubscribing to the websocket channel`, + ); + this.fetch(); + this.subscribe(); + } + } + // // Store selector // @@ -924,7 +960,7 @@ export class KnockGuideClient { // Get the next unarchived step. getStep() { // If debugging this guide, return the first step regardless of archive status - if (self.store.state.debug.forcedGuideKey === this.key) { + if (self.store.state.debug?.forcedGuideKey === this.key) { return this.steps[0]; } @@ -981,7 +1017,7 @@ export class KnockGuideClient { // Append debug params const debugState = this.store.state.debug; - if (debugState.forcedGuideKey) { + if (debugState?.forcedGuideKey || debugState?.debugging) { combinedParams.force_all_guides = true; } @@ -1150,8 +1186,15 @@ export class KnockGuideClient { this.knock.log(`[Guide] Detected a location change: ${href}`); + if (!this.options.trackDebugParams) { + this.setLocation(href); + return; + } + + // TODO(KNO-11523): Remove below once we ship toolbar v2. + // If entering debug mode, fetch all guides. - const currentDebugParams = this.store.state.debug; + const currentDebugParams = this.store.state.debug || {}; const newDebugParams = detectDebugParams(); this.setLocation(href, { debug: newDebugParams }); diff --git a/packages/client/src/clients/guide/types.ts b/packages/client/src/clients/guide/types.ts index 47e5f2093..0327d8270 100644 --- a/packages/client/src/clients/guide/types.ts +++ b/packages/client/src/clients/guide/types.ts @@ -202,6 +202,7 @@ export type QueryStatus = { }; export type DebugState = { + debugging?: boolean; forcedGuideKey?: string | null; previewSessionId?: string | null; }; @@ -218,7 +219,7 @@ export type StoreState = { queries: Record; location: string | undefined; counter: number; - debug: DebugState; + debug?: DebugState; }; export type QueryFilterParams = Pick; @@ -241,6 +242,7 @@ export type TargetParams = { export type ConstructorOpts = { trackLocationFromWindow?: boolean; + trackDebugParams?: boolean; orderResolutionDuration?: number; throttleCheckInterval?: number; }; diff --git a/packages/client/test/clients/guide/guide.test.ts b/packages/client/test/clients/guide/guide.test.ts index 2c43dbf8c..e68a710fd 100644 --- a/packages/client/test/clients/guide/guide.test.ts +++ b/packages/client/test/clients/guide/guide.test.ts @@ -165,7 +165,7 @@ describe("KnockGuideClient", () => { queries: {}, location: undefined, counter: 0, - debug: { forcedGuideKey: null, previewSessionId: null }, + debug: undefined, }); }); @@ -194,7 +194,7 @@ describe("KnockGuideClient", () => { queries: {}, location: "https://example.com", counter: 0, - debug: { forcedGuideKey: null, previewSessionId: null }, + debug: undefined, }); }); @@ -211,7 +211,7 @@ describe("KnockGuideClient", () => { queries: {}, location: undefined, counter: 0, - debug: { forcedGuideKey: null, previewSessionId: null }, + debug: undefined, }); }); @@ -234,7 +234,7 @@ describe("KnockGuideClient", () => { }); expect(() => { - new KnockGuideClient(mockKnock, channelId); + new KnockGuideClient(mockKnock, channelId, {}, { trackDebugParams: true }); }).not.toThrow(); expect(mockLocalStorageWithErrors.setItem).toHaveBeenCalled(); @@ -2959,7 +2959,7 @@ describe("KnockGuideClient", () => { mockKnock, channelId, {}, - { trackLocationFromWindow: true }, + { trackLocationFromWindow: true, trackDebugParams: true }, ); client.store.state.debug = { forcedGuideKey: null }; @@ -3016,7 +3016,7 @@ describe("KnockGuideClient", () => { mockKnock, channelId, {}, - { trackLocationFromWindow: true }, + { trackLocationFromWindow: true, trackDebugParams: true }, ); client.store.state.debug = { forcedGuideKey: "test_guide" }; @@ -3271,4 +3271,118 @@ describe("KnockGuideClient", () => { ); }); }); + + describe("setDebug", () => { + test("sets debug state with debugging: true", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + const fetchSpy = vi + .spyOn(client, "fetch") + .mockImplementation(() => Promise.resolve({ status: "ok" })); + const subscribeSpy = vi + .spyOn(client, "subscribe") + .mockImplementation(() => {}); + + client.setDebug(); + + expect(client.store.state.debug!.debugging!).toBe(true); + + // calls fetch and subscribe when not already debugging + expect(fetchSpy).toHaveBeenCalled(); + expect(subscribeSpy).toHaveBeenCalled(); + }); + + test("sets debug state with provided options", () => { + 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({ forcedGuideKey: "test_guide" }); + + expect(client.store.state.debug!.debugging!).toBe(true); + expect(client.store.state.debug!.forcedGuideKey!).toBe("test_guide"); + }); + + test("does not call fetch and subscribe when already debugging", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = { debugging: true }; + + const fetchSpy = vi + .spyOn(client, "fetch") + .mockImplementation(() => Promise.resolve({ status: "ok" })); + const subscribeSpy = vi + .spyOn(client, "subscribe") + .mockImplementation(() => {}); + + client.setDebug(); + + expect(client.store.state.debug!.debugging!).toBe(true); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + }); + + describe("unsetDebug", () => { + test("sets debug state to undefined", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = { debugging: true }; + + const fetchSpy = vi + .spyOn(client, "fetch") + .mockImplementation(() => Promise.resolve({ status: "ok" })); + const subscribeSpy = vi + .spyOn(client, "subscribe") + .mockImplementation(() => {}); + + client.unsetDebug(); + + expect(client.store.state.debug).toBe(undefined); + + // calls fetch and subscribe when was debugging + expect(fetchSpy).toHaveBeenCalled(); + expect(subscribeSpy).toHaveBeenCalled(); + }); + + test("does not call fetch and subscribe when was not debugging", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = undefined; + + const fetchSpy = vi + .spyOn(client, "fetch") + .mockImplementation(() => Promise.resolve({ status: "ok" })); + const subscribeSpy = vi + .spyOn(client, "subscribe") + .mockImplementation(() => {}); + + client.unsetDebug(); + + expect(client.store.state.debug).toBe(undefined); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + + test("does not call fetch and subscribe when debug exists but debugging is false", () => { + const client = new KnockGuideClient(mockKnock, channelId); + client.store.state.debug = { debugging: false }; + + const fetchSpy = vi + .spyOn(client, "fetch") + .mockImplementation(() => Promise.resolve({ status: "ok" })); + const subscribeSpy = vi + .spyOn(client, "subscribe") + .mockImplementation(() => {}); + + client.unsetDebug(); + + expect(client.store.state.debug).toBe(undefined); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(subscribeSpy).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx index 0d2de3354..b69f3ba33 100644 --- a/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx +++ b/packages/react-core/src/modules/guide/context/KnockGuideProvider.tsx @@ -23,6 +23,7 @@ export type KnockGuideProviderProps = { colorMode?: ColorMode; targetParams?: KnockGuideTargetParams; trackLocationFromWindow?: boolean; + trackDebugParams?: boolean; orderResolutionDuration?: number; // in milliseconds throttleCheckInterval?: number; // in milliseconds }; @@ -36,6 +37,10 @@ export const KnockGuideProvider: React.FC< colorMode = "light", targetParams = {}, trackLocationFromWindow = true, + // Whether the guide client should look for debug params in url/local storage + // to launch guide toolbar. Set to true if using toolbar v1. + // TODO(KNO-11523): Remove this once we ship v2. + trackDebugParams = false, // Default to 0 which works well for react apps as this "yields" to react for // one render cyle first and close the group stage. orderResolutionDuration = 0, @@ -55,6 +60,7 @@ export const KnockGuideProvider: React.FC< const knockGuideClient = React.useMemo(() => { return new KnockGuideClient(knock, channelId, stableTargetParams, { trackLocationFromWindow, + trackDebugParams, orderResolutionDuration, throttleCheckInterval, }); @@ -63,6 +69,7 @@ export const KnockGuideProvider: React.FC< channelId, stableTargetParams, trackLocationFromWindow, + trackDebugParams, orderResolutionDuration, throttleCheckInterval, ]); diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index 80214ade0..97ee3fcfa 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -43,7 +43,6 @@ export { Card, CardView, KnockGuideProvider, - GuideToolbar as KnockGuideToolbar, Modal, ModalView, } from "./modules/guide"; diff --git a/packages/react/src/modules/guide/components/GuideToolbar/index.ts b/packages/react/src/modules/guide/components/GuideToolbar/index.ts deleted file mode 100644 index 54c13648f..000000000 --- a/packages/react/src/modules/guide/components/GuideToolbar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { GuideToolbar } from "./GuideToolbar"; diff --git a/packages/react/src/modules/guide/components/Toolbar/KnockButton.tsx b/packages/react/src/modules/guide/components/Toolbar/KnockButton.tsx new file mode 100644 index 000000000..e83066696 --- /dev/null +++ b/packages/react/src/modules/guide/components/Toolbar/KnockButton.tsx @@ -0,0 +1,51 @@ +import { Button } from "@telegraph/button"; + +import { MAX_Z_INDEX } from "./shared"; +import "./styles.css"; + +type Props = { + onClick: () => void; +}; + +export const KnockButton = ({ onClick }: Props) => { + return ( + + ); +}; diff --git a/packages/react/src/modules/guide/components/GuideToolbar/GuideToolbar.tsx b/packages/react/src/modules/guide/components/Toolbar/V1/V1.tsx similarity index 57% rename from packages/react/src/modules/guide/components/GuideToolbar/GuideToolbar.tsx rename to packages/react/src/modules/guide/components/Toolbar/V1/V1.tsx index cb6d3989f..c8f3e3f6c 100644 --- a/packages/react/src/modules/guide/components/GuideToolbar/GuideToolbar.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V1/V1.tsx @@ -6,13 +6,11 @@ import { Text } from "@telegraph/typography"; import { Minimize2, Undo2, Wrench } from "lucide-react"; import { useState } from "react"; -import "./styles.css"; +import { KnockButton } from "../KnockButton"; +import { MAX_Z_INDEX } from "../shared"; +import "../styles.css"; -// 'max' z index based on max value of a signed 32-bit int -// Ref: https://stackoverflow.com/questions/491052/minimum-and-maximum-value-of-z-index/25461690#25461690 -const MAX_Z_INDEX = 2147483647; - -export const GuideToolbar = () => { +export const V1 = () => { const [isCollapsed, setIsCollapsed] = useState(false); const { client } = useGuideContext(); @@ -31,46 +29,7 @@ export const GuideToolbar = () => { }; if (isCollapsed) { - return ( - - ); + return ; } return ( diff --git a/packages/react/src/modules/guide/components/Toolbar/V1/index.ts b/packages/react/src/modules/guide/components/Toolbar/V1/index.ts new file mode 100644 index 000000000..f96635a89 --- /dev/null +++ b/packages/react/src/modules/guide/components/Toolbar/V1/index.ts @@ -0,0 +1 @@ +export { V1 as ToolbarV1 } from "./V1"; diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx new file mode 100644 index 000000000..b282c75f9 --- /dev/null +++ b/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx @@ -0,0 +1,109 @@ +import { useGuideContext, useStore } from "@knocklabs/react-core"; +import { Button } from "@telegraph/button"; +import { Box, Stack } from "@telegraph/layout"; +import { Text } from "@telegraph/typography"; +import { Minimize2, Undo2 } from "lucide-react"; +import React from "react"; + +import { KnockButton } from "../KnockButton"; +import { MAX_Z_INDEX } from "../shared"; +import "../styles.css"; + +import { detectToolbarParam } from "./helpers"; + +const useInspectGuideClientStore = () => { + const { client } = useGuideContext(); + + const snapshot = useStore(client.store, (state) => { + return { + debug: state.debug, + }; + }); + + if (!snapshot.debug?.debugging) { + return; + } + + // TODO: Transform the raw client state into more useful data for debugging. + return {}; +}; + +export const V2 = () => { + const { client } = useGuideContext(); + + const [isVisible, setIsVisible] = React.useState(detectToolbarParam()); + const [isCollapsed, setIsCollapsed] = React.useState(true); + + React.useEffect(() => { + if (!isVisible) { + return; + } + + client.setDebug(); + + return () => { + client.unsetDebug(); + }; + }, [isVisible, client]); + + const data = useInspectGuideClientStore(); + if (!data) { + return null; + } + + return ( + + {isCollapsed ? ( + setIsCollapsed(false)} /> + ) : ( + + + + + Toolbar v2 placeholder + + + + + +