Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Handle appear events #115

Merged
merged 1 commit into from
Jul 2, 2024
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
143 changes: 99 additions & 44 deletions packages/journey-manager/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ interface LoadStep {
}

/**
* Click step
* Step with additional query
*/
export interface StepWithQuery {
/**
Expand All @@ -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 () => {
Expand Down Expand Up @@ -126,6 +136,44 @@ const matchesUrlCondition = (urlCondition: UrlCondition): boolean => {
return false;
};

const withElements = async (
steps: StepWithQuery[],
): Promise<StepWithQueryAndElements[]> => {
const targets = await Promise.all(
steps
.filter(
({ urlCondition }) =>
urlCondition == null || matchesUrlCondition(urlCondition),
)
.map(async (step) => {
try {
return {
...step,
elements: await find(step.query),
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

For appear on mutation observer, I'd imagine you want the sync version rather than waiting up to a second for the element to appear.

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

Expand Down Expand Up @@ -317,17 +365,18 @@ export const run = async (props: RunProps): Promise<RunOutput> => {
* 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();
Expand Down Expand Up @@ -355,52 +404,43 @@ export const run = async (props: RunProps): Promise<RunOutput> => {

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;
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
return null;

not necessary

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit returns read clearer to me.

},
);

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<void> => {
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);
Expand Down Expand Up @@ -493,7 +533,22 @@ export const run = async (props: RunProps): Promise<RunOutput> => {
* 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
Expand Down
108 changes: 105 additions & 3 deletions packages/website/src/components/Prototyping.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { type FC, useRef, useEffect } from "react";
import { type FC, useRef, useEffect, useState } from "react";

const runJourneyManager = async (): Promise<unknown> => {
const { run } = await import("@nlxai/journey-manager");
Expand All @@ -18,14 +18,60 @@ const runJourneyManager = async (): Promise<unknown> => {
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;
};

export const Prototyping: FC<unknown> = () => {
const isRun = useRef(false);

const [showError, setShowError] = useState<boolean>(false);

useEffect(() => {
if (isRun.current) {
return;
Expand All @@ -37,5 +83,61 @@ export const Prototyping: FC<unknown> = () => {
});
}, []);

return <p>Hello</p>;
useEffect(() => {
const timeout = setTimeout(() => {
setShowError(true);
}, 3000);
return () => {
clearTimeout(timeout);
};
}, [setShowError]);

const [tab, setTab] = useState<string | null>(null);

useEffect(() => {
const tabFromHash = window.location.hash.replace("#", "");
setTab(tabFromHash === "" ? "tab1" : tabFromHash);
}, []);

return (
<div>
<h1 className="text-xl">Test page</h1>
{showError ? (
<div className="p-2 rounded text-red-600 bg-red-50" id="error">
Error
</div>
) : null}
<div>
<button id="click">Trigger click</button>
<button id="click-once">Trigger click once</button>
</div>
<div className="flex gap-1">
<a
href="#tab1"
onClick={() => {
setTab("tab1");
}}
>
Tab 1
</a>
<a
href="#tab2"
onClick={() => {
setTab("tab2");
}}
>
Tab 2
</a>
<a
href="#tab3"
onClick={() => {
setTab("tab3");
}}
>
Tab 3
</a>
</div>
{tab == null ? null : <p>{tab}</p>}
</div>
);
};