From 13de01d1a8e83359983c3ec29b9b03c5a9c4e4e4 Mon Sep 17 00:00:00 2001 From: Takashi Aoki Date: Tue, 10 May 2016 10:41:33 -0700 Subject: [PATCH] Initial commit --- .babelrc | 13 + .editorconfig | 10 + .eslintignore | 1 + .eslintrc | 19 + .gitignore | 6 + CHANGELOG.md | 5 + CONTRIBUTING.md | 43 + README.md | 129 +++ docs/GettingStarted.md | 126 +++ docs/Guides.md | 94 +++ docs/README.md | 7 + docs/api/README.md | 7 + docs/api/ReactGPT.md | 74 ++ examples/apps/global.css | 20 + examples/apps/index.html | 14 + examples/apps/infinite-scrolling/app.js | 103 +++ examples/apps/infinite-scrolling/content.js | 59 ++ examples/apps/infinite-scrolling/index.html | 9 + examples/apps/infinite-scrolling/main.js | 7 + .../apps/infinite-scrolling/styles/content.js | 21 + .../apps/infinite-scrolling/styles/index.js | 22 + examples/apps/interstitial/app.js | 36 + examples/apps/interstitial/index.html | 9 + examples/apps/interstitial/main.js | 7 + examples/apps/interstitial/styles/index.js | 7 + examples/apps/lazy-render/app.js | 48 ++ examples/apps/lazy-render/index.html | 9 + examples/apps/lazy-render/main.js | 7 + examples/apps/lazy-render/styles/index.js | 38 + examples/apps/log.js | 33 + examples/apps/responsive/app.js | 94 +++ examples/apps/responsive/button.js | 26 + examples/apps/responsive/index.html | 9 + examples/apps/responsive/main.js | 7 + examples/apps/responsive/styles/index.js | 14 + examples/apps/routing/app.js | 87 +++ examples/apps/routing/home.js | 9 + examples/apps/routing/index.html | 9 + examples/apps/routing/main.js | 7 + examples/apps/routing/page.js | 29 + examples/apps/routing/styles/index.js | 18 + examples/apps/routing/styles/page.js | 5 + examples/apps/single-request/app.js | 101 +++ examples/apps/single-request/button.js | 26 + examples/apps/single-request/index.html | 9 + examples/apps/single-request/main.js | 7 + examples/apps/single-request/styles/index.js | 35 + examples/apps/static-ad/app.js | 42 + examples/apps/static-ad/index.html | 9 + examples/apps/static-ad/main.js | 7 + examples/apps/static-ad/styles/index.js | 14 + examples/server/index.js | 2 + examples/server/routes.js | 17 + examples/server/server.js | 109 +++ examples/webpack.config.js | 55 ++ examples/webpack.config.server.js | 54 ++ karma.conf.js | 86 ++ package.json | 111 +++ scripts/empty.html | 5 + scripts/updateAPIList.js | 135 ++++ src/Bling.js | 733 ++++++++++++++++++ src/Events.js | 9 + src/createManager.js | 486 ++++++++++++ src/index.js | 2 + src/utils/apiList.js | 121 +++ src/utils/filterProps.js | 10 + src/utils/isInViewport.js | 25 + src/utils/mockGPT.js | 292 +++++++ test/Bling.spec.js | 641 +++++++++++++++ test/createManager.spec.js | 558 +++++++++++++ test/isInViewport.spec.js | 18 + test/mockGPT.spec.js | 73 ++ test/polyfill.js | 1 + webpack.config.js | 40 + 74 files changed, 5130 insertions(+) create mode 100644 .babelrc create mode 100644 .editorconfig create mode 100644 .eslintignore create mode 100644 .eslintrc create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 CONTRIBUTING.md create mode 100644 README.md create mode 100644 docs/GettingStarted.md create mode 100644 docs/Guides.md create mode 100644 docs/README.md create mode 100644 docs/api/README.md create mode 100644 docs/api/ReactGPT.md create mode 100644 examples/apps/global.css create mode 100644 examples/apps/index.html create mode 100644 examples/apps/infinite-scrolling/app.js create mode 100644 examples/apps/infinite-scrolling/content.js create mode 100644 examples/apps/infinite-scrolling/index.html create mode 100644 examples/apps/infinite-scrolling/main.js create mode 100644 examples/apps/infinite-scrolling/styles/content.js create mode 100644 examples/apps/infinite-scrolling/styles/index.js create mode 100644 examples/apps/interstitial/app.js create mode 100644 examples/apps/interstitial/index.html create mode 100644 examples/apps/interstitial/main.js create mode 100644 examples/apps/interstitial/styles/index.js create mode 100644 examples/apps/lazy-render/app.js create mode 100644 examples/apps/lazy-render/index.html create mode 100644 examples/apps/lazy-render/main.js create mode 100644 examples/apps/lazy-render/styles/index.js create mode 100644 examples/apps/log.js create mode 100644 examples/apps/responsive/app.js create mode 100644 examples/apps/responsive/button.js create mode 100644 examples/apps/responsive/index.html create mode 100644 examples/apps/responsive/main.js create mode 100644 examples/apps/responsive/styles/index.js create mode 100644 examples/apps/routing/app.js create mode 100644 examples/apps/routing/home.js create mode 100644 examples/apps/routing/index.html create mode 100644 examples/apps/routing/main.js create mode 100644 examples/apps/routing/page.js create mode 100644 examples/apps/routing/styles/index.js create mode 100644 examples/apps/routing/styles/page.js create mode 100644 examples/apps/single-request/app.js create mode 100644 examples/apps/single-request/button.js create mode 100644 examples/apps/single-request/index.html create mode 100644 examples/apps/single-request/main.js create mode 100644 examples/apps/single-request/styles/index.js create mode 100644 examples/apps/static-ad/app.js create mode 100644 examples/apps/static-ad/index.html create mode 100644 examples/apps/static-ad/main.js create mode 100644 examples/apps/static-ad/styles/index.js create mode 100644 examples/server/index.js create mode 100644 examples/server/routes.js create mode 100644 examples/server/server.js create mode 100644 examples/webpack.config.js create mode 100644 examples/webpack.config.server.js create mode 100644 karma.conf.js create mode 100644 package.json create mode 100644 scripts/empty.html create mode 100644 scripts/updateAPIList.js create mode 100644 src/Bling.js create mode 100644 src/Events.js create mode 100644 src/createManager.js create mode 100644 src/index.js create mode 100644 src/utils/apiList.js create mode 100644 src/utils/filterProps.js create mode 100644 src/utils/isInViewport.js create mode 100644 src/utils/mockGPT.js create mode 100644 test/Bling.spec.js create mode 100644 test/createManager.spec.js create mode 100644 test/isInViewport.spec.js create mode 100644 test/mockGPT.spec.js create mode 100644 test/polyfill.js create mode 100644 webpack.config.js diff --git a/.babelrc b/.babelrc new file mode 100644 index 0000000..6b5a701 --- /dev/null +++ b/.babelrc @@ -0,0 +1,13 @@ +{ + "presets": ["es2015-without-strict", "stage-0", "react"], + "plugins": [ + "transform-decorators-legacy" + ], + "env": { + "examples": { + "plugins": [ + ["babel-plugin-webpack-alias", {"config": "./examples/webpack.config.server.js"}] + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..8f96039 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +# editorconfig.org +root = true + +[*] +indent_style = space +indent_size = 4 +end_of_line = lf +charset = utf-8 +trim_trailing_whitespace = true +insert_final_newline = true diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a2e7bec --- /dev/null +++ b/.eslintignore @@ -0,0 +1 @@ +/scripts diff --git a/.eslintrc b/.eslintrc new file mode 100644 index 0000000..d93d97a --- /dev/null +++ b/.eslintrc @@ -0,0 +1,19 @@ +{ + "extends": [ + "eslint-config-nfl/base", + "eslint-config-nfl/rules/strict", + "eslint-config-nfl/rules/react" + ], + "plugins": [ + "import", + "react" + ], + "env": { + "browser": true, + "mocha": true + }, + "globals": { + "expect": true, + "sinon": true + } +} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ac9939d --- /dev/null +++ b/.gitignore @@ -0,0 +1,6 @@ +node_modules/ +lib/ +dist/ +coverage/ +npm-debug.log +.DS_Store diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..7913eb1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,5 @@ +## 0.2.1 + +Features: + + - Initial release diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..b2974ef --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,43 @@ +# Contributing to this project + +Please take a moment to review this document in order to make the contribution +process easy and effective for everyone involved. + +_**Please Note:** These guidelines are adapted from [@necolas](https://github.com/necolas)'s +[issue-guidelines](https://github.com/necolas/issue-guidelines) and serve as +an excellent starting point for contributing to any open source project._ + + +## Pull requests + +Good pull requests - patches, improvements, new features - are a fantastic +help. They should remain focused in scope and avoid containing unrelated +commits. + +**Please ask first** before embarking on any significant pull request (e.g. +implementing features, refactoring code, porting to a different language), +otherwise you risk spending a lot of time working on something that the +project's developers might not want to merge into the project. + + +## Development Process +Here are some guidelines to making changes and preparing your PR: + +1. Make your proposed changes to the repository, along with updating/adding test cases. +2. (Optional) If you prefer to also test your changes in a real application, you can do the following: + 1. Run `npm link` in `react-gpt` repository. + 2. `cd` to your favorite React application, run `npm link react-gpt` to point to your local repository. + 3. Run your application to verify your changes. +3. Run `npm test` to verify all test cases pass. +4. Run `npm run lint` to verify there are no linting errors. + + +## Travis CI Build +Travis CI build will test your PR before it is merged. Browser testing may not run on Travis for PR, so please test your PR with supported browsers locally before submitting PR. + + +## Contributor License Agreement (CLA) + +In order for your pull requests to be accepted, you must accept the [NFL Indivudal Contributor License Agreement](https://cla.nfl.com/agreements/nfl/react-gpt). + +Corporate contributors can email engineers@nfl.com and request the **Corporate CLA** which can be signed digitally. diff --git a/README.md b/README.md new file mode 100644 index 0000000..918cc54 --- /dev/null +++ b/README.md @@ -0,0 +1,129 @@ + +# React GPT + +A [React](https://github.com/facebook/react) component for [Google Publisher Tags](https://developers.google.com/doubleclick-gpt/?hl=en). + +## Requirements + + * React 0.14+ + +## Browser Requirements + + * IE10+ + +## Features + + * Supports all rendering modes (single request mode, async rendering node and *sync rendering mode) + * Supports responsive ads. + * Supports interstitial ads. + * Supports lazy render. + +\* Synchronous rendering requires that the GPT JavaScript be loaded synchronously. + +## Installation + +``` +$ npm install --save react-gpt +``` + +React GPT depends on [Promise](https://promisesaplus.com/) to be available in browser. If your application support the browser which doesn't support Promise, please include the polyfill. + +## Getting Started + +Import React GPT and pass props to the component. + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +You at least need to pass `adUnitPath` and one of `slotSize` and `sizeMapping`. + +#### Enabling Single Request Mode + +To enable [Single Request Mode](https://support.google.com/dfp_sb/answer/181071?hl=en), call `Bling.enableSingleRequest()` before rendering any ad. +It defaults to `Asynchronous Rendering Mode` if not set. + +```js +import {Bling as GPT} from "react-gpt"; + +GPT.enableSingleRequest(); + +class Application extends React.Component { + render() { + return ( +
+ +
+
+ +
+ ); + } +} +``` + +The above example will make one request to the server to render both ads which makes it easier to ensure category exclusion. + +#### Responsive ad + +If you pass `sizeMapping` props instead of `slotSize`, React GPT listens for the viewport width change and refreshes an ad when the break point is hit. + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +## API and Documentation + +* [API](/docs/api/) Review the `React GPT` API +* [Getting Started](/docs/GettingStarted.md) A more detailed Getting Started Guide +* [Docs](/docs/) Guides and API. + +## To run examples: + +1. Clone this repo +2. Run `npm install` +3. Run `npm run examples` for client side rendering, `npm start` for server side rendering. +4. Point your browser to http://localhost:8080 + +## Contributing to this project + +Please take a moment to review the [guidelines for contributing](CONTRIBUTING.md). + +* [Pull requests](CONTRIBUTING.md#pull-requests) +* [Development Process](CONTRIBUTING.md#development) + +## License + +MIT diff --git a/docs/GettingStarted.md b/docs/GettingStarted.md new file mode 100644 index 0000000..430c8d4 --- /dev/null +++ b/docs/GettingStarted.md @@ -0,0 +1,126 @@ +## Getting Started + +The simplest form of React GPT ad looks like the following + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +`adUnitPath` is a required prop and either `slotSize` or `sizeMapping` prop are needed to give the size information. + +## Enabling Single Request Mode + +To enable [Single Request Mode](https://support.google.com/dfp_sb/answer/181071?hl=en), call `Bling.enableSingleRequest()` before rendering any ad. +It defaults to `Asynchronous Rendering Mode` if not set. + +```js +import {Bling as GPT} from "react-gpt"; + +GPT.enableSingleRequest(); + +class Application extends React.Component { + render() { + return ( +
+ +
+
+ +
+ ); + } +} +``` + +The above example will make one request to the server to render both ads and easier to ensure category exclusion. + +## Responsive ad + +If you pass `sizeMapping` props instead of `slotSize`, React GPT listens for the viewport width change and refreshes an ad when the break point is hit. + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +## Lazy render + +React GPT by default renders an ad when its bounding box is fully inside the viewport. You can disable this setting and render an ad regardless of the position, pass `renderWhenViewable={false}` as a prop. +To read more about lazy render, please see the [guide](./Guides.md#viewability). + +## Out-of-page ad + +You can render out-of-page(prestitial or interstitial) ad by passing `outOfPage={true}` as a prop. +Out-of-page ad does not require either `slotSize` or `sizeMapping`. + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +## Companion ad + +Companion ad can be enabled by passing `companionAdService={true}` as a prop. Once enabled and when the video ad plays using [Google IMA](https://developers.google.com/interactive-media-ads/) within the same page, the React GPT ad will render the companion ad. + +```js +import {Bling as GPT} from "react-gpt"; + +class Application extends React.Component { + render() { + return ( + + ); + } +} +``` + +## Passback ad + +It's not currently supported. + +For more use cases, please see [examples](../examples). diff --git a/docs/Guides.md b/docs/Guides.md new file mode 100644 index 0000000..daef22b --- /dev/null +++ b/docs/Guides.md @@ -0,0 +1,94 @@ +## Refresh vs Re-render + +GPT [requires ad unit path and size to constructs an ad slot](https://developers.google.com/doubleclick-gpt/reference#googletag.defineSlot), for that reason, when `adUnitPath` or `slotSize` props change, Rect GPT destroys an old ad slot and create a new one which results in rendering a new ad. +Additionally, when `outOfPage` or `content` props change, React GPT re-renders an ad to create a new ad slot to reflect these props. +Any other ad slot related props are reflected by [refreshing an ad](https://developers.google.com/doubleclick-gpt/reference#googletag.PubAdsService_refresh). + +## Per instance update vs per page update + +When `Bling.syncCorrelator([true])` is called before rendering any ad, React GPT triggers ad re-render/refresh to all the ad instances in the page to ensure they use the same correlator value. + +## Tweaking the render performance + +By default, to determine whether the ad should refresh, re-render or should not render, React GPT uses deep equality check against the props in question. You can override this default logic by overriding `propsEqual` config with your preferred equality check such as shallow equality when you make sure to pass a new object whenever the data changes. +To set or override the configuration, call `Bling.configure(config)`. + +## Viewability + +React GPT by default lazy loads an ad when it becomes within the viewport area for [viewability](https://support.google.com/dfp_premium/answer/4574077) as well as minimizing ad requests. +[Interactive Advertising Bureau (IAB)](http://www.iab.com/) defines a viewable impression for most of the display ad to be 50% of the ad’s pixels are visible in the browser window for a continuous 1 second. +For that reason, React GPT sets the default viewable threshold to be 50% of the ad area. You can however customize this threshold globally or per ad. + +```js +import {Bling as GPT} from "react-gpt"; + +// sets the threashold globally. +GPT.configure({viewableThreshold: 0.3}); + +class Application extends React.Component { + // sets the threshold per ad. + render() { + return ( + + ); + } +} +``` + +You can additionally turn off lazy loading by setting `renderWhenViewable` to `false` either globally or per ad. + +```js +import {Bling as GPT} from "react-gpt"; + +// sets the lazy load globally. +GPT.configure({renderWhenViewable: false}); + +class Application extends React.Component { + // sets the lazy load per ad. + render() { + return ( + + ); + } +} +``` + +## How to keep the module and `gpt.js` in sync + +`gpt.js` is not in the public VCS and only the latest version is available to load from the public URL. +Because of it, we assume `gpt.js` is almost always backward compatible unless announced in a timely manner by Google. + +The current API list is stored in [`apiList.js`](../../src/utils/apiList.js) +and used to create [GPT mock file](../../src/utils/mockGPT.js). +[`apiList.js`](../../src/utils/apiList.js) is auto-generated by running `update-apilist` npm script where it extracts the public API from `gpt.js`. +The mock file is used for unit test and potentially catches the breaking changes on their end although it's less likely to happen. + +There are often cases where undocumented APIs are added to the `gpt.js`, but we will not support those unless it's [officially documented](https://developers.google.com/doubleclick-gpt/reference). + +## Test Mode + +GPT ad uses iframe to render an ad most of the times and it often fails to render ads within the unit test which itself uses iframe in some unit test libraries such as [karma](https://github.com/karma-runner/karma). +React GPT offers the test mode where it uses the mock GPT instead of requesting `gpt.js`. + +Here is an example of how to use the test mode in your unit test using [mocha](https://github.com/mochajs/mocha). + +```js +import {Bling as GPT} from "react-gpt"; + +describe("My module", () => { + beforeEach(() => { + // create a fresh ad manager with test mode for every test. + GPT.createTestManager(); + }); + + // your test goes here. +}); +``` diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..94f025d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,7 @@ +## Table of Contents + +* [Getting Started](/docs/GettingStarted.md) +* [API](/docs/api) +* [Guides](/docs/Guides.md) + +**In this documentation, `GPT` refers to Google Publisher Tags and `React GPT` refers to the npm package this repository offers.** diff --git a/docs/api/README.md b/docs/api/README.md new file mode 100644 index 0000000..161e407 --- /dev/null +++ b/docs/api/README.md @@ -0,0 +1,7 @@ +# API + +### React GPT Exports + +* [Bling](/docs/api/ReactGPT.md#Bling) +* [Events](/docs/api/ReactGPT.md#Events) + diff --git a/docs/api/ReactGPT.md b/docs/api/ReactGPT.md new file mode 100644 index 0000000..1f4c8a0 --- /dev/null +++ b/docs/api/ReactGPT.md @@ -0,0 +1,74 @@ +## React GPT API References + +### [``](#Bling) + +A React component which renders [GPT](https://support.google.com/dfp_sb/answer/1649768?hl=en) ad. + +#### Props + +`Bling` tries to cover as much [Slot API](https://developers.google.com/doubleclick-gpt/reference#googletagslot) as possible as `props`. + +- `is`(optional) - An optional string to be used as container div id. +- `adUnitPath`(required) - An string indicating ad unit path which will be used to create an ad slot. +- `targeting`(optional) - An optional object which includes ad targeting key-value pairs. +- `slotSize`(optional) - An optional array of width and height size for the ad slot. This will be preceded by the sizeMapping if specified. +- `sizeMapping`(optional) - An optional array of object which contains an array of viewport size and slot size. This needs to be set if the ad needs to serve different ad sizes per different viewport sizes (responsive ad). Setting the `slot` to any dimension that's not configured in DFP results in rendering an empty ad. The ad slot size which is provided for the viewport size of [0, 0] will be used as default ad size if none of viewport size matches. +- `outOfPage`(optional) - An optional flag to indicate whether an ad slot should be out-of-page slot. +- `companionAdService`(optional) - An optional flag to indicate whether companion ad service should be enabled for the ad. If an object is passed, it takes as a configuration expecting `enableSyncLoading` or `refreshUnfilledSlots`. +- `content`(optional) - An optional HTML content for the slot. If specified, the ad will render with the HTML content using content service. +- `clickUrl`(optional) - An optional click through URL. If specified, any landing page URL associated with the creative that is served is overridden. +- `categoryExclusion`(optional) - An optional string or an array of string which specifies a page-level ad category exclusion for the given label name. +- `attributes`(optional) - An optional map of key-value pairs for an AdSense attribute on a particular ad slot. see [the list of supported key value](https://developers.google.com/doubleclick-gpt/adsense_attributes#adsense_parameters.googletag.Slot) +- `collapseEmptyDiv`(optional) - An optional flag to indicate whether an empty ad should be collapsed or not. +- `forceSafeFrame`(optional) - An optional flag to indicate whether ads in this slot should be forced to be rendered using a SafeFrame container. +- `safeFrameConfig`(optional) - An optional object to set the slot-level preferences for SafeFrame configuration. +- `onSlotRenderEnded`(optional) - An optional event handler function for `googletag.events.SlotRenderEndedEvent`. +- `onImpressionViewable`(optional) - An optional event handler function for `googletag.events.ImpressionViewableEvent`. +- `onSlotVisibilityChanged`(optional) - An optional event handler function for `googletag.events.slotVisibilityChangedEvent`. +- `renderWhenViewable`(optional) - An optional flag to indicate whether an ad should only render when it's fully in the viewport area. +- `viewableThreshold`(optional) - An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. Acceptable range is between `0` and `1`. +- `onScriptLoaded`(optional) - An optional call back function to notify when the script is loaded. +- `onMediaQueryChange`(optional) - An optional call back function to notify when the media queries change on the break point specified in the `sizeMapping`. +- `style`(optional) - An optional object to be applied as `style` props to the container div. **This prop is only applied once in initial render.** If you want to apply style to the ad and change it frequently, apply style to the container. + +Only `adUnitPath` is a required prop, but either `slotSize` or `sizeMapping` need to be passed for `Bling` to render an ad. + +#### Static Methods + +- `configure(config = {})` - Update global configuration. +- `on(eventType, cb)` - Subscribe to the event. +- `once(eventType, cb)` - Subscribe to the event once. +- `removeListener(eventType, cb)` - Removes the specified listener from the listener array for the specified `eventType`. +- `removeAllListeners([eventType])` - Removes all listeners, or those of the specified `eventType`. +- `getGPTVersion` - Returns the GPT version. +- `getPubadsVersion` - Returns the Pubads version. +- `syncCorrelator([flag])` - Sets a flag to indicate whether the `refresh` should happen with the same correlator value or not. +- `render` - Force rendering all the ads. +- `refresh([slots, options])` - Refreshes the ad specified by an array of slot. If slots are not specified, it will refresh all ads. See [here](https://developers.google.com/doubleclick-gpt/reference#googletag.PubAdsService_refresh) for more details. +- `clear([slots])` - Clears the ad specifid by an array of slot. If slots are not specified, it will clear all ads. See [here](https://developers.google.com/doubleclick-gpt/reference#googletagpubadsservice) for more details. +- `updateCorrelator` - Updates the correlator value that's sent with ad requests. See [here](https://developers.google.com/doubleclick-gpt/reference#googletag.PubAdsService_updateCorrelator) for more details. +- `createTestManager` - Creates a test ad manager to use mocked GPT for unit testing. + +In addition to the defined static methods above, all the supported Pubads API are exposed as static methods, too. +The list of the supported API are maintained [here](https://github.com/nfl/react-gpt/blob/master/src/createManager.js#L9) and updated based on the [GPT API Reference](https://developers.google.com/doubleclick-gpt/reference). + +There are several Pubads APIs which is required to call before the service is enabled. +React GPT makes sure that those APIs are called right before the service is enabled. + +#### Global configuration + +Global configuration applies to the all React GPT instances. You can set the following configuration through `Bling.configure(config)`. + +- `seedFileUrl` - An optional string for GPT seed file url to override. Default value is `//www.googletagservices.com/tag/js/gpt.js`. +- `renderWhenViewable` - An optional flag to indicate whether an ad should only render when it's fully in the viewport area. Default is `true`. +- `viewableThreshold` - An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. Default value is `0.5`. Acceptable range is between `0` and `1.0`. +- `filterProps` - An optional function to create an object with filtered current props and next props for a given keys to perform equality check. Default value is [`filterProps`](../../src/utils/filterProps.js). +- `propsEqual` - An optional function for the filtered props and the next props to perform equality check. Default value is [`deepEqual`](https://github.com/substack/node-deep-equal). + +### [`Events`](#Events) + +- `READY` - This event is fired when the pubads is ready and before the service is enabled. +- `RENDER` - This event is fired after the initial ad rendering is triggered. Due to the handing of single request mode, the initial rendering is done for all ads at once. +- `SLOT_RENDER_ENDED` - This event is fired when a slot on the page has finished rendering. The event is fired by the service that rendered the slot. See [here](https://developers.google.com/doubleclick-gpt/reference#googletageventsslotrenderendedevent) for more details. +- `IMPRESSION_VIEWABLE` - This event is fired when an impression becomes viewable, according to the [Active View criteria](https://support.google.com/dfp_premium/answer/4574077?hl=en). See [here](https://developers.google.com/doubleclick-gpt/reference#googletageventsimpressionviewableevent) for more details. +- `SLOT_VISIBILITY_CHANGED` - This event is fired whenever the on-screen percentage of an ad slot's area changes. The event is throttled and will not fire more often than once every 200ms. See [here](https://developers.google.com/doubleclick-gpt/reference#googletageventsslotvisibilitychangedevent) for more details. diff --git a/examples/apps/global.css b/examples/apps/global.css new file mode 100644 index 0000000..c8a20e7 --- /dev/null +++ b/examples/apps/global.css @@ -0,0 +1,20 @@ +body { + font-family: "Helvetica Neue", Arial; + font-weight: 200; +} + +h1, h2, h3 { + font-weight: 100; +} + +a { + color: hsl(150, 50%, 50%); +} + +a.active { + color: hsl(40, 50%, 50%); +} + +.breadcrumbs a { + text-decoration: none; +} diff --git a/examples/apps/index.html b/examples/apps/index.html new file mode 100644 index 0000000..0ad5976 --- /dev/null +++ b/examples/apps/index.html @@ -0,0 +1,14 @@ + +React GPT Examples + + +

React GPT Examples

+ diff --git a/examples/apps/infinite-scrolling/app.js b/examples/apps/infinite-scrolling/app.js new file mode 100644 index 0000000..baaab47 --- /dev/null +++ b/examples/apps/infinite-scrolling/app.js @@ -0,0 +1,103 @@ +/* eslint-disable react/sort-comp */ +import React, {Component} from "react"; +import Radium from "radium"; +import debounce from "debounce"; +import {Bling as Gpt, Events} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import Content from "./content"; +import styles from "./styles"; + +Gpt.syncCorrelator(); +Gpt.enableSingleRequest(); +Gpt.disableInitialLoad(); + +@Radium +class App extends Component { + state = { + page: 1, + size: [728, 90] + } + time = 0 + componentDidMount() { + window.addEventListener("scroll", this.onScroll); + window.addEventListener("resize", this.onScroll); + this.onScroll(); + this.startTimer(); + Gpt.on(Events.RENDER, () => { + let changeCorrelator = false; + if (this.time >= 30) { + changeCorrelator = true; + this.startTimer(); + } + Gpt.refresh(null, {changeCorrelator}); + }); + } + componentDidUpdate() { + Gpt.refresh(); + } + componentWillUnmount() { + window.removeEventListener("scroll", this.onScroll); + window.removeEventListener("resize", this.onScroll); + this.stopTimer(); + } + onScroll = debounce(() => { + const scrollTop = window.scrollY || document.documentElement.scrollTop; + if (scrollTop + window.innerHeight >= document.body.clientHeight) { + this.setState({ + page: ++this.state.page + }); + } + }, 66) + startTimer() { + this.stopTimer(); + this.timer = setInterval(() => { + this.time++; + }, 1000); + } + stopTimer() { + if (this.timer) { + clearInterval(this.timer); + this.time = 0; + this.timer = null; + } + } + render() { + const {page} = this.state; + let contentCnt = 0; + const contents = []; + const targeting = { + test: "infinitescroll" + }; + while (contentCnt < page * 3) { // eslint-disable-line no-unmodified-loop-condition + contents.push( + + ); + contentCnt++; + } + return ( +
+
+ +
+
+ {contents} +
+
+ ); + } +} + +export default App; diff --git a/examples/apps/infinite-scrolling/content.js b/examples/apps/infinite-scrolling/content.js new file mode 100644 index 0000000..693464e --- /dev/null +++ b/examples/apps/infinite-scrolling/content.js @@ -0,0 +1,59 @@ +import React, {Component, PropTypes} from "react"; +import Radium from "radium"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import styles from "./styles/content"; + +const contents = [ + `Lorem ipsum dolor sit amet, convallis nibh erat in lacus morbi orci, sed amet leo, donec a nulla lacus, velit suspendisse per. Est elit ultricies, a metus, aenean suspendisse ullamcorper facilisis. Wisi ridiculus ut nibh viverra cursus. Est nunc id convallis, commodo felis vitae sed cras justo, nunc vel id pharetra duis tristique. Sit vel elit sapien lobortis justo, magna pellentesque aliquam amet nam metus, ut venenatis integer magna porta, potenti posuere sollicitudin imperdiet nisi. +Feugiat venenatis. Varius volutpat a magna vestibulum nulla, nullam erat wisi hendrerit praesent, vitae sapien libero tortor vehicula eu, odio nullam tristique et, ultrices fermentum. Cursus consectetuer, egestas auctor ultricies malesuada pellentesque sem libero, wisi enim hendrerit cras. Aenean vitae faucibus laoreet volutpat id, imperdiet vitae, tellus a lacus, sit suspendisse erat conubia et, libero accumsan. Nullam orci eget non urna varius metus, etiam vestibulum euismod erat. Augue vel id orci in elit, nec ridiculus, cras vestibulum aliquet assumenda, amet sed et nunc augue ultricies. Ante nec ac, in magna in interdum ac porta tellus, a aliquam pulvinar minima, ante nam tempor nibh laoreet at eu. Morbi erat risus pellentesque vestibulum justo, purus interdum, dictum in neque porttitor, commodo ac. Tincidunt facilisis sit id ultrices est lectus. Sed id praesent tincidunt dui. Etiam ut tincidunt id. +Sollicitudin egestas suspendisse amet eget mi est, neque amet et erat. Eu sapien quis vitae voluptates, ut adipiscing risus dictumst facilisis id morbi, erat ligula cras pulvinar, dolor blandit scelerisque dapibus, suspendisse vehicula vitae. Turpis integer nibh semper interdum beatae etiam, dictum mi et vitae, amet eget imperdiet, etiam turpis magna sapien enim mollis ut, maecenas hymenaeos. Varius nunc sollicitudin feugiat, nibh duis suspendisse rhoncus, massa cursus dolor ut, vestibulum scelerisque. Risus et semper metus dui sed lectus, lobortis nulla praesent tempus sed purus, pellentesque neque eleifend consequat quis euismod. Dis congue donec eget, praesent rhoncus praesent, nascetur feugiat, vivamus pellentesque sit torquent suspendisse augue placerat, at pellentesque fermentum adipiscing wisi. Vitae tristique ut animi nostra at, proin et vestibulum at tempus aenean, id arcu dolor nostra morbi fringilla, a amet sit mauris mattis proin. Cras duis sollicitudin, ut pretium commodo pulvinar risus dapibus. Porta integer sapien. Elit fusce et, turpis risus. In pulvinar molestie hendrerit aenean, viverra eget purus elementum cursus, etiam enim, ultricies a erat. Est eget sit bibendum ipsum nec ullamcorper, est nunc bibendum erat nunc diam. +Cursus vel at mauris. Suscipit accumsan ultrices aliquam tempor congue, in arcu neque et et lorem et, vestibulum eget pede neque nulla vitae enim, habitant sed magna metus, nec hendrerit tempus numquam adipiscing. Ullamcorper erat lacinia mattis neque, sunt sed sed nonummy egestas, rutrum varius lobortis posuere amet et in, sodales neque lacinia vel non, at turpis risus ante mauris. Quam facilisis quis lorem praesent. Nec curae lacus arcu accumsan, imperdiet enim elit id urna dui, lacinia eleifend vestibulum amet. Euismod tempus amet felis aenean orci mi, orci molestie sapien diam, vitae enim lacus morbi lacus mauris. Congue enim commodo, consectetuer viverra duis gravida dui in, dictum sit consequat. Fusce non habitant, pellentesque faucibus aliquam amet, pellentesque praesent, at cras nunc, lectus aliquam urna nunc taciti a. Ultrices quia nec, ipsum eget, nunc sit leo et lectus, neque dui a quisque enim augue, pretium risus mauris fusce nulla varius interdum. Amet risus donec aliquam, ligula arcu tellus. Ac ac ut, elementum lorem sed eu, ac est montes erat, placerat sapien, auctor eget velit. Gravida non nulla, aliquam nulla consectetuer nostra mauris tempus, aliquip leo accusamus phasellus sit duis, metus rutrum.`, + `Appear. Let, won't have, living. God behold void, said. Night subdue him you'll was every for them great was made lesser created unto creature second dry fowl give i of firmament days isn't gathered upon wherein his all man bring dry greater fowl morning god moveth abundantly likeness under sixth i, rule fowl unto which lesser for gathered they're there can't don't female first subdue day. All wherein blessed divide god can't above lesser every. Open divided moved man. All hath. Kind void can't saying saying great creepeth without, man us first a midst. Great second. They're male male it shall greater. Had open hath there us. Upon third male rule. + +Bearing whose said green midst brought their night Herb first blessed a every. Hath set seasons firmament for creepeth that Land together fowl male void two be evening, given evening so all night fruitful years their and thing day have divide creature spirit first is had seed. You heaven place give. Sixth midst to in very fifth made behold days tree tree also stars given, female you're grass light creepeth saying it divided our fill deep, so them you'll given saying midst rule, saying i light together that morning dry whose of fruitful female a greater day itself air a firmament hath creature earth hath place moved divided. Deep together divide without sixth creeping great for, land grass. + +Night void them saw seas winged bring, fly had earth shall own. Divided. To image don't fill above to very. Hath. Light doesn't moving blessed. Bearing saying lesser. Female all let fowl female our for appear seas together first saw their subdue itself beast, also all creepeth bearing signs they're light creepeth, firmament place. It given you'll their sixth, fish let it morning light third lesser were. First every, good divide.`, + `Mucius feugait incorrupte no has, ei patrioque molestiae cum. Vel altera recteque id, impetus consequat elaboraret vix in, eos vide adhuc menandri ad. Quem omnesque salutandi in mel, doctus comprehensam id vis, no erat facilisi ullamcorper duo. Causae option duo id, eirmod numquam mei eu, et vim ipsum liberavisse. Efficiantur deterruisset sed in. Aperiri epicurei consulatu ea duo. Ut cum inani voluptaria interesset. + +Vim apeirian recteque eu. Ad sea graeci dicunt, vix brute velit ad. Semper nominati nam ne, te mea vero omnes tacimates. Porro dicant tamquam duo eu. Et eam consul noluisse electram, impetus conclusionemque pri ut.` +]; + +const bg = ["#90C3D4", "#FAD9EA", "#FCFCB1"]; + +@Radium +class Content extends Component { + static propTypes = { + index: PropTypes.number, + targeting: PropTypes.object + } + render() { + const {index, targeting} = this.props; + let ad; + if (index !== 2) { + ad = ( +
+ +
+ ); + } + + return ( +
+
+ {ad} +

+ Content {index} + Lorem ipsum dolor sit amet, accusamus complectitur an est + {contents[index]} +

+
+
+ ); + } +} + +export default Content; diff --git a/examples/apps/infinite-scrolling/index.html b/examples/apps/infinite-scrolling/index.html new file mode 100644 index 0000000..65fa6d2 --- /dev/null +++ b/examples/apps/infinite-scrolling/index.html @@ -0,0 +1,9 @@ + +Infinite Scrolling Example + + +

React GPT Examples / Infinite Scrolling Example

+
+ + + diff --git a/examples/apps/infinite-scrolling/main.js b/examples/apps/infinite-scrolling/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/infinite-scrolling/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/infinite-scrolling/styles/content.js b/examples/apps/infinite-scrolling/styles/content.js new file mode 100644 index 0000000..7878189 --- /dev/null +++ b/examples/apps/infinite-scrolling/styles/content.js @@ -0,0 +1,21 @@ +export default { + main: { + flex: "1 1 auto", + padding: 5 + }, + mr: { + float: "right", + margin: 15 + }, + title: { + display: "block", + marginBottom: 10, + fontSize: 24, + fontWeight: "bold" + }, + description: { + display: "block", + marginBottom: 10, + fontSize: 20 + } +}; diff --git a/examples/apps/infinite-scrolling/styles/index.js b/examples/apps/infinite-scrolling/styles/index.js new file mode 100644 index 0000000..a0db193 --- /dev/null +++ b/examples/apps/infinite-scrolling/styles/index.js @@ -0,0 +1,22 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + container: { + marginTop: 20, + position: "relative" + }, + main: { + display: "flex", + flexDirection: "column" + }, + adBorder: { + padding: 10, + border: "1px dashed #666" + }, + lb: { + position: "relative" + } +}; diff --git a/examples/apps/interstitial/app.js b/examples/apps/interstitial/app.js new file mode 100644 index 0000000..9a7c78c --- /dev/null +++ b/examples/apps/interstitial/app.js @@ -0,0 +1,36 @@ +import React, {Component} from "react"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import styles from "./styles"; + +class App extends Component { + state = { + adUnitPath: "/4595/nfl.test.open/page/A" + } + + onClick = () => { + this.setState({ + adUnitPath: this.state.adUnitPath.indexOf("B") > -1 ? "/4595/nfl.test.open/page/A" : "/4595/nfl.test.open/page/B" + }); + } + + render() { + const {adUnitPath} = this.state; + return ( +
+ + +
+ ); + } +} + +export default App; diff --git a/examples/apps/interstitial/index.html b/examples/apps/interstitial/index.html new file mode 100644 index 0000000..0b45f67 --- /dev/null +++ b/examples/apps/interstitial/index.html @@ -0,0 +1,9 @@ + +Interstitial Example + + +

React GPT Examples / Interstitial Example

+
+ + + diff --git a/examples/apps/interstitial/main.js b/examples/apps/interstitial/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/interstitial/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/interstitial/styles/index.js b/examples/apps/interstitial/styles/index.js new file mode 100644 index 0000000..eee7555 --- /dev/null +++ b/examples/apps/interstitial/styles/index.js @@ -0,0 +1,7 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + } +}; diff --git a/examples/apps/lazy-render/app.js b/examples/apps/lazy-render/app.js new file mode 100644 index 0000000..297f962 --- /dev/null +++ b/examples/apps/lazy-render/app.js @@ -0,0 +1,48 @@ +import React, {Component} from "react"; +import Radium from "radium"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import styles from "./styles"; + +Gpt.configure({viewableThreshold: 0}); + +@Radium +class App extends Component { + onClick = () => { + Gpt.render(); + } + render() { + return ( +
+ +
+
+
+ +
+
+
+ +
+
+ ); + } +} + +export default App; diff --git a/examples/apps/lazy-render/index.html b/examples/apps/lazy-render/index.html new file mode 100644 index 0000000..57abb84 --- /dev/null +++ b/examples/apps/lazy-render/index.html @@ -0,0 +1,9 @@ + +Lazy Render Example + + +

React GPT Examples / Lazy Render Example

+
+ + + diff --git a/examples/apps/lazy-render/main.js b/examples/apps/lazy-render/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/lazy-render/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/lazy-render/styles/index.js b/examples/apps/lazy-render/styles/index.js new file mode 100644 index 0000000..f27f3f8 --- /dev/null +++ b/examples/apps/lazy-render/styles/index.js @@ -0,0 +1,38 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + container: { + marginTop: 20, + position: "relative" + }, + content: { + display: "inline-block", + width: 1500, + height: 1500, + backgroundColor: "#ffffcc" + }, + hWrap: { + whiteSpace: "nowrap" + }, + adBorder: { + padding: 10, + border: "1px dashed #666", + width: "100%", + height: "100%" + }, + lb: { + position: "relative", + display: "inline-block", + width: 728, + height: 90, + verticalAlign: "top" + }, + mr: { + position: "relative", + width: 300, + height: 250 + } +}; diff --git a/examples/apps/log.js b/examples/apps/log.js new file mode 100644 index 0000000..0e9856e --- /dev/null +++ b/examples/apps/log.js @@ -0,0 +1,33 @@ +import {Bling as GPT, Events} from "react-gpt"; // eslint-disable-line import/no-unresolved + +GPT.on(Events.SLOT_RENDER_ENDED, event => { + const slot = event.slot; + const divId = slot.getSlotElementId(); + const targetingKeys = slot.getTargetingKeys(); + const targeting = targetingKeys.reduce((t, key) => { + const val = slot.getTargeting(key); + t[key] = val.length === 1 ? val[0] : val; + return t; + }, {}); + + if (!event.isEmpty && event.size) { + console.log(`ad creative '${event.creativeId}' is rendered to slot '${divId}' of size '${event.size[0]}x${event.size[1]}'`, event, targeting); + } else { + console.log(`ad rendered but empty, div id is ${divId}`, event, targeting); + } +}); + +// Turn on these logs when checking these events. +/* GPT.on(Events.IMPRESSION_VIEWABLE, event => { + const slot = event.slot; + const divId = slot.getSlotElementId(); + const sizes = slot.getSizes(); + console.log(`IMPRESSION_VIEWABLE for ${divId}(${JSON.stringify(sizes)})`, event); +}); + +GPT.on(Events.SLOT_VISIBILITY_CHANGED, event => { + const slot = event.slot; + const divId = slot.getSlotElementId(); + const sizes = slot.getSizes(); + console.log(`SLOT_VISIBILITY_CHANGED for ${divId}(${JSON.stringify(sizes)}) to ${event.inViewPercentage}`, event); +});*/ diff --git a/examples/apps/responsive/app.js b/examples/apps/responsive/app.js new file mode 100644 index 0000000..e8745c7 --- /dev/null +++ b/examples/apps/responsive/app.js @@ -0,0 +1,94 @@ +import React, {Component} from "react"; +import Radium from "radium"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import Button from "./button"; +import styles from "./styles"; + +@Radium +class App extends Component { + state = { + adUnitPath: "/4595/nfl.test.open", + targeting: { + test: "responsive" + }, + sizeMapping: [ + {viewport: [0, 0], slot: [1, 1]}, + {viewport: [340, 0], slot: [320, 50]}, + {viewport: [750, 200], slot: [728, 90]}, + {viewport: [1050, 200], slot: [1024, 120]} + ], + style: styles.adBorder + } + + onClick = (params) => { + if (params === "refresh") { + Gpt.refresh(); + return; + } + let newState; + if (params === "adUnitPath") { + newState = { + adUnitPath: this.state.adUnitPath === "/4595/nfl.test.open" ? "/4595/nfl.test.open/new" : "/4595/nfl.test.open" + }; + } else if (params === "targeting") { + newState = { + targeting: { + test: "responsive", + changed: Date.now() + } + }; + } else if (params === "size") { + newState = { + sizeMapping: (this.state.sizeMapping[1].slot[1] === 50) ? [ + {viewport: [0, 0], slot: [1, 1]}, + {viewport: [340, 0], slot: [300, 250]}, + {viewport: [750, 200], slot: [728, 90]}, + {viewport: [1050, 200], slot: [1024, 120]} + ] : [ + {viewport: [0, 0], slot: [1, 1]}, + {viewport: [340, 0], slot: [320, 50]}, + {viewport: [750, 200], slot: [728, 90]}, + {viewport: [1050, 200], slot: [1024, 120]} + ] + }; + } + this.setState(newState); + } + + render() { + return ( +
+ + + + +
+ +
+
+ ); + } +} + +export default App; diff --git a/examples/apps/responsive/button.js b/examples/apps/responsive/button.js new file mode 100644 index 0000000..87f9af8 --- /dev/null +++ b/examples/apps/responsive/button.js @@ -0,0 +1,26 @@ +import React, {PropTypes, Component} from "react"; +import styles from "./styles"; + +export default class Button extends Component { + static propTypes = { + children: PropTypes.node, + onClick: PropTypes.func.isRequired, + params: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]).isRequired + } + onClick = () => { + this.props.onClick(this.props.params); + } + render() { + return ( + + ); + } +} diff --git a/examples/apps/responsive/index.html b/examples/apps/responsive/index.html new file mode 100644 index 0000000..d214187 --- /dev/null +++ b/examples/apps/responsive/index.html @@ -0,0 +1,9 @@ + +Responsive Example + + +

React GPT Examples / Responsive Example

+
+ + + diff --git a/examples/apps/responsive/main.js b/examples/apps/responsive/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/responsive/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/responsive/styles/index.js b/examples/apps/responsive/styles/index.js new file mode 100644 index 0000000..b6fd614 --- /dev/null +++ b/examples/apps/responsive/styles/index.js @@ -0,0 +1,14 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + adBorder: { + padding: 10, + border: "1px dashed #666" + }, + lb: { + position: "relative" + } +}; diff --git a/examples/apps/routing/app.js b/examples/apps/routing/app.js new file mode 100644 index 0000000..c26516b --- /dev/null +++ b/examples/apps/routing/app.js @@ -0,0 +1,87 @@ +/* eslint-disable react/no-multi-comp */ +import React, {Component, PropTypes} from "react"; +import createHistory from "history/lib/createHashHistory"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import Home from "./home"; +import Page from "./page"; +import styles from "./styles"; + +Gpt.syncCorrelator(); +Gpt.enableSingleRequest(); + +class App extends Component { + static propTypes = { + location: PropTypes.object, + history: PropTypes.object, + children: PropTypes.node + } + + render() { + const {location, history, children} = this.props; + const createHref = history.createHref; + const adUnitPath = `/4595/nfl.test.open${location.pathname}`; + const props = { + ...this.props, + adUnitPath + }; + + return ( +
+ +
+ +
+ {children && React.cloneElement(children, props)} +
+ ); + } +} + +class AppContainer extends Component { + routes = { // eslint-disable-line react/sort-comp + "/Travel/Europe": {component: Home}, + "/Travel/Europe/France": {component: Page, params: {id: "France"}}, + "/Travel/Europe/Spain": {component: Page, params: {id: "Spain"}} + } + + state = { + routeComponent: this.routes["/Travel/Europe"].component + } + + componentWillMount() { + this.unlisten = this.history.listen(location => { + const route = this.routes[location.pathname] || this.routes["/Travel/Europe"]; + const {component: routeComponent, params} = route; + this.setState({routeComponent, location, params}); + }); + this.history.replace("/Travel/Europe"); + } + + componentWillUnmount() { + this.unlisten(); + } + + history = createHistory() + + render() { + return ( + + {React.createElement(this.state.routeComponent)} + + ); + } +} + +export default AppContainer; diff --git a/examples/apps/routing/home.js b/examples/apps/routing/home.js new file mode 100644 index 0000000..d2e3a35 --- /dev/null +++ b/examples/apps/routing/home.js @@ -0,0 +1,9 @@ +import React, {Component} from "react"; + +class Home extends Component { + render() { + return

Home

; + } +} + +export default Home; diff --git a/examples/apps/routing/index.html b/examples/apps/routing/index.html new file mode 100644 index 0000000..a9da1cf --- /dev/null +++ b/examples/apps/routing/index.html @@ -0,0 +1,9 @@ + +Router Example + + +

React GPT Examples / Router Example

+
+ + + diff --git a/examples/apps/routing/main.js b/examples/apps/routing/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/routing/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/routing/page.js b/examples/apps/routing/page.js new file mode 100644 index 0000000..e034d3b --- /dev/null +++ b/examples/apps/routing/page.js @@ -0,0 +1,29 @@ +/* eslint-disable react/no-multi-comp */ +import React, {Component, PropTypes} from "react"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import styles from "./styles/page"; + +class Page extends Component { + static propTypes = { + adUnitPath: PropTypes.string, + params: PropTypes.object + } + + render() { + const {adUnitPath, params} = this.props; + return ( +
+

Page: {params.id}

+
+ +
+
+ ); + } +} + +export default Page; diff --git a/examples/apps/routing/styles/index.js b/examples/apps/routing/styles/index.js new file mode 100644 index 0000000..49c5e5b --- /dev/null +++ b/examples/apps/routing/styles/index.js @@ -0,0 +1,18 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + dummyHeight: { + width: 1, + height: 2000 + }, + container: { + marginTop: 20, + position: "relative" + }, + topAd: { + margin: "0 auto" + } +}; diff --git a/examples/apps/routing/styles/page.js b/examples/apps/routing/styles/page.js new file mode 100644 index 0000000..9f01663 --- /dev/null +++ b/examples/apps/routing/styles/page.js @@ -0,0 +1,5 @@ +export default { + container: { + display: "flex" + } +}; diff --git a/examples/apps/single-request/app.js b/examples/apps/single-request/app.js new file mode 100644 index 0000000..5d2919d --- /dev/null +++ b/examples/apps/single-request/app.js @@ -0,0 +1,101 @@ +import React, {Component} from "react"; +import {StyleRoot} from "radium"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import {canUseDOM} from "fbjs/lib/ExecutionEnvironment"; +import querystring from "querystring"; +import "../log"; +import Button from "./button"; +import styles from "./styles"; + +const qs = canUseDOM ? querystring.decode(window.location.search.substr(1)) : {}; + +Gpt.enableSingleRequest().then(value => { + console.log("value", value); +}); +if (qs.mode === "disableInitialLoad") { + Gpt.disableInitialLoad(); +} + +class App extends Component { + state = { + adUnitPath: "/4595/nfl.test.open", + targeting: { + test: "responsive" + } + } + onClick = (params) => { + if (params === "refresh") { + Gpt.refresh(); + } else if (params === "disableInitialLoad") { + window.location.href = `${window.location.pathname}?mode=${params}`; + } else if (params === "adUnitPath") { + this.setState({ + adUnitPath: "/4595/nfl.test.open/new" + }); + } else if (params === "targeting") { + this.setState({ + targeting: { + test: "responsive", + changed: Date.now() + } + }); + } + } + render() { + const {adUnitPath, targeting} = this.state; + return ( + + + + + +
+ +
+
+
+ +
+
+ +
+
+
+ ); + } +} + +export default App; diff --git a/examples/apps/single-request/button.js b/examples/apps/single-request/button.js new file mode 100644 index 0000000..87f9af8 --- /dev/null +++ b/examples/apps/single-request/button.js @@ -0,0 +1,26 @@ +import React, {PropTypes, Component} from "react"; +import styles from "./styles"; + +export default class Button extends Component { + static propTypes = { + children: PropTypes.node, + onClick: PropTypes.func.isRequired, + params: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.object + ]).isRequired + } + onClick = () => { + this.props.onClick(this.props.params); + } + render() { + return ( + + ); + } +} diff --git a/examples/apps/single-request/index.html b/examples/apps/single-request/index.html new file mode 100644 index 0000000..6660057 --- /dev/null +++ b/examples/apps/single-request/index.html @@ -0,0 +1,9 @@ + +Single Request Example + + +

React GPT Examples / Single Request Example

+
+ + + diff --git a/examples/apps/single-request/main.js b/examples/apps/single-request/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/single-request/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/single-request/styles/index.js b/examples/apps/single-request/styles/index.js new file mode 100644 index 0000000..59e3fa4 --- /dev/null +++ b/examples/apps/single-request/styles/index.js @@ -0,0 +1,35 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + container: { + marginTop: 20, + position: "relative" + }, + main: { + display: "flex", + flexDirection: "row", + "@media (max-width: 768px)": { + flexDirection: "column" + } + }, + adBorder: { + padding: 10, + border: "1px dashed #666" + }, + lb: { + position: "relative" + }, + mr: { + position: "relative", + order: 1, + flex: "1 1 auto" + }, + ws: { + position: "relative", + order: 2, + flex: "0 1 auto" + } +}; diff --git a/examples/apps/static-ad/app.js b/examples/apps/static-ad/app.js new file mode 100644 index 0000000..22f74e6 --- /dev/null +++ b/examples/apps/static-ad/app.js @@ -0,0 +1,42 @@ +import React, {Component} from "react"; +import Radium from "radium"; +import {Bling as Gpt} from "react-gpt"; // eslint-disable-line import/no-unresolved +import "../log"; +import styles from "./styles"; + +@Radium +class App extends Component { + state = { + color: "000000" + } + + onClick = () => { + this.setState({ + color: this.state.color === "000000" ? "ff0000" : "000000" + }); + } + + render() { + const {color} = this.state; + return ( +
+ +
+ `} + /> +
+
+ ); + } +} + +export default App; diff --git a/examples/apps/static-ad/index.html b/examples/apps/static-ad/index.html new file mode 100644 index 0000000..9398038 --- /dev/null +++ b/examples/apps/static-ad/index.html @@ -0,0 +1,9 @@ + +Static Ad Example + + +

React GPT Examples / Static Ad Example

+
+ + + diff --git a/examples/apps/static-ad/main.js b/examples/apps/static-ad/main.js new file mode 100644 index 0000000..7475b4d --- /dev/null +++ b/examples/apps/static-ad/main.js @@ -0,0 +1,7 @@ +import React from "react"; +import ReactDOM from "react-dom"; +import App from "./app"; + +ReactDOM.render(( + +), document.getElementById("example")); diff --git a/examples/apps/static-ad/styles/index.js b/examples/apps/static-ad/styles/index.js new file mode 100644 index 0000000..b6fd614 --- /dev/null +++ b/examples/apps/static-ad/styles/index.js @@ -0,0 +1,14 @@ +export default { + button: { + marginTop: 20, + padding: "2px 6px 3px", + border: "2px outset buttonface" + }, + adBorder: { + padding: 10, + border: "1px dashed #666" + }, + lb: { + position: "relative" + } +}; diff --git a/examples/server/index.js b/examples/server/index.js new file mode 100644 index 0000000..4d6ea0a --- /dev/null +++ b/examples/server/index.js @@ -0,0 +1,2 @@ +require("babel-register"); +require("./server"); diff --git a/examples/server/routes.js b/examples/server/routes.js new file mode 100644 index 0000000..d8cfc33 --- /dev/null +++ b/examples/server/routes.js @@ -0,0 +1,17 @@ +import InfiniteScrollApp from "../apps/infinite-scrolling/app"; +import LazyRenderApp from "../apps/lazy-render/app"; +import ResponsiveApp from "../apps/responsive/app"; +import RoutingApp from "../apps/routing/app"; +import SingleRequestApp from "../apps/single-request/app"; +import StaticAdApp from "../apps/static-ad/app"; +import InterstitialApp from "../apps/interstitial/app"; + +export default { + "infinite-scrolling": {title: "Infinite Scrolling Example", app: InfiniteScrollApp}, + "lazy-render": {title: "Lazy Render Example", app: LazyRenderApp}, + "responsive": {title: "Responsive Example", app: ResponsiveApp}, + "routing": {title: "Routing Example", app: RoutingApp}, + "single-request": {title: "Single Request Example", app: SingleRequestApp}, + "static-ad": {title: "Static Ad Example", app: StaticAdApp}, + "interstitial": {title: "Interstitial Example", app: InterstitialApp} +}; diff --git a/examples/server/server.js b/examples/server/server.js new file mode 100644 index 0000000..e3d4383 --- /dev/null +++ b/examples/server/server.js @@ -0,0 +1,109 @@ +/* eslint-disable no-console, no-use-before-define, import/default */ +import fs from "fs"; +import path from "path"; +import Express from "express"; + +import webpack from "webpack"; +import webpackDevMiddleware from "webpack-dev-middleware"; +import webpackConfig from "../webpack.config.server"; + +import React from "react"; +import {renderToString} from "react-dom/server"; + +import routes from "./routes"; + +const globalStyle = fs.readFileSync(path.resolve(__dirname, "../apps/global.css"), "utf8"); + +const app = new Express(); +const port = 8080; + +app.use(webpackDevMiddleware(webpack(webpackConfig), { + noInfo: true, + publicPath: webpackConfig.output.publicPath +})); +app.use(handleRoutes); + +function handleRoutes(req, res) { + const routeName = req.url.substr(1).split("/")[0]; + + if (routeName === "") { + res.send(renderIndex()); + return; + } + + if (!routes[routeName]) { + res.send(render404()); + return; + } + + const App = routes[routeName].app; + const title = routes[routeName].title; + const html = renderToString(); + + res.send(renderPage(routeName, html, title)); +} + +function renderIndex() { + return ` + + + + React GPT Examples + + + +

React GPT Examples

+ + + + `; +} + +function render404() { + return ` + + + + Page Not Found + + + +

Page Not Found

+ React GPT Examples + + + `; +} + +function renderPage(name, html, title) { + return ` + + + + ${title} + + + +

React GPT Examples / ${title}

+
${html}
+ + + + + `; +} + +app.listen(port, (error) => { + if (error) { + console.error(error); + } else { + console.info(`==> 🌎 Listening on port ${port}. Open up http://localhost:${port}/ in your browser.`); + } +}); diff --git a/examples/webpack.config.js b/examples/webpack.config.js new file mode 100644 index 0000000..fe740e3 --- /dev/null +++ b/examples/webpack.config.js @@ -0,0 +1,55 @@ +/* eslint-disable */ +var fs = require("fs"); +var path = require("path"); +var webpack = require("webpack"); + +var appDir = __dirname + "/apps"; + +module.exports = { + devtool: "inline-source-map", + + entry: fs.readdirSync(appDir).reduce(function (entries, dir) { + if (fs.statSync(path.join(appDir, dir)).isDirectory()) { + entries[dir] = ["core-js/fn/promise", path.join(appDir, dir, "main.js")]; + } + + return entries; + }, {}), + + output: { + path: __dirname + "/__build__", + filename: "[name].js", + chunkFilename: "[id].chunk.js", + publicPath: "/__build__/" + }, + + module: { + loaders: [ + { + test: /\.jsx?$/, + exclude: /node_modules/, + loader: "babel", + query: { + presets: ["es2015-without-strict", "stage-0", "react"], + plugins: ["transform-decorators-legacy"] + } + } + ] + }, + + resolve: { + alias: { + "react-gpt": process.cwd() + "/src" + } + }, + + cache: false, + + plugins: [ + new webpack.optimize.CommonsChunkPlugin("shared.js"), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "development") + }) + ] + +}; diff --git a/examples/webpack.config.server.js b/examples/webpack.config.server.js new file mode 100644 index 0000000..fce9809 --- /dev/null +++ b/examples/webpack.config.server.js @@ -0,0 +1,54 @@ +/* eslint-disable */ +var fs = require("fs"); +var path = require("path"); +var webpack = require("webpack"); + +var appDir = __dirname + "/apps"; + +module.exports = { + devtool: "inline-source-map", + + entry: fs.readdirSync(appDir).reduce(function (entries, dir) { + if (fs.statSync(path.join(appDir, dir)).isDirectory()) { + entries[dir] = ["core-js/fn/promise", path.join(appDir, dir, "main.js")]; + } + + return entries; + }, {}), + + output: { + path: __dirname + "/__build__", + filename: "[name].js", + chunkFilename: "[id].chunk.js", + publicPath: "/__build__/" + }, + + plugins: [ + new webpack.optimize.CommonsChunkPlugin("shared.js"), + new webpack.optimize.OccurenceOrderPlugin(), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify(process.env.NODE_ENV || "production") + }) + ], + + module: { + loaders: [ + { + test: /\.js$/, + loader: "babel", + exclude: /node_modules/, + include: __dirname, + query: { + presets: ["es2015-without-strict", "stage-0", "react"], + plugins: ["transform-decorators-legacy"] + } + } + ] + }, + + resolve: { + alias: { + "react-gpt": process.cwd() + "/lib" + } + } +}; diff --git a/karma.conf.js b/karma.conf.js new file mode 100644 index 0000000..486a839 --- /dev/null +++ b/karma.conf.js @@ -0,0 +1,86 @@ +var webpack = require("webpack"); +module.exports = function (config) { + config.set({ + basePath: "", + + browserNoActivityTimeout: 60000, + + client: { + mocha: { + reporter: "html" + } + }, + + frameworks: [ + "chai-sinon", + "mocha" + ], + + files: [ + "test/polyfill.js", + "test/**/*spec.js" + ], + + preprocessors: { + "test/polyfill.js": ["webpack"], + "test/**/*spec.js": ["webpack", "sourcemap"] + }, + + coverageReporter: { + reporters: [{ + type: "html", + subdir: "html" + }, { + type: "text" + }] + }, + + webpack: { + devtool: "inline-source-map", + module: { + loaders: [{ + test: /\.js$/, + exclude: /node_modules/, + loader: "babel" + }, { + test: /\.js$/, + // exclude this dirs from coverage + exclude: /(test|node_modules)\//, + loader: "isparta" + }] + }, + resolve: { + extensions: ["", ".js"] + }, + plugins: [ + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify("test") + }) + ], + watch: true + }, + + webpackServer: { + noInfo: true + }, + + reporters: [ + "mocha", + "coverage" + ], + + port: 9876, + + colors: true, + + logLevel: config.LOG_INFO, + + autoWatch: true, + + browsers: ["Chrome"], + + captureTimeout: 60000, + + singleRun: false + }); +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..facad61 --- /dev/null +++ b/package.json @@ -0,0 +1,111 @@ +{ + "name": "react-gpt", + "version": "0.2.1", + "description": "A react display ad component using Google Publisher Tag", + "main": "lib/index.js", + "contributors": [ + { + "name": "NFL Engineering" + } + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/nfl/react-gpt" + }, + "keywords": [ + "react-gpt", + "nfl", + "react", + "ad", + "gpt", + "google publisher tags" + ], + "bugs": { + "url": "https://github.com/nfl/react-gpt/issues" + }, + "files": [ + "*.md", + "docs", + "src", + "dist", + "lib" + ], + "dependencies": { + "debounce": "^1.0.0", + "deep-equal": "^1.0.1", + "eventemitter3": "^1.1.1", + "fbjs": "^0.8.1", + "hoist-non-react-statics": "^1.0.5" + }, + "devDependencies": { + "babel-cli": "^6.5.1", + "babel-core": "^6.5.1", + "babel-eslint": "^6.0.3", + "babel-loader": "^6.2.3", + "babel-plugin-transform-decorators-legacy": "^1.3.4", + "babel-plugin-webpack-alias": "^1.1.1", + "babel-preset-es2015-without-strict": "^0.0.2", + "babel-preset-react": "^6.5.0", + "babel-preset-stage-0": "^6.5.0", + "babel-register": "^6.7.2", + "chai": "^3.4.1", + "commitizen": "^2.8.1", + "conventional-changelog-cli": "^1.2.0", + "core-js": "^2.2.2", + "cz-conventional-changelog": "^1.1.6", + "eslint": "2.8.0", + "eslint-config-nfl": "8.0.1", + "eslint-plugin-import": "1.5.0", + "eslint-plugin-mocha": "2.2.0", + "eslint-plugin-react": "5.0.1", + "express": "^4.13.4", + "history": "^2.0.1", + "isparta-loader": "^2.0.0", + "karma": "^0.13.19", + "karma-chai-sinon": "^0.1.5", + "karma-chrome-launcher": "^0.2.2", + "karma-cli": "^0.1.2", + "karma-coverage": "^0.5.3", + "karma-mocha": "^0.2.1", + "karma-mocha-reporter": "^2.0.0", + "karma-sourcemap-loader": "^0.3.6", + "karma-tap-reporter": "0.0.6", + "karma-webpack": "^1.7.0", + "mocha": "^2.3.4", + "phantom": "^2.0.4", + "querystring": "^0.2.0", + "radium": "^0.17.1", + "react": "^15.0.1", + "react-addons-test-utils": "^15.0.1", + "react-dom": "^15.0.1", + "rimraf": "^2.5.2", + "serve-static": "^1.10.2", + "sinon": "^1.17.2", + "sinon-chai": "^2.8.0", + "webpack": "^1.4.13", + "webpack-dev-middleware": "^1.5.1", + "webpack-dev-server": "^1.14.1" + }, + "scripts": { + "commit": "git-cz", + "changelog": "conventional-changelog -p angular -i CHANGELOG.md -s", + "build": "npm run clean && npm run compile", + "build:umd": "NODE_ENV=development webpack src/index.js dist/react-gpt.js", + "build:umd:min": "NODE_ENV=production webpack -p src/index.js dist/react-gpt.min.js", + "clean": "rimraf lib coverage", + "compile": "babel src --out-dir lib", + "examples": "webpack-dev-server --config examples/webpack.config.js --content-base examples/apps --inline", + "lint": "eslint src test examples", + "start": "npm run build && env BABEL_ENV=examples node examples/server/index.js", + "pretest": "npm run build", + "prepublish": "npm run build && npm run build:umd && npm run build:umd:min", + "test": "npm run lint && karma start", + "update-apilist": "node ./scripts/updateAPIList.js" + }, + "config": { + "commitizen": { + "path": "./node_modules/cz-conventional-changelog" + } + } +} diff --git a/scripts/empty.html b/scripts/empty.html new file mode 100644 index 0000000..e87b8ec --- /dev/null +++ b/scripts/empty.html @@ -0,0 +1,5 @@ + + + + + diff --git a/scripts/updateAPIList.js b/scripts/updateAPIList.js new file mode 100644 index 0000000..42d441a --- /dev/null +++ b/scripts/updateAPIList.js @@ -0,0 +1,135 @@ +var phantom = require("phantom"); +var fs = require("fs"); + +function writeToFile(data) { + var stream = fs.createWriteStream(process.cwd() + "/src/utils/apiList.js"); + stream.once("open", function () { + stream.write("// DO NOT MODIFY THIS FILE MANUALLY.\n"); + stream.write("// This file is generated by `npm run update-apilist`.\n"); + stream.write("// Note that only APIs that's documented in https://developers.google.com/doubleclick-gpt/reference is officially supported.\n"); + stream.write("\n"); + Object.keys(data.apis).forEach(function (key) { + if (key === "gpt" || key === "pubads") { + stream.write("export const " + key + "Version = " + data.version[key] + ";\n"); + } + stream.write("export const " + key + "API = [" + "\n"); + data.apis[key].forEach(function (item, i) { + stream.write("\t" + JSON.stringify(item).split(",").join(", ") + (i === data.apis[key].length - 1 ? "" : ",") + "\n"); + }); + stream.write("];\n"); + }); + stream.end(); + }); +} + +phantom.create().then(function (ph) { + // a hack suggested here: https://github.com/amir20/phantomjs-node/issues/292 + function checkForData() { + ph.windowProperty("DATA").then(function (data) { + if (data !== undefined) { + writeToFile(data); + ph.exit(); + } else { + setTimeout(checkForData, 100); + } + }); + } + + checkForData(); + + ph.createPage().then(function (page) { + page.property("onConsoleMessage", function (msg) { + console.log(msg); + }); + page.property("onCallback", function (data) { + if (data) { + DATA = data; + page.close(); + } + }); + page.open(process.cwd() + "/scripts/empty.html").then(function () { + page.includeJs("http://www.googletagservices.com/tag/js/gpt.js").then(function () { + setTimeout(function () { + page.evaluate(function () { + var EXCLUDES = ["constructor"].concat(Object.getOwnPropertyNames(Object.getPrototypeOf({}))); + var adSlot; + + function filterKeysByType(obj) { + var total = arguments.length <= 1 || arguments[1] === undefined ? [] : arguments[1]; + var filterTypes = arguments.length <= 2 || arguments[2] === undefined ? [] : arguments[2]; + + return Object.getOwnPropertyNames(obj).filter(function (key) { + return ( + total.indexOf(key) === -1 && + EXCLUDES.indexOf(key) === -1 && + key.indexOf("_") !== 0 && // treat property starting with underscore as private + key.length > 2 && // treat property with less than 2 chars as private + obj.hasOwnProperty(key) && + filterTypes.length === 0 ? true : filterTypes.indexOf(typeof obj[key]) > -1 + ); + }).map(function (key) { + return [key, typeof obj[key]]; + }); + } + + function aggregateApisByType(obj) { + var total = arguments.length <= 1 || arguments[1] === undefined ? [] : arguments[1]; + var filterTypes = arguments.length <= 2 || arguments[2] === undefined ? [] : arguments[2]; + var keys = []; + while (obj !== null) { + var _keys; + var arr = filterKeysByType(obj, total, filterTypes); + (_keys = keys).push.apply(_keys, arr); + obj = Object.getPrototypeOf(obj); + } + keys = [].concat(keys); + return keys; + } + + // extracts lists of methods from each service object. + function extractApis(services) { + var filterTypes = arguments.length <= 1 || arguments[1] === undefined ? [] : arguments[1]; + + services = Array.isArray(services) ? services : [services]; + var apis = services.reduce(function (total, service) { + var obj = service.constructor === Object ? service : Object.getPrototypeOf(service); + var keys = aggregateApisByType(obj, total, filterTypes); + total.push.apply(total, keys); + return total; + }, []); + + return apis; + } + + function checkPubadsReady() { + if (googletag && googletag.pubadsReady) { + console.log("gpt version: v" + googletag.getVersion(), ", pubads version: v" + googletag.pubads().getVersion()); + if (typeof window.callPhantom === "function") { + window.callPhantom({ + apis: { + gpt: extractApis(googletag), + pubads: extractApis(googletag.pubads()), + slot: extractApis(adSlot) + }, + version: { + gpt: googletag.getVersion(), + pubads: googletag.pubads().getVersion() + } + }); + } + } else { + setTimeout(checkPubadsReady, 50); + } + } + + googletag.cmd.push(function () { + adSlot = googletag.defineSlot("/123", [0, 0]).addService(googletag.pubads()); + googletag.enableServices(); + checkPubadsReady(); + }); + }); + }, 2000); + }); + }); + }); +}); diff --git a/src/Bling.js b/src/Bling.js new file mode 100644 index 0000000..c6c24c1 --- /dev/null +++ b/src/Bling.js @@ -0,0 +1,733 @@ +/* eslint-disable react/sort-comp */ +import React, {Component, PropTypes} from "react"; +import ReactDOM from "react-dom"; +import invariant from "fbjs/lib/invariant"; +import deepEqual from "deep-equal"; +import hoistStatics from "hoist-non-react-statics"; +import Events from "./Events"; +import filterPropsSimple from "./utils/filterProps"; +import {createManager, pubadsAPI} from "./createManager"; +/** + * An Ad Component using Google Publisher Tags. + * This component should work standalone w/o context. + * https://developers.google.com/doubleclick-gpt/ + * + * @module Bling + * @class Bling + * @fires Bling#Events.READY + * @fires Bling#Events.SLOT_RENDER_ENDED + * @fires Bling#Events.IMPRESSION_VIEWABLE + * @fires Bling#Events.SLOT_VISIBILITY_CHANGED + */ +class Bling extends Component { + static propTypes = { + /** + * An optional string to be used as container div id. + * + * @property id + */ + id: PropTypes.string, + /** + * An optional string indicating ad unit path which will be used + * to create an ad slot. + * + * @property adUnitPath + */ + adUnitPath: PropTypes.string.isRequired, + /** + * An optional object which includes ad targeting key-value pairs. + * + * @property targeting + */ + targeting: PropTypes.object, + /** + * An optional array of width and height size for the ad slot. Additionally it accepts `fluid` for native ads. + * This will be preceded by the sizeMapping if specified. + * + * @property slotSize + */ + slotSize: PropTypes.oneOfType([ + PropTypes.array, + PropTypes.string + ]), + /** + * An optional array of object which contains an array of viewport size and slot size. + * This needs to be set if the ad needs to serve different ad sizes per different viewport sizes (responsive ad). + * Setting the `slot` to any dimension that's not configured in DFP results in rendering an empty ad. + * The ad slot size which is provided for the viewport size of [0, 0] will be used as default ad size if none of viewport size matches. + * + * https://support.google.com/dfp_premium/answer/3423562?hl=en + * + * e.g. + * + * sizeMapping={[ + * {viewport: [0, 0], slot: [320, 50]}, + * {viewport: [768, 0], slot: [728, 90]} + * ]} + * + * @property sizeMapping + */ + sizeMapping: PropTypes.arrayOf(PropTypes.shape({ + viewport: PropTypes.array, + slot: PropTypes.array + })), + /** + * An optional flag to indicate whether an ad slot should be out-of-page slot. + * + * @property outOfPage + */ + outOfPage: PropTypes.bool, + /** + * An optional flag to indicate whether companion ad service should be enabled for the ad. + * If an object is passed, it takes as a configuration expecting `enableSyncLoading` or `refreshUnfilledSlots`. + * + * @property companionAdService + */ + companionAdService: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.object + ]), + /** + * An optional HTML content for the slot. If specified, the ad will render with the HTML content using content service. + * + * @property content + */ + content: PropTypes.string, + /** + * An optional click through URL. If specified, any landing page URL associated with the creative that is served is overridden. + * + * @property clickUrl + */ + clickUrl: PropTypes.string, + /** + * An optional string or an array of string which specifies a page-level ad category exclusion for the given label name. + * + * @property categoryExclusion + */ + categoryExclusion: PropTypes.oneOfType([ + PropTypes.string, + PropTypes.array + ]), + /** + * An optional map of key-value pairs for an AdSense attribute on a particular ad slot. + * see the list of supported key value: https://developers.google.com/doubleclick-gpt/adsense_attributes#adsense_parameters.googletag.Slot + * + * @property attributes + */ + attributes: PropTypes.object, + /** + * An optional flag to indicate whether an empty ad should be collapsed or not. + * + * @property collapseEmptyDiv + */ + collapseEmptyDiv: PropTypes.oneOfType([ + PropTypes.bool, + PropTypes.array + ]), + /** + * An optional flag to indicate whether ads in this slot should be forced to be rendered using a SafeFrame container. + * + * @property forceSafeFrame + */ + forceSafeFrame: PropTypes.bool, + /** + * An optional object to set the slot-level preferences for SafeFrame configuration. + * + * @property safeFrameConfig + */ + safeFrameConfig: PropTypes.object, + /** + * An optional event handler function for `googletag.events.SlotRenderEndedEvent`. + * + * @property onSlotRenderEnded + */ + onSlotRenderEnded: PropTypes.func, + /** + * An optional event handler function for `googletag.events.ImpressionViewableEvent`. + * + * @property onImpressionViewable + */ + onImpressionViewable: PropTypes.func, + /** + * An optional event handler function for `googletag.events.slotVisibilityChangedEvent`. + * + * @property onSlotVisibilityChanged + */ + onSlotVisibilityChanged: PropTypes.func, + /** + * An optional flag to indicate whether an ad should only render when it's fully in the viewport area. + * + * @property renderWhenViewable + */ + renderWhenViewable: PropTypes.bool, + /** + * An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. + * Acceptable range is between 0 and 1. + * + * @property viewableThreshold + */ + viewableThreshold: PropTypes.number, + /** + * An optional call back function to notify when the script is loaded. + * + * @property onScriptLoaded + */ + onScriptLoaded: PropTypes.func, + /** + * An optional call back function to notify when the media queries on the document change. + * + * @property onMediaQueryChange + */ + onMediaQueryChange: PropTypes.func, + /** + * An optional object to be applied as `style` props to the container div. + * + * @property style + */ + style: PropTypes.object + } + + /** + * An array of prop names which can reflect to the ad by calling `refresh`. + * + * @property refreshableProps + * @static + */ + static refreshableProps = [ + "targeting", + "sizeMapping", + "clickUrl", + "categoryExclusion", + "attributes", + "collapseEmptyDiv", + "companionAdService", + "forceSafeFrame", + "safeFrameConfig" + ] + /** + * An array of prop names which requires to create a new ad slot and render as a new ad. + * + * @property reRenderProps + * @static + */ + static reRenderProps = [ + "adUnitPath", + "slotSize", + "outOfPage", + "content" + ] + /** + * An instance of ad manager. + * + * @property _adManager + * @private + * @static + */ + static _adManager = createManager() + /** + * + * @property + * @private + * @static + */ + static _config = { + /** + * An optional string for GPT seed file url to override. + */ + seedFileUrl: "//www.googletagservices.com/tag/js/gpt.js", + /** + * An optional flag to indicate whether an ad should only render when it's fully in the viewport area. Default is `true`. + */ + renderWhenViewable: true, + /** + * An optional number to indicate how much percentage of an ad area needs to be in a viewable area before rendering. Default value is 0.5. + * Acceptable range is between 0 and 1. + */ + viewableThreshold: 0.5, + /** + * An optional function to create an object with filtered current props and next props for a given keys to perform equality check. + */ + filterProps: filterPropsSimple, + /** + * An optional function for the filtered props and the next props to perform equality check. + */ + propsEqual: deepEqual + } + + static on(eventType, cb) { + Bling._on("on", eventType, cb); + } + + static once(eventType, cb) { + Bling._on("once", eventType, cb); + } + + static removeListener(...args) { + Bling._adManager.removeListener(...args); + } + + static removeAllListeners(...args) { + Bling._adManager.removeAllListeners(...args); + } + + static _on(fn, eventType, cb) { + if (typeof cb !== "function") { + return; + } + if (eventType === Events.READY && Bling._adManager.isReady) { + cb.call(Bling._adManager, Bling._adManager.googletag); + } else { + Bling._adManager[fn](eventType, cb); + } + } + + static configure(config = {}) { + Bling._config = { + ...Bling._config, + ...config + }; + } + /** + * Returns the GPT version. + * + * @method getGPTVersion + * @returns {Number|boolean} a version or false if GPT is not yet ready. + * @static + */ + static getGPTVersion() { + return Bling._adManager.getGPTVersion(); + } + /** + * Returns the Pubads Service version. + * + * @method getPubadsVersion + * @returns {Number|boolean} a version or false if Pubads Service is not yet ready. + * @static + */ + static getPubadsVersion() { + return Bling._adManager.getPubadsVersion(); + } + /** + * Sets a flag to indicate whether the correlator value should always be same across the ads in the page or not. + * + * @method syncCorrelator + * @param {boolean} value + * @static + */ + static syncCorrelator(value) { + Bling._adManager.syncCorrelator(value); + } + /** + * Trigger re-rendering of all the ads. + * + * @method render + * @static + */ + static render() { + Bling._adManager.renderAll(); + } + /** + * Refreshes all the ads in the page with a new correlator value. + * + * @param {Array} slots An array of ad slots. + * @param {Object} options You can pass `changeCorrelator` flag. + * @static + */ + static refresh(slots, options) { + Bling._adManager.refresh(slots, options); + } + /** + * Clears the ads for the specified ad slots, if no slots are provided, all the ads will be cleared. + * + * @method clear + * @param {Array} slots An optional array of slots to clear. + * @static + */ + static clear(slots) { + Bling._adManager.clear(slots); + } + /** + * Updates the correlator value for the next ad request. + * + * @method updateCorrelator + * @static + */ + static updateCorrelator() { + Bling._adManager.updateCorrelator(); + } + + static createTestManager() { + Bling._adManager = createManager({test: true}); + } + + state = { + scriptLoaded: false, + inViewport: false + } + + get adSlot() { + return this._adSlot; + } + + get viewableThreshold() { + return this.props.viewableThreshold >= 0 ? this.props.viewableThreshold : Bling._config.viewableThreshold; + } + + componentDidMount() { + Bling._adManager.addInstance(this); + Bling._adManager.load(Bling._config.seedFileUrl).then(this.onScriptLoaded.bind(this)).catch(this.onScriptError.bind(this)); + } + + componentWillReceiveProps(nextProps) { + const {propsEqual} = Bling._config; + const {sizeMapping} = this.props; + if ((nextProps.sizeMapping || sizeMapping) && !propsEqual(nextProps.sizeMapping, sizeMapping)) { + Bling._adManager.removeMQListener(this, nextProps); + } + } + + shouldComponentUpdate(nextProps, nextState) { + // if adUnitPath changes, need to create a new slot, re-render + // otherwise, just refresh + const {scriptLoaded, inViewport} = nextState; + const notInViewport = this.notInViewport(nextProps, nextState); + const inViewportChanged = (this.state.inViewport !== inViewport); + const isScriptLoaded = (this.state.scriptLoaded !== scriptLoaded); + + // Exit early for visibility change, before executing deep equality check. + if (notInViewport) { + return false; + } else if (inViewportChanged) { + return true; + } + + const {filterProps, propsEqual} = Bling._config; + const refreshableProps = filterProps(Bling.refreshableProps, this.props, nextProps); + const reRenderProps = filterProps(Bling.reRenderProps, this.props, nextProps); + const shouldRender = !propsEqual(reRenderProps.props, reRenderProps.nextProps); + const shouldRefresh = !shouldRender && !propsEqual(refreshableProps.props, refreshableProps.nextProps); + // console.log(`shouldRefresh: ${shouldRefresh}, shouldRender: ${shouldRender}, isScriptLoaded: ${isScriptLoaded}, syncCorrelator: ${Bling._adManager._syncCorrelator}`); + + if (shouldRefresh) { + this.configureSlot(this._adSlot, nextProps); + } + + if (Bling._adManager._syncCorrelator) { + if (shouldRefresh) { + Bling._adManager.refresh(); + } else if (shouldRender || isScriptLoaded) { + Bling._adManager.renderAll(); + } + } else { + if (shouldRefresh) { + this.refresh(); + return false; + } + if (shouldRender || isScriptLoaded) { + return true; + } + } + + return false; + } + + componentDidUpdate() { + if (this.notInViewport(this.props, this.state)) { + return; + } + if (this._divId) { + // initial render will enable pubads service before any ad renders + // so taken care of by the manager + if (Bling._adManager._initialRender) { + Bling._adManager.render(); + } else { + this.renderAd(); + } + } + } + + componentWillUnmount() { + Bling._adManager.removeInstance(this); + if (this._adSlot) { + Bling._adManager.googletag.destroySlots([this._adSlot]); + this._adSlot = null; + } + } + + onScriptLoaded() { + const {onScriptLoaded} = this.props; + + if (this.getRenderWhenViewable()) { + this.foldCheck(); + } + this.setState({scriptLoaded: true}, onScriptLoaded); // eslint-disable-line react/no-did-mount-set-state + } + + onScriptError(err) { + console.warn(`Ad: Failed to load gpt for ${Bling._config.seedFileUrl}`, err); + } + + getRenderWhenViewable(props = this.props) { + return props.renderWhenViewable !== undefined ? props.renderWhenViewable : Bling._config.renderWhenViewable; + } + + foldCheck() { + if (this.state.inViewport) { + return; + } + + let slotSize = this.getSlotSize(); + if (Array.isArray(slotSize) && Array.isArray(slotSize[0])) { + slotSize = slotSize[0]; + } + if (slotSize === "fluid") { + slotSize = [0, 0]; + } + + const inViewport = Bling._adManager.isInViewport(ReactDOM.findDOMNode(this), slotSize, this.viewableThreshold); + if (inViewport) { + this.setState({inViewport: true}); + } + } + + defineSizeMapping(adSlot, sizeMapping) { + if (sizeMapping) { + Bling._adManager.addMQListener(this, this.props); + const sizeMappingArray = sizeMapping.reduce((mapping, size) => { + return mapping.addSize(size.viewport, size.slot); + }, Bling._adManager.googletag.sizeMapping()).build(); + adSlot.defineSizeMapping(sizeMappingArray); + } + } + + setAttributes(adSlot, attributes) { + // no clear method, attempting to clear existing attributes before setting new ones. + const attributeKeys = adSlot.getAttributeKeys(); + attributeKeys.forEach(key => { + adSlot.set(key, null); + }); + if (attributes) { + Object.keys(attributes).forEach(key => { + adSlot.set(key, attributes[key]); + }); + } + } + + setTargeting(adSlot, targeting) { + adSlot.clearTargeting(); + if (targeting) { + Object.keys(targeting).forEach(key => { + adSlot.setTargeting(key, targeting[key]); + }); + } + } + + addCompanionAdService(serviceConfig, adSlot) { + const companionAdsService = Bling._adManager.googletag.companionAds(); + adSlot.addService(companionAdsService); + if (typeof serviceConfig === "object") { + if (serviceConfig.hasOwnProperty("enableSyncLoading")) { + companionAdsService.enableSyncLoading(); + } + if (serviceConfig.hasOwnProperty("refreshUnfilledSlots")) { + companionAdsService.setRefreshUnfilledSlots(serviceConfig.refreshUnfilledSlots); + } + } + } + + getSlotSize() { + const { + slotSize: origSlotSize, + sizeMapping: origSizeMapping + } = this.props; + let slotSize; + if (origSlotSize) { + slotSize = origSlotSize; + } else if (origSizeMapping) { + const sizeMapping = origSizeMapping; + slotSize = sizeMapping[0] && sizeMapping[0].slot; + } + + return slotSize; + } + + renderAd() { + this.defineSlot(); + this.display(); + } + + notInViewport(props = this.props, state = this.state) { + const {inViewport} = state; + return this.getRenderWhenViewable(props) && !inViewport; + } + + defineSlot() { + const { + adUnitPath, + outOfPage + } = this.props; + const divId = this._divId; + const slotSize = this.getSlotSize(); + + if (!this._adSlot) { + if (outOfPage) { + this._adSlot = Bling._adManager.googletag.defineOutOfPageSlot(adUnitPath, divId); + } else { + this._adSlot = Bling._adManager.googletag.defineSlot(adUnitPath, slotSize || [], divId); + } + } + + this.configureSlot(this._adSlot); + } + + configureSlot(adSlot, props = this.props) { + const { + sizeMapping, + attributes, + targeting, + companionAdService, + categoryExclusion, + collapseEmptyDiv, + safeFrameConfig, + content, + clickUrl, + forceSafeFrame + } = props; + + this.defineSizeMapping(adSlot, sizeMapping); + + if (collapseEmptyDiv !== undefined) { + if (Array.isArray(collapseEmptyDiv)) { + adSlot.setCollapseEmptyDiv.call(adSlot, ...collapseEmptyDiv); + } else { + adSlot.setCollapseEmptyDiv(collapseEmptyDiv); + } + } + + // Overrides click url + if (clickUrl) { + adSlot.setClickUrl(clickUrl); + } + + // Sets category exclusion + if (categoryExclusion) { + let exclusion = categoryExclusion; + if (typeof exclusion === "string") { + exclusion = [exclusion]; + } + adSlot.clearCategoryExclusions(); + exclusion.forEach(item => { + adSlot.setCategoryExclusion(item); + }); + } + + // Sets AdSense attributes + this.setAttributes(adSlot, attributes); + + // Sets custom targeting parameters + this.setTargeting(adSlot, targeting); + + if (safeFrameConfig) { + adSlot.setSafeFrameConfig(safeFrameConfig); + } + + if (forceSafeFrame) { + adSlot.setForceSafeFrame(forceSafeFrame); + } + + // Enables companion ad service + if (companionAdService) { + this.addCompanionAdService(companionAdService, adSlot); + } + + // GPT checks if the same service is already added. + if (content) { + adSlot.addService(Bling._adManager.googletag.content()); + } else { + adSlot.addService(Bling._adManager.googletag.pubads()); + } + } + + display() { + const {content} = this.props; + const divId = this._divId; + const adSlot = this._adSlot; + + if (content) { + Bling._adManager.googletag.content().setContent(adSlot, content); + } else { + if (!Bling._adManager._disableInitialLoad && !Bling._adManager._syncCorrelator) { + Bling._adManager.updateCorrelator(); + } + Bling._adManager.googletag.display(divId); + if (Bling._adManager._disableInitialLoad && !Bling._adManager._initialRender) { + this.refresh(); + } + } + } + + clear() { + const adSlot = this._adSlot; + if (adSlot) { + // googletag.ContentService doesn't clear content + const services = adSlot.getServices(); + if (this._divId && services.some(s => !!s.setContent)) { + document.getElementById(this._divId).innerHTML = ""; + return; + } + Bling._adManager.clear([adSlot]); + } + } + + refresh(options) { + const adSlot = this._adSlot; + if (adSlot) { + this.clear(); + Bling._adManager.refresh([adSlot], options); + } + } + + render() { + const {scriptLoaded} = this.state; + const {id, outOfPage, style} = this.props; + const shouldNotRender = this.notInViewport(this.props, this.state); + + if (!scriptLoaded || shouldNotRender) { + let slotSize = this.getSlotSize(); + + if (!outOfPage) { + invariant( + slotSize, + "Either 'slotSize' or 'sizeMapping' prop needs to be set." + ); + } + + if (Array.isArray(slotSize) && Array.isArray(slotSize[0])) { + slotSize = slotSize[0]; + } + // https://developers.google.com/doubleclick-gpt/reference?hl=en#googletag.NamedSize + if (slotSize === "fluid") { + slotSize = ["auto", "auto"]; + } + const emptyStyle = slotSize && {width: slotSize[0], height: slotSize[1]}; + // render node element instead of script element so that `inViewport` check works. + return
; + } + + // clear the current ad if exists + this.clear(); + if (this._adSlot) { + Bling._adManager.googletag.destroySlots([this._adSlot]); + this._adSlot = null; + } + this._divId = id || Bling._adManager.generateDivId(); + + return
; + } +} + +// proxy pubads API through Bling +export default hoistStatics(Bling, pubadsAPI.reduce((api, method) => { + api[method] = (...args) => Bling._adManager.pubadsProxy({method, args}); + return api; +}, {})); diff --git a/src/Events.js b/src/Events.js new file mode 100644 index 0000000..38123f2 --- /dev/null +++ b/src/Events.js @@ -0,0 +1,9 @@ +const Events = { + READY: "ready", + RENDER: "render", + SLOT_RENDER_ENDED: "slotRenderEnded", + IMPRESSION_VIEWABLE: "impressionViewable", + SLOT_VISIBILITY_CHANGED: "slotVisibilityChanged" +}; + +export default Events; diff --git a/src/createManager.js b/src/createManager.js new file mode 100644 index 0000000..37c70fb --- /dev/null +++ b/src/createManager.js @@ -0,0 +1,486 @@ +import EventEmitter from "eventemitter3"; +import debounce from "debounce"; +import {canUseDOM} from "fbjs/lib/ExecutionEnvironment"; +import Events from "./Events"; +import isInViewport from "./utils/isInViewport"; +import {GPTMock} from "./utils/mockGPT"; + +// based on https://developers.google.com/doubleclick-gpt/reference?hl=en +export const pubadsAPI = [ + "enableAsyncRendering", + "enableSingleRequest", + "enableSyncRendering", + "disableInitialLoad", + "collapseEmptyDivs", + "enableVideoAds", + "set", + "get", + "getAttributeKeys", + "setTargeting", + "clearTargeting", + "setCategoryExclusion", + "clearCategoryExclusions", + "setCentering", + "setCookieOptions", + "setLocation", + "setPublisherProvidedId", + "setTagForChildDirectedTreatment", + "clearTagForChildDirectedTreatment", + "setVideoContent", + "setForceSafeFrame" +]; + +export const APIToCallBeforeServiceEnabled = [ + "enableAsyncRendering", + "enableSingleRequest", + "enableSyncRendering", + "disableInitialLoad", + "collapseEmptyDivs", + "setCentering" +]; + +export class AdManager extends EventEmitter { + constructor(config = {}) { + super(config); + + if (config.test) { + this.testMode = config.test; + } + } + + _adCnt = 0 + + _initialRender = true + + _syncCorrelator = false + + _testMode = false + + get googletag() { + return this._googletag; + } + + get isLoaded() { + return !!this._isLoaded; + } + + get isReady() { + return !!this._isReady; + } + + get apiReady() { + return this.googletag && this.googletag.apiReady; + } + + get pubadsReady() { + return this.googletag && this.googletag.pubadsReady; + } + + get testMode() { + return this._testMode; + } + + set testMode(config) { + if (process.env.NODE_ENV === "production") { + return; + } + this._googletag = new GPTMock(config); + this._isLoaded = true; + this._testMode = !!config; + } + + _processPubadsQueue() { + if (this._pubadsProxyQueue) { + Object.keys(this._pubadsProxyQueue).forEach(method => { + if ((this.googletag && !this.googletag.pubadsReady && APIToCallBeforeServiceEnabled.indexOf(method) > -1) || + this.pubadsReady) { + this.pubadsProxy(this._pubadsProxyQueue[method]); + delete this._pubadsProxyQueue[method]; + } + }); + if (!Object.keys(this._pubadsProxyQueue).length) { + this._pubadsProxyQueue = null; + } + } + } + + _callPubads({method, args, resolve, reject}) { + if (typeof this.googletag.pubads()[method] !== "function") { + reject(new Error(`googletag.pubads does not support ${method}, please update pubadsAPI`)); + } else { + try { + const result = this.googletag.pubads()[method](...args); + resolve(result); + } catch (err) { + reject(err); + } + } + } + + _toggleListener(add) { + ["scroll", "resize"].forEach(eventName => { + window[add ? "addEventListener" : "removeEventListener"](eventName, this._foldCheck); + }); + } + + _foldCheck = debounce(event => { + const instances = this.getMountedInstances(); + instances.forEach(instance => { + if (instance.getRenderWhenViewable()) { + instance.foldCheck(event); + } + }); + }, 66) + + _handleMediaQueryChange = event => { + if (this._syncCorrelator) { + this.refresh(); + return; + } + // IE returns `event.media` value differently, need to use regex to evaluate. + const res = (/min-width:\s?(\d+)px/).exec(event.media); + const viewportWidth = res && res[1]; + + if (viewportWidth && this._mqls[viewportWidth]) { + this._mqls[viewportWidth].listeners.forEach(instance => { + instance.refresh(); + if (instance.props.onMediaQueryChange) { + instance.props.onMediaQueryChange(event); + } + }); + } + } + + _listen() { + if (!this._listening) { + [ + Events.SLOT_RENDER_ENDED, + Events.IMPRESSION_VIEWABLE, + Events.SLOT_VISIBILITY_CHANGED + ].forEach(eventType => { + ["pubads", "content", "companionAds"].forEach(service => { + // there is no API to remove listeners. + this.googletag[service]().addEventListener(eventType, this._onEvent.bind(this, eventType)); + }); + }); + this._listening = true; + } + } + + _onEvent(eventType, event) { + // fire to the global listeners + if (this.listeners(eventType, true)) { + this.emit(eventType, event); + } + // call event handler props + const instances = this.getMountedInstances(); + const {slot} = event; + const funcName = `on${eventType.charAt(0).toUpperCase()}${eventType.substr(1)}`; + const instance = instances.filter(inst => slot === inst.adSlot)[0]; + if (instance && instance.props[funcName]) { + instance.props[funcName](event); + } + } + + syncCorrelator(value = true) { + this._syncCorrelator = value; + } + + generateDivId() { + return `bling-${++this._adCnt}`; + } + + getMountedInstances() { + if (!this.mountedInstances) { + this.mountedInstances = []; + } + return this.mountedInstances; + } + + addInstance(instance) { + const instances = this.getMountedInstances(); + const index = instances.indexOf(instance); + if (index === -1) { + // The first instance starts listening for the event. + if (instances.length === 0) { + this._toggleListener(true); + } + this.addMQListener(instance, instance.props); + instances.push(instance); + } + } + + removeInstance(instance) { + const instances = this.getMountedInstances(); + const index = instances.indexOf(instance); + if (index >= 0) { + instances.splice(index, 1); + // The last instance removes listening for the event. + if (instances.length === 0) { + this._toggleListener(false); + } + this.removeMQListener(instance, instance.props); + } + } + + addMQListener(instance, {sizeMapping}) { + if (!sizeMapping || !Array.isArray(sizeMapping)) { + return; + } + + sizeMapping.forEach(size => { + const viewportWidth = size.viewport && size.viewport[0]; + if (viewportWidth !== undefined) { + if (!this._mqls) { + this._mqls = {}; + } + if (!this._mqls[viewportWidth]) { + const mql = window.matchMedia(`(min-width: ${viewportWidth}px)`); + mql.addListener(this._handleMediaQueryChange); + this._mqls[viewportWidth] = { + mql, + listeners: [] + }; + } + if (this._mqls[viewportWidth].listeners.indexOf(instance) === -1) { + this._mqls[viewportWidth].listeners.push(instance); + } + } + }); + } + + removeMQListener(instance) { + if (!this._mqls) { + return; + } + + Object.keys(this._mqls) + .forEach(key => { + const index = this._mqls[key].listeners.indexOf(instance); + if (index > -1) { + this._mqls[key].listeners.splice(index, 1); + } + if (this._mqls[key].listeners.length === 0) { + this._mqls[key].mql.removeListener(this._handleMediaQueryChange); + delete this._mqls[key]; + } + }); + } + + isInViewport(...args) { + return isInViewport(...args); + } + + /** + * Refreshes all the ads in the page with a new correlator value. + * + * @param {Array} slots An array of ad slots. + * @param {Object} options You can pass `changeCorrelator` flag. + * @static + */ + refresh(slots, options) { + if (!this.pubadsReady) { + return false; + } + + // gpt already debounces refresh + this.googletag.pubads().refresh(slots, options); + + return true; + } + + clear(slots) { + if (!this.pubadsReady) { + return false; + } + + this.googletag.pubads().clear(slots); + + return true; + } + + render = debounce(() => { + if (!this._initialRender) { + return; + } + + const checkPubadsReady = cb => { + if (this.pubadsReady) { + cb(); + } else { + setTimeout(checkPubadsReady, 50, cb); + } + }; + + const instances = this.getMountedInstances(); + let hasPubAdsService = false; + let dummyAdSlot; + + // Define all the slots + instances.forEach((instance) => { + if (!instance.notInViewport()) { + instance.defineSlot(); + const adSlot = instance.adSlot; + const services = adSlot.getServices(); + if (!hasPubAdsService) { + hasPubAdsService = services.filter(service => !!service.enableAsyncRendering).length > 0; + } + } + }); + // if none of the ad slots uses pubads service, create dummy slot to use pubads service. + if (!hasPubAdsService) { + dummyAdSlot = this.googletag.defineSlot("/", []); + dummyAdSlot.addService(this.googletag.pubads()); + } + + // Call pubads API which needs to be called before service is enabled. + this._processPubadsQueue(); + + // Enable service + this.googletag.enableServices(); + + // After the service is enabled, check periodically until `pubadsReady` flag returns true before proceeding the rest. + checkPubadsReady(() => { + // destroy dummy ad slot if exists. + if (dummyAdSlot) { + this.googletag.destroySlots([dummyAdSlot]); + } + // Call the rest of the pubads API that's in the queue. + this._processPubadsQueue(); + // listen for GPT events + this._listen(); + // client should be able to set any page-level setting within the event handler. + this._isReady = true; + this.emit(Events.READY, this.googletag); + + // Call display + instances.forEach((instance) => { + if (!instance.notInViewport()) { + instance.display(); + } + }); + + this.emit(Events.RENDER, this.googletag); + + this._initialRender = false; + }); + }, 4) + + /** + * Re-render(not refresh) all the ads in the page and the first ad will update the correlator value. + * Updating correlator value ensures competitive exclusion. + * + * @method renderAll + * @static + */ + renderAll = debounce(() => { + if (!this.apiReady) { + return false; + } + + // first instance updates correlator value and re-render each ad + const instances = this.getMountedInstances(); + instances.forEach((instance, i) => { + if (i === 0) { + this.updateCorrelator(); + } + instance.forceUpdate(); + }); + + return true; + }, 4) + + getGPTVersion() { + if (!this.apiReady) { + return false; + } + return this.googletag.getVersion(); + } + + getPubadsVersion() { + if (!this.pubadsReady) { + return false; + } + return this.googletag.pubads().getVersion(); + } + + updateCorrelator() { + if (!this.pubadsReady) { + return false; + } + this.googletag.pubads().updateCorrelator(); + + return true; + } + + load(url) { + return this._loadPromise || (this._loadPromise = new Promise((resolve, reject) => { + // test mode can't be enabled in production mode + if (this.testMode) { + resolve(this.googletag); + return; + } + if (!canUseDOM) { + reject(new Error("DOM not available")); + return; + } + if (!url) { + reject(new Error("url is missing")); + return; + } + const onLoad = () => { + if (window.googletag) { + this._googletag = window.googletag; + // make sure API is ready for use. + this.googletag.cmd.push(() => { + this._isLoaded = true; + resolve(this.googletag); + }); + } else { + reject(new Error("window.googletag is not available")); + } + }; + if (window.googletag) { + onLoad(); + } else { + const script = document.createElement("script"); + script.async = true; + script.onload = onLoad; + script.onerror = () => { + reject(new Error("failed to load script")); + }; + script.src = url; + document.head.appendChild(script); + } + })); + } + + pubadsProxy({method, args = [], resolve, reject}) { + if (!resolve) { + // there are couple pubads API which doesn't provide getter methods for later use, + // so remember them here. + if (APIToCallBeforeServiceEnabled.indexOf(method) > -1) { + this[`_${method}`] = (args && args.length && args[0]) || true; + } + return new Promise((resolve2, reject2) => { + const params = {method, args, resolve: resolve2, reject: reject2}; + if (!this.pubadsReady) { + if (!this._pubadsProxyQueue) { + this._pubadsProxyQueue = {}; + } + this._pubadsProxyQueue[method] = params; + } else { + this._callPubads(params); + } + }); + } + + this._callPubads({method, args, resolve, reject}); + + return Promise.resolve(); + } +} + +export function createManager(config) { + return new AdManager(config); +} diff --git a/src/index.js b/src/index.js new file mode 100644 index 0000000..4495a8d --- /dev/null +++ b/src/index.js @@ -0,0 +1,2 @@ +export {default as Bling} from "./Bling"; +export {default as Events} from "./Events"; diff --git a/src/utils/apiList.js b/src/utils/apiList.js new file mode 100644 index 0000000..b2fedde --- /dev/null +++ b/src/utils/apiList.js @@ -0,0 +1,121 @@ +// DO NOT MODIFY THIS FILE MANUALLY. +// This file is generated by `npm run update-apilist`. +// Note that only APIs that's documented in https://developers.google.com/doubleclick-gpt/reference is officially supported. + +export const gptVersion = 83; +export const gptAPI = [ + ["getVersion", "function"], + ["cmd", "object"], + ["getEventLog", "function"], + ["enableServices", "function"], + ["setAdIframeTitle", "function"], + ["impl", "object"], + ["pubads", "function"], + ["defineOutOfPageSlot", "function"], + ["defineSlot", "function"], + ["defineUnit", "function"], + ["destroySlots", "function"], + ["display", "function"], + ["companionAds", "function"], + ["content", "function"], + ["debug_log", "object"], + ["service_manager_instance", "object"], + ["disablePublisherConsole", "function"], + ["onPubConsoleJsLoad", "function"], + ["sizeMapping", "function"], + ["evalScripts", "function"], + ["apiReady", "boolean"], + ["slot_manager_instance", "object"], + ["pubadsReady", "boolean"] +]; +export const pubadsVersion = 84; +export const pubadsAPI = [ + ["set", "function"], + ["get", "function"], + ["getAttributeKeys", "function"], + ["display", "function"], + ["getName", "function"], + ["setCookieOptions", "function"], + ["setTagForChildDirectedTreatment", "function"], + ["clearTagForChildDirectedTreatment", "function"], + ["setKidsFriendlyAds", "function"], + ["setTargeting", "function"], + ["clearTargeting", "function"], + ["setCategoryExclusion", "function"], + ["clearCategoryExclusions", "function"], + ["disableInitialLoad", "function"], + ["enableSingleRequest", "function"], + ["enableAsyncRendering", "function"], + ["enableSyncRendering", "function"], + ["setCentering", "function"], + ["setPublisherProvidedId", "function"], + ["definePassback", "function"], + ["defineOutOfPagePassback", "function"], + ["refresh", "function"], + ["enableVideoAds", "function"], + ["setVideoContent", "function"], + ["getVideoContent", "function"], + ["getCorrelator", "function"], + ["setCorrelator", "function"], + ["updateCorrelator", "function"], + ["isAdRequestFinished", "function"], + ["collapseEmptyDivs", "function"], + ["clear", "function"], + ["setLocation", "function"], + ["getVersion", "function"], + ["forceExperiment", "function"], + ["markAsAmp", "function"], + ["setSafeFrameConfig", "function"], + ["setForceSafeFrame", "function"], + ["markAsGladeControl", "function"], + ["getName", "function"], + ["getVersion", "function"], + ["getSlots", "function"], + ["getSlotIdMap", "function"], + ["enable", "function"], + ["addEventListener", "function"] +]; +export const slotAPI = [ + ["getPassbackPageUrl", "function"], + ["set", "function"], + ["get", "function"], + ["getAttributeKeys", "function"], + ["addService", "function"], + ["getName", "function"], + ["getAdUnitPath", "function"], + ["getSlotElementId", "function"], + ["getSlotId", "function"], + ["getServices", "function"], + ["getSizes", "function"], + ["defineSizeMapping", "function"], + ["hasWrapperDiv", "function"], + ["setClickUrl", "function"], + ["getClickUrl", "function"], + ["setForceSafeFrame", "function"], + ["setCategoryExclusion", "function"], + ["clearCategoryExclusions", "function"], + ["getCategoryExclusions", "function"], + ["setTargeting", "function"], + ["clearTargeting", "function"], + ["getTargetingMap", "function"], + ["getTargeting", "function"], + ["getTargetingKeys", "function"], + ["getOutOfPage", "function"], + ["getAudExtId", "function"], + ["gtfcd", "function"], + ["setCollapseEmptyDiv", "function"], + ["getCollapseEmptyDiv", "function"], + ["getDivStartsCollapsed", "function"], + ["fetchStarted", "function"], + ["getContentUrl", "function"], + ["fetchEnded", "function"], + ["renderStarted", "function"], + ["getResponseInformation", "function"], + ["renderEnded", "function"], + ["impressionViewable", "function"], + ["visibilityChanged", "function"], + ["setFirstLook", "function"], + ["getFirstLook", "function"], + ["getDefinedId", "function"], + ["setSafeFrameConfig", "function"] +]; diff --git a/src/utils/filterProps.js b/src/utils/filterProps.js new file mode 100644 index 0000000..be489d4 --- /dev/null +++ b/src/utils/filterProps.js @@ -0,0 +1,10 @@ +export default function filterProps(propKeys, props, nextProps) { + return propKeys.reduce((filtered, key) => { + filtered.props[key] = props[key]; + filtered.nextProps[key] = nextProps[key]; + return filtered; + }, { + props: {}, + nextProps: {} + }); +} diff --git a/src/utils/isInViewport.js b/src/utils/isInViewport.js new file mode 100644 index 0000000..646a5e1 --- /dev/null +++ b/src/utils/isInViewport.js @@ -0,0 +1,25 @@ +export default function isInViewport(el, [width, height] = [0, 0], offset = 0) { + if (!el || el.nodeType !== 1) { + return false; + } + const clientRect = el.getBoundingClientRect(); + const rect = { + top: clientRect.top, + left: clientRect.left, + bottom: clientRect.bottom, + right: clientRect.right + }; + const viewport = { + top: 0, + left: 0, + bottom: window.innerHeight, + right: window.innerWidth + }; + const inViewport = ( + rect.bottom >= (viewport.top + height * offset) && + rect.right >= (viewport.left + width * offset) && + rect.top <= (viewport.bottom - height * offset) && + rect.left <= (viewport.right - width * offset) + ); + return inViewport; +} diff --git a/src/utils/mockGPT.js b/src/utils/mockGPT.js new file mode 100644 index 0000000..6c03561 --- /dev/null +++ b/src/utils/mockGPT.js @@ -0,0 +1,292 @@ +import {gptAPI, pubadsAPI, slotAPI, gptVersion, pubadsVersion} from "./apiList"; +import Events from "../Events"; + +function createMock(list) { + return list.reduce((mock, [api, type]) => { + if (type === "function") { + mock[api] = (...args) => { + if (args.length) { + return args[0]; + } + return {}; + }; + } else if (type === "boolean") { + mock[api] = true; + } else { + mock[api] = {}; + } + return mock; + }, {}); +} + +function getSize(slot) { + const sizes = slot.getSizes(); + let item = sizes; + while (Array.isArray(item[0])) { + item = item[0]; + } + + return item; +} + +const SlotMock = function (adUnitPath, size, divId) { + this.adUnitPath = adUnitPath; + this.size = size; + this.divId = divId; + this.services = []; + this.attributes = {}; + this.categoryExclusions = []; + this._targeting = {}; +}; +SlotMock.prototype = { + ...createMock(slotAPI), + defineSizeMapping(sizeMapping) { + this.size = sizeMapping; + return this; + }, + addService(service) { + this.services.push(service); + }, + getServices() { + return this.services; + }, + set(key, value) { + this.attributes[key] = value; + return this; + }, + get(key) { + return this.attributes[key]; + }, + getAttributeKeys() { + return Object.keys(this.attributes); + }, + setCollapseEmptyDiv(collapse, collapseBeforeAdFetch) { + this.collapseEmptyDiv = collapse; + this.collapseBeforeAdFetch = collapseBeforeAdFetch; + return this; + }, + getCollapseEmptyDiv() { + return this.collapseEmptyDiv; + }, + setClickUrl(clickUrl) { + this.clickUrl = clickUrl; + return this; + }, + getClickUrl() { + return this.clickUrl; + }, + setCategoryExclusion(categoryExclusion) { + this.categoryExclusions.push(categoryExclusion); + return this; + }, + getCategoryExclusions() { + return this.categoryExclusions; + }, + clearCategoryExclusions() { + this.categoryExclusions = []; + return this; + }, + setTargeting(key, value) { + this._targeting[key] = value; + return this; + }, + getAdUnitPath() { + return this.adUnitPath; + }, + clearTargeting() { + this._targeting = {}; + return this; + }, + getTargeting(key) { + return this._targeting && this._targeting[key]; + }, + getTargetingKeys() { + return this._targeting && Object.keys(this._targeting); + }, + getSizes() { + return this.size; + }, + getSlotElementId() { + return this.divId; + } +}; + +const SizeMappingBuilderMock = function (config = {}) { + this.config = config; +}; +SizeMappingBuilderMock.prototype = { + addSize(viewportSize, slotSize) { + if (!this.mapping) { + this.mapping = []; + } + this.mapping.push([viewportSize, slotSize]); + return this; + }, + build() { + return this.mapping; + } +}; + +const BaseService = function () { + this.listeners = {}; +}; +BaseService.prototype = { + addEventListener(eventType, cb) { + if (!this.listeners[eventType]) { + this.listeners[eventType] = []; + } + this.listeners[eventType].push(cb); + } +}; + +const PubAdsServiceMock = function (config = {}) { + this.config = config; + this.version = pubadsVersion; + this.listeners = {}; + this.slots = {}; +}; +PubAdsServiceMock.prototype = { + ...createMock(pubadsAPI), + ...BaseService.prototype, + getVersion() { + return this.version; + }, + getSlots() { + return Object.keys(this.slots).map(key => this.slots[key]); + }, + refresh(slots) { + if (!slots) { + slots = Object.keys(this.slots).map(key => this.slots[key]); + } + setTimeout(() => { + const key = Events.SLOT_RENDER_ENDED; + slots.forEach(slot => { + if (this.listeners[key]) { + this.listeners[key].forEach(cb => { + const isEmpty = !!this.config.emptyAd; + const event = { + isEmpty, + creativeId: isEmpty ? null : Date.now(), + lineItemId: isEmpty ? null : Date.now(), + serviceName: "publisher_ads", + size: isEmpty ? null : getSize(slot), + slot + }; + cb(event); + }); + } + }); + }, 0); + } +}; + +const CompanionAdsServiceMock = function (config = {}) { + this.config = config; + this.listeners = {}; +}; +CompanionAdsServiceMock.prototype = { + ...BaseService.prototype, + enableSyncLoading() { + this._enableSyncLoading = true; + }, + setRefreshUnfilledSlots(value) { + if (typeof value === "boolean") { + this._refreshUnfilledSlots = value; + } + } +}; + +const ContentServiceMock = function (config = {}) { + this.config = config; + this.listeners = {}; +}; +ContentServiceMock.prototype = { + ...BaseService.prototype, + setContent(slot, content) { + slot._content = content; + } +}; + +const GPTMock = function (config = {}) { + this.config = config; + this.version = gptVersion; + this.cmd = {}; + this.cmd.push = cb => {cb();}; +}; +GPTMock.prototype = { + ...createMock(gptAPI), + pubadsReady: false, + getVersion() { + return this.version; + }, + enableServices() { + setTimeout(() => { + this.pubadsReady = true; + }, 0); + }, + sizeMapping() { + if (!this.sizeMappingBuilder) { + this.sizeMappingBuilder = new SizeMappingBuilderMock(this.config); + } + return this.sizeMappingBuilder; + }, + pubads() { + if (!this._pubads) { + this._pubads = new PubAdsServiceMock(this.config); + } + return this._pubads; + }, + companionAds() { + if (!this._companionAds) { + this._companionAds = new CompanionAdsServiceMock(this.config); + } + return this._companionAds; + }, + content() { + if (!this._content) { + this._content = new ContentServiceMock(this.config); + } + return this._content; + }, + defineSlot(adUnitPath, size, divId) { + const slot = new SlotMock(adUnitPath, size, divId); + this.pubads().slots[divId] = slot; + return slot; + }, + defineOutOfPageSlot(adUnitPath, divId) { + const slot = new SlotMock(adUnitPath, [1, 1], divId); + this.pubads().slots[divId] = slot; + return slot; + }, + display(divId) { + const pubads = this.pubads(); + setTimeout(() => { + Object.keys(pubads.listeners).forEach(key => { + if (pubads.listeners[key]) { + pubads.listeners[key].forEach(cb => { + const slot = pubads.slots[divId]; + const isEmpty = !!this.config.emptyAd; + const event = { + isEmpty, + creativeId: isEmpty ? null : Date.now(), + lineItemId: isEmpty ? null : Date.now(), + serviceName: "publisher_ads", + size: isEmpty ? null : getSize(slot), + slot + }; + cb(event); + }); + } + }); + }, 0); + } +}; + +export { + GPTMock, + SlotMock, + SizeMappingBuilderMock, + PubAdsServiceMock, + CompanionAdsServiceMock, + ContentServiceMock +}; diff --git a/test/Bling.spec.js b/test/Bling.spec.js new file mode 100644 index 0000000..2f8f7fc --- /dev/null +++ b/test/Bling.spec.js @@ -0,0 +1,641 @@ +/* eslint-disable react/no-multi-comp */ +import React, {Component, PropTypes} from "react"; +import ReactTestUtils from "react-addons-test-utils"; +import Bling from "../src/Bling"; +import Events from "../src/Events"; +import {pubadsAPI, APIToCallBeforeServiceEnabled} from "../src/createManager"; +import {gptVersion, pubadsVersion} from "../src/utils/apiList"; + +describe("Bling", () => { + let googletag; + const stubs = []; + + beforeEach(() => { + Bling.configure({renderWhenViewable: false}); + Bling.createTestManager(); + googletag = Bling._adManager.googletag; + }); + + afterEach(() => { + stubs.forEach(stub => { + stub.restore(); + }); + }); + + it("throws when either slotSize or sizeMapping is missing", () => { + expect(() => { + ReactTestUtils.renderIntoDocument( + + ); + }).to.throw("Either 'slotSize' or 'sizeMapping' prop needs to be set."); + }); + + it("initially renders empty div with style", () => { + const renderer = ReactTestUtils.createRenderer(); + renderer.render(); + const result = renderer.getRenderOutput(); + expect(result.type).to.equal("div"); + expect(result.props.style).to.eql({width: 728, height: 90}); + }); + + it("returns gpt version", (done) => { + Bling.once(Events.READY, () => { + expect(Bling.getGPTVersion()).to.equal(gptVersion); + done(); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("returns pubads version", (done) => { + Bling.once(Events.READY, () => { + expect(Bling.getPubadsVersion()).to.equal(pubadsVersion); + done(); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("accepts syncCorrelator", (done) => { + const render = sinon.stub(Bling._adManager, "render", function () { + expect(this._syncCorrelator).to.be.true; + render.restore(); + done(); + }); + + Bling.syncCorrelator(); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("accepts pubads API", (done) => { + const apiStubs = {}; + pubadsAPI.forEach(method => { + apiStubs[method] = sinon.stub(googletag.pubads(), method); + }); + + Bling.once(Events.RENDER, () => { + APIToCallBeforeServiceEnabled.forEach(method => { + expect(Bling._adManager[`_${method}`]).to.be.true; + }); + Object.keys(apiStubs).forEach(method => { + const stub = apiStubs[method]; + expect(stub.calledOnce).to.be.true; + if (method === "collapseEmptyDivs") { + expect(stub.calledWith(true)).to.be.true; + } else if (method === "setTargeting") { + expect(stub.calledWith("key", "value")).to.be.true; + } + sinon.restore(stub); + }); + done(); + }); + + pubadsAPI.forEach(method => { + let args = []; + if (method === "collapseEmptyDivs") { + args = [true]; + } else if (method === "setTargeting") { + args = ["key", "value"]; + } + Bling[method](...args); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("fires once event", (done) => { + const events = Object.keys(Events).map(key => Events[key]); + + function afterReady() { + Bling.once(Events.READY, () => { + done(); + }); + } + + events.forEach(event => { + Bling.once(event, () => { + events.splice(events.indexOf(event), 1); + if (events.length === 0) { + afterReady(); + } + }); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("fires on event", (done) => { + const events = Object.keys(Events).map(key => Events[key]); + + function afterReady() { + Bling.on(Events.READY, () => { + Bling.removeAllListeners(); + done(); + }); + } + + events.forEach(event => { + Bling.on(event, () => { + events.splice(events.indexOf(event), 1); + if (events.length === 0) { + afterReady(); + } + }); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("removes event", (done) => { + const spy = sinon.spy(); + Bling.on(Events.RENDER, spy); + Bling.removeListener(Events.RENDER, spy); + + Bling.once(Events.SLOT_RENDER_ENDED, () => { + expect(spy.calledOnce).to.be.false; + done(); + }); + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("refreshes ads", () => { + const refresh = sinon.stub(Bling._adManager, "refresh"); + const slots = []; + const options = {}; + + Bling.refresh(slots, options); + + expect(refresh.calledOnce).to.be.true; + expect(refresh.calledWith(slots, options)).to.be.true; + refresh.restore(); + }); + + it("clears ads", () => { + const clear = sinon.stub(Bling._adManager, "clear"); + const slots = []; + + Bling.clear(slots); + + expect(clear.calledOnce).to.be.true; + expect(clear.calledWith(slots)).to.be.true; + clear.restore(); + }); + + it("updates correlator", () => { + const updateCorrelator = sinon.stub(Bling._adManager, "updateCorrelator"); + + Bling.updateCorrelator(); + + expect(updateCorrelator.calledOnce).to.be.true; + updateCorrelator.restore(); + }); + + it("reflects adUnitPath props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getAdUnitPath()).to.equal("/4595/nfl.test.open"); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects slotSize props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getSizes()).to.eql([300, 250]); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects sizeMapping props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getSizes()).to.eql([[[0, 0], [320, 50]], [[750, 200], [728, 90]], [[1050, 200], [1024, 120]]]); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects targeting props to adSlot", (done) => { + const targeting = {t1: "v1", t2: [1, 2, 3]}; + + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getTargetingKeys()).to.eql(["t1", "t2"]); + expect(adSlot.getTargeting("t1")).to.equal(targeting.t1); + expect(adSlot.getTargeting("t2")).to.eql(targeting.t2); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects collapseEmptyDiv props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + expect(ads[0].adSlot.getCollapseEmptyDiv()).to.be.true; + expect(ads[1].adSlot.getCollapseEmptyDiv()).to.be.false; + done(); + }); + + class Wrapper extends Component { + static propTypes = { + children: PropTypes.node + } + render() { + return ( +
{this.props.children}
+ ); + } + } + + const instance = ReactTestUtils.renderIntoDocument( + + + + + ); + const ads = ReactTestUtils.scryRenderedComponentsWithType(instance, Bling); + }); + + it("reflects attributes props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.get("attr1")).to.equal("val1"); + expect(adSlot.get("attr2")).to.equal("val2"); + expect(adSlot.getAttributeKeys()).to.eql(["attr1", "attr2"]); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects categoryExclusion props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + expect(ads[0].adSlot.getCategoryExclusions()).to.eql(["Airline"]); + expect(ads[1].adSlot.getCategoryExclusions()).to.eql(["Airline"]); + done(); + }); + + class Wrapper extends Component { + static propTypes = { + children: PropTypes.node + } + render() { + return ( +
{this.props.children}
+ ); + } + } + + const instance = ReactTestUtils.renderIntoDocument( + + + + + ); + const ads = ReactTestUtils.scryRenderedComponentsWithType(instance, Bling); + }); + + it("reflects clickUrl props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getClickUrl()).to.equal("clickUrl"); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects companionAdService props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + const services = adSlot.getServices(); + const companionAdService = services.filter(service => !!service.setRefreshUnfilledSlots)[0]; + expect(companionAdService._enableSyncLoading).to.be.true; + expect(companionAdService._refreshUnfilledSlots).to.be.true; + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("reflects outOfPage props to adSlot", (done) => { + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot.getSizes()).to.eql([1, 1]); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("renders static ad", (done) => { + const content = ``; + + Bling.once(Events.RENDER, () => { + const adSlot = instance.adSlot; + expect(adSlot._content).to.equal(content); + done(); + }); + + const instance = ReactTestUtils.renderIntoDocument( + + ); + }); + + it("does not render ad when renderWhenViewable prop is set to true and the component is not in viewport", (done) => { + const isInViewport = sinon.stub(Bling._adManager, "isInViewport", () => false); + + Bling.once(Events.SLOT_RENDER_ENDED, event => { + expect(event.slot).to.equal(ads[1].adSlot); + isInViewport.restore(); + done(); + }); + + function onScriptLoaded() { + expect(ads[0].state.inViewport).to.be.false; + } + + class Wrapper extends Component { + static propTypes = { + children: PropTypes.node + } + render() { + return ( +
{this.props.children}
+ ); + } + } + + const instance = ReactTestUtils.renderIntoDocument( + + + + + ); + const ads = ReactTestUtils.scryRenderedComponentsWithType(instance, Bling); + }); + + it("renders ad as soon as the component gets in the viewport", (done) => { + const isInViewport = sinon.stub(Bling._adManager, "isInViewport", () => false); + + Bling.once(Events.SLOT_RENDER_ENDED, event => { + expect(event.slot).to.equal(ads[0].adSlot); + done(); + }); + + function onScriptLoaded() { + expect(ads[0].state.inViewport).to.be.false; + + // simulate resize/scroll + isInViewport.restore(); + Bling._adManager._foldCheck(); + } + + class Wrapper extends Component { + static propTypes = { + children: PropTypes.node + } + render() { + return ( +
{this.props.children}
+ ); + } + } + + const instance = ReactTestUtils.renderIntoDocument( + + + + ); + const ads = ReactTestUtils.scryRenderedComponentsWithType(instance, Bling); + }); + + it("refreshes ad when refreshable prop changes", (done) => { + let count = 0; + + Bling.syncCorrelator(); + + class Wrapper extends Component { + state = { + targeting: {prop: "val"} + } + onSlotRenderEnded = event => { + if (count === 0) { + expect(event.slot.getTargeting("prop")).to.equal("val"); + this.setState({ + targeting: {prop: "val2"} + }); + count++; + } else { + expect(event.slot.getTargeting("prop")).to.equal("val2"); + done(); + } + } + render() { + const {targeting} = this.state; + return ( + + ); + } + } + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("refreshes ad when refreshableProps changes w/o sync correlator", (done) => { + let count = 0; + + Bling.syncCorrelator(false); + + class Wrapper extends Component { + state = { + targeting: {prop: "val"} + } + onSlotRenderEnded = event => { + if (count === 0) { + expect(event.slot.getTargeting("prop")).to.equal("val"); + this.setState({ + targeting: {prop: "val2"} + }); + count++; + } else { + expect(event.slot.getTargeting("prop")).to.equal("val2"); + done(); + } + } + render() { + const {targeting} = this.state; + return ( + + ); + } + } + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("re-renders ad when reRenderProps changes", (done) => { + let count = 0; + + Bling.syncCorrelator(); + + class Wrapper extends Component { + state = { + adUnitPath: "/4595/nfl.test.open" + } + onSlotRenderEnded = event => { + if (count === 0) { + expect(event.slot.getAdUnitPath()).to.equal("/4595/nfl.test.open"); + this.setState({ + adUnitPath: "/4595/nfl.test.open/new" + }); + count++; + } else { + expect(event.slot.getAdUnitPath()).to.equal("/4595/nfl.test.open/new"); + done(); + } + } + render() { + const {adUnitPath} = this.state; + return ( + + ); + } + } + + ReactTestUtils.renderIntoDocument( + + ); + }); + + it("removes itself from registry when unmounted", () => { + const instance = ReactTestUtils.renderIntoDocument( + + ); + instance.componentWillUnmount(); + expect(Bling._adManager.getMountedInstances()).to.have.length(0); + }); +}); diff --git a/test/createManager.spec.js b/test/createManager.spec.js new file mode 100644 index 0000000..91e9671 --- /dev/null +++ b/test/createManager.spec.js @@ -0,0 +1,558 @@ +import {createManager, AdManager, pubadsAPI, APIToCallBeforeServiceEnabled} from "../src/createManager"; +import Events from "../src/Events"; +import {gptVersion} from "../src/utils/apiList"; + +describe("createManager", () => { + let googletag; + let adManager; + + beforeEach(() => { + adManager = createManager({test: true}); + googletag = adManager.googletag; + }); + + afterEach(() => { + window.googletag = undefined; + }); + + it("accepts syncCorrelator", () => { + adManager.syncCorrelator(true); + expect(adManager._syncCorrelator).to.be.true; + }); + + it("accepts pubads API before pubads is ready", (done) => { + const apiStubs = {}; + pubadsAPI.forEach(method => { + apiStubs[method] = sinon.stub(googletag.pubads(), method); + }); + + pubadsAPI.forEach(method => { + let args = []; + if (method === "collapseEmptyDivs") { + args = [true]; + } else if (method === "setTargeting") { + args = ["key", "value"]; + } + adManager.pubadsProxy({method, args}); + }); + + adManager.once(Events.RENDER, () => { + APIToCallBeforeServiceEnabled.forEach(method => { + expect(adManager[`_${method}`]).to.be.true; + }); + Object.keys(apiStubs).forEach(method => { + const stub = apiStubs[method]; + expect(stub.calledOnce).to.be.true; + if (method === "collapseEmptyDivs") { + expect(stub.calledWith(true)).to.be.true; + } else if (method === "setTargeting") { + expect(stub.calledWith("key", "value")).to.be.true; + } + sinon.restore(stub); + }); + + done(); + }); + + adManager.render(); + }); + + it("accepts pubads API after pubads is ready", (done) => { + const apiStubs = {}; + pubadsAPI.forEach(method => { + apiStubs[method] = sinon.stub(googletag.pubads(), method); + }); + + adManager.once(Events.RENDER, () => { + pubadsAPI.forEach(method => { + let args = []; + if (method === "collapseEmptyDivs") { + args = [true]; + } else if (method === "setTargeting") { + args = ["key", "value"]; + } + adManager.pubadsProxy({method, args}); + }); + APIToCallBeforeServiceEnabled.forEach(method => { + expect(adManager[`_${method}`]).to.be.true; + }); + Object.keys(apiStubs).forEach(method => { + const stub = apiStubs[method]; + expect(stub.calledOnce).to.be.true; + if (method === "collapseEmptyDivs") { + expect(stub.calledWith(true)).to.be.true; + } else if (method === "setTargeting") { + expect(stub.calledWith("key", "value")).to.be.true; + } + sinon.restore(stub); + }); + + done(); + }); + + adManager.render(); + }); + + it("loads gpt", (done) => { + adManager.load("//www.googletagservices.com/tag/js/gpt.js") + .then(result => { + expect(result).to.be.an("object"); + expect(adManager.isLoaded).to.be.true; + done(); + }) + .catch(done); + }); + + it("uses gpt when already exists", (done) => { + window.googletag = googletag; + adManager.load("//www.googletagservices.com/tag/js/gpt-invalid.js") + .then(() => { + expect(adManager.isLoaded).to.be.true; + done(); + }) + .catch(done); + }); + + it("handles missing url", (done) => { + adManager = createManager(); + adManager.load("") + .catch(err => { + expect(err.message).to.equal("url is missing"); + done(); + }); + }); + + it("handles invalid url", (done) => { + adManager = createManager(); + adManager.load("//www.googletagservices.com/tag/js/gpt-invalid.js") + .catch(err => { + expect(err.message).to.equal("failed to load script"); + done(); + }); + }); + + it("handles gpt existence", (done) => { + adManager = createManager(); + adManager.load("//www.google.com/jsapi") + .catch(err => { + expect(err.message).to.equal("window.googletag is not available"); + done(); + }); + }); + + it("returns gpt version", () => { + expect(adManager.getGPTVersion()).to.equal(gptVersion); + }); + + it("maintains instance list", () => { + const _toggleListener = sinon.stub(AdManager.prototype, "_toggleListener"); + const addMQListener = sinon.stub(AdManager.prototype, "addMQListener"); + const removeMQListener = sinon.stub(AdManager.prototype, "removeMQListener"); + const instances = [{}, {}]; + + adManager.addInstance(instances[0]); + + expect(_toggleListener.calledWith(true)).to.be.true; + expect(_toggleListener.calledOnce).to.be.true; + expect(addMQListener.calledWith(instances[0])).to.be.true; + expect(addMQListener.calledOnce).to.be.true; + + adManager.addInstance(instances[1]); + + expect(_toggleListener.calledOnce).to.be.true; + expect(addMQListener.calledWith(instances[1])).to.be.true; + expect(addMQListener.calledTwice).to.be.true; + + adManager.removeInstance(instances[0]); + + expect(removeMQListener.calledWith(instances[0])).to.be.true; + expect(removeMQListener.calledOnce).to.be.true; + + adManager.removeInstance(instances[1]); + + expect(_toggleListener.calledWith(false)).to.be.true; + expect(_toggleListener.calledTwice).to.be.true; + expect(removeMQListener.calledWith(instances[1])).to.be.true; + expect(removeMQListener.calledTwice).to.be.true; + + _toggleListener.restore(); + addMQListener.restore(); + removeMQListener.restore(); + }); + + it("adds/removes instance to matchMedia query listener", () => { + // case 1 - missing `sizeMapping` + + let instance = { + props: { + + } + }; + + adManager.addInstance(instance); + + expect(adManager._mqls).to.be.undefined; + + adManager.removeInstance(instance); + + // case 2 - non-array `sizeMapping` + + instance = { + props: { + sizeMapping: 100 + } + }; + + adManager.addInstance(instance); + + expect(adManager._mqls).to.be.undefined; + + adManager.removeInstance(instance); + + // case 3 - invalid `sizeMapping` item + + instance = { + props: { + sizeMapping: [320, 50] + } + }; + + adManager.addInstance(instance); + + expect(adManager._mqls).to.be.undefined; + + adManager.removeInstance(instance); + + // case 4 - valid `sizeMapping` item + + instance = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + } + }; + + adManager.addInstance(instance); + + expect(adManager._mqls).to.be.an("object"); + expect(adManager._mqls["0"]).to.be.an("object"); + expect(adManager._mqls["0"].listeners.length).to.be.equal(1); + + adManager.removeInstance(instance); + + // case 5 - multiple instance listens for the same matchMedia query + + let instance2 = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + } + }; + + adManager.addInstance(instance); + adManager.addInstance(instance2); + + expect(adManager._mqls).to.be.an("object"); + expect(adManager._mqls["0"]).to.be.an("object"); + expect(adManager._mqls["0"].listeners.length).to.be.equal(2); + + adManager.removeInstance(instance); + + expect(adManager._mqls["0"].listeners.length).to.be.equal(1); + + adManager.removeInstance(instance2); + + expect(adManager._mqls).to.be.an("object"); + expect(adManager._mqls["0"]).to.be.undefined; + + // case 6 - removing an instance that's not in listeners won't accidentally remove listeners + + instance2 = { + props: {} + }; + + adManager.addInstance(instance); + adManager.addInstance(instance2); + + adManager.removeInstance(instance2); + + expect(adManager._mqls).to.be.an("object"); + expect(adManager._mqls["0"]).to.be.an("object"); + expect(adManager._mqls["0"].listeners.length).to.be.equal(1); + }); + + it("handles media query change", () => { + adManager.syncCorrelator(); + + const refresh = sinon.stub(googletag.pubads(), "refresh"); + + googletag.pubadsReady = true; + + const instance = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + }, + refresh() {} + }; + + const instanceRefresh = sinon.stub(instance, "refresh"); + + adManager.addInstance(instance); + adManager._handleMediaQueryChange({ + media: "(min-width: 0px)" + }); + + expect(refresh.calledOnce).to.be.true; + + adManager.syncCorrelator(false); + + adManager._handleMediaQueryChange({ + media: "(min-width: 0px)" + }); + + expect(instanceRefresh.calledOnce).to.be.true; + + // IE + adManager._handleMediaQueryChange({ + media: "all and (min-width:0px)" + }); + + expect(instanceRefresh.calledTwice).to.be.true; + + adManager.removeInstance(instance); + + refresh.restore(); + instanceRefresh.restore(); + }); + + it("debounces render", (done) => { + const enableServices = sinon.stub(googletag, "enableServices", googletag.enableServices); + + adManager.once(Events.RENDER, () => { + expect(enableServices.calledOnce).to.be.true; + enableServices.restore(); + done(); + }); + + adManager.render(); + adManager.render(); + adManager.render(); + }); + + it("executes render once", (done) => { + const enableServices = sinon.stub(googletag, "enableServices", googletag.enableServices); + + adManager.once(Events.RENDER, () => { + expect(enableServices.calledOnce).to.be.true; + + setTimeout(() => { + expect(enableServices.calledTwice).to.be.false; + enableServices.restore(); + done(); + }, 300); + + adManager.render(); + }); + + adManager.render(); + adManager.render(); + adManager.render(); + }); + + it("manages initial render", (done) => { + adManager.pubadsProxy({method: "disableInitialLoad"}); + adManager.pubadsProxy({method: "collapseEmptyDivs", args: [false]}); + + const disableInitialLoad = sinon.stub(googletag.pubads(), "disableInitialLoad"); + const collapseEmptyDivs = sinon.stub(googletag.pubads(), "collapseEmptyDivs"); + + const instance = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + }, + notInViewport() {return false;}, + defineSlot() {}, + display() {}, + adSlot: googletag.defineSlot("/", []) + }; + + const defineSlot = sinon.stub(instance, "defineSlot"); + const display = sinon.stub(instance, "display"); + + adManager.addInstance(instance); + + adManager.once(Events.RENDER, () => { + expect(disableInitialLoad.calledOnce).to.be.true; + expect(collapseEmptyDivs.calledWith(false)).to.be.true; + expect(defineSlot.calledOnce).to.be.true; + expect(display.calledOnce).to.be.true; + + disableInitialLoad.restore(); + collapseEmptyDivs.restore(); + defineSlot.restore(); + display.restore(); + adManager.removeInstance(instance); + done(); + }); + + adManager.render(); + adManager.render(); + adManager.render(); + }); + + it("debounces foldCheck", (done) => { + const instance = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + }, + getRenderWhenViewable() { + return true; + }, + foldCheck() {} + }; + + const instance2 = { + props: { + sizeMapping: [{viewport: [0, 0], slot: [320, 50]}] + }, + getRenderWhenViewable() { + return false; + }, + foldCheck() {} + }; + + const foldCheck = sinon.stub(instance, "foldCheck"); + const foldCheck2 = sinon.stub(instance2, "foldCheck"); + + adManager.addInstance(instance); + adManager.addInstance(instance2); + + setTimeout(() => { + expect(foldCheck.calledOnce).to.be.true; + expect(foldCheck2.calledOnce).to.be.false; + foldCheck.restore(); + foldCheck2.restore(); + adManager.removeInstance(instance); + adManager.removeInstance(instance2); + done(); + }, 100); + + adManager._foldCheck(); + adManager._foldCheck(); + adManager._foldCheck(); + }); + + it("renders all ads", (done) => { + googletag.apiReady = false; + const updateCorrelator = sinon.stub(AdManager.prototype, "updateCorrelator"); + + const instance = { + props: {}, + forceUpdate() {} + }; + + const instance2 = { + props: {}, + forceUpdate() {} + }; + + const forceUpdate = sinon.stub(instance, "forceUpdate"); + const forceUpdate2 = sinon.stub(instance2, "forceUpdate"); + + adManager.addInstance(instance); + adManager.addInstance(instance2); + + setTimeout(() => { + expect(updateCorrelator.calledOnce).to.be.false; + expect(forceUpdate.calledOnce).to.be.false; + expect(forceUpdate2.calledOnce).to.be.false; + + googletag.apiReady = true; + + setTimeout(() => { + expect(updateCorrelator.calledOnce).to.be.true; + expect(forceUpdate.calledOnce).to.be.true; + expect(forceUpdate2.calledOnce).to.be.true; + + updateCorrelator.restore(); + forceUpdate.restore(); + forceUpdate2.restore(); + adManager.removeInstance(instance); + adManager.removeInstance(instance2); + done(); + }, 300); + + adManager.renderAll(); + }, 300); + + adManager.renderAll(); + }); + + it("refreshes ads", () => { + const refresh = sinon.stub(googletag.pubads(), "refresh"); + + adManager.refresh(); + expect(refresh.calledOnce).to.be.false; + + googletag.pubadsReady = true; + adManager.refresh(); + expect(refresh.calledOnce).to.be.true; + refresh.restore(); + }); + + it("clears ads", () => { + const clear = sinon.stub(googletag.pubads(), "clear"); + + adManager.clear(); + expect(clear.calledOnce).to.be.false; + + googletag.pubadsReady = true; + adManager.clear(); + expect(clear.calledOnce).to.be.true; + clear.restore(); + }); + + it("calls prop function for gpt event", (done) => { + const listeners = []; + const slot = googletag.defineSlot("/", []); + const addEventListener = sinon.stub(googletag.pubads(), "addEventListener", (eventType, cb) => { + if (!listeners[eventType]) { + listeners[eventType] = []; + } + listeners[eventType].push(cb); + }); + + const instance = { + props: { + onSlotRenderEnded() {} + }, + adSlot: slot, + notInViewport() {return false;}, + defineSlot() {}, + display() {} + }; + + const display = sinon.stub(instance, "display", () => { + Object.keys(listeners).forEach(key => { + if (listeners[key]) { + listeners[key].forEach(cb => { + cb({slot}); + }); + } + }); + }); + + const onSlotRenderEnded = sinon.stub(instance.props, "onSlotRenderEnded"); + + adManager.addInstance(instance); + + adManager.once(Events.RENDER, () => { + expect(onSlotRenderEnded.calledOnce).to.be.true; + addEventListener.restore(); + display.restore(); + onSlotRenderEnded.restore(); + adManager.removeInstance(instance); + done(); + }); + + adManager.render(); + }); +}); diff --git a/test/isInViewport.spec.js b/test/isInViewport.spec.js new file mode 100644 index 0000000..b3dc546 --- /dev/null +++ b/test/isInViewport.spec.js @@ -0,0 +1,18 @@ +import isInViewport from "../src/utils/isInViewport"; + +describe("isInViewport", () => { + it("returns false when an element is invalid", () => { + let result = isInViewport(); + expect(result).to.be.false; + const textNode = document.createTextNode("text"); + result = isInViewport(textNode); + expect(result).to.be.false; + }); + + it("checks intersection with viewport", () => { + const el = document.createElement("div"); + document.body.appendChild(el); + const result = isInViewport(el, [0, 0], 0); + expect(result).to.be.true; + }); +}); diff --git a/test/mockGPT.spec.js b/test/mockGPT.spec.js new file mode 100644 index 0000000..37a3078 --- /dev/null +++ b/test/mockGPT.spec.js @@ -0,0 +1,73 @@ +import { + GPTMock, + SlotMock, + SizeMappingBuilderMock, + PubAdsServiceMock, + CompanionAdsServiceMock, + ContentServiceMock +} from "../src/utils/mockGPT"; +import {gptVersion} from "../src/utils/apiList"; + +describe("mockGPT", () => { + let gptMock; + + beforeEach(() => { + gptMock = new GPTMock(); + }); + + it("returns version from getVersion()", () => { + expect(gptMock.getVersion()).to.equal(gptVersion); + }); + + it("returns sizeMappingBuilder from sizeMapping()", () => { + const sizeMappingBuilder = gptMock.sizeMapping(); + expect(sizeMappingBuilder).to.be.an.instanceof(SizeMappingBuilderMock); + const sizeMappingBuilder2 = sizeMappingBuilder + .addSize([1024, 768], [970, 250]) + .addSize([980, 690], [728, 90]) + .addSize([640, 480], "fluid"); + expect(sizeMappingBuilder).to.equal(sizeMappingBuilder2); + const mapping = sizeMappingBuilder2.build(); + expect(mapping).to.eql([[[1024, 768], [970, 250]], [[980, 690], [728, 90]], [[640, 480], "fluid"]]); + }); + + it("returns pubAdsService from pubads()", () => { + const pubAdsService = gptMock.pubads(); + expect(pubAdsService).to.be.an.instanceof(PubAdsServiceMock); + }); + + it("returns companionAdsService from companionAds()", () => { + const companionAdsService = gptMock.companionAds(); + expect(companionAdsService).to.be.an.instanceof(CompanionAdsServiceMock); + }); + + it("returns contentService from content()", () => { + const contentService = gptMock.content(); + expect(contentService).to.be.an.instanceof(ContentServiceMock); + }); + + it("returns slot from defineSlot()", () => { + const adUnitPath = "/1234/abc"; + const size = [728, 90]; + const divId = "div-1"; + const slot = gptMock.defineSlot(adUnitPath, size, divId); + expect(slot).to.be.an.instanceof(SlotMock); + expect(slot.getSlotElementId()).to.equal(divId); + expect(slot.getSizes()).to.equal(size); + expect(slot.getAdUnitPath()).to.equal(adUnitPath); + + const adUnitPath2 = "/1234/def"; + const size2 = [300, 250]; + const divId2 = "div-2"; + const slot2 = gptMock.defineSlot(adUnitPath2, size2, divId2); + + const pubAdsService = gptMock.pubads(); + expect(pubAdsService.getSlots()).to.eql([slot, slot2]); + }); + + it("executes callback from cmd()", () => { + const spy = sinon.spy(); + gptMock.cmd.push(spy); + expect(spy.called).to.be.true; + }); +}); diff --git a/test/polyfill.js b/test/polyfill.js new file mode 100644 index 0000000..c4d4bc0 --- /dev/null +++ b/test/polyfill.js @@ -0,0 +1 @@ +import "core-js/fn/promise"; diff --git a/webpack.config.js b/webpack.config.js new file mode 100644 index 0000000..c20e627 --- /dev/null +++ b/webpack.config.js @@ -0,0 +1,40 @@ +var webpack = require("webpack"); +var env = process.env.NODE_ENV; + +var reactExternal = { + root: "React", + commonjs2: "react", + commonjs: "react", + amd: "react" +}; + +var reactDomExternal = { + root: "ReactDOM", + commonjs2: "react-dom", + commonjs: "react-dom", + amd: "react-dom" +}; + +var config = { + externals: { + "react": reactExternal, + "react-dom": reactDomExternal + }, + module: { + loaders: [ + {test: /\.js$/, loaders: ["babel-loader"], exclude: /node_modules/} + ] + }, + output: { + library: "ReactGPT", + libraryTarget: "umd" + }, + plugins: [ + new webpack.optimize.OccurenceOrderPlugin(), + new webpack.DefinePlugin({ + "process.env.NODE_ENV": JSON.stringify(env) + }) + ] +}; + +module.exports = config;