Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/good-beers-wink.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@knocklabs/react": patch
---

[guides] add support for focused_guide_key param in toolbar run config
11 changes: 6 additions & 5 deletions packages/react/src/modules/guide/components/Toolbar/V2/V2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,15 +53,16 @@ export const V2 = () => {
const { client } = useGuideContext();

const [guidesListDisplayOption, setGuidesListDisplayOption] =
React.useState<DisplayOption>("only-displayable");
React.useState<DisplayOption>("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 () => {
Expand All @@ -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;
}
Expand Down
39 changes: 27 additions & 12 deletions packages/react/src/modules/guide/components/Toolbar/V2/helpers.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,70 @@
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<KnockGuide["key"], true>;
};

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)
}
};

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);
}
Expand All @@ -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)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ import {
} from "@knocklabs/client";
import { useGuideContext, useStore } from "@knocklabs/react-core";

import { ToolbarV2RunConfig } from "./helpers";

const byKey = <T extends { key: string }>(items: T[]) => {
return items.reduce((acc, item) => ({ ...acc, [item.key]: item }), {});
};
Expand Down Expand Up @@ -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<
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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,
};
Expand Down
50 changes: 50 additions & 0 deletions packages/react/test/guide/Toolbar/V2/helpers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -121,8 +121,11 @@ const setSnapshot = (partial: Record<string, unknown>) => {
};

// 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<string, true> } = defaultRunConfig,
) => {
const { result } = renderHook(() => useInspectGuideClientStore(runConfig));
return result.current;
};

Expand Down Expand Up @@ -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();
});
});
});
Loading