diff options
author | ScottDowne <scott.downe@gmail.com> | 2019-06-05 12:27:12 -0400 |
---|---|---|
committer | GitHub <noreply@github.com> | 2019-06-05 12:27:12 -0400 |
commit | 61746983b59d3b4daad5a18a2c3e5f5184755a1e (patch) | |
tree | 8a1205f7769f05472d37e465a40ea806d53a0322 | |
parent | b9b953b70e1e222a3bac0b7bbc2747688273ae5d (diff) |
Bug 1542863 - Adding feed load fail fallback. (#5085)
14 files changed, 264 insertions, 20 deletions
diff --git a/common/Actions.jsm b/common/Actions.jsm index d333ae90..16531feb 100644 --- a/common/Actions.jsm +++ b/common/Actions.jsm @@ -50,6 +50,7 @@ for (const type of [ "DISCOVERY_STREAM_LAYOUT_UPDATE", "DISCOVERY_STREAM_LINK_BLOCKED", "DISCOVERY_STREAM_LOADED_CONTENT", + "DISCOVERY_STREAM_RETRY_FEED", "DISCOVERY_STREAM_SPOCS_CAPS", "DISCOVERY_STREAM_SPOCS_ENDPOINT", "DISCOVERY_STREAM_SPOCS_FILL", diff --git a/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx b/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx index 7ee73a48..8239813f 100644 --- a/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx +++ b/content-src/components/DiscoveryStreamBase/DiscoveryStreamBase.jsx @@ -141,6 +141,7 @@ export class _DiscoveryStreamBase extends React.PureComponent { return ( <List data={component.data} + feed={component.feed} fullWidth={component.properties.full_width} hasBorders={component.properties.border === "border"} hasImages={component.properties.has_images} diff --git a/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx b/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx index ab2ff05c..0e136736 100644 --- a/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx +++ b/content-src/components/DiscoveryStreamComponents/CardGrid/CardGrid.jsx @@ -60,7 +60,9 @@ export class CardGrid extends React.PureComponent { return (<div> <div className="ds-header">{this.props.title}</div> {isEmpty ? - <div className="ds-card-grid empty"><DSEmptyState /></div> : + <div className="ds-card-grid empty"> + <DSEmptyState status={data.status} dispatch={this.props.dispatch} feed={this.props.feed} /> + </div> : this.renderCards() } </div>); diff --git a/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx b/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx index bcd995b4..444f9462 100644 --- a/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx +++ b/content-src/components/DiscoveryStreamComponents/DSEmptyState/DSEmptyState.jsx @@ -1,12 +1,84 @@ +import {actionCreators as ac, actionTypes as at} from "common/Actions.jsm"; import React from "react"; export class DSEmptyState extends React.PureComponent { + constructor(props) { + super(props); + this.onReset = this.onReset.bind(this); + this.state = {}; + } + + componentWillUnmount() { + if (this.timeout) { + clearTimeout(this.timeout); + } + } + + onReset() { + if (this.props.dispatch && this.props.feed) { + const {feed} = this.props; + const {url} = feed; + this.props.dispatch({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: { + ...feed, + data: { + ...feed.data, + status: "waiting", + }, + }, + url, + }, + }); + + this.setState({waiting: true}); + this.timeout = setTimeout(() => { + this.timeout = null; + this.setState({ + waiting: false, + }); + }, 300); + + this.props.dispatch(ac.OnlyToMain({type: at.DISCOVERY_STREAM_RETRY_FEED, data: {feed}})); + } + } + + renderButton() { + if (this.props.status === "waiting" || this.state.waiting) { + return ( + <button className="try-again-button waiting">Loading...</button> + ); + } + + return ( + <button className="try-again-button" onClick={this.onReset}>Try Again</button> + ); + } + + renderState() { + if (this.props.status === "waiting" || this.props.status === "failed") { + return ( + <React.Fragment> + <h2>Oops! We almost loaded this section, but not quite.</h2> + {this.renderButton()} + </React.Fragment> + ); + } + + return ( + <React.Fragment> + <h2>You are caught up!</h2> + <p>Check back later for more stories.</p> + </React.Fragment> + ); + } + render() { return ( <div className="section-empty-state"> <div className="empty-state-message"> - <h2>You are caught up!</h2> - <p>Check back later for more stories.</p> + {this.renderState()} </div> </div> ); diff --git a/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss b/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss index 23c53088..039369b9 100644 --- a/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss +++ b/content-src/components/DiscoveryStreamComponents/DSEmptyState/_DSEmptyState.scss @@ -2,7 +2,7 @@ border: $border-secondary; border-radius: 4px; display: flex; - height: $grid-unit; + height: $card-height-compact; width: 100%; .empty-state-message { @@ -14,8 +14,65 @@ max-width: 936px; } + .try-again-button { + margin-top: 12px; + padding: 6px 32px; + border-radius: 2px; + border: 0; + background: var(--newtab-feed-button-background); + color: var(--newtab-feed-button-text); + cursor: pointer; + position: relative; + transition: background 0.2s ease, color 0.2s ease; + + &:not(.waiting) { + &:focus { + @include ds-fade-in; + + @include dark-theme-only { + @include ds-fade-in($blue-40-40); + } + } + + &:hover { + @include ds-fade-in($grey-30); + + @include dark-theme-only { + @include ds-fade-in($grey-60); + } + } + } + + &::after { + content: ''; + height: 20px; + width: 20px; + animation: spinner 1s linear infinite; + opacity: 0; + position: absolute; + top: 50%; + left: 50%; + margin: -10px 0 0 -10px; + mask-image: url('../data/content/assets/spinner.svg'); + mask-size: 20px; + background: var(--newtab-feed-button-spinner); + } + + &.waiting { + cursor: initial; + background: var(--newtab-feed-button-background-faded); + color: var(--newtab-feed-button-text-faded); + transition: background 0.2s ease; + + &::after { + transition: opacity 0.2s ease; + opacity: 1; + } + } + } + h2 { - font-size: 14px; + font-size: 15px; font-weight: 600; margin: 0; } @@ -24,3 +81,7 @@ margin: 0; } } + +@keyframes spinner { + to { transform: rotate(360deg); } +} diff --git a/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx b/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx index ed404fea..5127d8c9 100644 --- a/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx +++ b/content-src/components/DiscoveryStreamComponents/Hero/Hero.jsx @@ -111,6 +111,7 @@ export class Hero extends React.PureComponent { <List recStartingPoint={1} data={this.props.data} + feed={this.props.feed} hasImages={true} hasBorders={this.props.border === `border`} items={this.props.items - 1} @@ -144,7 +145,9 @@ export class Hero extends React.PureComponent { <div> <div className="ds-header">{this.props.title}</div> {isEmpty ? - <div className="ds-hero empty"><DSEmptyState /></div> : + <div className="ds-hero empty"> + <DSEmptyState status={data.status} dispatch={this.props.dispatch} feed={this.props.feed} /> + </div> : this.renderHero() } </div> diff --git a/content-src/components/DiscoveryStreamComponents/List/List.jsx b/content-src/components/DiscoveryStreamComponents/List/List.jsx index 3c31db08..093eae27 100644 --- a/content-src/components/DiscoveryStreamComponents/List/List.jsx +++ b/content-src/components/DiscoveryStreamComponents/List/List.jsx @@ -126,19 +126,21 @@ export function _List(props) { ); }; - const feed = props.data; - if (!feed || !feed.recommendations) { + const {data} = props; + if (!data || !data.recommendations) { return null; } // Handle the case where a user has dismissed all recommendations - const isEmpty = feed.recommendations.length === 0; + const isEmpty = data.recommendations.length === 0; return ( <div> {props.header && props.header.title ? <div className="ds-header">{props.header.title}</div> : null } {isEmpty ? - <div className="ds-list empty"><DSEmptyState /></div> : + <div className="ds-list empty"> + <DSEmptyState status={data.status} dispatch={props.dispatch} feed={props.feed} /> + </div> : renderList() } </div> diff --git a/content-src/lib/selectLayoutRender.js b/content-src/lib/selectLayoutRender.js index 6f46998f..52eee4d8 100644 --- a/content-src/lib/selectLayoutRender.js +++ b/content-src/lib/selectLayoutRender.js @@ -79,7 +79,7 @@ export const selectLayoutRender = (state, prefs, rickRollCache) => { if (feed && feed.data) { data = { ...feed.data, - recommendations: [...feed.data.recommendations], + recommendations: [...(feed.data.recommendations || [])], }; } @@ -90,8 +90,10 @@ export const selectLayoutRender = (state, prefs, rickRollCache) => { }; } + // Ensure we have recs available for this feed. + const hasRecs = data && data.recommendations && data.recommendations.length; // Do we ever expect to possibly have a spoc. - if (data && component.spocs && component.spocs.positions && component.spocs.positions.length) { + if (hasRecs && component.spocs && component.spocs.positions && component.spocs.positions.length) { // We expect a spoc, spocs are loaded, and the server returned spocs. if (spocs.loaded && spocs.data.spocs && spocs.data.spocs.length) { data = rollForSpocs(data, component.spocs); diff --git a/content-src/styles/_theme.scss b/content-src/styles/_theme.scss index 667254ea..5b0691e7 100644 --- a/content-src/styles/_theme.scss +++ b/content-src/styles/_theme.scss @@ -24,8 +24,6 @@ body { --newtab-background-color: #{$grey-10}; --newtab-border-primary-color: #{$grey-40}; --newtab-border-secondary-color: #{$grey-30}; - --newtab-button-primary-color: #{$blue-60}; - --newtab-button-secondary-color: inherit; --newtab-element-active-color: #{$grey-30-60}; --newtab-element-hover-color: #{$grey-20}; --newtab-icon-primary-color: #{$grey-90-80}; @@ -41,6 +39,17 @@ body { --newtab-textbox-border: #{$grey-90-20}; @include textbox-focus($blue-60); // sass-lint:disable-line mixins-before-declarations + // Buttons + --newtab-button-primary-color: #{$blue-60}; + --newtab-button-secondary-color: inherit; + // Feed buttons + --newtab-feed-button-background: #{$grey-20}; + --newtab-feed-button-text: #{$grey-90}; + --newtab-feed-button-background-faded: #{$grey-20-60}; + --newtab-feed-button-text-faded: #{$grey-90-00}; + --newtab-feed-button-spinner: #{$grey-50}; + + // Context menu --newtab-contextmenu-background-color: #{$grey-10}; --newtab-contextmenu-button-color: #{$white}; @@ -107,6 +116,13 @@ body { --newtab-textbox-border: #{$grey-10-20}; @include textbox-focus($blue-40); // sass-lint:disable-line mixins-before-declarations + // Feed buttons + --newtab-feed-button-background: #{$grey-70}; + --newtab-feed-button-text: #{$grey-10}; + --newtab-feed-button-background-faded: #{$grey-70-60}; + --newtab-feed-button-text-faded: #{$grey-10-00}; + --newtab-feed-button-spinner: #{$grey-30}; + // Context menu --newtab-contextmenu-background-color: #{$grey-60}; --newtab-contextmenu-button-color: #{$grey-80}; diff --git a/content-src/styles/_variables.scss b/content-src/styles/_variables.scss index 8e678a5e..3d18caab 100644 --- a/content-src/styles/_variables.scss +++ b/content-src/styles/_variables.scss @@ -22,6 +22,7 @@ $yellow-50: #FFE900; $violet-20: #CB9EFF; // Photon opacity from http://design.firefox.com/photon/visuals/color.html#opacity +$grey-10-00: rgba($grey-10, 0); $grey-10-10: rgba($grey-10, 0.1); $grey-10-20: rgba($grey-10, 0.2); $grey-10-30: rgba($grey-10, 0.3); @@ -35,7 +36,10 @@ $grey-20-80: rgba($grey-20, 0.8); $grey-30-60: rgba($grey-30, 0.6); $grey-60-60: rgba($grey-60, 0.6); $grey-60-70: rgba($grey-60, 0.7); +$grey-70-40: rgba($grey-70, 0.4); +$grey-70-60: rgba($grey-70, 0.6); $grey-80-95: rgba($grey-80, 0.95); +$grey-90-00: rgba($grey-90, 0); $grey-90-10: rgba($grey-90, 0.1); $grey-90-20: rgba($grey-90, 0.2); $grey-90-30: rgba($grey-90, 0.3); diff --git a/data/content/assets/spinner.svg b/data/content/assets/spinner.svg new file mode 100644 index 00000000..8478b886 --- /dev/null +++ b/data/content/assets/spinner.svg @@ -0,0 +1 @@ +<svg width="73" height="73" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink"><defs><linearGradient x1="93.093%" y1="52.773%" x2="68.513%" y2="119.326%" id="a"><stop stop-color="#FFF" stop-opacity="0" offset="0%"/><stop stop-color="#FFF" offset="69.37%"/><stop stop-color="#FFF" offset="100%"/><stop stop-color="#FFF" stop-opacity=".005" offset="100%"/><stop stop-color="#FFF" stop-opacity="0" offset="100%"/><stop stop-color="#FFF" stop-opacity="0" offset="100%"/></linearGradient><path id="b" d="M0 0h48v60H0z"/></defs><g transform="translate(-5 -1)" fill="none" fill-rule="evenodd"><path d="M41.8 73.8c-19.9 0-36-16.1-36-36 0-19.7 15.8-35.6 35.3-36h.7c2.8.4 5 2.7 5 5.5s-2.2 5.2-5 5.4c-13.8.1-25 11.3-25 25.1s11.2 25 25 25 25-11.2 25-25h11c0 19.9-16.1 36-36 36z" fill="url(#a)"/><mask id="c" fill="#fff"><use xlink:href="#b"/></mask><path d="M41.8 73.8c-19.9 0-36-16.1-36-36 0-19.7 15.8-35.6 35.3-36h.7c2.8.4 5 2.7 5 5.5s-2.2 5.2-5 5.4c-13.8.1-25 11.3-25 25.1s11.2 25 25 25 25-11.2 25-25h11c0 19.9-16.1 36-36 36z" fill="#FFF" mask="url(#c)"/></g></svg>
\ No newline at end of file diff --git a/lib/DiscoveryStreamFeed.jsm b/lib/DiscoveryStreamFeed.jsm index d957d4f4..a2f34ac4 100644 --- a/lib/DiscoveryStreamFeed.jsm +++ b/lib/DiscoveryStreamFeed.jsm @@ -220,6 +220,7 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { lastUpdated: Date.now(), spocs: layoutResponse.spocs, layout: layoutResponse.layout, + status: "success", }; await this.cache.set("layout", layout); @@ -616,6 +617,19 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { return true; } + async retryFeed(feed) { + const {url} = feed; + const result = await this.getComponentFeed(url); + const newFeed = this.filterRecommendations(result); + this.store.dispatch(ac.BroadcastToContent({ + type: at.DISCOVERY_STREAM_FEED_UPDATE, + data: { + feed: newFeed, + url, + }, + })); + } + async getComponentFeed(feedUrl, isStartup) { const cachedData = await this.cache.get() || {}; const {feeds} = cachedData; @@ -633,6 +647,7 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { data: { settings: feedResponse.settings, recommendations, + status: "success", }, }; } else { @@ -640,7 +655,12 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { } } - return feed; + // If we have no feed at this point, both fetch and cache failed for some reason. + return feed || { + data: { + status: "failed", + }, + }; } /** @@ -859,7 +879,7 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { // longer part of the response. cleanUpTopRecImpressionPref(newFeeds) { // Need to build a single list of stories. - const activeStories = Object.keys(newFeeds).reduce((accumulator, currentValue) => { + const activeStories = Object.keys(newFeeds).filter(currentValue => newFeeds[currentValue].data).reduce((accumulator, currentValue) => { const {recommendations} = newFeeds[currentValue].data; return accumulator.concat(recommendations.map(i => `${i.id}`)); }, []); @@ -918,6 +938,9 @@ this.DiscoveryStreamFeed = class DiscoveryStreamFeed { [action.data.name]: action.data.value, }))); break; + case at.DISCOVERY_STREAM_RETRY_FEED: + this.retryFeed(action.data.feed); + break; case at.DISCOVERY_STREAM_CONFIG_CHANGE: // When the config pref changes, load or unload data as needed. await this.onPrefChange(); diff --git a/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx b/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx index e241d994..cd528e50 100644 --- a/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx +++ b/test/unit/content-src/components/DiscoveryStreamComponents/DSEmptyState.test.jsx @@ -14,9 +14,36 @@ describe("<DSEmptyState>", () => { assert.ok(wrapper.find(".section-empty-state").exists()); }); - it("should render message", () => { + it("should render defaultempty state message", () => { assert.ok(wrapper.find(".empty-state-message").exists()); assert.ok(wrapper.find("h2").exists()); assert.ok(wrapper.find("p").exists()); }); + + it("should render failed state message", () => { + wrapper = shallowWithIntl(<DSEmptyState status="failed" />); + assert.ok(wrapper.find("button.try-again-button").exists()); + }); + + it("should render waiting state message", () => { + wrapper = shallowWithIntl(<DSEmptyState status="waiting" />); + assert.ok(wrapper.find("button.try-again-button.waiting").exists()); + }); + + it("should dispatch DISCOVERY_STREAM_RETRY_FEED on failed state button click", () => { + const dispatch = sinon.spy(); + + wrapper = shallowWithIntl(<DSEmptyState status="failed" dispatch={dispatch} feed={{url: "https://foo.com", data: {}}} />); + wrapper.find("button.try-again-button").simulate("click"); + + assert.calledTwice(dispatch); + let [action] = dispatch.firstCall.args; + assert.equal(action.type, "DISCOVERY_STREAM_FEED_UPDATE"); + assert.deepEqual(action.data.feed, {url: "https://foo.com", data: {status: "waiting"}}); + + [action] = dispatch.secondCall.args; + + assert.equal(action.type, "DISCOVERY_STREAM_RETRY_FEED"); + assert.deepEqual(action.data.feed, {url: "https://foo.com", data: {}}); + }); }); diff --git a/test/unit/lib/DiscoveryStreamFeed.test.js b/test/unit/lib/DiscoveryStreamFeed.test.js index 91920401..950aeda4 100644 --- a/test/unit/lib/DiscoveryStreamFeed.test.js +++ b/test/unit/lib/DiscoveryStreamFeed.test.js @@ -12,7 +12,7 @@ const REC_IMPRESSION_TRACKING_PREF = "discoverystream.rec.impressions"; const THIRTY_MINUTES = 30 * 60 * 1000; const ONE_WEEK = 7 * 24 * 60 * 60 * 1000; // 1 week -describe("DiscoveryStreamFeed", () => { +describe("DiscoveryStreamFeed", () => { // eslint-disable-line max-statements let DiscoveryStreamFeed; let feed; let sandbox; @@ -78,6 +78,7 @@ describe("DiscoveryStreamFeed", () => { sandbox.stub(feed, "_maybeUpdateCachedData").resolves(); globals = new GlobalOverrider(); + globals.set("setTimeout", callback => { callback(); }); fakeNewTabUtils = { blockedLinks: { links: [], @@ -346,7 +347,7 @@ describe("DiscoveryStreamFeed", () => { assert.calledWith(feed.store.dispatch.firstCall, { type: at.DISCOVERY_STREAM_FEED_UPDATE, - data: {feed: null, url: "foo.com"}, + data: {feed: {data: {status: "failed"}}, url: "foo.com"}, }); assert.calledWith(feed.store.dispatch.secondCall, { type: at.DISCOVERY_STREAM_FEEDS_UPDATE, @@ -440,7 +441,7 @@ describe("DiscoveryStreamFeed", () => { const feedResp = await feed.getComponentFeed("foo.com"); - assert.isNull(feedResp); + assert.deepEqual(feedResp, {data: {status: "failed"}}); }); }); @@ -879,6 +880,25 @@ describe("DiscoveryStreamFeed", () => { }); }); + describe("#retryFeed", () => { + it("should retry a feed fetch", async () => { + sandbox.stub(feed, "getComponentFeed").returns(Promise.resolve({})); + sandbox.stub(feed, "filterRecommendations").returns({}); + sandbox.spy(feed.store, "dispatch"); + + await feed.retryFeed({url: "https://feed.com"}); + + assert.calledOnce(feed.getComponentFeed); + assert.calledOnce(feed.filterRecommendations); + assert.calledOnce(feed.store.dispatch); + assert.equal(feed.store.dispatch.firstCall.args[0].type, "DISCOVERY_STREAM_FEED_UPDATE"); + assert.deepEqual(feed.store.dispatch.firstCall.args[0].data, { + feed: {}, + url: "https://feed.com", + }); + }); + }); + describe("#recordCampaignImpression", () => { it("should return false if time based cap is hit", () => { sandbox.stub(feed, "readImpressionsPref").returns({}); @@ -1207,6 +1227,15 @@ describe("DiscoveryStreamFeed", () => { }); }); + describe("#onAction: DISCOVERY_STREAM_RETRY_FEED", async () => { + it("should call retryFeed", async () => { + sandbox.spy(feed, "retryFeed"); + feed.onAction({type: at.DISCOVERY_STREAM_RETRY_FEED, data: {feed: {url: "https://feed.com"}}}); + assert.calledOnce(feed.retryFeed); + assert.calledWith(feed.retryFeed, {url: "https://feed.com"}); + }); + }); + describe("#onAction: DISCOVERY_STREAM_CONFIG_CHANGE", () => { it("should call this.loadLayout if config.enabled changes to true ", async () => { sandbox.stub(feed.cache, "set").returns(Promise.resolve()); |