From a7fe0290a1ed7ef9c26e2e3f7d35bbea9fa28b99 Mon Sep 17 00:00:00 2001 From: Peter Szerzo Date: Tue, 2 Jul 2024 15:43:14 +0200 Subject: [PATCH] Appear event handling --- packages/journey-manager/src/index.ts | 143 ++++++++++++------ .../website/src/components/Prototyping.tsx | 108 ++++++++++++- 2 files changed, 204 insertions(+), 47 deletions(-) diff --git a/packages/journey-manager/src/index.ts b/packages/journey-manager/src/index.ts index 0e468497..aefa7736 100644 --- a/packages/journey-manager/src/index.ts +++ b/packages/journey-manager/src/index.ts @@ -70,7 +70,7 @@ interface LoadStep { } /** - * Click step + * Step with additional query */ export interface StepWithQuery { /** @@ -91,6 +91,16 @@ export interface StepWithQuery { urlCondition?: UrlCondition; } +/** + * Step with query and found elements + */ +export type StepWithQueryAndElements = StepWithQuery & { + /** + * Elements found + */ + elements?: HTMLElement[]; +}; + const debounce = (func: () => void, timeout = 300): (() => void) => { let timer: NodeJS.Timer | null = null; return () => { @@ -126,6 +136,44 @@ const matchesUrlCondition = (urlCondition: UrlCondition): boolean => { return false; }; +const withElements = async ( + steps: StepWithQuery[], +): Promise => { + const targets = await Promise.all( + steps + .filter( + ({ urlCondition }) => + urlCondition == null || matchesUrlCondition(urlCondition), + ) + .map(async (step) => { + try { + return { + ...step, + elements: await find(step.query), + }; + } catch (e) { + return step; + } + }), + ); + return targets; +}; + +const withElementsSync = ( + steps: StepWithQuery[], +): StepWithQueryAndElements[] => { + return filterMap(steps, (step) => { + if (step.urlCondition != null && !matchesUrlCondition(step.urlCondition)) { + return null; + } + const elements = getAll(step.query); + if (elements.length === 0) { + return null; + } + return { ...step, elements }; + }); +}; + const localStorageKey = (conversationId: string): string => `jb-triggered-steps-${conversationId}`; @@ -317,17 +365,18 @@ export const run = async (props: RunProps): Promise => { * Handle load steps */ - const loadSteps: LoadStep[] = Object.entries(triggers).reduce( - (prev: LoadStep[], [stepId, trigger]: [StepId, Trigger]) => { + const loadSteps: LoadStep[] = filterMap( + Object.entries(triggers), + ([stepId, trigger]: [StepId, Trigger]) => { if (trigger.event === "pageLoad") { - return [ - ...prev, - { stepId, urlCondition: trigger.urlCondition, once: trigger.once }, - ]; + return { + stepId, + urlCondition: trigger.urlCondition, + once: trigger.once, + }; } - return prev; + return null; }, - [], ); let previousUrl = window.location.toString(); @@ -355,52 +404,43 @@ export const run = async (props: RunProps): Promise => { handleLoadSteps(); - const clickSteps: StepWithQuery[] = Object.entries(triggers).reduce( - (prev: StepWithQuery[], [stepId, trigger]: [StepId, Trigger]) => { + const clickSteps: StepWithQuery[] = filterMap( + Object.entries(triggers), + ([stepId, trigger]: [StepId, Trigger]) => { if (trigger.event === "click" && trigger.query != null) { - const newEntry: StepWithQuery = { + return { stepId, query: decode(trigger.query), urlCondition: trigger.urlCondition, once: trigger.once, }; - return [...prev, newEntry]; } - return prev; + return null; + }, + ); + + const appearSteps: StepWithQuery[] = filterMap( + Object.entries(triggers), + ([stepId, trigger]: [StepId, Trigger]) => { + if (trigger.event === "appear" && trigger.query != null) { + return { + stepId, + query: decode(trigger.query), + urlCondition: trigger.urlCondition, + once: trigger.once, + }; + } + return null; }, - [], ); const handleGlobalClickForAnnotations = async (ev: any): Promise => { - const targets = await Promise.all( - clickSteps - .filter( - ({ urlCondition }) => - urlCondition == null || matchesUrlCondition(urlCondition), - ) - .map(async ({ stepId, query }) => { - try { - return { - stepId, - query, - elements: await find(query), - }; - } catch (e) { - return { stepId, query }; - } - }), - ); + const targets = await withElements(clickSteps); const node = ev.target; - const clickStep: - | (StepWithQuery & { - /** - * - */ - elements?: HTMLElement[]; - }) - | undefined = targets.find(({ elements }) => - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument - (elements ?? []).some((element: HTMLElement) => element.contains(node)), + const clickStep: StepWithQueryAndElements | undefined = targets.find( + ({ elements }) => + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + (elements ?? []).some((element: HTMLElement) => element.contains(node)), ); if (clickStep != null) { sendStep(clickStep.stepId, clickStep.once ?? false); @@ -493,7 +533,22 @@ export const run = async (props: RunProps): Promise => { * Change detection */ - const documentObserver = new MutationObserver(() => { + const documentObserver = new MutationObserver((mutations) => { + // If any of the added nodes are inside matches on appear events, trigger those events + const targets = withElementsSync(appearSteps); + mutations.forEach((mutation) => { + targets.forEach(({ stepId, once, elements }) => { + if ( + (elements ?? []).some((element) => { + return [...mutation.addedNodes].some((addedNode) => { + return addedNode.contains(element); + }); + }) + ) { + sendStep(stepId, once ?? false); + } + }); + }); debouncedSetHighlights(); // If the document changed for any reason (click, popstate event etc.), check if the URL also changed // If it did, handle page load events diff --git a/packages/website/src/components/Prototyping.tsx b/packages/website/src/components/Prototyping.tsx index 28a2e604..4f3bfe6f 100644 --- a/packages/website/src/components/Prototyping.tsx +++ b/packages/website/src/components/Prototyping.tsx @@ -1,4 +1,4 @@ -import { type FC, useRef, useEffect } from "react"; +import { type FC, useRef, useEffect, useState } from "react"; const runJourneyManager = async (): Promise => { const { run } = await import("@nlxai/journey-manager"); @@ -18,7 +18,51 @@ const runJourneyManager = async (): Promise => { fontFamily: "'Neue Haas Grotesk'", }, }, - triggers: {}, + triggers: { + // Page load + "5c539d6c-ea44-455d-a138-0cb8094c09b2": { + event: "pageLoad", + }, + // Page load: URL condition + "63e5b924-7e12-47fc-a24c-655119fd32d9": { + event: "pageLoad", + urlCondition: { + operator: "contains", + value: "#tab2", + }, + }, + // Appear step + "9d668597-207d-4d1d-9136-1f6c7c89b49f": { + event: "appear", + query: { + parent: null, + options: null, + name: "QuerySelector", + target: "#error", + }, + }, + // Click step + "1cf7eeb6-1906-47a2-ac79-4bf1a1e8849e": { + event: "click", + query: { + parent: null, + name: "QuerySelector", + options: null, + target: "#click", + }, + }, + // Click step: once + "2e952b0c-0b03-4a99-b5d1-1e3fdd77839b": { + event: "click", + once: true, + query: { + parent: null, + options: null, + name: "QuerySelector", + target: "#click-once", + }, + }, + }, }); return null; }; @@ -26,6 +70,8 @@ const runJourneyManager = async (): Promise => { export const Prototyping: FC = () => { const isRun = useRef(false); + const [showError, setShowError] = useState(false); + useEffect(() => { if (isRun.current) { return; @@ -37,5 +83,61 @@ export const Prototyping: FC = () => { }); }, []); - return

Hello

; + useEffect(() => { + const timeout = setTimeout(() => { + setShowError(true); + }, 3000); + return () => { + clearTimeout(timeout); + }; + }, [setShowError]); + + const [tab, setTab] = useState(null); + + useEffect(() => { + const tabFromHash = window.location.hash.replace("#", ""); + setTab(tabFromHash === "" ? "tab1" : tabFromHash); + }, []); + + return ( +
+

Test page

+ {showError ? ( +
+ Error +
+ ) : null} +
+ + +
+ + {tab == null ? null :

{tab}

} +
+ ); };