From 3c05fcae1bf33bb99cc9242f5cfecf1163aacc4c Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Mon, 22 Nov 2021 15:35:39 +0000 Subject: [PATCH 1/6] added post-tracker to x-live-blog-wrapper component --- .../src/__tests__/post-tracker.test.js | 93 ++++ .../src/lib/post-tracker.js | 430 ++++++++++++++++++ 2 files changed, 523 insertions(+) create mode 100644 components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js create mode 100644 components/x-live-blog-wrapper/src/lib/post-tracker.js diff --git a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js new file mode 100644 index 000000000..19e1dd0d6 --- /dev/null +++ b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js @@ -0,0 +1,93 @@ +import { IntersectionObserverTracker } from '../lib/post-tracker' +const jsdom = require('jsdom') +const { JSDOM } = jsdom + +const doc = new JSDOM( + '
{}) + new IntersectionObserverTracker({ query: 'article[data-trackable="live-post"]', onError: spy }) + expect(spy).toHaveBeenCalledTimes(1) + }) + + it('should emit an error when a query is not passed to config', function () { + setupIntersectionObserverMock() + global.window = doc + global.window.addEventListener = jest.fn(() => {}) + global.document = doc.window + global.document.querySelectorAll = jest.fn(() => [{}]) + let spy = jest.fn(() => {}) + new IntersectionObserverTracker({ onError: spy }) + expect(spy).toHaveBeenCalledTimes(1) //.equal(1, 'onError not called when config misses query'); + }) + + it("should emit an error when a query doesn't match any elements ", function () { + setupIntersectionObserverMock() + let spy = jest.fn(() => {}) + global.window = doc + global.window.addEventListener = jest.fn(() => {}) + + global.document = doc.window + global.document.querySelectorAll = jest.fn(() => []) + new IntersectionObserverTracker({ query: 'lord-of-the-rings', onError: spy }) + expect(spy).toHaveBeenCalledTimes(1) + }) +}) + +function setupIntersectionObserverMock({ + root = null, + rootMargin = '', + thresholds = [], + disconnect = () => null, + observe = () => null, + takeRecords = () => [], + unobserve = () => null +} = {}) { + class MockIntersectionObserver { + constructor() { + this.root = root + this.rootMargin = rootMargin + this.thresholds = thresholds + this.disconnect = disconnect + this.observe = observe + this.takeRecords = takeRecords + this.unobserve = unobserve + } + } + + Object.defineProperty(window, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver + }) + + Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: MockIntersectionObserver + }) +} + +function clearMocks() { + Object.defineProperty(global, 'IntersectionObserver', { + writable: true, + configurable: true, + value: null + }) +} diff --git a/components/x-live-blog-wrapper/src/lib/post-tracker.js b/components/x-live-blog-wrapper/src/lib/post-tracker.js new file mode 100644 index 000000000..07004f1a6 --- /dev/null +++ b/components/x-live-blog-wrapper/src/lib/post-tracker.js @@ -0,0 +1,430 @@ +/** + * Callback for when an element enters the user's view port. + * + * @callback onEntersViewport + * @param {{timestamp: string, element: HTMLElement}} event - the callback triggered when a post enters the user's viewport. + */ + +/** + * Callback for the view event. + * + * @callback onRead + * @param {{post: object, viewport: object: ?element: HTMLElement, summary: object[]}} event - the view event. + */ + +/** + * Callback for the error event. + * + * @callback onError + * @param {Error} event - the error event. + */ + +/** + * @typedef ObserverOptions + * @type {object} + * @property {?HTMLElement} root - The element that is used as the viewport for checking visibility of the target. null referes to browser viewport. + * @property {string} rootMargin - Margin around the root. Can have values similar to the CSS margin property, e.g. "10px 20px 30px 40px" (top, right, bottom, left). + * @property {number[]} threshold - Either a single number or an array of numbers which indicate at what percentage of the target's visibility the observer's callback should be executed. + * + * @see {@link https://developer.mozilla.org/en-US/docs/Web/API/IntersectionObserver/IntersectionObserver} + */ + +/** + * @typedef IntersectionObserverTrackerConfig + * @type {object} + * @property {ObserverOptions} options - The options object passed into the IntersectionObserver() constructor let you control the circumstances under which the observer's callback is invoked. + * @property {string} query - The query string used to query elements from the DOM. + * @property {Boolean} returnVisibleElement - When this is set to true, listeners of the visible event will get the target element id and the element. Defaults to false + * @property {number} minMillisecondsToReport - The minimum time in milliseconds a DOM object must be visible on the users view port to report a View event as seen. defaults to 5000 + * @property {?string} observerUpdateEventString - The DOM event to listen for(on the document level) to update the elements being observed. + * @property {?string} liveBlogWrapperQuery - HTMLElement query string for the liveblogwrapper element. Used to get the element (document.querySelector) to listen for DOM events + * @property {!onEntersViewport} onEntersViewport - Function to call when a Post enters a user's viewport + * @property {!onRead} onRead - Function to call when a Post has been in the user's viewport for >= {@param minMillisecondsToReport} || 5000ms + * @property {!onError} onError - Function to call when there's an error + */ + +/** + * The height and width of the user's viewPort + * @typedef {{height: number, width: number}} ViewPort + */ + +/** + * @typedef VisibilityData + * @type {object} + * @property {number} elementHeight - the actual height (in pixels) of the element being observed + * @property {number} elementWidth - the actual width (in pixels) of the element being observed + * @property {Date} start - the Date time inwhich this specific threshold came into view + * @property {number} threshold - the percentage of the element currently visible on the users viewport. 0 -> 1 + * @property {number} time - the time in milliseconds this intersection was recorded relative to the IntersectionObserver's origin time + * @property {string} [type="INTERSECTION_DATA"] - the type of data recorded [INTERSECTION_DATA || TAB_VISIBILIYTY_DATA] + * @property {ViewPort} viewPort - the current height and width of the viewport + * @property {number} visibleHeight - the currently visibleHeight of the element on the user's viewport + * @property {number} visibleWidth - the currently visible width of the element on the user's viewport + * @property {number} xBound - The x cartesian coordinate of the left side of the element. Coordinates start from the left side of the screen. 0 = absolute left + * @property {number} yBound - The y cartesian coordinate of the top side of the element. Coordinates start from the top of the screen. 0 = absolute top + * @property {?number} duration - A computed total number of time this intersection was visible before the next intersection was recorded + */ + +/** + * @typedef TabVisibilityData + * @type {object} + * @property {Boolean} hidden - if the tab is current hidden from the user i.e browser minimised or user on a different tab + * @property {Date} timestamp - Datetime in which the visibility data was recorded + * @property {number} time - time in milliseconds inwhich the tab visibility data was recorded + * @property {string} [type="TAB_VISIBILITY_DATA"] - the type of data recorded [INTERSECTION_DATA || TAB_VISIBILITY_DATA] + */ + +const DATA_TYPES = { + intersectionData: 'INTERSECTION_DATA', + tabVisibilityDATA: 'TAB_VISIBILITY_DATA' +} + +/** + * A class representing an intersection observer tracker. + * + * Used to track how a user reads through articles on FT pages. + * + * If no elements are found IntersectionObserverTracker the onError callback is called. + */ +export class IntersectionObserverTracker { + /** + * Create a new Intersection Observer tracker + * @param {IntersectionObserverTrackerConfig} config + */ + constructor(config) { + this.config = config + this.currentlyObservedElements = new Set() + this.visibleElements = new Set() + if (config) { + this.onEntersViewport = config.onEntersViewport + this.onRead = config.onRead + this.onError = config.onError + } + this.defaultOptions = { + root: null, + rootMargin: '0px', + threshold: [0.1, 0.2, 0.5, 0.75, 0.99] + } + this.setUp() + } + + setUp() { + if (IntersectionObserver) { + if (!this.isValidConfig()) { + return + } + + if (!this.config.minMillisecondsToReport) { + // set minTimeToReport to a default value of 5000 + this.config.minMillisecondsToReport = 5000 + } + + this.observer = new IntersectionObserver((entries) => this.manageIntersection(entries), { + ...this.defaultOptions, + ...this.config.options + }) + if (this.observer) { + this.observeElements(this.config.query) + } + + document.addEventListener('visibilitychange', (event) => this.handleVisibilityChange(event)) + window.addEventListener('beforeunload', () => this.handleWindowClose()) + + // updates the list of elements currently being observed + if (this.config.observerUpdateEventString) { + /** + * @type {HTMLElement} + */ + let wrapperElement = document.querySelector(this.config.liveBlogWrapperQuery) + if (wrapperElement) { + wrapperElement.addEventListener(this.config.observerUpdateEventString, () => + this.observeElements(this.config.query) + ) + } + } + } else { + this.triggerError(new Error('Unsupported browser.')) + } + } + + /** + * checks if the IntersectionObserverTrackerConfig passed in is valid + * + * @returns {Boolean} + */ + isValidConfig() { + if (!this.config) { + // eslint wasn't happy with the spaces in the template literal. Therefore the long line error message + let errorMessage = + 'IntersectionObserverTrackerConfig is missing. \nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {\nroot: null,\nrootMargin: "0px",\nthreshold: [0.1, 0.2, 0.5, 0.75, 1]\n},\nquery: \'live-blog-post\'})' + this.triggerError(new Error(errorMessage)) + return false + } + + if (!this.config.query) { + let errorMessage = + "IntersectionObserverTrackerConfig.query is missing.\nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {...},\nquery: 'live-blog-post'})" + this.triggerError(new Error(errorMessage)) + return false + } + + return true + } + + /** + * Handles reporting when a DOM element gets into the view + * Also reports when a post has been on the viewport for more than 5s + * + * @param {IntersectionObserverEntry[]} entries + */ + manageIntersection(entries) { + entries.forEach((entry) => { + if (this.onEntersViewport) { + this.reportOnEntersViewport(entry) + } + + if (this.onRead) { + this.summariseAndReport(entry) + } + }) + } + + /** + * Adds window visibility data to dataset.visibilityData for all DOM elements if this.visibleElements + */ + handleVisibilityChange(event) { + const timestamp = new Date() + if (this.visibleElements.size > 0) { + this.visibleElements.forEach((element) => { + if (element.dataset.visibilityData) { + let visibilityData = JSON.parse(element.dataset.visibilityData) + if (Array.isArray(visibilityData)) { + let data = { + type: DATA_TYPES.tabVisibilityDATA, + hidden: document.hidden, + timestamp, + time: event.timeStamp + } + visibilityData.push(data) + element.dataset.visibilityData = JSON.stringify(visibilityData) + } + } + }) + } + } + + /** + * Starts observing a list of elements that match query. + * Calls the on error callback if no element is found. + * + * @param {string} query - query string to be used for matching + */ + observeElements(query) { + let elements = document.querySelectorAll(query) + if (query && elements.length) { + elements.forEach((element) => { + if (!this.isElementBeingObserved(element)) { + this.observer.observe(element) + this.currentlyObservedElements.add(element) + } + }) + } else { + this.triggerError( + new Error('No DOM elements found with the query passed in IntersectionObserverTrackerConfig') + ) + } + } + + /** + * Calls the onEntersViewport callback only once when the target element of an IntersectionObserverEntry enters the user's viewPort. + * + * @param {IntersectionObserverEntry} entry + */ + reportOnEntersViewport(entry) { + if (entry.isIntersecting && !entry.target.dataset.seen) { + let element = entry.target + element.dataset.seen = true + let eventData = { + timestamp: new Date().toISOString() + } + if (this.config.returnVisibleElement) { + eventData = { + ...eventData, + element + } + } + this.onEntersViewport(eventData) + } + } + + /** + * + * @param {IntersectionObserverEntry} entry + */ + summariseAndReport(entry) { + // only add summary data when threshold/intersectionRatio is greater than 0 + if (entry.intersectionRatio > 0) { + this.summariseAndStoreViewData(entry) + } + + if (!entry.isIntersecting) { + this.reportRead(entry.target) + } + + this.updateVisibleElements(entry) + } + + /** + * Gets the summary data from the IntersectionObserverEntry and stores it + * in the IntersectionObserverEntry.target.dataset.visibilityData as a + * JSON.stringified object (refers to an Array). + * + * @param {IntersectionObserverEntry} entry + */ + summariseAndStoreViewData(entry) { + const element = entry.target + const data = { + type: DATA_TYPES.intersectionData, + threshold: entry.intersectionRatio, + xBound: entry.intersectionRect.x, // x coordinate on the screen. left of screen = 0 + yBound: entry.intersectionRect.y, // y coordinate on the screen. top of screen = 0 + visibleHeight: entry.intersectionRect.height, + visibleWidth: entry.intersectionRect.width, + elementHeight: element.clientHeight, + elementWidth: element.clientWidth, + start: new Date(), + time: entry.time, + viewport: { + height: window.innerHeight, + width: window.innerWidth + } + } + if (element.dataset.visibilityData) { + let currentData = JSON.parse(element.dataset.visibilityData) + currentData.push(data) + element.dataset.visibilityData = JSON.stringify(currentData) + } else { + element.dataset.visibilityData = JSON.stringify([data]) + } + } + + /** + * Emits the View data to the consumer + * + * @param {HTMLElement} element + */ + reportRead(element) { + /** + * @type {(VisibilityData|TabVisibilityData)[]} + */ + let summary = element.dataset.visibilityData + if (summary) { + summary = JSON.parse(summary) + let viewData = { + ...this.processTime(summary), + element: this.config.returnVisibleElement ? element : null, + post: { + height: element.clientHeight, + width: element.clientWidth + }, + viewport: { + height: window.innerHeight, + width: window.innerWidth + } + } + if (viewData.duration >= this.config.minMillisecondsToReport) { + this.onRead(viewData) + } + } + + delete element.dataset.visibilityData + } + + /** + * Adds or removes a DOM element from the this.visibleElements depending on if + * the element is intersecting + * + * @param {IntersectionObserverEntry} entry + */ + updateVisibleElements(entry) { + if (entry.isIntersecting && !this.visibleElements.has(entry.target)) { + this.visibleElements.add(entry.target) + } else if (this.visibleElements.has(entry.target)) { + this.visibleElements.delete(entry.target) + } + } + + /** + * Processes the times for when the DOM element was in view + * + * @param {(VisibilityData|TabVisibilityData)[]} summary + */ + processTime(summary) { + /** + * total time visible + * time away from tab + * time a specific intersection was visible + */ + let aggregatedSummary = [] + let totalTime = 0 + const start = summary[0].start + let end + for (let i = 0; i < summary.length; i++) { + const element = summary[i] + const next = summary[i + 1] + let newElement + // calculate duration for total time spent for specific intersection + if (element.type === DATA_TYPES.intersectionData) { + newElement = { + ...element, + // if no next element, use window performance to calculate total time of intersection + duration: (next ? next.time : performance.now()) - element.time + } + totalTime += newElement.duration + } + + // this also considers situations where the user closes the tab + // while it is hidden. The last dataset should be tab_visibility_data with hidden = true + if (!next) { + end = element.timestamp || new Date().toISOString() + } + + aggregatedSummary.push(newElement || element) + } + + return { + summary: aggregatedSummary, + duration: totalTime, + start, + end + } + } + + /** + * Emits the view event for all visible elements when the user closes the browser window + */ + handleWindowClose() { + this.visibleElements.forEach((element) => this.reportRead(element)) + } + + /** + * Calls the onError callback if a function is passed to the + * @param {*} error + */ + triggerError(error) { + if (this.onError) { + this.onError(error) + } + } + + /** + * checks if an element is currently being observed + * + * @param {HTMLElement} element + * + * @returns {boolean} + */ + isElementBeingObserved(element) { + return this.currentlyObservedElements.has(element) + } +} From 24acf6fc663045c90494416835373b4e1bf658d2 Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Tue, 23 Nov 2021 17:53:44 +0000 Subject: [PATCH 2/6] added post tracker to live-blog-wrapper component --- .../src/LiveBlogWrapper.jsx | 157 ++++++++++++++---- .../src/__tests__/post-tracker.test.js | 13 +- .../src/lib/post-tracker.js | 79 ++++++--- .../x-live-blog-wrapper/storybook/index.jsx | 25 +++ 4 files changed, 211 insertions(+), 63 deletions(-) diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx index e76727ca3..4b1933a0f 100644 --- a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx +++ b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx @@ -1,9 +1,19 @@ -import { h } from '@financial-times/x-engine' +import { h, Component } from '@financial-times/x-engine' import { LiveBlogPost } from '@financial-times/x-live-blog-post' import { withActions } from '@financial-times/x-interaction' import { normalisePost } from './normalisePost' import { dispatchEvent } from './dispatchEvent' import { registerComponent } from '@financial-times/x-interaction' +import { PostTracker } from './lib/post-tracker' + +/** + * @typedef PostTrackerConfig + * @type {object} + * @property {Function} onEntersViewport - function to be called when the criteria for `view` in Post tracker has been fulfiled + * @property {Function} onRead - function to be called when the criteria for `read` in Post tracker has been fulfiled + * @property {Function} onError - function to be called when PostTracker encounters an error + * @property {Boolean} usePostTracker - condition checked before creating an instance of post tracker + */ const withLiveBlogWrapperActions = withActions({ insertPost(newPost, wrapper) { @@ -20,45 +30,120 @@ const withLiveBlogWrapperActions = withActions({ } }) -const BaseLiveBlogWrapper = ({ - posts = [], - ads = {}, - articleUrl, - showShareButtons, - id, - liveBlogWrapperElementRef -}) => { - posts.sort((a, b) => { - const timestampA = a.publishedDate || a.publishedTimestamp - const timestampB = b.publishedDate || b.publishedTimestamp - - // Newer posts on top - if (timestampA > timestampB) { - return -1 +class BaseLiveBlogWrapper extends Component { + /** + * @param {Object} props + * @param {Object[]} props.post - A list of liveblog posts + * @param {Object} props.ads + * @param {string} props.articleUrl - The base url for the article + * @param {boolean} props.showShareButtons - condition to show shareButtons + * @param {string} props.id - The id of the liveblog package + * @param {*} props.liveBlogWrapperElementRef + * @param {PostTrackerConfig} props.postTrackerConfig - Optional config for tracking post + */ + constructor(props) { + super(props) + } + + componentDidMount() { + this.setUp() + } + + componentWillUnmount() { + if (this.state.tracker) { + this.state.tracker.destroy() + } + } + + setUp() { + const { onEntersViewport, onRead, onError, usePostTracker } = this.props.postTrackerConfig || {} + + if (!usePostTracker) { + return } - if (timestampB > timestampA) { - return 1 + if (!onEntersViewport || !onRead || !onError) { + // eslint-disable-next-line no-console + console.error( + 'onEntersViewport, onRead and onError callback functions are required to use Post tracker' + ) + return + } + + const tracker = this.setUpPostTracking({ onEntersViewport, onRead, onError }, this.props.id) + this.setState({ + tracker + }) + } + + /** + * + * @param {Object} callbacks - Callbacks to be called by PostTracker class + * @param {Function} callbacks.onEntersViewport - function to be called when the criteria for `view` in Post tracker has been fulfiled + * @param {Function} callbacks.onRead - function to be called when the criteria for `read` in Post tracker has been fulfiled + * @param {Function} callbacks.onError - function to be called when PostTracker encounters an error + * @param {string} id + * @returns {PostTracker} + */ + setUpPostTracking(callbacks, id) { + /** + * @type {import('./lib/post-tracker').PostTrackerConfig} + */ + let config = { + query: 'article[data-trackable="live-post"]', + minMillisecondsToReport: 5000, + returnVisibleElement: true, + observerUpdateEventString: 'LiveBlogWrapper.INSERT_POST', + liveBlogWrapperQuery: `div[data-live-blog-wrapper-id="${id}"]`, + liveBlogWrapper: this.props.liveBlogWrapperElementRef + ? this.props.liveBlogWrapperElementRef.current + : undefined, + onEntersViewport: (event) => callbacks.onEntersViewport(event), + onRead: (event) => callbacks.onRead(event), + onError: (event) => callbacks.onError(event) } - return 0 - }) - - const postElements = posts.map((post, index) => ( - - )) - - return ( -
- {postElements} -
- ) + /** + * @type {PostTracker} + */ + return new PostTracker(config) + } + + render() { + const { posts = [], ads = {}, articleUrl, showShareButtons, id, liveBlogWrapperElementRef } = this.props + + posts.sort((a, b) => { + const timestampA = a.publishedDate || a.publishedTimestamp + const timestampB = b.publishedDate || b.publishedTimestamp + + // Newer posts on top + if (timestampA > timestampB) { + return -1 + } + + if (timestampB > timestampA) { + return 1 + } + + return 0 + }) + + const postElements = posts.map((post, index) => ( + + )) + + return ( +
+ {postElements} +
+ ) + } } const LiveBlogWrapper = withLiveBlogWrapperActions(BaseLiveBlogWrapper) diff --git a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js index 19e1dd0d6..f818d0c04 100644 --- a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js +++ b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js @@ -1,9 +1,9 @@ -import { IntersectionObserverTracker } from '../lib/post-tracker' +import { PostTracker } from '../lib/post-tracker' const jsdom = require('jsdom') const { JSDOM } = jsdom const doc = new JSDOM( - '
{}) - new IntersectionObserverTracker({ query: 'article[data-trackable="live-post"]', onError: spy }) + new PostTracker({ query: 'article[data-trackable="live-post"]', onError: spy }) expect(spy).toHaveBeenCalledTimes(1) }) @@ -33,8 +33,8 @@ describe('Live blog Visibility Tracker', function () { global.document = doc.window global.document.querySelectorAll = jest.fn(() => [{}]) let spy = jest.fn(() => {}) - new IntersectionObserverTracker({ onError: spy }) - expect(spy).toHaveBeenCalledTimes(1) //.equal(1, 'onError not called when config misses query'); + new PostTracker({ onError: spy }) + expect(spy).toHaveBeenCalledTimes(1) }) it("should emit an error when a query doesn't match any elements ", function () { @@ -45,7 +45,8 @@ describe('Live blog Visibility Tracker', function () { global.document = doc.window global.document.querySelectorAll = jest.fn(() => []) - new IntersectionObserverTracker({ query: 'lord-of-the-rings', onError: spy }) + global.document.querySelector = jest.fn(() => undefined) + new PostTracker({ query: 'lord-of-the-rings', onError: spy }) expect(spy).toHaveBeenCalledTimes(1) }) }) diff --git a/components/x-live-blog-wrapper/src/lib/post-tracker.js b/components/x-live-blog-wrapper/src/lib/post-tracker.js index 07004f1a6..188846b22 100644 --- a/components/x-live-blog-wrapper/src/lib/post-tracker.js +++ b/components/x-live-blog-wrapper/src/lib/post-tracker.js @@ -30,7 +30,7 @@ */ /** - * @typedef IntersectionObserverTrackerConfig + * @typedef PostTrackerConfig * @type {object} * @property {ObserverOptions} options - The options object passed into the IntersectionObserver() constructor let you control the circumstances under which the observer's callback is invoked. * @property {string} query - The query string used to query elements from the DOM. @@ -38,6 +38,7 @@ * @property {number} minMillisecondsToReport - The minimum time in milliseconds a DOM object must be visible on the users view port to report a View event as seen. defaults to 5000 * @property {?string} observerUpdateEventString - The DOM event to listen for(on the document level) to update the elements being observed. * @property {?string} liveBlogWrapperQuery - HTMLElement query string for the liveblogwrapper element. Used to get the element (document.querySelector) to listen for DOM events + * @property {?HTMLElemet} liveBlogWrapper - liveblogwrapper element * @property {!onEntersViewport} onEntersViewport - Function to call when a Post enters a user's viewport * @property {!onRead} onRead - Function to call when a Post has been in the user's viewport for >= {@param minMillisecondsToReport} || 5000ms * @property {!onError} onError - Function to call when there's an error @@ -86,15 +87,26 @@ const DATA_TYPES = { * * If no elements are found IntersectionObserverTracker the onError callback is called. */ -export class IntersectionObserverTracker { +export class PostTracker { /** * Create a new Intersection Observer tracker - * @param {IntersectionObserverTrackerConfig} config + * @param {PostTrackerConfig} config */ constructor(config) { + /** + * Create a new Intersection Observer tracker + * @type {PostTrackerConfig} config + */ this.config = config this.currentlyObservedElements = new Set() this.visibleElements = new Set() + /** + * live blog wrapper element + * @type {HTMLElement} + */ + this.wrapperElement = config.liveBlogWrapper + ? config.liveBlogWrapper + : document.querySelector(config.liveBlogWrapperQuery) if (config) { this.onEntersViewport = config.onEntersViewport this.onRead = config.onRead @@ -114,6 +126,11 @@ export class IntersectionObserverTracker { return } + // check if the parent wrapper element for posts exist + if (!this.wrapperElement) { + return + } + if (!this.config.minMillisecondsToReport) { // set minTimeToReport to a default value of 5000 this.config.minMillisecondsToReport = 5000 @@ -131,16 +148,10 @@ export class IntersectionObserverTracker { window.addEventListener('beforeunload', () => this.handleWindowClose()) // updates the list of elements currently being observed - if (this.config.observerUpdateEventString) { - /** - * @type {HTMLElement} - */ - let wrapperElement = document.querySelector(this.config.liveBlogWrapperQuery) - if (wrapperElement) { - wrapperElement.addEventListener(this.config.observerUpdateEventString, () => - this.observeElements(this.config.query) - ) - } + if (this.config.observerUpdateEventString && this.wrapperElement) { + this.wrapperElement.addEventListener(this.config.observerUpdateEventString, () => + this.observeElements(this.config.query) + ) } } else { this.triggerError(new Error('Unsupported browser.')) @@ -148,7 +159,7 @@ export class IntersectionObserverTracker { } /** - * checks if the IntersectionObserverTrackerConfig passed in is valid + * checks if the PostTrackerConfig passed in is valid * * @returns {Boolean} */ @@ -156,14 +167,14 @@ export class IntersectionObserverTracker { if (!this.config) { // eslint wasn't happy with the spaces in the template literal. Therefore the long line error message let errorMessage = - 'IntersectionObserverTrackerConfig is missing. \nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {\nroot: null,\nrootMargin: "0px",\nthreshold: [0.1, 0.2, 0.5, 0.75, 1]\n},\nquery: \'live-blog-post\'})' + 'PostTrackerConfig is missing. \nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {\nroot: null,\nrootMargin: "0px",\nthreshold: [0.1, 0.2, 0.5, 0.75, 1]\n},\nquery: \'live-blog-post\'})' this.triggerError(new Error(errorMessage)) return false } if (!this.config.query) { let errorMessage = - "IntersectionObserverTrackerConfig.query is missing.\nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {...},\nquery: 'live-blog-post'})" + "PostTrackerConfig.query is missing.\nUsage example:\nconst tracker = new IntersectionObserverTracker({\noptions: {...},\nquery: 'live-blog-post'})" this.triggerError(new Error(errorMessage)) return false } @@ -220,7 +231,7 @@ export class IntersectionObserverTracker { * @param {string} query - query string to be used for matching */ observeElements(query) { - let elements = document.querySelectorAll(query) + let elements = this.wrapperElement.querySelectorAll(query) if (query && elements.length) { elements.forEach((element) => { if (!this.isElementBeingObserved(element)) { @@ -229,12 +240,31 @@ export class IntersectionObserverTracker { } }) } else { - this.triggerError( - new Error('No DOM elements found with the query passed in IntersectionObserverTrackerConfig') - ) + this.triggerError(new Error('No DOM elements found with the query passed in PostTrackerConfig')) } } + /** + * Disconnects the intersection observer and stops watching for visibility changes + * @returns + */ + stopObservation() { + if (!this.observer || !this.config.query || !this.wrapperElement) { + return + } + this.observer.disconnect() + } + + /** + * Stops observing element visibility and cleans up resources + */ + destroy() { + this.runFinalReadReport() + this.stopObservation() + this.currentlyObservedElements = new Set() + this.visibleElements = new Set() + } + /** * Calls the onEntersViewport callback only once when the target element of an IntersectionObserverEntry enters the user's viewPort. * @@ -401,9 +431,16 @@ export class IntersectionObserverTracker { } /** - * Emits the view event for all visible elements when the user closes the browser window + * Emits the read event for all visible elements when the user closes the browser window */ handleWindowClose() { + this.runFinalReport() + } + + /** + * runs read report for all visible elements + */ + runFinalReadReport() { this.visibleElements.forEach((element) => this.reportRead(element)) } diff --git a/components/x-live-blog-wrapper/storybook/index.jsx b/components/x-live-blog-wrapper/storybook/index.jsx index f6e9ed7c0..9248bdc55 100644 --- a/components/x-live-blog-wrapper/storybook/index.jsx +++ b/components/x-live-blog-wrapper/storybook/index.jsx @@ -35,6 +35,13 @@ const Ad = (props) => { const defaultProps = { message: 'Test', + id: 'live-blog-wrapper', + postTrackerConfig: { + onEntersViewport: () => {}, + onRead: () => {}, + onError: () => {}, + usePostTracker: true + }, posts: [ { id: 12345, @@ -62,6 +69,24 @@ const defaultProps = { publishedDate: '2020-05-13T20:52:28.000Z', articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', showShareButtons: true + }, + { + id: 12348, + title: 'Title 4', + bodyHTML: '

Post 4

', + isBreakingNews: false, + publishedDate: '2020-05-13T20:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true + }, + { + id: 12349, + title: 'Title 5', + bodyHTML: '

Post 5

', + isBreakingNews: false, + publishedDate: '2020-05-13T20:52:28.000Z', + articleUrl: 'https://www.ft.com/content/2b665ec7-a88f-3998-8f39-5371f9c791ed', + showShareButtons: true } ], ads: { From 7600928a3f281bb513bf7c1107748ec4bba1b487 Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Wed, 24 Nov 2021 08:55:58 +0000 Subject: [PATCH 3/6] fixed post-tracker test --- .../x-live-blog-wrapper/src/__tests__/post-tracker.test.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js index f818d0c04..9126dfb5b 100644 --- a/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js +++ b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js @@ -44,8 +44,9 @@ describe('Live blog Visibility Tracker', function () { global.window.addEventListener = jest.fn(() => {}) global.document = doc.window - global.document.querySelectorAll = jest.fn(() => []) - global.document.querySelector = jest.fn(() => undefined) + global.document.querySelector = jest.fn(() => { + return { querySelectorAll: jest.fn(() => []) } + }) new PostTracker({ query: 'lord-of-the-rings', onError: spy }) expect(spy).toHaveBeenCalledTimes(1) }) From bbe48a46699f09ca60c92a3a56e89273c83f0ded Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Wed, 24 Nov 2021 10:41:18 +0000 Subject: [PATCH 4/6] added check for prop type to post-tracker setup --- components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx index 4b1933a0f..bbb5f8919 100644 --- a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx +++ b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx @@ -62,7 +62,11 @@ class BaseLiveBlogWrapper extends Component { return } - if (!onEntersViewport || !onRead || !onError) { + if ( + typeof onEntersViewport !== 'function' || + typeof onRead !== 'function' || + typeof onError !== 'function' + ) { // eslint-disable-next-line no-console console.error( 'onEntersViewport, onRead and onError callback functions are required to use Post tracker' From 6f2387dfa5d3b58738efd4668e43bccc35315bb7 Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Wed, 24 Nov 2021 14:04:57 +0000 Subject: [PATCH 5/6] exported Post Tracker class --- components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx index bbb5f8919..75d93dfa0 100644 --- a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx +++ b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx @@ -155,4 +155,4 @@ const LiveBlogWrapper = withLiveBlogWrapperActions(BaseLiveBlogWrapper) // This enables the component to work with x-interaction hydration registerComponent(LiveBlogWrapper, 'LiveBlogWrapper') -export { LiveBlogWrapper } +export { LiveBlogWrapper, PostTracker } From 64a82ae47dc858f5529e22d2eb0a6ac493b33a88 Mon Sep 17 00:00:00 2001 From: Emmanuel Enwenede Date: Wed, 24 Nov 2021 14:23:32 +0000 Subject: [PATCH 6/6] updated readme for x-live-blog-wrapper --- components/x-live-blog-wrapper/readme.md | 40 +++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/components/x-live-blog-wrapper/readme.md b/components/x-live-blog-wrapper/readme.md index e9d8411b2..28178d23d 100644 --- a/components/x-live-blog-wrapper/readme.md +++ b/components/x-live-blog-wrapper/readme.md @@ -18,7 +18,15 @@ The [`x-engine`][engine] module is used to inject your chosen runtime into the c ## Usage -The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). For example if you were writing your application using React you could use the component like this: +The components provided by this module are all functions that expect a map of [properties](#properties). They can be used with vanilla JavaScript or JSX (If you are not familiar check out [WTF is JSX][jsx-wtf] first). Also worth noting, this component handles visibility tracking when passed `postTrackerConfig` property. The `postTrackerConfig` property contains the follow fields: +- onEntersViewport: Callback function with an event parameter +- OnRead: Callback function with an event parameter +- OnError: Callback function with an event parameter +- usePostTracker: Boolean. When set to `true` LiveBlogWrapper component creates and manages an instance of PostTracker and reports read, view, error events. +All fields are required to use post tracking. + + + For example if you were writing your application using React you could use the component like this: ```jsx import React from 'react'; @@ -34,6 +42,36 @@ All `x-` components are designed to be compatible with a variety of runtimes, no [jsx-wtf]: https://jasonformat.com/wtf-is-jsx/ +The `x-live-blog-wrapper` component also exports `PostTracker`, a class used to track post visibility. It reports a read and a view event for individual posts in the `LiveBlogWrapper` component. If you choose to handle post tracking yourself, this class should be used as an alternative. + +```js +import { PostTracker } from '@financial-times/x-live-blog-wrapper'; + +const onEntersViewPort = (event) => {} // Enrich event with app context and report to tracking medium. +const onRead = (event) => {} // Enrich event with app context and report to tracking medium. +const onError = (event) => {} // Enrich event with app context and report to tracking medium. +const liveBlogPackageId = '00000-00000-00000-00000' + +/** + * @type {import('@financial-times/x-live-blog-wrapper').PostTracker.PostTrackerConfig} + */ +let config = { + query: 'article[data-trackable="live-post"]', // required + minMillisecondsToReport: 5000, + returnVisibleElement: true, + observerUpdateEventString: 'LiveBlogWrapper.INSERT_POST', // required + liveBlogWrapperQuery: `div[data-live-blog-wrapper-id="${liveBlogPackageId}"]`, // required, where id = liveblogpackage.id + liveBlogWrapper: this.props.liveBlogWrapperElementRef + ? this.props.liveBlogWrapperElementRef.current + : undefined, + onEntersViewport: (event) => callbacks.onEntersViewport(event), // required + onRead: (event) => callbacks.onRead(event), // required + onError: (event) => callbacks.onError(event) // required +} +new PostTracker(config); + +``` + ### Client side rendering This component can be used at the client side.