Skip to content

Commit

Permalink
feat: tracker-util to register and track events (#15)
Browse files Browse the repository at this point in the history
  • Loading branch information
paras-fundwave committed Jan 3, 2024
1 parent e87038b commit a4287aa
Show file tree
Hide file tree
Showing 13 changed files with 5,642 additions and 6,394 deletions.
2 changes: 1 addition & 1 deletion lerna.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
"packages/*"
],
"useWorkspaces": true,
"version": "2.0.1",
"version": "2.0.2-trackers.5",
"command": {
"version": {
"message": "chore(release): publish %s \n [skip ci]"
Expand Down
11,663 changes: 5,270 additions & 6,393 deletions package-lock.json

Large diffs are not rendered by default.

4 changes: 4 additions & 0 deletions packages/trackers/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
*.js
*.js.map
*.d.ts
!lib/*
1 change: 1 addition & 0 deletions packages/trackers/.npmignore
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
lib/*
36 changes: 36 additions & 0 deletions packages/trackers/README.md
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();
```
119 changes: 119 additions & 0 deletions packages/trackers/lib/index.ts
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);
}
}
18 changes: 18 additions & 0 deletions packages/trackers/lib/interfaces/index.ts
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;
};
23 changes: 23 additions & 0 deletions packages/trackers/lib/stores/index.ts
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);
}
}
51 changes: 51 additions & 0 deletions packages/trackers/lib/stores/notion.ts
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[]);
}
1 change: 1 addition & 0 deletions packages/trackers/lib/utils/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export const SHADOW_ROOT_IDENTIFIER = ">>>";
86 changes: 86 additions & 0 deletions packages/trackers/lib/utils/index.ts
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;
}
23 changes: 23 additions & 0 deletions packages/trackers/package.json
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"
}
}
9 changes: 9 additions & 0 deletions packages/trackers/tsconfig.json
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"]
}

0 comments on commit a4287aa

Please sign in to comment.