Bug 1516008 - Add pocket layout code behind its own separate pref (#4629)

This commit is contained in:
Kate Hudson 2018-12-21 19:02:14 -05:00 коммит произвёл GitHub
Родитель ca13342298
Коммит a45a540a14
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
15 изменённых файлов: 361 добавлений и 104 удалений

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

@ -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();

150
lib/DiscoveryStreamFeed.jsm Normal file
Просмотреть файл

@ -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);
});
});
});