-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: tracker-util to register and track events (#15)
- Loading branch information
1 parent
e87038b
commit a4287aa
Showing
13 changed files
with
5,642 additions
and
6,394 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
*.js | ||
*.js.map | ||
*.d.ts | ||
!lib/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
lib/* |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
## @fundwave/trackers | ||
|
||
A utility that traverses the DOM and registers listeners to elements (even those present within shadowDOMs) from a provided list of events that are to be tracked. | ||
|
||
### 1. How it works | ||
|
||
The consumer can either provide a store configuration and the events will be fetched and parsed from the store or an array of events to be tracked. | ||
|
||
Schema of an event-config: | ||
|
||
```json | ||
{ | ||
"jsPath": "document.querySelector...", | ||
"location": "/dashboard", // glob pattern of the url where the target-element is to be tracked | ||
"title": "Clicked at dashboard edit-button", | ||
"event": "click" // type of event | All js events are supported | ||
} | ||
``` | ||
|
||
### 2. Usage | ||
|
||
```js | ||
const tracker = new Trackers({ | ||
store: { | ||
type: "notion", | ||
context: { | ||
url: "URL_TO_RETRIEVE_NOTION_DB", | ||
pageId: "<<PAGE_ID>>", | ||
}, | ||
}, | ||
track: yourTrackingMethod, | ||
debug: true, | ||
}); | ||
|
||
tracker.initialize(); | ||
``` |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,119 @@ | ||
import { getNodeTree, matchPathPattern } from "./utils/index.js"; | ||
import { fetchEvents } from "./stores/index.js"; | ||
import { IEvent, TrackerContext } from "./interfaces/index.js"; | ||
|
||
export class Trackers { | ||
observer: MutationObserver; | ||
track: TrackerContext["track"]; | ||
events: IEvent[]; | ||
store: TrackerContext["store"]; | ||
debug: boolean; | ||
onEventsFetched: TrackerContext["onEventsFetched"]; | ||
|
||
constructor(context: TrackerContext) { | ||
if (!Boolean(context.track)) throw new Error("Missing required `track` method!"); | ||
|
||
this.store = context.store; | ||
this.observer = new MutationObserver(() => this.registerTrackers()); | ||
this.track = context.track; | ||
this.events = []; | ||
this.debug = Boolean(context.debug); | ||
if (context.onEventsFetched) this.onEventsFetched = context.onEventsFetched; | ||
} | ||
|
||
/** | ||
* Fetches events and starts registering observers to begin tracking | ||
* note: optionally can pass events, if not they'll be fetched from the store config | ||
* @param {IEvent[]} events (optional) - array of events to perform the tracking for | ||
**/ | ||
async initialize(events?: IEvent[]) { | ||
if ((window as any).fwTrackersRegistered) return; | ||
|
||
if (!events?.length && !this.store) { | ||
console.error("Malformed configuration. Please provide store config or list of events"); | ||
throw new Error("Malformed configuration. Please provide store config or list of events"); | ||
} | ||
|
||
try { | ||
if (events?.length) this.events = events; | ||
else this.events = (await fetchEvents(this.store)) || []; | ||
|
||
if (!this.events) return this.#debug("No events fetched from store: ", this.store); | ||
|
||
if (this.onEventsFetched) this.events = await this.onEventsFetched(this.events); | ||
this.#debug("Events fetch from store", { events: this.events, store: this.store }); | ||
|
||
this.registerTrackers(); | ||
this.observer.observe(document, { subtree: true, attributes: true, childList: true }); | ||
|
||
(window as any).fwTrackersRegistered = true; | ||
} catch (error) { | ||
console.warn("Error while registering event-trackers", error); | ||
throw error; | ||
} | ||
} | ||
|
||
/** | ||
* identifies targets and attaches required listeners for tracking | ||
**/ | ||
registerTrackers() { | ||
(this.events || []).forEach((eventConfig) => { | ||
if (!eventConfig.location) return; | ||
if (!matchPathPattern(location.pathname, eventConfig.location)) return; | ||
|
||
const tree = getNodeTree(eventConfig.jsPath, document); | ||
const element = tree.destination; | ||
this.#debug("Attempting registration of trackers @ ", { tree, config: eventConfig }); | ||
|
||
if (!element) tree.shadowRoots.forEach((root) => this.observeNode(root)); | ||
|
||
if (!element || !eventConfig.title) return; | ||
|
||
const trackerRegistered = Boolean(element.getAttribute("fw-events-registered")); | ||
this.#debug("Tracking status", { element, trackerRegistered }); | ||
|
||
if (trackerRegistered) return; | ||
|
||
const eventsToTrack = (eventConfig.type || "").split(",").filter(Boolean) || []; | ||
if (!Boolean(eventsToTrack.length)) eventsToTrack.push("click"); | ||
|
||
eventsToTrack.forEach((eventType: any) => | ||
element.addEventListener(eventType, (event) => { | ||
try { | ||
if (!Boolean(this.track)) return this.#debug("Missing `track` method!"); | ||
|
||
this.#debug("Event triggered: ", eventConfig, event); | ||
this.track(eventConfig.title!, element, event, eventConfig); | ||
} catch (error) { | ||
this.#debug("Failed to register events.", error); | ||
} | ||
}) | ||
); | ||
|
||
element.setAttribute("fw-events-registered", "true"); | ||
this.#debug("Tracker registered @ ", { element, eventConfig }); | ||
}); | ||
} | ||
|
||
/** | ||
* attaches mutation-observer to provided node | ||
* @param {ShadowRoot | null} node - ShadowRoot to attach the observer to | ||
*/ | ||
observeNode(node: ShadowRoot | null) { | ||
if (!Boolean(node)) return; | ||
|
||
try { | ||
this.observer?.observe(node!, { subtree: true, attributes: true, childList: true }); | ||
this.#debug("Registering observer on target: ", node?.host || node); | ||
} catch (error) { | ||
this.#debug("Failed to register observer on target: ", { error, node: node!.host || node }); | ||
} | ||
} | ||
|
||
/* | ||
* dumps logs to console when in debug mode | ||
*/ | ||
#debug(...params: any[]) { | ||
if (this.debug) console.log(...params); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,18 @@ | ||
import { TStoreContext, supportedStores } from "../stores"; | ||
|
||
export interface IEvent { | ||
jsPath: string; | ||
location?: string; | ||
title?: string; | ||
type?: string; | ||
} | ||
|
||
export type TrackerContext = { | ||
store?: { | ||
type: (typeof supportedStores)[number]; | ||
context: TStoreContext[(typeof supportedStores)[number]]; | ||
}; | ||
track: (title: string, target: Element, event: any, config: IEvent) => any; | ||
onEventsFetched?: (events: IEvent[]) => Promise<IEvent[]>; | ||
debug?: boolean; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
import { TrackerContext } from "../interfaces/index.js"; | ||
import { fetchEventsFromNotion } from "./notion.js"; | ||
|
||
export type TStoreContext = { | ||
notion: { | ||
url: string, | ||
pageId: string; | ||
authToken?: string; | ||
}; | ||
}; | ||
|
||
export const supportedStores = ["notion"] as const; | ||
|
||
export async function fetchEvents(store: TrackerContext["store"]) { | ||
if (!store) return console.warn("Store configuration missing"); | ||
|
||
switch (store.type) { | ||
case "notion": | ||
const { context } = store; | ||
if (!Boolean(context.url)) throw new Error("Missing url to retrieve events from!"); | ||
return fetchEventsFromNotion(context); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,51 @@ | ||
import { ExtendedRecordMap } from "notion-types"; | ||
import { getSelectorForShadowRootJsPath } from "../utils"; | ||
import { IEvent } from "../interfaces/index.js"; | ||
import { parsePageId } from "notion-utils"; | ||
import { TStoreContext } from "./index.js"; | ||
|
||
export async function fetchEventsFromNotion(context: TStoreContext["notion"]) { | ||
if (!context.pageId) { | ||
console.warn("Notion-Store | Missing page-id parameter"); | ||
return; | ||
} | ||
|
||
const notionPage: ExtendedRecordMap = await fetch(context.url) | ||
.then((res) => res.json()) | ||
.catch((err) => console.log(err)); | ||
|
||
if (!notionPage) return; | ||
const pageId = parsePageId(context.pageId); | ||
|
||
const core = notionPage.block[pageId]; | ||
|
||
const collectionId = core.value.type === "collection_view_page" ? core.value.collection_id : null; | ||
|
||
if (!collectionId) return; | ||
|
||
const databaseProperties = notionPage.collection[collectionId].value.schema; | ||
if (!databaseProperties) return; | ||
|
||
return Object.values(notionPage.block).reduce((store, block) => { | ||
const pageProperties = block.value.properties; | ||
|
||
if (block.value.type !== "page" || !pageProperties) return store; | ||
|
||
const result: IEvent = { jsPath: "" }; | ||
|
||
Object.entries(pageProperties).forEach(([property, value]) => { | ||
const key = databaseProperties[property].name as keyof IEvent; | ||
|
||
if (Array.isArray(value)) value = value.toString(); | ||
if (key === "jsPath") value = getSelectorForShadowRootJsPath(value as string); | ||
|
||
result[key] = value as string; | ||
}); | ||
|
||
if (!Boolean(result.jsPath)) return store; | ||
if (!Boolean(result.location)) result.location = "/"; | ||
|
||
store.push(result); | ||
return store; | ||
}, [] as IEvent[]); | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
export const SHADOW_ROOT_IDENTIFIER = ">>>"; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,86 @@ | ||
import { SHADOW_ROOT_IDENTIFIER } from "./constants.js"; | ||
|
||
/** | ||
* retrieves an element for a provided selector | ||
* @param {string} selector - css-selector | ||
* @param {Document | ShadowRoot | null} context - context from where to start tree-traversal | ||
* @returns {Element | null} | ||
*/ | ||
export function getElementFromSelector(selector: string = "", context: Document | ShadowRoot | null = document): Element | null { | ||
if (context === null) context = document; | ||
|
||
if (!context?.querySelector) { | ||
console.warn("Provided context doesn't provide `querySelector` handler"); | ||
return null; | ||
} | ||
|
||
if (!selector.includes(SHADOW_ROOT_IDENTIFIER)) return context!.querySelector(selector); | ||
|
||
const [current, ...nested] = selector.split(SHADOW_ROOT_IDENTIFIER); | ||
const element = context!.querySelector(current); | ||
if (!nested) return element; | ||
|
||
return getElementFromSelector(nested.join(SHADOW_ROOT_IDENTIFIER), element?.shadowRoot); | ||
} | ||
|
||
/** | ||
* converts provided js-path string into traversable string | ||
* note: helps avoid performing eval over a bare js-path | ||
* @param {string} jsPath - js-path to be converted | ||
* @returns {string} | ||
*/ | ||
export function getSelectorForShadowRootJsPath(jsPath: string = "") { | ||
if (!jsPath.includes("document") || !jsPath.includes("querySelector")) return null; | ||
|
||
return Array.from(jsPath.matchAll(/\"([^\"]*)\"/g)) | ||
.map(([_, selector]) => selector) | ||
.join(` ${SHADOW_ROOT_IDENTIFIER} `); | ||
} | ||
|
||
/** | ||
* matches provided path with given glob-pattern | ||
* note: only works for basic global-patterns in urls | ||
* @param {string} path - path to match the pattern with | ||
* @param {string} pattern - glob-pattern for urls | ||
* @returns {boolean} | ||
**/ | ||
export function matchPathPattern(path: string | string[], pattern: string | string[]): boolean { | ||
if (!Array.isArray(path)) path = path.split("/").filter(Boolean); | ||
if (!Array.isArray(pattern)) pattern = pattern.split("/").filter(Boolean); | ||
|
||
for (let [index, patternDir] of pattern.entries()) { | ||
if (patternDir === "**") return true; | ||
else if (patternDir === "*") { | ||
if (Boolean(pattern[index + 1])) continue; | ||
return !Boolean(path[index + 1]); | ||
} | ||
else if (patternDir === path[index]) { | ||
if (!pattern[index + 1] && !path[index + 1]) return true; | ||
} else return false; | ||
} | ||
|
||
return false; | ||
} | ||
|
||
/** | ||
* retrieves the DOM-element for the provided selector, along with all of it's parent shadowRoots | ||
* @param {string} selector - css-selector | ||
* @param {Document | ShadowRoot | null} context - context from where to start tree-traversal | ||
* @returns {{ destination: Element | null, context: Document | ShadowRoot, shadowRoots: ShadowRoot[] }} | ||
*/ | ||
export function getNodeTree(selector: string, context: Document | ShadowRoot) { | ||
const tree = selector.split(SHADOW_ROOT_IDENTIFIER); | ||
|
||
const nodeTreeContext = { destination: null as Element | null, context, shadowRoots: [] as ShadowRoot[] }; | ||
|
||
for (selector of tree) { | ||
const element = getElementFromSelector(selector, nodeTreeContext.context); | ||
if (!element) return nodeTreeContext; | ||
|
||
if (element.shadowRoot) nodeTreeContext.shadowRoots.push(element.shadowRoot); | ||
if (tree.indexOf(selector) === tree.length - 1) nodeTreeContext.destination = element; | ||
if (element.shadowRoot) nodeTreeContext.context = element.shadowRoot; | ||
} | ||
|
||
return nodeTreeContext; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,23 @@ | ||
{ | ||
"name": "@fw-components/trackers", | ||
"version": "2.0.2-trackers.5", | ||
"description": "Usage trackers for web components", | ||
"publishConfig": { | ||
"access": "public" | ||
}, | ||
"main": "index.js", | ||
"scripts": { | ||
"prepublishOnly": "npm run build", | ||
"build": "tsc --composite false", | ||
"dev": "tsc -w --composite false" | ||
}, | ||
"author": "The Fundwave Authors", | ||
"license": "ISC", | ||
"dependencies": { | ||
"notion-utils": "^6.16.0" | ||
}, | ||
"devDependencies": { | ||
"notion-types": "^6.16.0", | ||
"typescript": "^5.2.2" | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
{ | ||
"extends": "../tsconfig.json", | ||
"compilerOptions": { | ||
"rootDir": "lib", | ||
"outDir": ".", | ||
"allowJs": true | ||
}, | ||
"include": ["lib"] | ||
} |