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. diff --git a/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx b/components/x-live-blog-wrapper/src/LiveBlogWrapper.jsx index e76727ca3..75d93dfa0 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,124 @@ 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 ( + 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' + ) + 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) @@ -66,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 } 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..9126dfb5b --- /dev/null +++ b/components/x-live-blog-wrapper/src/__tests__/post-tracker.test.js @@ -0,0 +1,95 @@ +import { PostTracker } from '../lib/post-tracker' +const jsdom = require('jsdom') +const { JSDOM } = jsdom + +const doc = new JSDOM( + '
{}) + new PostTracker({ 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 PostTracker({ onError: spy }) + expect(spy).toHaveBeenCalledTimes(1) + }) + + 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.querySelector = jest.fn(() => { + return { querySelectorAll: jest.fn(() => []) } + }) + new PostTracker({ 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..188846b22 --- /dev/null +++ b/components/x-live-blog-wrapper/src/lib/post-tracker.js @@ -0,0 +1,467 @@ +/** + * 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 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. + * @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 {?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 + */ + +/** + * 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 PostTracker { + /** + * Create a new Intersection Observer tracker + * @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 + 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 + } + + // 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 + } + + 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 && this.wrapperElement) { + this.wrapperElement.addEventListener(this.config.observerUpdateEventString, () => + this.observeElements(this.config.query) + ) + } + } else { + this.triggerError(new Error('Unsupported browser.')) + } + } + + /** + * checks if the PostTrackerConfig 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 = + '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 = + "PostTrackerConfig.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 = this.wrapperElement.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 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. + * + * @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 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)) + } + + /** + * 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) + } +} 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: {