feat(sections): Add section ordering, and custom sections to the pref pane (#3216)

Move TopStories config out of TopStoriesFeed and PreferencesPane and
into SectionsManager.jsm. Underlying work for #3194.
This commit is contained in:
Adam Hillier 2017-08-24 13:18:37 -04:00 коммит произвёл GitHub
Родитель 36e9a79e13
Коммит d24ebe2968
13 изменённых файлов: 479 добавлений и 281 удалений

Просмотреть файл

@ -1,66 +1,65 @@
# Sections in Activity Stream
Each section in Activity Stream displays data from a corresponding section feed
in a standardised `Section` UI component. Section feeds are responsible for
registering/deregistering their UI component and supplying rows data (the sites
for the section to display).
Activity Stream loads sections from the `SECTIONS` map in `ActivityStream.jsm`.
Configuration objects must be keyed by a unique section id and have the
properties `{feed, showByDefault}`, where `feed` is the section feed class.
in a standardised `Section` UI component. Each section feed is responsible for
listening to events and updating the section options such as the title, icon,
and rows (the cards for the section to display).
The `Section` UI component displays the rows provided by the section feed. If no
rows are available it displays an empty state consisting of an icon and a
message. Optionally, the section may have a info option menu that is displayed
when users hover over the info icon.
On load, `SectionsManager` and `SectionsFeed` in `SectionsManager.jsm` add the
sections configured in the `BUILT_IN_SECTIONS` map to the state. These sections
are initially disabled, so aren't visible. The section's feed may use the
methods provided by the `SectionsManager` to enable its section and update its
properties.
The section configuration in `BUILT_IN_SECTIONS` constists of a generator
function keyed by the pref name for the section feed. The generator function
takes an `options` argument as the only parameter, which is passed the object
stored as serialised JSON in the pref `{feed_pref_name}.options`, or the empty
object if this doesn't exist. The generator returns a section configuration
object which may have the following properties:
Property | Type | Description
--- | --- | ---
id | String | Non-optional unique id.
title | Localisation object | Has property `id`, the string localisation id, and optionaly a `values` object to fill in placeholders.
icon | String | Icon id. New icons should be added in icons.scss.
maxRows | Integer | Maximum number of rows of cards to display. Should be >= 1.
contextMenuOptions | Array of strings | The menu options to provide in the card context menus.
shouldHidePref | Boolean | If true, will the section preference in the preferences pane will not be shown.
pref | Object | Configures the section preference to show in the preferences pane. Has properties `titleString` and `descString`.
emptyState | Object | Configures the empty state of the section. Has properties `message` and `icon`.
infoOption | Object | Configures the info option. Has properties `header`, `body`, and `link`.
## Section feeds
Each section feed is given the pref
`{Activity Stream pref branch}.feeds.section.{section_id}`. This pref turns the
section feed on and off.
Each section feed should be controlled by the pref `feeds.section.{section_id}`.
### Registering the section
### Enabling the section
The section feed must listen for the events `INIT` (dispatched
when Activity Stream is initialised) and `FEED_INIT` (dispatched when a feed is
re-enabled having been turned off, with the feed id as the `data`) and respond
by dispatching a `SECTION_REGISTER` action to enable the section's UI component.
The action's `data` object should have the following properties:
The section feed must listen for the events `INIT` (dispatched when Activity
Stream is initialised) and `FEED_INIT` (dispatched when a feed is re-enabled
having been turned off, with the feed id as the `data`). On these events it must
call `SectionsManager.enableSection(id)`. Care should be taken that this happens
only once `SectionsManager` has also initialised; the feed can use the method
`SectionsManager.onceInitialized()`.
```js
{
id, // Unique section id
icon, // Section icon id - new icons should be added to icons.scss
title: {id, values}, // Title localisation id and placeholder values
maxCards, // Max number of site cards to dispay
contextMenuOptions, // Default context-menu options for cards
infoOption: { // Info option dialog
header: {id, values}, // Header localisation id and values
body: {id, values}, // Body localisation id and values
link: {href, id, values}, // Link href, localisation id and values
},
emptyState: { // State if no cards are visible
message: {id, values}, // Message localisation id and values
icon // Icon id - new icons should be added to icons.scss
}
}
```
### Deregistering the section
### Disabling the section
The section feed must have an `uninit` method. This is called when the section
feed is disabled by turning the section's pref off.
In `uninit` the feed must broadcast a `SECTION_DEREGISTER` action with the
section id as the data. This will remove the section's UI component from every
existing Activity Stream page.
feed is disabled by turning the section's pref off. In `uninit` the feed must
call `SectionsManager.disableSection(id)`. This will remove the section's UI
component from every existing Activity Stream page.
### Updating the section rows
The section feed can dispatch a `SECTION_ROWS_UPDATE` action to update its rows.
The action's data object must be passed the section's `id` and an array `rows`
of sites to display. Each site object must have the following properties:
The section feed can call `SectionsManager.updateSection(id, options)` to update
section options. The `rows` array property of `options` stores the cards of
sites to display. Each card object may have the following properties:
```js
{
@ -71,6 +70,3 @@ of sites to display. Each site object must have the following properties:
url // Site url
}
```
Optionally, rows can also be passed with the `SECTION_REGISTER` action if the
feed already has rows to display.

Просмотреть файл

@ -56,8 +56,10 @@ for (const type of [
"SAVE_TO_POCKET",
"SCREENSHOT_UPDATED",
"SECTION_DEREGISTER",
"SECTION_DISABLE",
"SECTION_ENABLE",
"SECTION_REGISTER",
"SECTION_ROWS_UPDATE",
"SECTION_UPDATE",
"SET_PREF",
"SNIPPETS_DATA",
"SNIPPETS_RESET",

Просмотреть файл

@ -189,14 +189,27 @@ function Sections(prevState = INITIAL_STATE.Sections, action) {
}
return section;
});
// If section doesn't exist in prevState, create a new section object and
// append it to the sections state
// Invariant: Sections array sorted in increasing order of property `order`.
// If section doesn't exist in prevState, create a new section object. If
// the section has an order, insert it at the correct place in the array.
// Otherwise, prepend it and set the order to be minimal.
if (!hasMatch) {
const initialized = action.data.rows && action.data.rows.length > 0;
newState.push(Object.assign({title: "", initialized, rows: []}, action.data));
let order;
let index;
if (prevState.length > 0) {
order = action.data.order || prevState[0].order - 1;
index = newState.findIndex(section => section.order >= order);
} else {
order = action.data.order || 1;
index = 0;
}
const section = Object.assign({title: "", initialized, rows: [], order, enabled: false}, action.data);
newState.splice(index, 0, section);
}
return newState;
case at.SECTION_ROWS_UPDATE:
case at.SECTION_UPDATE:
return prevState.map(section => {
if (section && section.id === action.data.id) {
return Object.assign({}, section, action.data);

Просмотреть файл

@ -1,15 +1,20 @@
const React = require("react");
const {connect} = require("react-redux");
const {injectIntl, FormattedMessage} = require("react-intl");
const {actionCreators: ac} = require("common/Actions.jsm");
const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
const getFormattedMessage = message =>
(typeof message === "string" ? <span>{message}</span> : <FormattedMessage {...message} />);
const PreferencesInput = props => (
<section>
<input type="checkbox" id={props.prefName} name={props.prefName} checked={props.value} onChange={props.onChange} className={props.className} />
<label htmlFor={props.prefName}>
<FormattedMessage id={props.titleStringId} values={props.titleStringValues} />
{getFormattedMessage(props.titleString)}
</label>
{props.descStringId && <p className="prefs-input-description"><FormattedMessage id={props.descStringId} /></p>}
{props.descString && <p className="prefs-input-description">
{getFormattedMessage(props.descString)}
</p>}
</section>
);
@ -18,18 +23,9 @@ class PreferencesPane extends React.Component {
super(props);
this.state = {visible: false};
this.handleClickOutside = this.handleClickOutside.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handlePrefChange = this.handlePrefChange.bind(this);
this.handleSectionChange = this.handleSectionChange.bind(this);
this.togglePane = this.togglePane.bind(this);
// TODO This is temporary until sections register their PreferenceInput component automatically
const optionJSON = props.Prefs.values["feeds.section.topstories.options"];
if (optionJSON) {
try {
this.topStoriesOptions = JSON.parse(optionJSON);
} catch (e) {
console.error("Problem parsing feeds.section.topstories.options", e); // eslint-disable-line no-console
}
}
}
componentDidMount() {
document.addEventListener("click", this.handleClickOutside);
@ -43,10 +39,16 @@ class PreferencesPane extends React.Component {
this.togglePane();
}
}
handleChange(event) {
handlePrefChange(event) {
const target = event.target;
this.props.dispatch(ac.SetPref(target.name, target.checked));
}
handleSectionChange(event) {
const target = event.target;
const id = target.name;
const type = target.checked ? at.SECTION_ENABLE : at.SECTION_DISABLE;
this.props.dispatch(ac.SendToMain({type, data: id}));
}
togglePane() {
this.setState({visible: !this.state.visible});
const event = this.state.visible ? "CLOSE_NEWTAB_PREFS" : "OPEN_NEWTAB_PREFS";
@ -55,6 +57,7 @@ class PreferencesPane extends React.Component {
render() {
const props = this.props;
const prefs = props.Prefs.values;
const sections = props.Sections;
const isVisible = this.state.visible;
return (
<div className="prefs-pane-wrapper" ref="wrapper">
@ -70,17 +73,19 @@ class PreferencesPane extends React.Component {
<h1><FormattedMessage id="settings_pane_header" /></h1>
<p><FormattedMessage id="settings_pane_body" /></p>
<PreferencesInput className="showSearch" prefName="showSearch" value={prefs.showSearch} onChange={this.handleChange}
titleStringId="settings_pane_search_header" descStringId="settings_pane_search_body" />
<PreferencesInput className="showSearch" prefName="showSearch" value={prefs.showSearch} onChange={this.handlePrefChange}
titleString={{id: "settings_pane_search_header"}} descString={{id: "settings_pane_search_body"}} />
<PreferencesInput className="showTopSites" prefName="showTopSites" value={prefs.showTopSites} onChange={this.handleChange}
titleStringId="settings_pane_topsites_header" descStringId="settings_pane_topsites_body" />
<PreferencesInput className="showTopSites" prefName="showTopSites" value={prefs.showTopSites} onChange={this.handlePrefChange}
titleString={{id: "settings_pane_topsites_header"}} descString={{id: "settings_pane_topsites_body"}} />
{sections
.filter(section => !section.shouldHidePref)
.map(({id, title, enabled, pref}) =>
<PreferencesInput key={id} className="showSection" prefName={(pref && pref.feed) || id}
value={enabled} onChange={(pref && pref.feed) ? this.handlePrefChange : this.handleSectionChange}
titleString={(pref && pref.titleString) || title} descString={pref && pref.descString} />)}
{this.topStoriesOptions && !this.topStoriesOptions.hidden &&
<PreferencesInput className="showTopStories" prefName="feeds.section.topstories"
value={prefs["feeds.section.topstories"]} onChange={this.handleChange}
titleStringId="header_recommended_by" titleStringValues={{provider: this.topStoriesOptions.provider_name}}
descStringId={this.topStoriesOptions.provider_description} />}
</div>
<section className="actions">
<button className="done" onClick={this.togglePane}>
@ -93,6 +98,6 @@ class PreferencesPane extends React.Component {
}
}
module.exports = connect(state => ({Prefs: state.Prefs}))(injectIntl(PreferencesPane));
module.exports = connect(state => ({Prefs: state.Prefs, Sections: state.Sections}))(injectIntl(PreferencesPane));
module.exports.PreferencesPane = PreferencesPane;
module.exports.PreferencesInput = PreferencesInput;

Просмотреть файл

@ -106,7 +106,7 @@ class Section extends React.Component {
const {id, eventSource, title, icon, rows, infoOption, emptyState, dispatch, maxRows, contextMenuOptions, intl} = this.props;
const maxCards = 3 * maxRows;
const initialized = rows && rows.length > 0;
const shouldShowTopics = (id === "TopStories" &&
const shouldShowTopics = (id === "topstories" &&
this.props.topics &&
this.props.topics.length > 0 &&
this.props.read_more_endpoint);
@ -182,7 +182,9 @@ class Sections extends React.Component {
const sections = this.props.Sections;
return (
<div className="sections-list">
{sections.map(section => <SectionIntl key={section.id} {...section} dispatch={this.props.dispatch} />)}
{sections
.filter(section => section.enabled)
.map(section => <SectionIntl key={section.id} {...section} dispatch={this.props.dispatch} />)}
</div>
);
}

Просмотреть файл

@ -7,21 +7,85 @@ const {utils: Cu} = Components;
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
Cu.import("resource://gre/modules/EventEmitter.jsm");
/*
* Generators for built in sections, keyed by the pref name for their feed.
* Built in sections may depend on options stored as serialised JSON in the pref
* `${feed_pref_name}.options`.
*/
const BUILT_IN_SECTIONS = {
"feeds.section.topstories": options => ({
id: "topstories",
pref: {
titleString: {id: "header_recommended_by", values: {provider: options.provider_name}},
descString: {id: options.provider_description}
},
shouldHidePref: options.hidden,
eventSource: "TOP_STORIES",
icon: options.provider_icon,
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
maxRows: 1,
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
infoOption: {
header: {id: "pocket_feedback_header"},
body: {id: options.provider_description},
link: {href: options.survey_link, id: "pocket_send_feedback"}
},
emptyState: {
message: {id: "topstories_empty_state", values: {provider: options.provider_name}},
icon: "check"
}
})
};
const SectionsManager = {
ACTIONS_TO_PROXY: ["SYSTEM_TICK", "NEW_TAB_LOAD"],
initialized: false,
sections: new Set(),
sections: new Map(),
init(prefs = {}) {
for (const feedPrefName of Object.keys(BUILT_IN_SECTIONS)) {
const optionsPrefName = `${feedPrefName}.options`;
this.addBuiltInSection(feedPrefName, prefs[optionsPrefName]);
}
this.initialized = true;
this.emit(this.INIT);
},
addBuiltInSection(feedPrefName, optionsPrefValue = "{}") {
let options;
try {
options = JSON.parse(optionsPrefValue);
} catch (e) {
options = {};
Cu.reportError("Problem parsing options pref", e);
}
const section = BUILT_IN_SECTIONS[feedPrefName](options);
section.pref.feed = feedPrefName;
this.addSection(section.id, Object.assign(section, {options}));
},
addSection(id, options) {
this.sections.add(id);
this.sections.set(id, options);
this.emit(this.ADD_SECTION, id, options);
},
removeSection(id) {
this.emit(this.REMOVE_SECTION, id);
this.sections.delete(id);
},
updateRows(id, rows, shouldBroadcast) {
enableSection(id) {
this.updateSection(id, {enabled: true}, true);
},
disableSection(id) {
this.updateSection(id, {enabled: false, rows: []}, true);
},
updateSection(id, options, shouldBroadcast) {
if (this.sections.has(id)) {
this.emit(this.UPDATE_ROWS, id, rows, shouldBroadcast);
this.sections.set(id, Object.assign(this.sections.get(id), options));
this.emit(this.UPDATE_SECTION, id, options, shouldBroadcast);
}
},
onceInitialized(callback) {
if (this.initialized) {
callback();
} else {
this.once(this.INIT, callback);
}
}
};
@ -30,7 +94,7 @@ for (const action of [
"ACTION_DISPATCHED",
"ADD_SECTION",
"REMOVE_SECTION",
"UPDATE_ROWS",
"UPDATE_SECTION",
"INIT",
"UNINIT"
]) {
@ -41,17 +105,20 @@ EventEmitter.decorate(SectionsManager);
class SectionsFeed {
constructor() {
this.init = this.init.bind(this);
this.onAddSection = this.onAddSection.bind(this);
this.onRemoveSection = this.onRemoveSection.bind(this);
this.onUpdateRows = this.onUpdateRows.bind(this);
this.onUpdateSection = this.onUpdateSection.bind(this);
SectionsManager.onceInitialized(this.init);
}
init() {
SectionsManager.on(SectionsManager.ADD_SECTION, this.onAddSection);
SectionsManager.on(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
SectionsManager.on(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
SectionsManager.initialized = true;
SectionsManager.emit(SectionsManager.INIT);
SectionsManager.on(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
// Catch any sections that have already been added
SectionsManager.sections.forEach((section, id) =>
this.onAddSection(SectionsManager.ADD_SECTION, id, section));
}
uninit() {
@ -59,7 +126,7 @@ class SectionsFeed {
SectionsManager.emit(SectionsManager.UNINIT);
SectionsManager.off(SectionsManager.ADD_SECTION, this.onAddSection);
SectionsManager.off(SectionsManager.REMOVE_SECTION, this.onRemoveSection);
SectionsManager.off(SectionsManager.UPDATE_ROWS, this.onUpdateRows);
SectionsManager.off(SectionsManager.UPDATE_SECTION, this.onUpdateSection);
}
onAddSection(event, id, options) {
@ -72,17 +139,29 @@ class SectionsFeed {
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: id}));
}
onUpdateRows(event, id, rows, shouldBroadcast = false) {
if (rows) {
const action = {type: at.SECTION_ROWS_UPDATE, data: {id, rows}};
onUpdateSection(event, id, options, shouldBroadcast = false) {
if (options) {
const action = {type: at.SECTION_UPDATE, data: Object.assign(options, {id})};
this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : action);
}
}
onAction(action) {
switch (action.type) {
case at.INIT:
this.init();
// Wait for pref values, as some sections have options stored in prefs
case at.PREFS_INITIAL_VALUES:
SectionsManager.init(action.data);
break;
case at.PREF_CHANGED:
if (action.data && action.data.name.match(/^feeds.section.(\S+).options$/i)) {
SectionsManager.addBuiltInSection(action.data.name.slice(0, -8), action.data.value);
}
break;
case at.SECTION_DISABLE:
SectionsManager.disableSection(action.data);
break;
case at.SECTION_ENABLE:
SectionsManager.enableSection(action.data);
break;
}
if (SectionsManager.ACTIONS_TO_PROXY.includes(action.type) && SectionsManager.sections.size > 0) {

Просмотреть файл

@ -9,26 +9,30 @@ Cu.import("resource://gre/modules/Services.jsm");
Cu.import("resource://gre/modules/NewTabUtils.jsm");
Cu.importGlobalProperties(["fetch"]);
const {actionCreators: ac, actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
const {actionTypes: at} = Cu.import("resource://activity-stream/common/Actions.jsm", {});
const {Prefs} = Cu.import("resource://activity-stream/lib/ActivityStreamPrefs.jsm", {});
const {shortURL} = Cu.import("resource://activity-stream/lib/ShortURL.jsm", {});
const {SectionsManager} = Cu.import("resource://activity-stream/lib/SectionsManager.jsm", {});
const STORIES_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
const TOPICS_UPDATE_TIME = 3 * 60 * 60 * 1000; // 3 hours
const STORIES_NOW_THRESHOLD = 24 * 60 * 60 * 1000; // 24 hours
const SECTION_ID = "TopStories";
const SECTION_ID = "topstories";
const FEED_PREF = "feeds.section.topstories";
const SECTION_OPTIONS_PREF = "feeds.section.topstories.options";
this.TopStoriesFeed = class TopStoriesFeed {
init() {
try {
this.storiesLastUpdated = 0;
this.topicsLastUpdated = 0;
this.storiesLastUpdated = 0;
this.topicsLastUpdated = 0;
const prefs = new Prefs();
const options = JSON.parse(prefs.get(SECTION_OPTIONS_PREF));
SectionsManager.onceInitialized(this.parseOptions.bind(this));
}
parseOptions() {
SectionsManager.enableSection(SECTION_ID);
const options = SectionsManager.sections.get(SECTION_ID).options;
try {
const apiKey = this._getApiKeyFromPref(options.api_key_pref);
this.stories_endpoint = this._produceFinalEndpointUrl(options.stories_endpoint, apiKey);
this.topics_endpoint = this._produceFinalEndpointUrl(options.topics_endpoint, apiKey);
@ -36,30 +40,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
this.read_more_endpoint = options.read_more_endpoint;
this.stories_referrer = options.stories_referrer;
// TODO https://github.com/mozilla/activity-stream/issues/2902
const sectionOptions = {
id: SECTION_ID,
eventSource: "TOP_STORIES",
icon: options.provider_icon,
title: {id: "header_recommended_by", values: {provider: options.provider_name}},
rows: [],
maxRows: 1,
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
infoOption: {
header: {id: "pocket_feedback_header"},
body: {id: options.provider_description},
link: {
href: options.survey_link,
id: "pocket_send_feedback"
}
},
emptyState: {
message: {id: "topstories_empty_state", values: {provider: options.provider_name}},
icon: "check"
}
};
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_REGISTER, data: sectionOptions}));
this.fetchStories();
this.fetchTopics();
} catch (e) {
@ -68,7 +48,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
}
uninit() {
this.store.dispatch(ac.BroadcastToContent({type: at.SECTION_DEREGISTER, data: SECTION_ID}));
SectionsManager.disableSection(SECTION_ID);
}
async fetchStories() {
@ -100,8 +80,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
.catch(error => Cu.reportError(`Failed to fetch content: ${error.message}`));
if (stories) {
this.dispatchUpdateEvent(this.storiesLastUpdated,
{"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "rows": stories}});
this.dispatchUpdateEvent(this.storiesLastUpdated, {rows: stories});
this.storiesLastUpdated = Date.now();
}
}
@ -120,19 +99,14 @@ this.TopStoriesFeed = class TopStoriesFeed {
.catch(error => Cu.reportError(`Failed to fetch topics: ${error.message}`));
if (topics) {
this.dispatchUpdateEvent(this.topicsLastUpdated,
{"type": at.SECTION_ROWS_UPDATE, "data": {"id": SECTION_ID, "topics": topics, "read_more_endpoint": this.read_more_endpoint}});
this.dispatchUpdateEvent(this.topicsLastUpdated, {topics, read_more_endpoint: this.read_more_endpoint});
this.topicsLastUpdated = Date.now();
}
}
}
dispatchUpdateEvent(lastUpdated, evt) {
if (lastUpdated === 0) {
this.store.dispatch(ac.BroadcastToContent(evt));
} else {
this.store.dispatch(evt);
}
dispatchUpdateEvent(lastUpdated, data) {
SectionsManager.updateSection(SECTION_ID, data, lastUpdated === 0);
}
_getApiKeyFromPref(apiKeyPref) {
@ -191,11 +165,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
this.init();
}
break;
case at.PREF_CHANGED:
if (action.data.name === SECTION_OPTIONS_PREF) {
this.init();
}
break;
}
}
};
@ -204,5 +173,4 @@ this.STORIES_UPDATE_TIME = STORIES_UPDATE_TIME;
this.TOPICS_UPDATE_TIME = TOPICS_UPDATE_TIME;
this.SECTION_ID = SECTION_ID;
this.FEED_PREF = FEED_PREF;
this.SECTION_OPTIONS_PREF = SECTION_OPTIONS_PREF;
this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID", "FEED_PREF", "SECTION_OPTIONS_PREF"];
this.EXPORTED_SYMBOLS = ["TopStoriesFeed", "STORIES_UPDATE_TIME", "TOPICS_UPDATE_TIME", "SECTION_ID", "FEED_PREF"];

Просмотреть файл

@ -210,7 +210,8 @@ describe("Reducers", () => {
id: `foo_bar_${i}`,
title: `Foo Bar ${i}`,
initialized: false,
rows: [{url: "www.foo.bar"}, {url: "www.other.url"}]
rows: [{url: "www.foo.bar"}, {url: "www.other.url"}],
order: i
}));
});
@ -230,6 +231,29 @@ describe("Reducers", () => {
const insertedSection = newState.find(section => section.id === "foo_bar_5");
assert.propertyVal(insertedSection, "title", action.data.title);
});
it("should ensure sections are sorted by property `order` (increasing) on SECTION_REGISTER", () => {
let newState = [];
const state = Object.assign([], oldState);
state.forEach(section => {
Object.assign(section, {order: 5 - section.order});
const action = {type: at.SECTION_REGISTER, data: section};
newState = Sections(newState, action);
});
// Should have been inserted into newState in reverse order
assert.deepEqual(newState.map(section => section.id), state.map(section => section.id).reverse());
const newSection = {id: "new_section", order: 2.5};
const action = {type: at.SECTION_REGISTER, data: newSection};
newState = Sections(newState, action);
// Should have been inserted at index 2, between second and third section
assert.equal(newState[2].id, newSection.id);
});
it("should insert sections without an `order` at the top on SECTION_REGISTER", () => {
const newSection = {id: "new_section"};
const action = {type: at.SECTION_REGISTER, data: newSection};
const newState = Sections(oldState, action);
assert.equal(newState[0].id, newSection.id);
assert.ok(newState[0].order < newState[1].order);
});
it("should set newSection.rows === [] if no rows are provided on SECTION_REGISTER", () => {
const action = {type: at.SECTION_REGISTER, data: {id: "foo_bar_5", title: "Foo Bar 5"}};
const newState = Sections(oldState, action);
@ -244,17 +268,17 @@ describe("Reducers", () => {
const updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.ok(updatedSection && updatedSection.title === NEW_TITLE);
});
it("should have no effect on SECTION_ROWS_UPDATE if the id doesn't exist", () => {
const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "fake_id", data: "fake_data"}};
it("should have no effect on SECTION_UPDATE if the id doesn't exist", () => {
const action = {type: at.SECTION_UPDATE, data: {id: "fake_id", data: "fake_data"}};
const newState = Sections(oldState, action);
assert.deepEqual(oldState, newState);
});
it("should update the section rows with the correct data on SECTION_ROWS_UPDATE", () => {
const FAKE_DATA = ["some", "fake", "data"];
const action = {type: at.SECTION_ROWS_UPDATE, data: {id: "foo_bar_2", rows: FAKE_DATA}};
it("should update the section with the correct data on SECTION_UPDATE", () => {
const FAKE_DATA = {rows: ["some", "fake", "data"], foo: "bar"};
const action = {type: at.SECTION_UPDATE, data: Object.assign(FAKE_DATA, {id: "foo_bar_2"})};
const newState = Sections(oldState, action);
const updatedSection = newState.find(section => section.id === "foo_bar_2");
assert.equal(updatedSection.rows, FAKE_DATA);
assert.include(updatedSection, FAKE_DATA);
});
it("should remove blocked and deleted urls from all rows in all sections", () => {
const blockAction = {type: at.PLACES_LINK_BLOCKED, data: {url: "www.foo.bar"}};

Просмотреть файл

@ -6,13 +6,13 @@ const {PreferencesPane, PreferencesInput} = require("content-src/components/Pref
const {actionCreators: ac} = require("common/Actions.jsm");
describe("<PreferencesInput>", () => {
const testStringIds = {
titleStringId: "settings_pane_search_header",
descStringId: "settings_pane_search_body"
const testStrings = {
titleString: {id: "settings_pane_search_header"},
descString: {id: "settings_pane_search_body"}
};
let wrapper;
beforeEach(() => {
wrapper = shallow(<PreferencesInput prefName="foo" {...testStringIds} />);
wrapper = shallow(<PreferencesInput prefName="foo" {...testStrings} />);
});
it("should set the name and id on the input and the 'for' attribute on the label to props.prefName", () => {
@ -23,17 +23,17 @@ describe("<PreferencesInput>", () => {
assert.propertyVal(labelProps, "htmlFor", "foo");
});
it("should set the checked value of the input to props.value", () => {
wrapper = shallow(<PreferencesInput prefName="foo" value={true} {...testStringIds} />);
wrapper = shallow(<PreferencesInput prefName="foo" value={true} {...testStrings} />);
assert.propertyVal(wrapper.find("input").props(), "checked", true);
});
it("should render a FormattedString in the label with id=titleStringId", () => {
assert.propertyVal(wrapper.find("label").find(FormattedMessage).props(), "id", testStringIds.titleStringId);
it("should render a FormattedString in the label with id=titleString", () => {
assert.propertyVal(wrapper.find("label").find(FormattedMessage).props(), "id", testStrings.titleString.id);
});
it("should render a FormattedString in the description with id=descStringId", () => {
assert.propertyVal(wrapper.find(".prefs-input-description").find(FormattedMessage).props(), "id", testStringIds.descStringId);
it("should render a FormattedString in the description with id=descString", () => {
assert.propertyVal(wrapper.find(".prefs-input-description").find(FormattedMessage).props(), "id", testStrings.descString.id);
});
it("should not render the description element if no descStringId is given", () => {
wrapper = shallow(<PreferencesInput prefName="foo" titleStringId="bar" />);
wrapper = shallow(<PreferencesInput prefName="foo" titleString="bar" />);
assert.lengthOf(wrapper.find(".prefs-input-description"), 0);
});
});
@ -44,7 +44,12 @@ describe("<PreferencesPane>", () => {
beforeEach(() => {
dispatch = sinon.spy();
const fakePrefs = {values: {showSearch: true, showTopSites: true}};
wrapper = shallowWithIntl(<PreferencesPane dispatch={dispatch} Prefs={fakePrefs} />);
const fakeSections = [
{id: "section1", shouldHidePref: false, enabled: true, pref: {title: "fake_title", feed: "section1_feed"}},
{id: "section2", shouldHidePref: false, enabled: false, pref: {}},
{id: "section3", shouldHidePref: true, enabled: true}
];
wrapper = shallowWithIntl(<PreferencesPane dispatch={dispatch} Prefs={fakePrefs} Sections={fakeSections} />);
});
it("should hide the sidebar and show a settings icon by default", () => {
assert.isTrue(wrapper.find(".sidebar").hasClass("hidden"));
@ -77,10 +82,23 @@ describe("<PreferencesPane>", () => {
wrapper.find("button.done").simulate("click");
assert.isTrue(wrapper.find(".sidebar").hasClass("hidden"));
});
it("should dispatch a SetPref action when a PreferencesInput is clicked", () => {
it("should dispatch a SetPref action when a non-section PreferencesInput is clicked", () => {
const showSearchWrapper = wrapper.find(".showSearch");
showSearchWrapper.simulate("change", {target: {name: "showSearch", checked: false}});
assert.calledOnce(dispatch);
assert.calledWith(dispatch, ac.SetPref("showSearch", false));
});
it("should show PreferencesInputs for a section if and only if shouldHidePref is false", () => {
const sectionsWrapper = wrapper.find(".showSection");
assert.equal(sectionsWrapper.length, 2);
assert.ok(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section1_feed" />));
assert.ok(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section2" />));
assert.notOk(sectionsWrapper.containsMatchingElement(<PreferencesInput prefName="section3" />));
});
it("should set the value prop of a section PreferencesInput to equal section.enabled", () => {
const section1 = wrapper.findWhere(prefInput => prefInput.props().prefName === "section1_feed");
const section2 = wrapper.findWhere(prefInput => prefInput.props().prefName === "section2");
assert.equal(section1.props().value, true);
assert.equal(section2.props().value, false);
});
});

Просмотреть файл

@ -13,6 +13,7 @@ describe("<Sections>", () => {
id: `foo_bar_${i}`,
title: `Foo Bar ${i}`,
initialized: false,
enabled: !!(i % 2),
rows: []
}));
wrapper = shallow(<Sections Sections={FAKE_SECTIONS} />);
@ -20,10 +21,13 @@ describe("<Sections>", () => {
it("should render a Sections element", () => {
assert.ok(wrapper.exists());
});
it("should render a Section for each one passed in props.Sections", () => {
it("should render a Section for each one passed in props.Sections with .enabled === true", () => {
const sectionElems = wrapper.find(SectionIntl);
assert.lengthOf(sectionElems, 5);
sectionElems.forEach((section, i) => assert.equal(section.props().id, FAKE_SECTIONS[i].id));
assert.lengthOf(sectionElems, 2);
sectionElems.forEach((section, i) => {
assert.equal(section.props().id, FAKE_SECTIONS[2 * i + 1].id);
assert.equal(section.props().enabled, true);
});
});
});
@ -102,7 +106,7 @@ describe("<Section>", () => {
it("should render topics component for non-empty topics", () => {
let TOP_STORIES_SECTION = {
id: "TopStories",
id: "topstories",
title: "TopStories",
rows: [{guid: 1, link: "http://localhost", isDefault: true}],
topics: [],

Просмотреть файл

@ -1,20 +1,52 @@
"use strict";
const {SectionsFeed, SectionsManager} = require("lib/SectionsManager.jsm");
const {EventEmitter} = require("test/unit/utils");
const {EventEmitter, GlobalOverrider} = require("test/unit/utils");
const {MAIN_MESSAGE_TYPE, CONTENT_MESSAGE_TYPE} = require("common/Actions.jsm");
const FAKE_ID = "FAKE_ID";
const FAKE_OPTIONS = {icon: "FAKE_ICON", title: "FAKE_TITLE"};
const FAKE_ROWS = [{url: "1"}, {url: "2"}, {"url": "3"}];
let globals;
beforeEach(() => {
globals = new GlobalOverrider();
});
afterEach(() => {
globals.restore();
// Redecorate SectionsManager to remove any listeners that have been added
EventEmitter.decorate(SectionsManager);
SectionsManager.init();
});
describe("SectionsManager", () => {
it("should be initialised with .initialized == false", () => {
assert.notOk(SectionsManager.initialized);
describe("#init", () => {
it("should initialise the sections map with the built in sections", () => {
SectionsManager.sections.clear();
SectionsManager.initialized = false;
SectionsManager.init();
assert.equal(SectionsManager.sections.size, 1);
assert.ok(SectionsManager.sections.has("topstories"));
});
it("should set .initialized to true", () => {
SectionsManager.sections.clear();
SectionsManager.initialized = false;
SectionsManager.init();
assert.ok(SectionsManager.initialized);
});
});
describe("#addBuiltInSection", () => {
it("should not report an error if options is undefined", () => {
globals.sandbox.spy(global.Components.utils, "reportError");
SectionsManager.addBuiltInSection("feeds.section.topstories", undefined);
assert.notCalled(Components.utils.reportError);
});
it("should report an error if options is malformed", () => {
globals.sandbox.spy(global.Components.utils, "reportError");
SectionsManager.addBuiltInSection("feeds.section.topstories", "invalid");
assert.calledOnce(Components.utils.reportError);
});
});
describe("#addSection", () => {
it("should add the id to sections and emit an ADD_SECTION event", () => {
@ -38,29 +70,67 @@ describe("SectionsManager", () => {
assert.calledWith(spy, SectionsManager.REMOVE_SECTION, FAKE_ID);
});
});
describe("#updateRows", () => {
it("should emit an UPDATE_ROWS event with correct arguments", () => {
describe("#enableSection", () => {
it("should call updateSection with {enabled: true}", () => {
sinon.spy(SectionsManager, "updateSection");
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
SectionsManager.enableSection(FAKE_ID);
assert.calledOnce(SectionsManager.updateSection);
assert.calledWith(SectionsManager.updateSection, FAKE_ID, {enabled: true}, true);
SectionsManager.updateSection.restore();
});
});
describe("#disableSection", () => {
it("should call updateSection with {enabled: false, rows: []}", () => {
sinon.spy(SectionsManager, "updateSection");
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
SectionsManager.disableSection(FAKE_ID);
assert.calledOnce(SectionsManager.updateSection);
assert.calledWith(SectionsManager.updateSection, FAKE_ID, {enabled: false, rows: []}, true);
SectionsManager.updateSection.restore();
});
});
describe("#updateSection", () => {
it("should emit an UPDATE_SECTION event with correct arguments", () => {
SectionsManager.addSection(FAKE_ID, FAKE_OPTIONS);
const spy = sinon.spy();
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
SectionsManager.updateSection(FAKE_ID, {rows: FAKE_ROWS}, true);
assert.calledOnce(spy);
assert.calledWith(spy, SectionsManager.UPDATE_ROWS, FAKE_ID, FAKE_ROWS, true);
assert.calledWith(spy, SectionsManager.UPDATE_SECTION, FAKE_ID, {rows: FAKE_ROWS}, true);
});
it("should do nothing if the section doesn't exist", () => {
SectionsManager.removeSection(FAKE_ID);
const spy = sinon.spy();
SectionsManager.on(SectionsManager.UPDATE_ROWS, spy);
SectionsManager.updateRows(FAKE_ID, FAKE_ROWS, true);
SectionsManager.on(SectionsManager.UPDATE_SECTION, spy);
SectionsManager.updateSection(FAKE_ID, {rows: FAKE_ROWS}, true);
assert.notCalled(spy);
});
});
describe("#onceInitialized", () => {
it("should call the callback immediately if SectionsManager is initialised", () => {
SectionsManager.initialized = true;
const callback = sinon.spy();
SectionsManager.onceInitialized(callback);
assert.calledOnce(callback);
});
it("should bind the callback to .once(INIT) if SectionsManager is not initialised", () => {
SectionsManager.initialized = false;
sinon.spy(SectionsManager, "once");
const callback = () => {};
SectionsManager.onceInitialized(callback);
assert.calledOnce(SectionsManager.once);
assert.calledWith(SectionsManager.once, SectionsManager.INIT, callback);
});
});
});
describe("SectionsFeed", () => {
let feed;
beforeEach(() => {
SectionsManager.sections.clear();
SectionsManager.initialized = false;
feed = new SectionsFeed();
feed.store = {dispatch: sinon.spy()};
});
@ -68,8 +138,14 @@ describe("SectionsFeed", () => {
feed.uninit();
});
describe("#init", () => {
it("should create a SectionsFeed", () => {
it("should create a SectionsFeed and bind to SectionsManager.INIT", () => {
assert.instanceOf(feed, SectionsFeed);
SectionsManager.initialized = false;
sinon.spy(SectionsManager, "once");
feed = new SectionsFeed();
assert.calledOnce(SectionsManager.once);
assert.calledWith(SectionsManager.once, SectionsManager.INIT, feed.init);
SectionsManager.once.restore();
});
it("should bind appropriate listeners", () => {
sinon.spy(SectionsManager, "on");
@ -78,18 +154,11 @@ describe("SectionsFeed", () => {
for (const [event, listener] of [
[SectionsManager.ADD_SECTION, feed.onAddSection],
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
[SectionsManager.UPDATE_SECTION, feed.onUpdateSection]
]) {
assert.calledWith(SectionsManager.on, event, listener);
}
});
it("should emit an INIT event and set SectionsManager.initialized to true", () => {
const spy = sinon.spy();
SectionsManager.on(SectionsManager.INIT, spy);
feed.init();
assert.calledOnce(spy);
assert.ok(SectionsManager.initialized);
});
});
describe("#uninit", () => {
it("should unbind all listeners", () => {
@ -100,7 +169,7 @@ describe("SectionsFeed", () => {
for (const [event, listener] of [
[SectionsManager.ADD_SECTION, feed.onAddSection],
[SectionsManager.REMOVE_SECTION, feed.onRemoveSection],
[SectionsManager.UPDATE_ROWS, feed.onUpdateRows]
[SectionsManager.UPDATE_SECTION, feed.onUpdateSection]
]) {
assert.calledWith(SectionsManager.off, event, listener);
}
@ -135,21 +204,21 @@ describe("SectionsFeed", () => {
assert.equal(action.meta.to, CONTENT_MESSAGE_TYPE);
});
});
describe("#onUpdateRows", () => {
describe("#onUpdateSection", () => {
it("should do nothing if no rows are provided", () => {
feed.onUpdateRows(null, FAKE_ID, null);
feed.onUpdateSection(null, FAKE_ID, null);
assert.notCalled(feed.store.dispatch);
});
it("should dispatch a SECTION_ROWS_UPDATE action with the correct data", () => {
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS);
it("should dispatch a SECTION_UPDATE action with the correct data", () => {
feed.onUpdateSection(null, FAKE_ID, {rows: FAKE_ROWS});
const action = feed.store.dispatch.firstCall.args[0];
assert.equal(action.type, "SECTION_ROWS_UPDATE");
assert.equal(action.type, "SECTION_UPDATE");
assert.deepEqual(action.data, {id: FAKE_ID, rows: FAKE_ROWS});
// Should be not broadcast by default, so meta should not exist
assert.notOk(action.meta);
});
it("should broadcast the action only if shouldBroadcast is true", () => {
feed.onUpdateRows(null, FAKE_ID, FAKE_ROWS, true);
feed.onUpdateSection(null, FAKE_ID, {rows: FAKE_ROWS}, true);
const action = feed.store.dispatch.firstCall.args[0];
// Should be broadcast
assert.equal(action.meta.from, MAIN_MESSAGE_TYPE);
@ -157,18 +226,40 @@ describe("SectionsFeed", () => {
});
});
describe("#onAction", () => {
it("should call init() on action INIT", () => {
sinon.spy(feed, "init");
feed.onAction({type: "INIT"});
assert.calledOnce(feed.init);
it("should call SectionsManager.init on action PREFS_INITIAL_VALUES", () => {
sinon.spy(SectionsManager, "init");
feed.onAction({type: "PREFS_INITIAL_VALUES", data: {foo: "bar"}});
assert.calledOnce(SectionsManager.init);
assert.calledWith(SectionsManager.init, {foo: "bar"});
});
it("should call SectionsManager.addBuiltInSection on suitable PREF_CHANGED events", () => {
sinon.spy(SectionsManager, "addBuiltInSection");
feed.onAction({type: "PREF_CHANGED", data: {name: "feeds.section.topstories.options", value: "foo"}});
assert.calledOnce(SectionsManager.addBuiltInSection);
assert.calledWith(SectionsManager.addBuiltInSection, "feeds.section.topstories", "foo");
});
it("should call SectionsManager.disableSection on SECTION_DISABLE", () => {
sinon.spy(SectionsManager, "disableSection");
feed.onAction({type: "SECTION_DISABLE", data: 1234});
assert.calledOnce(SectionsManager.disableSection);
assert.calledWith(SectionsManager.disableSection, 1234);
SectionsManager.disableSection.restore();
});
it("should call SectionsManager.enableSection on SECTION_ENABLE", () => {
sinon.spy(SectionsManager, "enableSection");
feed.onAction({type: "SECTION_ENABLE", data: 1234});
assert.calledOnce(SectionsManager.enableSection);
assert.calledWith(SectionsManager.enableSection, 1234);
SectionsManager.enableSection.restore();
});
it("should emit a ACTION_DISPATCHED event and forward any action in ACTIONS_TO_PROXY if there are any sections", () => {
const spy = sinon.spy();
const allowedActions = SectionsManager.ACTIONS_TO_PROXY;
const disallowedActions = ["PREF_CHANGED", "OPEN_PRIVATE_WINDOW"];
feed.init();
SectionsManager.on(SectionsManager.ACTION_DISPATCHED, spy);
// Make sure we start with no sections - no event should be emitted
assert.equal(SectionsManager.sections.size, 0);
SectionsManager.sections.clear();
feed.onAction({type: allowedActions[0]});
assert.notCalled(spy);
// Then add a section and check correct behaviour

Просмотреть файл

@ -1,7 +1,7 @@
"use strict";
const injector = require("inject!lib/TopStoriesFeed.jsm");
const {FakePrefs} = require("test/unit/utils");
const {actionCreators: ac, actionTypes: at} = require("common/Actions.jsm");
const {actionTypes: at} = require("common/Actions.jsm");
const {GlobalOverrider} = require("test/unit/utils");
describe("Top Stories Feed", () => {
@ -10,34 +10,45 @@ describe("Top Stories Feed", () => {
let TOPICS_UPDATE_TIME;
let SECTION_ID;
let FEED_PREF;
let SECTION_OPTIONS_PREF;
let instance;
let clock;
let globals;
let sectionsManagerStub;
let shortURLStub;
const FAKE_OPTIONS = {
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"stories_referrer": "https://somedomain.org/referrer",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
"survey_link": "https://www.surveymonkey.com/r/newtabffx",
"api_key_pref": "apiKeyPref",
"provider_name": "test-provider",
"provider_icon": "provider-icon",
"provider_description": "provider_desc"
};
beforeEach(() => {
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"stories_referrer": "https://somedomain.org/referrer",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey",
"survey_link": "https://www.surveymonkey.com/r/newtabffx",
"api_key_pref": "apiKeyPref",
"provider_name": "test-provider",
"provider_icon": "provider-icon",
"provider_description": "provider_desc"
}`;
FakePrefs.prototype.prefs.apiKeyPref = "test-api-key";
globals = new GlobalOverrider();
globals.set("Services", {locale: {getRequestedLocale: () => "en-CA"}});
sectionsManagerStub = {
onceInitialized: sinon.stub().callsFake(callback => callback()),
enableSection: sinon.spy(),
disableSection: sinon.spy(),
updateSection: sinon.spy(),
sections: new Map([["topstories", {options: FAKE_OPTIONS}]])
};
clock = sinon.useFakeTimers();
shortURLStub = sinon.stub().callsFake(site => site.url);
({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID, FEED_PREF, SECTION_OPTIONS_PREF} = injector({
({TopStoriesFeed, STORIES_UPDATE_TIME, TOPICS_UPDATE_TIME, SECTION_ID, FEED_PREF} = injector({
"lib/ActivityStreamPrefs.jsm": {Prefs: FakePrefs},
"lib/ShortURL.jsm": {shortURL: shortURLStub}
"lib/ShortURL.jsm": {shortURL: shortURLStub},
"lib/SectionsManager.jsm": {SectionsManager: sectionsManagerStub}
}));
instance = new TopStoriesFeed();
instance.store = {getState() { return {}; }, dispatch: sinon.spy()};
@ -52,42 +63,20 @@ describe("Top Stories Feed", () => {
it("should create a TopStoriesFeed", () => {
assert.instanceOf(instance, TopStoriesFeed);
});
it("should initialize endpoints based on prefs", () => {
it("should bind parseOptions to SectionsManager.onceInitialized", () => {
instance.onAction({type: at.INIT});
assert.calledOnce(sectionsManagerStub.onceInitialized);
});
it("should initialize endpoints based on options", () => {
instance.onAction({type: at.INIT});
assert.equal("https://somedomain.org/stories?key=test-api-key", instance.stories_endpoint);
assert.equal("https://somedomain.org/referrer", instance.stories_referrer);
assert.equal("https://somedomain.org/topics?key=test-api-key", instance.topics_endpoint);
});
it("should register section", () => {
const expectedSectionOptions = {
id: SECTION_ID,
eventSource: "TOP_STORIES",
icon: "provider-icon",
title: {id: "header_recommended_by", values: {provider: "test-provider"}},
rows: [],
maxRows: 1,
contextMenuOptions: ["CheckBookmark", "SaveToPocket", "Separator", "OpenInNewWindow", "OpenInPrivateWindow", "Separator", "BlockUrl"],
infoOption: {
header: {id: "pocket_feedback_header"},
body: {id: "provider_desc"},
link: {
href: "https://www.surveymonkey.com/r/newtabffx",
id: "pocket_send_feedback"
}
},
emptyState: {
message: {id: "topstories_empty_state", values: {provider: "test-provider"}},
icon: "check"
}
};
it("should enable its section", () => {
instance.onAction({type: at.INIT});
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_REGISTER);
assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
type: at.SECTION_REGISTER,
data: expectedSectionOptions
}));
assert.calledOnce(sectionsManagerStub.enableSection);
assert.calledWith(sectionsManagerStub.enableSection, SECTION_ID);
});
it("should fetch stories on init", () => {
instance.fetchStories = sinon.spy();
@ -104,13 +93,13 @@ describe("Top Stories Feed", () => {
it("should not fetch if endpoint not configured", () => {
let fetchStub = globals.sandbox.stub();
globals.set("fetch", fetchStub);
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "{}";
sectionsManagerStub.sections.set("topstories", {options: {}});
instance.init();
assert.notCalled(fetchStub);
});
it("should report error for invalid configuration", () => {
globals.sandbox.spy(global.Components.utils, "reportError");
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = "invalid";
sectionsManagerStub.sections.set("topstories", {options: {api_key_pref: "invalid"}});
instance.init();
assert.called(Components.utils.reportError);
@ -119,31 +108,27 @@ describe("Top Stories Feed", () => {
let fakeServices = {prefs: {getCharPref: sinon.spy()}, locale: {getRequestedLocale: sinon.spy()}};
globals.set("Services", fakeServices);
globals.sandbox.spy(global.Components.utils, "reportError");
FakePrefs.prototype.prefs["feeds.section.topstories.options"] = `{
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey"
}`;
sectionsManagerStub.sections.set("topstories", {
options: {
"stories_endpoint": "https://somedomain.org/stories?key=$apiKey",
"topics_endpoint": "https://somedomain.org/topics?key=$apiKey"
}
});
instance.init();
assert.called(Components.utils.reportError);
});
it("should deregister section", () => {
instance.onAction({type: at.UNINIT});
assert.calledOnce(instance.store.dispatch);
assert.calledWith(instance.store.dispatch, ac.BroadcastToContent({
type: at.SECTION_DEREGISTER,
data: SECTION_ID
}));
});
it("should initialize on FEED_INIT", () => {
instance.init = sinon.spy();
instance.onAction({type: at.FEED_INIT, data: FEED_PREF});
assert.calledOnce(instance.init);
});
it("should initialize on PREF_CHANGED", () => {
instance.init = sinon.spy();
instance.onAction({type: at.PREF_CHANGED, data: {name: SECTION_OPTIONS_PREF}});
assert.calledOnce(instance.init);
});
describe("#uninit", () => {
it("should disable its section", () => {
instance.onAction({type: at.UNINIT});
assert.calledOnce(sectionsManagerStub.disableSection);
assert.calledWith(sectionsManagerStub.disableSection, SECTION_ID);
});
});
describe("#fetch", () => {
@ -179,14 +164,12 @@ describe("Top Stories Feed", () => {
assert.calledOnce(fetchStub);
assert.calledOnce(shortURLStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint);
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.rows, stories);
assert.calledOnce(sectionsManagerStub.updateSection);
assert.calledWith(sectionsManagerStub.updateSection, SECTION_ID, {rows: stories});
});
it("should dispatch events", () => {
it("should call SectionsManager.updateSection", () => {
instance.dispatchUpdateEvent(123, {});
assert.calledOnce(instance.store.dispatch);
assert.calledOnce(sectionsManagerStub.updateSection);
});
it("should report error for unexpected stories response", async () => {
let fetchStub = globals.sandbox.stub();
@ -199,7 +182,7 @@ describe("Top Stories Feed", () => {
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.stories_endpoint);
assert.notCalled(instance.store.dispatch);
assert.notCalled(sectionsManagerStub.updateSection);
assert.called(Components.utils.reportError);
});
it("should exclude blocked (dismissed) URLs", async () => {
@ -212,10 +195,9 @@ describe("Top Stories Feed", () => {
fetchStub.resolves({ok: true, status: 200, text: () => response});
await instance.fetchStories();
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows.length, 1);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].url, "not_blocked");
assert.calledOnce(sectionsManagerStub.updateSection);
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 1);
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].url, "not_blocked");
});
it("should mark stories as new", async () => {
let fetchStub = globals.sandbox.stub();
@ -234,12 +216,11 @@ describe("Top Stories Feed", () => {
fetchStub.resolves({ok: true, status: 200, text: () => response});
await instance.fetchStories();
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows.length, 3);
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[0].type, "now");
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[1].type, "trending");
assert.equal(instance.store.dispatch.firstCall.args[0].data.rows[2].type, "trending");
assert.calledOnce(sectionsManagerStub.updateSection);
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows.length, 3);
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[0].type, "now");
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[1].type, "trending");
assert.equal(sectionsManagerStub.updateSection.firstCall.args[1].rows[2].type, "trending");
});
it("should fetch topics and send event", async () => {
let fetchStub = globals.sandbox.stub();
@ -260,10 +241,8 @@ describe("Top Stories Feed", () => {
assert.calledOnce(fetchStub);
assert.calledWithExactly(fetchStub, instance.topics_endpoint);
assert.calledOnce(instance.store.dispatch);
assert.propertyVal(instance.store.dispatch.firstCall.args[0], "type", at.SECTION_ROWS_UPDATE);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.id, SECTION_ID);
assert.deepEqual(instance.store.dispatch.firstCall.args[0].data.topics, topics);
assert.calledOnce(sectionsManagerStub.updateSection);
assert.calledWithMatch(sectionsManagerStub.updateSection, SECTION_ID, {topics});
});
it("should report error for unexpected topics response", async () => {
let fetchStub = globals.sandbox.stub();
@ -282,6 +261,7 @@ describe("Top Stories Feed", () => {
});
describe("#update", () => {
it("should fetch stories after update interval", () => {
instance.init();
instance.fetchStories = sinon.spy();
instance.onAction({type: at.SYSTEM_TICK});
assert.notCalled(instance.fetchStories);
@ -291,6 +271,7 @@ describe("Top Stories Feed", () => {
assert.calledOnce(instance.fetchStories);
});
it("should fetch topics after update interval", () => {
instance.init();
instance.fetchTopics = sinon.spy();
instance.onAction({type: at.SYSTEM_TICK});
assert.notCalled(instance.fetchTopics);

Просмотреть файл

@ -127,6 +127,7 @@ EventEmitter.decorate = function(objectToDecorate) {
let emitter = new EventEmitter();
objectToDecorate.on = emitter.on.bind(emitter);
objectToDecorate.off = emitter.off.bind(emitter);
objectToDecorate.once = emitter.once.bind(emitter);
objectToDecorate.emit = emitter.emit.bind(emitter);
};
EventEmitter.prototype = {
@ -150,6 +151,20 @@ EventEmitter.prototype = {
));
}
},
once(event, listener) {
return new Promise(resolve => {
let handler = (_, first, ...rest) => {
this.off(event, handler);
if (listener) {
listener(event, first, ...rest);
}
resolve(first);
};
handler._originalListener = listener;
this.on(event, handler);
});
},
// All arguments to this method will be sent to listeners
emit(event, ...args) {
if (!this._eventEmitterListeners || !this._eventEmitterListeners.has(event)) {