Bug 1514006 - Part 1, Add impression wrapper for Pocket sections (#4655)
This commit is contained in:
Родитель
2a10f2fb86
Коммит
ac1cc87ef5
content-src/components
test/unit/content-src/components/DiscoveryStreamComponents
|
@ -2,11 +2,22 @@ import {CardGrid} from "content-src/components/DiscoveryStreamComponents/CardGri
|
|||
import {connect} from "react-redux";
|
||||
import {Hero} from "content-src/components/DiscoveryStreamComponents/Hero/Hero";
|
||||
import {HorizontalRule} from "content-src/components/DiscoveryStreamComponents/HorizontalRule/HorizontalRule";
|
||||
import {ImpressionStats} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
|
||||
import {List} from "content-src/components/DiscoveryStreamComponents/List/List";
|
||||
import React from "react";
|
||||
import {SectionTitle} from "content-src/components/DiscoveryStreamComponents/SectionTitle/SectionTitle";
|
||||
import {TopSites} from "content-src/components/DiscoveryStreamComponents/TopSites/TopSites";
|
||||
|
||||
// According to the Pocket API endpoint specs, `component.properties.items` is a required property with following values:
|
||||
// - Lists 1-5 items
|
||||
// - Hero 1-5 items
|
||||
// - CardGrid 1-8 items
|
||||
// To enforce that, we define various maximium items for individual components as an extra check.
|
||||
// Note that these values are subject to the future changes of the specs.
|
||||
const MAX_ROWS_HERO = 5;
|
||||
// const MAX_ROWS_LISTS = 5;
|
||||
// const MAX_ROWS_CARDGRID = 8;
|
||||
|
||||
export class _DiscoveryStreamBase extends React.PureComponent {
|
||||
renderComponent(component) {
|
||||
switch (component.type) {
|
||||
|
@ -17,11 +28,18 @@ export class _DiscoveryStreamBase extends React.PureComponent {
|
|||
case "CardGrid":
|
||||
return (<CardGrid feed={component.feed} />);
|
||||
case "Hero":
|
||||
return (<Hero
|
||||
title={component.header.title}
|
||||
feed={component.feed}
|
||||
style={component.properties.style}
|
||||
items={component.properties.items} />);
|
||||
const feed = this.props.DiscoveryStream.feeds[component.feed.url];
|
||||
const items = Math.min(component.properties.items, MAX_ROWS_HERO);
|
||||
const rows = feed ? feed.data.recommendations.slice(0, items) : [];
|
||||
return (
|
||||
<ImpressionStats rows={rows} dispatch={this.props.dispatch} source={component.type}>
|
||||
<Hero
|
||||
title={component.header.title}
|
||||
feed={component.feed}
|
||||
style={component.properties.style}
|
||||
items={items} />
|
||||
</ImpressionStats>
|
||||
);
|
||||
case "HorizontalRule":
|
||||
return (<HorizontalRule />);
|
||||
case "List":
|
||||
|
|
|
@ -0,0 +1,93 @@
|
|||
import {actionCreators as ac} from "common/Actions.jsm";
|
||||
import React from "react";
|
||||
|
||||
const VISIBLE = "visible";
|
||||
const VISIBILITY_CHANGE_EVENT = "visibilitychange";
|
||||
|
||||
export class ImpressionStats extends React.PureComponent {
|
||||
// This checks if the given cards are the same as those in the last impression ping.
|
||||
// If so, it should not send the same impression ping again.
|
||||
_needsImpressionStats(cards) {
|
||||
if (!this.impressionCardGuids || (this.impressionCardGuids.length !== cards.length)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
for (let i = 0; i < cards.length; i++) {
|
||||
if (cards[i].id !== this.impressionCardGuids[i]) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
_dispatchImpressionStats() {
|
||||
const {props} = this;
|
||||
const cards = props.rows;
|
||||
|
||||
if (this._needsImpressionStats(cards)) {
|
||||
props.dispatch(ac.ImpressionStats({
|
||||
source: props.source.toUpperCase(),
|
||||
tiles: cards.map(link => ({id: link.id})),
|
||||
}));
|
||||
this.impressionCardGuids = cards.map(link => link.id);
|
||||
}
|
||||
}
|
||||
|
||||
// This sends an event when a user sees a set of new content. If content
|
||||
// changes while the page is hidden (i.e. preloaded or on a hidden tab),
|
||||
// only send the event if the page becomes visible again.
|
||||
sendImpressionStatsOrAddListener() {
|
||||
const {props} = this;
|
||||
|
||||
if (!props.dispatch) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.document.visibilityState === VISIBLE) {
|
||||
this._dispatchImpressionStats();
|
||||
} else {
|
||||
// We should only ever send the latest impression stats ping, so remove any
|
||||
// older listeners.
|
||||
if (this._onVisibilityChange) {
|
||||
props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
|
||||
this._onVisibilityChange = () => {
|
||||
if (props.document.visibilityState === VISIBLE) {
|
||||
this._dispatchImpressionStats();
|
||||
props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
};
|
||||
props.document.addEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
componentDidMount() {
|
||||
if (this.props.rows.length) {
|
||||
this.sendImpressionStatsOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
componentDidUpdate(prevProps) {
|
||||
if (this.props.rows.length && this.props.rows !== prevProps.rows) {
|
||||
this.sendImpressionStatsOrAddListener();
|
||||
}
|
||||
}
|
||||
|
||||
componentWillUnmount() {
|
||||
if (this._onVisibilityChange) {
|
||||
this.props.document.removeEventListener(VISIBILITY_CHANGE_EVENT, this._onVisibilityChange);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
return this.props.children;
|
||||
}
|
||||
}
|
||||
|
||||
ImpressionStats.defaultProps = {
|
||||
document: global.document,
|
||||
rows: [],
|
||||
source: "",
|
||||
};
|
|
@ -0,0 +1,137 @@
|
|||
import {actionTypes as at} from "common/Actions.jsm";
|
||||
import {ImpressionStats} from "content-src/components/DiscoveryStreamImpressionStats/ImpressionStats";
|
||||
import React from "react";
|
||||
import {shallow} from "enzyme";
|
||||
|
||||
const SOURCE = "TEST_SOURCE";
|
||||
|
||||
describe("<ImpressionStats>", () => {
|
||||
const DEFAULT_PROPS = {
|
||||
rows: [{id: 1}, {id: 2}, {id: 3}],
|
||||
source: SOURCE,
|
||||
document: {
|
||||
visibilityState: "visible",
|
||||
addEventListener: sinon.stub(),
|
||||
removeEventListener: sinon.stub(),
|
||||
},
|
||||
};
|
||||
|
||||
const InnerEl = () => (<div>Inner Element</div>);
|
||||
|
||||
function renderImpressionStats(props = {}) {
|
||||
return shallow(<ImpressionStats {...DEFAULT_PROPS} {...props}>
|
||||
<InnerEl />
|
||||
</ImpressionStats>);
|
||||
}
|
||||
|
||||
it("should render props.children", () => {
|
||||
const wrapper = renderImpressionStats();
|
||||
assert.ok(wrapper.contains(<InnerEl />));
|
||||
});
|
||||
it("should send impression with the right stats when the page loads", () => {
|
||||
const dispatch = sinon.spy();
|
||||
const props = {dispatch};
|
||||
renderImpressionStats(props);
|
||||
|
||||
assert.calledOnce(dispatch);
|
||||
|
||||
const [action] = dispatch.firstCall.args;
|
||||
assert.equal(action.type, at.TELEMETRY_IMPRESSION_STATS);
|
||||
assert.equal(action.data.source, SOURCE);
|
||||
assert.deepEqual(action.data.tiles, [{id: 1}, {id: 2}, {id: 3}]);
|
||||
});
|
||||
it("should send 1 impression when the page becomes visibile after loading", () => {
|
||||
const props = {
|
||||
document: {
|
||||
visibilityState: "hidden",
|
||||
addEventListener: sinon.spy(),
|
||||
removeEventListener: sinon.spy(),
|
||||
},
|
||||
dispatch: sinon.spy(),
|
||||
};
|
||||
|
||||
renderImpressionStats(props);
|
||||
|
||||
// Was the event listener added?
|
||||
assert.calledWith(props.document.addEventListener, "visibilitychange");
|
||||
|
||||
// Make sure dispatch wasn't called yet
|
||||
assert.notCalled(props.dispatch);
|
||||
|
||||
// Simulate a visibilityChange event
|
||||
const [, listener] = props.document.addEventListener.firstCall.args;
|
||||
props.document.visibilityState = "visible";
|
||||
listener();
|
||||
|
||||
// Did we actually dispatch an event?
|
||||
assert.calledOnce(props.dispatch);
|
||||
assert.equal(props.dispatch.firstCall.args[0].type, at.TELEMETRY_IMPRESSION_STATS);
|
||||
|
||||
// Did we remove the event listener?
|
||||
assert.calledWith(props.document.removeEventListener, "visibilitychange", listener);
|
||||
});
|
||||
it("should remove visibility change listener when wrapper is removed", () => {
|
||||
const props = {
|
||||
dispatch: sinon.spy(),
|
||||
document: {
|
||||
visibilityState: "hidden",
|
||||
addEventListener: sinon.spy(),
|
||||
removeEventListener: sinon.spy(),
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = renderImpressionStats(props);
|
||||
assert.calledWith(props.document.addEventListener, "visibilitychange");
|
||||
const [, listener] = props.document.addEventListener.firstCall.args;
|
||||
|
||||
wrapper.unmount();
|
||||
assert.calledWith(props.document.removeEventListener, "visibilitychange", listener);
|
||||
});
|
||||
it("should send an impression if props are updated and props.rows are different", () => {
|
||||
const props = {dispatch: sinon.spy()};
|
||||
const wrapper = renderImpressionStats(props);
|
||||
props.dispatch.resetHistory();
|
||||
|
||||
// New rows
|
||||
wrapper.setProps({...DEFAULT_PROPS, ...{rows: [{id: 4}]}});
|
||||
|
||||
assert.calledOnce(props.dispatch);
|
||||
});
|
||||
it("should not send an impression if props are updated but IDs are the same", () => {
|
||||
const props = {dispatch: sinon.spy()};
|
||||
const wrapper = renderImpressionStats(props);
|
||||
props.dispatch.resetHistory();
|
||||
|
||||
wrapper.setProps(DEFAULT_PROPS);
|
||||
|
||||
assert.notCalled(props.dispatch);
|
||||
});
|
||||
it("should only send the latest impression on a visibility change", () => {
|
||||
const listeners = new Set();
|
||||
const props = {
|
||||
dispatch: sinon.spy(),
|
||||
document: {
|
||||
visibilityState: "hidden",
|
||||
addEventListener: (ev, cb) => listeners.add(cb),
|
||||
removeEventListener: (ev, cb) => listeners.delete(cb),
|
||||
},
|
||||
};
|
||||
|
||||
const wrapper = renderImpressionStats(props);
|
||||
|
||||
// Update twice
|
||||
wrapper.setProps({...props, ...{rows: [{id: 123}]}});
|
||||
wrapper.setProps({...props, ...{rows: [{id: 2432}]}});
|
||||
|
||||
assert.notCalled(props.dispatch);
|
||||
|
||||
// Simulate listeners getting called
|
||||
props.document.visibilityState = "visible";
|
||||
listeners.forEach(l => l());
|
||||
|
||||
// Make sure we only sent the latest event
|
||||
assert.calledOnce(props.dispatch);
|
||||
const [action] = props.dispatch.firstCall.args;
|
||||
assert.deepEqual(action.data.tiles, [{id: 2432}]);
|
||||
});
|
||||
});
|
Загрузка…
Ссылка в новой задаче