Bug 1516008 - Add pocket layout code behind its own separate pref (#4629)
This commit is contained in:
Родитель
ca13342298
Коммит
a45a540a14
|
@ -33,13 +33,15 @@ for (const type of [
|
|||
"AS_ROUTER_TELEMETRY_USER_EVENT",
|
||||
"BLOCK_URL",
|
||||
"BOOKMARK_URL",
|
||||
"CONTENT_LAYOUT",
|
||||
"COPY_DOWNLOAD_LINK",
|
||||
"DELETE_BOOKMARK_BY_ID",
|
||||
"DELETE_FROM_POCKET",
|
||||
"DELETE_HISTORY_URL",
|
||||
"DIALOG_CANCEL",
|
||||
"DIALOG_OPEN",
|
||||
"DISCOVERY_STREAM_CONFIG_CHANGE",
|
||||
"DISCOVERY_STREAM_CONFIG_SETUP",
|
||||
"DISCOVERY_STREAM_LAYOUT_UPDATE",
|
||||
"DOWNLOAD_CHANGED",
|
||||
"FILL_SEARCH_TERM",
|
||||
"INIT",
|
||||
|
|
|
@ -47,7 +47,12 @@ const INITIAL_STATE = {
|
|||
pocketCta: {},
|
||||
waitingForSpoc: true,
|
||||
},
|
||||
Layout: [],
|
||||
// This is the new pocket configurable layout state.
|
||||
DiscoveryStream: {
|
||||
// This is a JSON-parsed copy of the discoverystream.config pref value.
|
||||
config: {enabled: false, layout_endpoint: ""},
|
||||
layout: [],
|
||||
},
|
||||
};
|
||||
|
||||
function App(prevState = INITIAL_STATE.App, action) {
|
||||
|
@ -430,10 +435,14 @@ function Pocket(prevState = INITIAL_STATE.Pocket, action) {
|
|||
}
|
||||
}
|
||||
|
||||
function Layout(prevState = INITIAL_STATE.Layout, action) {
|
||||
function DiscoveryStream(prevState = INITIAL_STATE.DiscoveryStream, action) {
|
||||
switch (action.type) {
|
||||
case at.CONTENT_LAYOUT:
|
||||
return action.data;
|
||||
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
|
||||
// The reason this is a separate action is so it doesn't trigger a listener update on init
|
||||
case at.DISCOVERY_STREAM_CONFIG_SETUP:
|
||||
return {...prevState, config: action.data || {}};
|
||||
case at.DISCOVERY_STREAM_LAYOUT_UPDATE:
|
||||
return {...prevState, layout: action.data || []};
|
||||
default:
|
||||
return prevState;
|
||||
}
|
||||
|
@ -443,6 +452,6 @@ this.INITIAL_STATE = INITIAL_STATE;
|
|||
this.TOP_SITES_DEFAULT_ROWS = TOP_SITES_DEFAULT_ROWS;
|
||||
this.TOP_SITES_MAX_SITES_PER_ROW = TOP_SITES_MAX_SITES_PER_ROW;
|
||||
|
||||
this.reducers = {TopSites, App, ASRouter, Snippets, Prefs, Dialog, Sections, Pocket, Layout};
|
||||
this.reducers = {TopSites, App, ASRouter, Snippets, Prefs, Dialog, Sections, Pocket, DiscoveryStream};
|
||||
|
||||
const EXPORTED_SYMBOLS = ["reducers", "INITIAL_STATE", "insertPinned", "TOP_SITES_DEFAULT_ROWS", "TOP_SITES_MAX_SITES_PER_ROW"];
|
||||
|
|
|
@ -3,6 +3,7 @@ import {addLocaleData, injectIntl, IntlProvider} from "react-intl";
|
|||
import {ASRouterAdmin} from "content-src/components/ASRouterAdmin/ASRouterAdmin";
|
||||
import {ConfirmDialog} from "content-src/components/ConfirmDialog/ConfirmDialog";
|
||||
import {connect} from "react-redux";
|
||||
import {DiscoveryStreamBase} from "content-src/components/DiscoveryStreamBase/DiscoveryStreamBase";
|
||||
import {ErrorBoundary} from "content-src/components/ErrorBoundary/ErrorBoundary";
|
||||
import {ManualMigration} from "content-src/components/ManualMigration/ManualMigration";
|
||||
import {PrerenderData} from "common/PrerenderData.jsm";
|
||||
|
@ -139,6 +140,7 @@ export class BaseContent extends React.PureComponent {
|
|||
|
||||
const shouldBeFixedToTop = PrerenderData.arePrefsValid(name => prefs[name]);
|
||||
const noSectionsEnabled = !prefs["feeds.topsites"] && props.Sections.filter(section => section.enabled).length === 0;
|
||||
const isDiscoveryStream = props.DiscoveryStream.config && props.DiscoveryStream.config.enabled;
|
||||
|
||||
const outerClassName = [
|
||||
"outer-wrapper",
|
||||
|
@ -159,12 +161,12 @@ export class BaseContent extends React.PureComponent {
|
|||
</div>
|
||||
}
|
||||
<div className={`body-wrapper${(initialized ? " on" : "")}`}>
|
||||
{!prefs.migrationExpired &&
|
||||
{!isDiscoveryStream && !prefs.migrationExpired &&
|
||||
<div className="non-collapsible-section">
|
||||
<ManualMigration />
|
||||
</div>
|
||||
}
|
||||
<Sections />
|
||||
{isDiscoveryStream ? <DiscoveryStreamBase /> : <Sections />}
|
||||
<PrefsButton onClick={this.openPreferences} />
|
||||
</div>
|
||||
<ConfirmDialog />
|
||||
|
@ -174,4 +176,4 @@ export class BaseContent extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Sections: state.Sections}))(_Base);
|
||||
export const Base = connect(state => ({App: state.App, Prefs: state.Prefs, Sections: state.Sections, DiscoveryStream: state.DiscoveryStream}))(_Base);
|
||||
|
|
|
@ -0,0 +1,22 @@
|
|||
import {connect} from "react-redux";
|
||||
import React from "react";
|
||||
|
||||
export class _DiscoveryStreamBase extends React.PureComponent {
|
||||
render() {
|
||||
return (
|
||||
<div className="discovery-stream layout">
|
||||
{this.props.DiscoveryStream.layout.map((section, sectionIndex) => (
|
||||
<div key={`section-${sectionIndex}`} className={`column column-${section.width}`}>
|
||||
{section.components.map((component, componentIndex) => (
|
||||
<div key={`component-${componentIndex}`}>
|
||||
<div>{component.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const DiscoveryStreamBase = connect(state => ({DiscoveryStream: state.DiscoveryStream}))(_DiscoveryStreamBase);
|
|
@ -0,0 +1,20 @@
|
|||
.discovery-stream.layout {
|
||||
$columns: 12;
|
||||
display: grid;
|
||||
grid-template-columns: repeat($columns, 1fr);
|
||||
grid-column-gap: 10px;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
.column {
|
||||
border: 1px solid $black;
|
||||
}
|
||||
|
||||
@while $columns > 0 {
|
||||
.column-#{$columns} {
|
||||
grid-column-start: auto;
|
||||
grid-column-end: span $columns;
|
||||
}
|
||||
|
||||
$columns: $columns - 1;
|
||||
}
|
||||
}
|
|
@ -306,26 +306,7 @@ export class _Sections extends React.PureComponent {
|
|||
return sections;
|
||||
}
|
||||
|
||||
renderLayout() {
|
||||
return (
|
||||
<div className="sections-list layout">
|
||||
{this.props.Layout.map((section, sectionIndex) => (
|
||||
<div key={`section-${sectionIndex}`} className={`column column-${section.width}`}>
|
||||
{section.components.map((component, componentIndex) => (
|
||||
<div key={`component-${componentIndex}`}>
|
||||
<div>{component.type}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.props.Layout && this.props.Layout.length) {
|
||||
return this.renderLayout();
|
||||
}
|
||||
return (
|
||||
<div className="sections-list">
|
||||
{this.renderSections()}
|
||||
|
@ -334,4 +315,4 @@ export class _Sections extends React.PureComponent {
|
|||
}
|
||||
}
|
||||
|
||||
export const Sections = connect(state => ({Sections: state.Sections, Prefs: state.Prefs, Layout: state.Layout}))(_Sections);
|
||||
export const Sections = connect(state => ({Sections: state.Sections, Prefs: state.Prefs}))(_Sections);
|
||||
|
|
|
@ -1,25 +1,4 @@
|
|||
.sections-list {
|
||||
&.layout {
|
||||
$columns: 12;
|
||||
display: grid;
|
||||
grid-template-columns: repeat($columns, 1fr);
|
||||
grid-column-gap: 10px;
|
||||
grid-row-gap: 10px;
|
||||
|
||||
.column {
|
||||
border: 1px solid $black;
|
||||
}
|
||||
|
||||
@while $columns > 0 {
|
||||
.column-#{$columns} {
|
||||
grid-column-start: auto;
|
||||
grid-column-end: span $columns;
|
||||
}
|
||||
|
||||
$columns: $columns - 1;
|
||||
}
|
||||
}
|
||||
|
||||
.section-list {
|
||||
display: grid;
|
||||
grid-gap: $base-gutter;
|
||||
|
|
|
@ -143,6 +143,7 @@ input {
|
|||
@import '../components/ASRouterAdmin/ASRouterAdmin';
|
||||
@import '../components/PocketLoggedInCta/PocketLoggedInCta';
|
||||
@import '../components/MoreRecommendations/MoreRecommendations';
|
||||
@import '../components/DiscoveryStreamBase/DiscoveryStreamBase';
|
||||
|
||||
// AS Router
|
||||
@import '../asrouter/components/Button/Button';
|
||||
|
|
|
@ -29,6 +29,7 @@ const {TopSitesFeed} = ChromeUtils.import("resource://activity-stream/lib/TopSit
|
|||
const {TopStoriesFeed} = ChromeUtils.import("resource://activity-stream/lib/TopStoriesFeed.jsm", {});
|
||||
const {HighlightsFeed} = ChromeUtils.import("resource://activity-stream/lib/HighlightsFeed.jsm", {});
|
||||
const {ASRouterFeed} = ChromeUtils.import("resource://activity-stream/lib/ASRouterFeed.jsm", {});
|
||||
const {DiscoveryStreamFeed} = ChromeUtils.import("resource://activity-stream/lib/DiscoveryStreamFeed.jsm", {});
|
||||
|
||||
const DEFAULT_SITES = new Map([
|
||||
// This first item is the global list fallback for any unexpected geos
|
||||
|
@ -210,6 +211,14 @@ const PREFS_CONFIG = new Map([
|
|||
}),
|
||||
}],
|
||||
// See browser/app/profile/firefox.js for other ASR preferences. They must be defined there to enable roll-outs.
|
||||
["discoverystream.config", {
|
||||
title: "Configuration for the new pocket new tab",
|
||||
value: JSON.stringify({
|
||||
enabled: false,
|
||||
// Set this to https://gist.githubusercontent.com/ScottDowne/164995d9535b4203846048bdee29d169/raw/0cf538411e6ee898eb116208d70842c62c8d52f1/spoc.json to test
|
||||
layout_endpoint: "",
|
||||
}),
|
||||
}],
|
||||
]);
|
||||
|
||||
// Array of each feed's FEEDS_CONFIG factory and values to add to PREFS_CONFIG
|
||||
|
@ -306,6 +315,12 @@ const FEEDS_DATA = [
|
|||
title: "Handles AS Router messages, such as snippets and onboaridng",
|
||||
value: true,
|
||||
},
|
||||
{
|
||||
name: "discoverystreamfeed",
|
||||
factory: () => new DiscoveryStreamFeed(),
|
||||
title: "Handles new pocket ui for the new tab page",
|
||||
value: true,
|
||||
},
|
||||
];
|
||||
|
||||
const FEEDS_CONFIG = new Map();
|
||||
|
|
|
@ -0,0 +1,150 @@
|
|||
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||||
"use strict";
|
||||
|
||||
ChromeUtils.import("resource://gre/modules/XPCOMUtils.jsm");
|
||||
XPCOMUtils.defineLazyGlobalGetters(this, ["fetch"]);
|
||||
ChromeUtils.import("resource://gre/modules/Services.jsm");
|
||||
|
||||
const {actionTypes: at, actionCreators: ac} = ChromeUtils.import("resource://activity-stream/common/Actions.jsm", {});
|
||||
const {PersistentCache} = ChromeUtils.import("resource://activity-stream/lib/PersistentCache.jsm", {});
|
||||
|
||||
const CACHE_KEY = "discovery_stream";
|
||||
const LAYOUT_UPDATE_TIME = 30 * 60 * 1000; // 30 minutes
|
||||
const CONFIG_PREF_NAME = "browser.newtabpage.activity-stream.discoverystream.config";
|
||||
|
||||
this.DiscoveryStreamFeed = class DiscoveryStreamFeed {
|
||||
constructor() {
|
||||
// Internal state for checking if we've intialized all our data
|
||||
this.loaded = false;
|
||||
|
||||
// Persistent cache for remote endpoint data.
|
||||
this.cache = new PersistentCache(CACHE_KEY, true);
|
||||
// Internal in-memory cache for parsing json prefs.
|
||||
this._prefCache = {};
|
||||
}
|
||||
|
||||
get config() {
|
||||
if (this._prefCache.config) {
|
||||
return this._prefCache.config;
|
||||
}
|
||||
try {
|
||||
this._prefCache.config = JSON.parse(Services.prefs.getStringPref(CONFIG_PREF_NAME, ""));
|
||||
} catch (e) {
|
||||
// istanbul ignore next
|
||||
this._prefCache.config = {};
|
||||
// istanbul ignore next
|
||||
Cu.reportError(`Could not parse preference. Try resetting ${CONFIG_PREF_NAME} in about:config.`);
|
||||
}
|
||||
return this._prefCache.config;
|
||||
}
|
||||
|
||||
setupPrefs() {
|
||||
Services.prefs.addObserver(CONFIG_PREF_NAME, this);
|
||||
// Send the initial state of the pref on our reducer
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_CONFIG_SETUP, data: this.config}));
|
||||
}
|
||||
|
||||
uninitPrefs() {
|
||||
Services.prefs.removeObserver(CONFIG_PREF_NAME, this);
|
||||
// Reset in-memory cache
|
||||
this._prefCache = {};
|
||||
}
|
||||
|
||||
observe(aSubject, aTopic, aPrefName) {
|
||||
if (aPrefName === CONFIG_PREF_NAME) {
|
||||
this._prefCache.config = null;
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_CONFIG_CHANGE, data: this.config}));
|
||||
}
|
||||
}
|
||||
|
||||
async fetchLayout() {
|
||||
const endpoint = this.config.layout_endpoint;
|
||||
if (!endpoint) {
|
||||
Cu.reportError("No endpoint configured for pocket, so could not fetch layout");
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const response = await fetch(endpoint, {credentials: "omit"});
|
||||
if (!response.ok) {
|
||||
// istanbul ignore next
|
||||
throw new Error(`Stories endpoint returned unexpected status: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
} catch (error) {
|
||||
// istanbul ignore next
|
||||
Cu.reportError(`Failed to fetch layout: ${error.message}`);
|
||||
}
|
||||
// istanbul ignore next
|
||||
return null;
|
||||
}
|
||||
|
||||
async loadCachedData() {
|
||||
const cachedData = await this.cache.get() || {};
|
||||
|
||||
let {layout: layoutResponse} = cachedData;
|
||||
if (!layoutResponse || !(Date.now() - layoutResponse._timestamp > LAYOUT_UPDATE_TIME)) {
|
||||
layoutResponse = await this.fetchLayout();
|
||||
if (layoutResponse && layoutResponse.layout) {
|
||||
layoutResponse._timestamp = Date.now();
|
||||
await this.cache.set("layout", layoutResponse);
|
||||
} else {
|
||||
Cu.reportError("No response for response.layout prop");
|
||||
}
|
||||
}
|
||||
|
||||
if (layoutResponse && layoutResponse.layout) {
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: layoutResponse.layout}));
|
||||
}
|
||||
}
|
||||
|
||||
async enable() {
|
||||
await this.loadCachedData();
|
||||
this.loaded = true;
|
||||
}
|
||||
|
||||
async disable() {
|
||||
// Clear cache
|
||||
await this.cache.set("layout", {});
|
||||
// Reset reducer
|
||||
this.store.dispatch(ac.BroadcastToContent({type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: []}));
|
||||
this.loaded = false;
|
||||
}
|
||||
|
||||
async onPrefChange() {
|
||||
// Load data from all endpoints if our config.enabled = true.
|
||||
if (this.config.enabled) {
|
||||
await this.enable();
|
||||
}
|
||||
|
||||
// Clear state and relevant listeners if config.enabled = false.
|
||||
if (this.loaded && !this.config.enabled) {
|
||||
await this.disable();
|
||||
}
|
||||
}
|
||||
|
||||
async onAction(action) {
|
||||
switch (action.type) {
|
||||
case at.INIT:
|
||||
// During the initialization of Firefox:
|
||||
// 1. Set-up listeners and initialize the redux state for config;
|
||||
this.setupPrefs();
|
||||
// 2. If config.enabled is true, start loading data.
|
||||
if (this.config.enabled) {
|
||||
await this.enable();
|
||||
}
|
||||
break;
|
||||
case at.DISCOVERY_STREAM_CONFIG_CHANGE:
|
||||
// When the config pref changes, load or unload data as needed.
|
||||
await this.onPrefChange();
|
||||
break;
|
||||
case at.UNINIT:
|
||||
// When this feed is shutting down:
|
||||
this.uninitPrefs();
|
||||
break;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const EXPORTED_SYMBOLS = ["DiscoveryStreamFeed"];
|
|
@ -112,13 +112,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
|
||||
}
|
||||
|
||||
maybeDispatchLayoutUpdate(data, shouldBroadcast) {
|
||||
if (data && data.length) {
|
||||
const action = {type: at.CONTENT_LAYOUT, data};
|
||||
this.store.dispatch(shouldBroadcast ? ac.BroadcastToContent(action) : ac.AlsoToPreloaded(action));
|
||||
}
|
||||
}
|
||||
|
||||
doContentUpdate(shouldBroadcast) {
|
||||
let updateProps = {};
|
||||
if (this.stories) {
|
||||
|
@ -181,7 +174,6 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
}
|
||||
|
||||
const body = await response.json();
|
||||
this.maybeDispatchLayoutUpdate(body.layout);
|
||||
this.updateSettings(body.settings);
|
||||
this.stories = this.rotate(this.transform(body.recommendations));
|
||||
this.cleanUpTopRecImpressionPref();
|
||||
|
@ -202,9 +194,7 @@ this.TopStoriesFeed = class TopStoriesFeed {
|
|||
async loadCachedData() {
|
||||
const data = await this.cache.get();
|
||||
let stories = data.stories && data.stories.recommendations;
|
||||
let layout = data.stories && data.stories.layout;
|
||||
let topics = data.topics && data.topics.topics;
|
||||
this.maybeDispatchLayoutUpdate(layout);
|
||||
|
||||
let affinities = data.domainAffinities;
|
||||
if (this.personalized && affinities && affinities.scores) {
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
import {INITIAL_STATE, insertPinned, reducers} from "common/Reducers.jsm";
|
||||
const {TopSites, App, Snippets, Prefs, Dialog, Sections, Pocket, Layout} = reducers;
|
||||
const {TopSites, App, Snippets, Prefs, Dialog, Sections, Pocket, DiscoveryStream} = reducers;
|
||||
import {actionTypes as at} from "common/Actions.jsm";
|
||||
|
||||
describe("Reducers", () => {
|
||||
|
@ -655,13 +655,17 @@ describe("Reducers", () => {
|
|||
assert.equal(state.pocketCta.useCta, data.use_cta);
|
||||
});
|
||||
});
|
||||
describe("Layout", () => {
|
||||
describe("DiscoveryStream", () => {
|
||||
it("should return INITIAL_STATE by default", () => {
|
||||
assert.equal(Layout(undefined, {type: "some_action"}), INITIAL_STATE.Layout);
|
||||
assert.equal(DiscoveryStream(undefined, {type: "some_action"}), INITIAL_STATE.DiscoveryStream);
|
||||
});
|
||||
it("should set layout data with layout.type CONTENT_LAYOUT", () => {
|
||||
const state = Layout(undefined, {type: at.CONTENT_LAYOUT, data: ["test"]});
|
||||
assert.equal(state[0], "test");
|
||||
it("should set layout data with DISCOVERY_STREAM_LAYOUT_UPDATE", () => {
|
||||
const state = DiscoveryStream(undefined, {type: at.DISCOVERY_STREAM_LAYOUT_UPDATE, data: ["test"]});
|
||||
assert.equal(state.layout[0], "test");
|
||||
});
|
||||
it("should set config data with DISCOVERY_STREAM_CONFIG_CHANGE", () => {
|
||||
const state = DiscoveryStream(undefined, {type: at.DISCOVERY_STREAM_CONFIG_CHANGE, data: {enabled: true}});
|
||||
assert.deepEqual(state.config, {enabled: true});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -5,7 +5,7 @@ import {Search} from "content-src/components/Search/Search";
|
|||
import {shallow} from "enzyme";
|
||||
|
||||
describe("<Base>", () => {
|
||||
let DEFAULT_PROPS = {store: {getState: () => {}}, App: {initialized: true}, Prefs: {values: {}}, Sections: [], dispatch: () => {}};
|
||||
let DEFAULT_PROPS = {store: {getState: () => {}}, App: {initialized: true}, Prefs: {values: {}}, Sections: [], DiscoveryStream: {config: {enabled: false}}, dispatch: () => {}};
|
||||
|
||||
it("should render Base component", () => {
|
||||
const wrapper = shallow(<Base {...DEFAULT_PROPS} />);
|
||||
|
@ -27,7 +27,7 @@ describe("<Base>", () => {
|
|||
});
|
||||
|
||||
describe("<BaseContent>", () => {
|
||||
let DEFAULT_PROPS = {store: {getState: () => {}}, App: {initialized: true}, Prefs: {values: {}}, Sections: [], dispatch: () => {}};
|
||||
let DEFAULT_PROPS = {store: {getState: () => {}}, App: {initialized: true}, Prefs: {values: {}}, Sections: [], DiscoveryStream: {config: {enabled: false}}, dispatch: () => {}};
|
||||
|
||||
it("should render an ErrorBoundary with a Search child", () => {
|
||||
const searchEnabledProps =
|
||||
|
|
|
@ -0,0 +1,118 @@
|
|||
import {combineReducers, createStore} from "redux";
|
||||
import {actionTypes as at} from "common/Actions.jsm";
|
||||
import {DiscoveryStreamFeed} from "lib/DiscoveryStreamFeed.jsm";
|
||||
import {reducers} from "common/Reducers.jsm";
|
||||
|
||||
const CONFIG_PREF_NAME = "browser.newtabpage.activity-stream.discoverystream.config";
|
||||
|
||||
describe("DiscoveryStreamFeed", () => {
|
||||
let feed;
|
||||
let sandbox;
|
||||
let configPrefStub;
|
||||
let fetchStub;
|
||||
|
||||
beforeEach(() => {
|
||||
// Pref
|
||||
sandbox = sinon.createSandbox();
|
||||
configPrefStub = sandbox.stub(global.Services.prefs, "getStringPref")
|
||||
.withArgs(CONFIG_PREF_NAME)
|
||||
.returns(JSON.stringify({enabled: false, layout_endpoint: "foo.com"}));
|
||||
|
||||
// Fetch
|
||||
fetchStub = sandbox.stub(global, "fetch");
|
||||
|
||||
// Feed
|
||||
feed = new DiscoveryStreamFeed();
|
||||
feed.store = createStore(combineReducers(reducers));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
sandbox.restore();
|
||||
});
|
||||
|
||||
describe("#observe", () => {
|
||||
it("should update state.DiscoveryStream.config when the pref changes", async () => {
|
||||
configPrefStub.returns(JSON.stringify({enabled: true, layout_endpoint: "foo"}));
|
||||
|
||||
feed.observe(null, null, CONFIG_PREF_NAME);
|
||||
|
||||
assert.deepEqual(feed.store.getState().DiscoveryStream.config, {enabled: true, layout_endpoint: "foo"});
|
||||
});
|
||||
});
|
||||
|
||||
describe("#loadCachedData", () => {
|
||||
it("should fetch data and populate the cache if it is empty", async () => {
|
||||
const resp = {layout: ["foo", "bar"]};
|
||||
const fakeCache = {};
|
||||
sandbox.stub(feed.cache, "get").returns(Promise.resolve(fakeCache));
|
||||
sandbox.stub(feed.cache, "set").returns(Promise.resolve());
|
||||
|
||||
fetchStub.resolves({ok: true, json: () => Promise.resolve(resp)});
|
||||
|
||||
await feed.loadCachedData();
|
||||
|
||||
assert.calledOnce(fetchStub);
|
||||
assert.calledWith(feed.cache.set, "layout", resp);
|
||||
});
|
||||
});
|
||||
|
||||
describe("#onAction: INIT", () => {
|
||||
it("should be .loaded=false before initialization", () => {
|
||||
assert.isFalse(feed.loaded);
|
||||
});
|
||||
it("should load data, add pref observer, and set .loaded=true if config.enabled is true", async () => {
|
||||
configPrefStub.returns(JSON.stringify({enabled: true}));
|
||||
sandbox.stub(feed, "loadCachedData").returns(Promise.resolve());
|
||||
sandbox.stub(global.Services.prefs, "addObserver");
|
||||
|
||||
await feed.onAction({type: at.INIT});
|
||||
|
||||
assert.calledOnce(feed.loadCachedData);
|
||||
assert.calledWith(global.Services.prefs.addObserver, CONFIG_PREF_NAME, feed);
|
||||
assert.isTrue(feed.loaded);
|
||||
});
|
||||
});
|
||||
describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => {
|
||||
it("should call this.loadCachedData if config.enabled changes to true ", async () => {
|
||||
sandbox.stub(feed.cache, "set").returns(Promise.resolve());
|
||||
// First initialize
|
||||
await feed.onAction({type: at.INIT});
|
||||
assert.isFalse(feed.loaded);
|
||||
|
||||
// force clear cached pref value
|
||||
feed._prefCache = {};
|
||||
configPrefStub.returns(JSON.stringify({enabled: true}));
|
||||
|
||||
sandbox.stub(feed, "loadCachedData").returns(Promise.resolve());
|
||||
await feed.onAction({type: at.DISCOVERY_STREAM_CONFIG_CHANGE});
|
||||
|
||||
assert.calledOnce(feed.loadCachedData);
|
||||
assert.isTrue(feed.loaded);
|
||||
});
|
||||
it("should call this.loadCachedData if config.enabled changes to true ", async () => {
|
||||
sandbox.stub(feed.cache, "set").returns(Promise.resolve());
|
||||
// force clear cached pref value
|
||||
feed._prefCache = {};
|
||||
configPrefStub.returns(JSON.stringify({enabled: true}));
|
||||
|
||||
await feed.onAction({type: at.INIT});
|
||||
assert.isTrue(feed.loaded);
|
||||
|
||||
feed._prefCache = {};
|
||||
configPrefStub.returns(JSON.stringify({enabled: false}));
|
||||
sandbox.stub(feed, "loadCachedData").returns(Promise.resolve());
|
||||
await feed.onAction({type: at.DISCOVERY_STREAM_CONFIG_CHANGE});
|
||||
|
||||
assert.notCalled(feed.loadCachedData);
|
||||
assert.isFalse(feed.loaded);
|
||||
});
|
||||
});
|
||||
describe("#onAction: UNINIT", () => {
|
||||
it("should remove pref listeners", async () => {
|
||||
sandbox.stub(global.Services.prefs, "removeObserver");
|
||||
|
||||
await feed.onAction({type: at.UNINIT});
|
||||
assert.calledWith(global.Services.prefs.removeObserver, CONFIG_PREF_NAME, feed);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -1460,40 +1460,4 @@ describe("Top Stories Feed", () => {
|
|||
assert.calledOnce(instance.uninit);
|
||||
assert.calledOnce(instance.init);
|
||||
});
|
||||
describe("#layout", () => {
|
||||
it("should call maybeDispatchLayoutUpdate from fetchStories", async () => {
|
||||
instance.stories_endpoint = "stories-endpoint";
|
||||
let fetchStub = globals.sandbox.stub();
|
||||
|
||||
const response = {
|
||||
"layout": [1, 2],
|
||||
};
|
||||
globals.set("fetch", fetchStub);
|
||||
fetchStub.resolves({ok: true, status: 200, json: () => Promise.resolve(response)});
|
||||
sinon.spy(instance, "maybeDispatchLayoutUpdate");
|
||||
|
||||
await instance.fetchStories();
|
||||
assert.calledOnce(instance.maybeDispatchLayoutUpdate);
|
||||
assert.calledWith(instance.maybeDispatchLayoutUpdate, [1, 2]);
|
||||
});
|
||||
it("should call maybeDispatchLayoutUpdate from loadCachedData", async () => {
|
||||
sinon.spy(instance, "maybeDispatchLayoutUpdate");
|
||||
instance.cache.get = () => ({stories: {layout: [2, 3]}});
|
||||
|
||||
await instance.loadCachedData();
|
||||
assert.calledOnce(instance.maybeDispatchLayoutUpdate);
|
||||
assert.calledWith(instance.maybeDispatchLayoutUpdate, [2, 3]);
|
||||
});
|
||||
it("should call dispatch from maybeDispatchLayoutUpdate with available data", () => {
|
||||
instance.maybeDispatchLayoutUpdate([1, 2]);
|
||||
assert.calledOnce(instance.store.dispatch);
|
||||
const [action] = instance.store.dispatch.firstCall.args;
|
||||
assert.equal(action.type, "CONTENT_LAYOUT");
|
||||
assert.deepEqual(action.data, [1, 2]);
|
||||
});
|
||||
it("should not call dispatch from maybeDispatchLayoutUpdate with no available data", () => {
|
||||
instance.maybeDispatchLayoutUpdate([]);
|
||||
assert.notCalled(instance.store.dispatch);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
Загрузка…
Ссылка в новой задаче