Bug 1514006 - Part 1, Add impression wrapper for Pocket sections ()

This commit is contained in:
Nan Jiang 2019-01-16 12:17:42 -05:00 коммит произвёл GitHub
Родитель 2a10f2fb86
Коммит ac1cc87ef5
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
3 изменённых файлов: 253 добавлений и 5 удалений
content-src/components
DiscoveryStreamBase
DiscoveryStreamImpressionStats
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}]);
});
});