diff --git a/.changeset/good-beers-wink.md b/.changeset/good-beers-wink.md new file mode 100644 index 000000000..da8fcd8e8 --- /dev/null +++ b/.changeset/good-beers-wink.md @@ -0,0 +1,5 @@ +--- +"@knocklabs/react": patch +--- + +[guides] add support for focused_guide_key param in toolbar run config diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx b/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx index b9ab96a3e..0342bfcec 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx +++ b/packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx @@ -53,15 +53,16 @@ export const V2 = () => { const { client } = useGuideContext(); const [guidesListDisplayOption, setGuidesListDisplayOption] = - React.useState("only-displayable"); + React.useState("all-guides"); const [runConfig, setRunConfig] = React.useState(() => getRunConfig()); - const [isCollapsed, setIsCollapsed] = React.useState(true); + const [isCollapsed, setIsCollapsed] = React.useState(false); React.useEffect(() => { + const { isVisible = false, focusedGuideKeys = {} } = runConfig || {}; const isDebugging = client.store.state.debug?.debugging; - if (runConfig?.isVisible && !isDebugging) { - client.setDebug(); + if (isVisible && !isDebugging) { + client.setDebug({ focusedGuideKeys }); } return () => { @@ -77,7 +78,7 @@ export const V2 = () => { initialPosition: { top: 16, right: 16 }, }); - const result = useInspectGuideClientStore(); + const result = useInspectGuideClientStore(runConfig); if (!result || !runConfig?.isVisible) { return null; } diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/helpers.ts b/packages/react/src/modules/guide/components/Toolbar/V2/helpers.ts index 4f760e703..913029a5f 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/helpers.ts +++ b/packages/react/src/modules/guide/components/Toolbar/V2/helpers.ts @@ -1,48 +1,59 @@ +import { KnockGuide } from "@knocklabs/client"; + import { checkForWindow } from "../../../../../modules/core"; // Use this param to start Toolbar and enter into a debugging session when // it is present and set to true. const TOOLBAR_QUERY_PARAM = "knock_guide_toolbar"; +// Optional, when present pin/focus on this guide. +const GUIDE_KEY_PARAM = "focused_guide_key"; + // Use this key to read and write the run config data. const LOCAL_STORAGE_KEY = "knock_guide_debug"; -type ToolbarV2RunConfig = { +export type ToolbarV2RunConfig = { isVisible: boolean; + focusedGuideKeys?: Record; }; -export const getRunConfig = (): ToolbarV2RunConfig | undefined => { +export const getRunConfig = (): ToolbarV2RunConfig => { + const fallback = { isVisible: false }; + const win = checkForWindow(); if (!win || !win.location) { - return undefined; + return fallback; } const urlSearchParams = new URLSearchParams(win.location.search); const toolbarParamValue = urlSearchParams.get(TOOLBAR_QUERY_PARAM); + const guideKeyParamValue = urlSearchParams.get(GUIDE_KEY_PARAM); // If toolbar param detected in the URL, write to local storage before // returning. if (toolbarParamValue !== null) { - const config = { + const config: ToolbarV2RunConfig = { isVisible: toolbarParamValue === "true", }; + if (guideKeyParamValue) { + config.focusedGuideKeys = { [guideKeyParamValue]: true }; + } + writeRunConfigLS(config); return config; } // If not detected, check local storage for a persisted run config. If not // present then fall back to a default config. - return ( - readRunConfigLS() || { - isVisible: false, - } - ); + return readRunConfigLS() || fallback; }; const writeRunConfigLS = (config: ToolbarV2RunConfig) => { const win = checkForWindow(); + if (!win || !win.localStorage) return; + try { - win?.localStorage?.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); + win.localStorage.setItem(LOCAL_STORAGE_KEY, JSON.stringify(config)); } catch { // localStorage may be unavailable (e.g. private browsing) } @@ -50,8 +61,10 @@ const writeRunConfigLS = (config: ToolbarV2RunConfig) => { const readRunConfigLS = (): ToolbarV2RunConfig | undefined => { const win = checkForWindow(); + if (!win || !win.localStorage) return undefined; + try { - const stored = win?.localStorage?.getItem(LOCAL_STORAGE_KEY); + const stored = win.localStorage.getItem(LOCAL_STORAGE_KEY); if (stored) { return JSON.parse(stored); } @@ -63,8 +76,10 @@ const readRunConfigLS = (): ToolbarV2RunConfig | undefined => { export const clearRunConfigLS = () => { const win = checkForWindow(); + if (!win || !win.localStorage) return; + try { - win?.localStorage?.removeItem(LOCAL_STORAGE_KEY); + win.localStorage.removeItem(LOCAL_STORAGE_KEY); } catch { // localStorage may be unavailable (e.g. private browsing) } diff --git a/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts b/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts index 188c7c271..85cf5eba9 100644 --- a/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts +++ b/packages/react/src/modules/guide/components/Toolbar/V2/useInspectGuideClientStore.ts @@ -9,6 +9,8 @@ import { } from "@knocklabs/client"; import { useGuideContext, useStore } from "@knocklabs/react-core"; +import { ToolbarV2RunConfig } from "./helpers"; + const byKey = (items: T[]) => { return items.reduce((acc, item) => ({ ...acc, [item.key]: item }), {}); }; @@ -115,7 +117,7 @@ export type UnknownGuide = { export type InspectionResult = { guides: (AnnotatedGuide | UnknownGuide)[]; - error?: "no_guide_group"; + error?: "no_guide_group" | "no_guide_present"; }; type StoreStateSnapshot = Pick< @@ -394,7 +396,9 @@ const newUnknownGuide = (key: KnockGuide["key"]) => }, }) as UnknownGuide; -export const useInspectGuideClientStore = (): InspectionResult | undefined => { +export const useInspectGuideClientStore = ( + runConfig: ToolbarV2RunConfig, +): InspectionResult | undefined => { const { client } = useGuideContext(); // Extract a snapshot of the client store state for debugging. @@ -440,6 +444,20 @@ export const useInspectGuideClientStore = (): InspectionResult | undefined => { return annotateGuide(guide, snapshot, groupStage); }); + // Check if the focused guide actually exists and is selectable on the page. + if (groupStage?.status === "closed" && runConfig.focusedGuideKeys) { + const focusableGuide = orderedGuides.find( + (g) => + runConfig.focusedGuideKeys![g.key] && g.annotation.selectable.status, + ); + if (!focusableGuide) { + return { + error: "no_guide_present", + guides: [], + }; + } + } + return { guides: orderedGuides, }; diff --git a/packages/react/test/guide/Toolbar/V2/helpers.test.ts b/packages/react/test/guide/Toolbar/V2/helpers.test.ts index 7e6c9b296..124ce998a 100644 --- a/packages/react/test/guide/Toolbar/V2/helpers.test.ts +++ b/packages/react/test/guide/Toolbar/V2/helpers.test.ts @@ -118,6 +118,56 @@ describe("Toolbar V2 helpers", () => { expect(config).toEqual({ isVisible: false }); }); + test("includes focusedGuideKeys when focused_guide_key URL param is present", () => { + Object.defineProperty(window, "location", { + value: { + search: "?knock_guide_toolbar=true&focused_guide_key=my_guide", + }, + writable: true, + configurable: true, + }); + + const config = getRunConfig(); + + expect(config).toEqual({ + isVisible: true, + focusedGuideKeys: { my_guide: true }, + }); + }); + + test("writes focusedGuideKeys to localStorage when focused_guide_key param is present", () => { + Object.defineProperty(window, "location", { + value: { + search: "?knock_guide_toolbar=true&focused_guide_key=my_guide", + }, + writable: true, + configurable: true, + }); + + getRunConfig(); + + expect(setItemSpy).toHaveBeenCalledWith( + LOCAL_STORAGE_KEY, + JSON.stringify({ + isVisible: true, + focusedGuideKeys: { my_guide: true }, + }), + ); + }); + + test("does not include focusedGuideKeys when focused_guide_key param is absent", () => { + Object.defineProperty(window, "location", { + value: { search: "?knock_guide_toolbar=true" }, + writable: true, + configurable: true, + }); + + const config = getRunConfig(); + + expect(config).toEqual({ isVisible: true }); + expect(config).not.toHaveProperty("focusedGuideKeys"); + }); + test("URL param takes precedence over localStorage", () => { Object.defineProperty(window, "location", { value: { search: "?knock_guide_toolbar=false" }, diff --git a/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts b/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts index 55c59b50a..a88b7f0fe 100644 --- a/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts +++ b/packages/react/test/guide/Toolbar/V2/useInspectGuideClientStore.test.ts @@ -121,8 +121,11 @@ const setSnapshot = (partial: Record) => { }; // Shorthand for rendering the hook and extracting the result. -const renderInspect = () => { - const { result } = renderHook(() => useInspectGuideClientStore()); +const defaultRunConfig = { isVisible: true }; +const renderInspect = ( + runConfig: { isVisible: boolean; focusedGuideKeys?: Record } = defaultRunConfig, +) => { + const { result } = renderHook(() => useInspectGuideClientStore(runConfig)); return result.current; }; @@ -1542,4 +1545,102 @@ describe("useInspectGuideClientStore", () => { ).toBe(true); }); }); + + // ----- focused guide (no_guide_present) ----- + + describe("focused guide filtering", () => { + test("returns no_guide_present when focused guide is not selectable on closed stage", () => { + mockGroupStage = { + status: "closed", + ordered: ["g1"], + resolved: "g1", + timeoutId: null, + results: { + key: { g1: { one: makeSelectionResult() } }, + }, + }; + const guide = makeGuide({ key: "g1" }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect({ + isVisible: true, + focusedGuideKeys: { other_guide: true }, + }); + expect(result).toEqual({ error: "no_guide_present", guides: [] }); + }); + + test("returns guides normally when focused guide is selectable", () => { + mockGroupStage = { + status: "closed", + ordered: ["g1"], + resolved: "g1", + timeoutId: null, + results: { + key: { g1: { one: makeSelectionResult() } }, + }, + }; + const guide = makeGuide({ key: "g1" }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect({ + isVisible: true, + focusedGuideKeys: { g1: true }, + }); + expect(result!.guides).toHaveLength(1); + expect(result!.guides[0]!.key).toBe("g1"); + expect(result!.error).toBeUndefined(); + }); + + test("skips focused guide check when focusedGuideKeys is not set", () => { + mockGroupStage = { + status: "closed", + ordered: ["g1"], + resolved: "g1", + timeoutId: null, + results: { + key: { g1: { one: makeSelectionResult() } }, + }, + }; + const guide = makeGuide({ key: "g1" }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect({ isVisible: true }); + expect(result!.guides).toHaveLength(1); + expect(result!.error).toBeUndefined(); + }); + + test("skips focused guide check when stage is not closed", () => { + mockGroupStage = { + status: "open", + ordered: [], + results: {}, + timeoutId: null, + }; + const guide = makeGuide({ key: "g1" }); + setSnapshot({ + guideGroups: [makeGuideGroup(["g1"])], + guides: { g1: guide }, + ineligibleGuides: {}, + }); + + const result = renderInspect({ + isVisible: true, + focusedGuideKeys: { missing: true }, + }); + expect(result!.guides).toHaveLength(1); + expect(result!.error).toBeUndefined(); + }); + }); });