diff --git a/src/newsreader/js/pages/homepage/actions/categories.js b/src/newsreader/js/pages/homepage/actions/categories.js index f30424f..0fc63a6 100644 --- a/src/newsreader/js/pages/homepage/actions/categories.js +++ b/src/newsreader/js/pages/homepage/actions/categories.js @@ -28,49 +28,6 @@ export const receiveCategories = categories => ({ export const requestCategory = () => ({ type: REQUEST_CATEGORY }); export const requestCategories = () => ({ type: REQUEST_CATEGORIES }); -export const fetchCategories = () => { - return dispatch => { - dispatch(requestCategories()); - - return fetch('/api/categories/') - .then(response => response.json()) - .then(json => { - const categories = {}; - - json.forEach(category => { - categories[category.id] = { ...category }; - }); - - dispatch(receiveCategories(categories)); - return json; - }) - .then(json => { - const promises = json.map(category => { - return fetch(`/api/categories/${category.id}/rules/`); - }); - - dispatch(requestRules()); - return Promise.all(promises); - }) - .then(responses => { - return Promise.all(responses.map(response => response.json())); - }) - .then(responseData => { - let rules = {}; - - responseData.forEach(json => { - const data = Object.values(json); - - data.forEach(item => { - rules = { ...rules, [item.id]: item }; - }); - }); - - dispatch(receiveRules(rules)); - }); - }; -}; - export const fetchCategory = category => { return (dispatch, getState) => { const { selected } = getState(); @@ -93,3 +50,28 @@ export const fetchCategory = category => { }); }; }; + +export const fetchCategories = () => { + return dispatch => { + dispatch(requestCategories()); + + return fetch('/api/categories/') + .then(response => response.json()) + .then(categories => { + dispatch(receiveCategories(categories)); + + return categories; + }) + .then(categories => { + dispatch(requestRules()); + + const promises = categories.map(category => { + return fetch(`/api/categories/${category.id}/rules/`); + }); + + return Promise.all(promises); + }) + .then(responses => Promise.all(responses.map(response => response.json()))) + .then(nestedRules => dispatch(receiveRules(nestedRules.flat()))); + }; +}; diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 598dde1..d1fc79b 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -75,15 +75,7 @@ export const fetchPostsBySection = (section, page = false) => { return fetch(url) .then(response => response.json()) - .then(json => { - const posts = {}; - - json.results.forEach(post => { - posts[post.id] = post; - }); - - dispatch(receivePosts(posts, json.next)); - }) + .then(posts => dispatch(receivePosts(posts.results, posts.next))) .catch(error => { if (error instanceof TypeError) { console.log(`Unable to parse posts from request: ${error}`); diff --git a/src/newsreader/js/pages/homepage/actions/rules.js b/src/newsreader/js/pages/homepage/actions/rules.js index 41e2e06..0f45f1d 100644 --- a/src/newsreader/js/pages/homepage/actions/rules.js +++ b/src/newsreader/js/pages/homepage/actions/rules.js @@ -61,14 +61,6 @@ export const fetchRulesByCategory = category => { return fetch(`/api/categories/${category.id}/rules/`) .then(response => response.json()) - .then(responseData => { - const rules = {}; - - responseData.forEach(rule => { - rules[rule.id] = { ...rule }; - }); - - dispatch(receiveRules(rules)); - }); + .then(rules => dispatch(receiveRules(rules))); }; }; diff --git a/src/newsreader/js/pages/homepage/reducers/categories.js b/src/newsreader/js/pages/homepage/reducers/categories.js index a1f8961..612b98f 100644 --- a/src/newsreader/js/pages/homepage/reducers/categories.js +++ b/src/newsreader/js/pages/homepage/reducers/categories.js @@ -2,6 +2,8 @@ import { isEqual } from 'lodash'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; +import { objectsFromArray } from '../../../utils.js'; + import { RECEIVE_CATEGORY, RECEIVE_CATEGORIES, @@ -26,13 +28,7 @@ export const categories = (state = { ...defaultState }, action) => { isFetching: false, }; case RECEIVE_CATEGORIES: - const receivedCategories = {}; - - Object.values({ ...action.categories }).forEach(category => { - receivedCategories[category.id] = { - ...category, - }; - }); + const receivedCategories = objectsFromArray(action.categories, 'id'); return { ...state, @@ -41,10 +37,7 @@ export const categories = (state = { ...defaultState }, action) => { }; case REQUEST_CATEGORIES: case REQUEST_CATEGORY: - return { - ...state, - isFetching: true, - }; + return { ...state, isFetching: true }; case MARK_POST_READ: let category = {}; diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 4e7cb8c..220c59b 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -1,4 +1,6 @@ import { isEqual } from 'lodash'; + +import { objectsFromArray } from '../../../utils.js'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { @@ -15,12 +17,6 @@ const defaultState = { items: {}, isFetching: false }; export const posts = (state = { ...defaultState }, action) => { switch (action.type) { - case RECEIVE_POSTS: - return { - ...state, - isFetching: false, - items: { ...state.items, ...action.posts }, - }; case REQUEST_POSTS: return { ...state, isFetching: true }; case RECEIVE_POST: @@ -28,6 +24,14 @@ export const posts = (state = { ...defaultState }, action) => { ...state, items: { ...state.items, [action.post.id]: { ...action.post } }, }; + case RECEIVE_POSTS: + const receivedItems = objectsFromArray(action.posts, 'id'); + + return { + ...state, + isFetching: false, + items: { ...state.items, ...receivedItems }, + }; case MARK_SECTION_READ: const updatedPosts = {}; let relatedPosts = []; diff --git a/src/newsreader/js/pages/homepage/reducers/rules.js b/src/newsreader/js/pages/homepage/reducers/rules.js index 0802576..ea3480c 100644 --- a/src/newsreader/js/pages/homepage/reducers/rules.js +++ b/src/newsreader/js/pages/homepage/reducers/rules.js @@ -1,5 +1,7 @@ import { isEqual } from 'lodash'; +import { objectsFromArray } from '../../../utils.js'; + import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { @@ -17,14 +19,13 @@ export const rules = (state = { ...defaultState }, action) => { switch (action.type) { case REQUEST_RULE: case REQUEST_RULES: - return { - ...state, - isFetching: true, - }; + return { ...state, isFetching: true }; case RECEIVE_RULES: + const receivedItems = objectsFromArray(action.rules, 'id'); + return { ...state, - items: { ...state.items, ...action.rules }, + items: { ...state.items, ...receivedItems }, isFetching: false, }; case RECEIVE_RULE: diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js index 632654d..babcb82 100644 --- a/src/newsreader/js/pages/homepage/reducers/selected.js +++ b/src/newsreader/js/pages/homepage/reducers/selected.js @@ -67,15 +67,9 @@ export const selected = (state = { ...defaultState }, action) => { ...state, }; case SELECT_POST: - return { - ...state, - post: action.post, - }; + return { ...state, post: action.post }; case UNSELECT_POST: - return { - ...state, - post: {}, - }; + return { ...state, post: {} }; case MARK_POST_READ: return { ...state, diff --git a/src/newsreader/js/tests/homepage/actions/category.test.js b/src/newsreader/js/tests/homepage/actions/category.test.js index 235de76..a6be5ad 100644 --- a/src/newsreader/js/tests/homepage/actions/category.test.js +++ b/src/newsreader/js/tests/homepage/actions/category.test.js @@ -96,22 +96,13 @@ describe('category actions', () => { }); it('should create multiple actions when fetching categories', () => { - const categories = { - 1: { id: 1, name: 'Tech', unread: 29 }, - 2: { id: 2, name: 'World news', unread: 956 }, - }; + const categories = [ + { id: 1, name: 'Tech', unread: 29 }, + { id: 2, name: 'World news', unread: 956 }, + ]; - const rules = { - 4: { - id: 4, - name: 'BBC', - url: 'http://feeds.bbci.co.uk/news/world/rss.xml', - favicon: - 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', - category: 2, - unread: 345, - }, - 5: { + const rules = [ + { id: 5, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -119,19 +110,28 @@ describe('category actions', () => { category: 1, unread: 7, }, - }; + { + id: 6, + name: 'BBC', + url: 'http://feeds.bbci.co.uk/news/world/rss.xml', + favicon: + 'https://m.files.bbci.co.uk/modules/bbc-morph-news-waf-page-meta/2.5.2/apple-touch-icon-57x57-precomposed.png', + category: 2, + unread: 345, + }, + ]; fetchMock .get('/api/categories/', { - body: Object.values({ ...categories }), + body: categories, headers: { 'content-type': 'application/json' }, }) .get('/api/categories/1/rules/', { - body: [{ ...rules[5] }], + body: [{ ...rules[0] }], headers: { 'content-type': 'application/json' }, }) .get('/api/categories/2/rules/', { - body: [{ ...rules[4] }], + body: [{ ...rules[1] }], headers: { 'content-type': 'application/json' }, }); @@ -161,8 +161,8 @@ describe('category actions', () => { unread: 0, }; - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -170,7 +170,7 @@ describe('category actions', () => { category: 1, unread: 200, }, - 2: { + { id: 2, name: 'Hacker News', url: 'https://news.ycombinator.com/rss', @@ -178,7 +178,7 @@ describe('category actions', () => { category: 1, unread: 350, }, - }; + ]; fetchMock .get('/api/categories/1', { @@ -186,7 +186,7 @@ describe('category actions', () => { headers: { 'content-type': 'application/json' }, }) .get('/api/categories/1/rules/', { - body: Object.values({ ...rules }), + body: rules, headers: { 'content-type': 'application/json' }, }); @@ -197,7 +197,7 @@ describe('category actions', () => { category: { ...category, unread: 500 }, }, { type: ruleActions.REQUEST_RULES }, - { type: ruleActions.RECEIVE_RULES, rules: { ...rules } }, + { type: ruleActions.RECEIVE_RULES, rules }, ]; const store = mockStore({ diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js index 31bb666..65967b4 100644 --- a/src/newsreader/js/tests/homepage/actions/post.test.js +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -163,8 +163,8 @@ describe('rule actions', () => { }); it('should create multiple actions to fetch posts by rule', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -177,7 +177,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -189,7 +189,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - }; + ]; const rule = { id: 4, @@ -206,7 +206,7 @@ describe('rule actions', () => { count: 2, next: 'https://durp.com/api/rules/4/posts/?page=2&read=false', previous: null, - results: Object.values({ ...posts }), + results: posts, }, headers: { 'content-type': 'application/json' }, }); @@ -233,8 +233,8 @@ describe('rule actions', () => { }); it('should create multiple actions to fetch posts by category', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -247,7 +247,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -259,7 +259,7 @@ describe('rule actions', () => { rule: 4, read: false, }, - }; + ]; const category = { id: 1, @@ -273,7 +273,7 @@ describe('rule actions', () => { count: 2, next: 'https://durp.com/api/categories/4/posts/?page=2&read=false', previous: null, - results: Object.values({ ...posts }), + results: posts, }, headers: { 'content-type': 'application/json' }, }); diff --git a/src/newsreader/js/tests/homepage/actions/rule.test.js b/src/newsreader/js/tests/homepage/actions/rule.test.js index 509938d..70a3a89 100644 --- a/src/newsreader/js/tests/homepage/actions/rule.test.js +++ b/src/newsreader/js/tests/homepage/actions/rule.test.js @@ -2,6 +2,8 @@ import configureMockStore from 'redux-mock-store'; import thunk from 'redux-thunk'; import fetchMock from 'fetch-mock'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/rules.js'; import * as constants from '../../../pages/homepage/constants.js'; import * as categoryActions from '../../../pages/homepage/actions/categories.js'; @@ -63,8 +65,8 @@ describe('rule actions', () => { }); it('should create an action to receive multiple rules', () => { - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Test rule', unread: 100, @@ -72,7 +74,7 @@ describe('rule actions', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - 2: { + { id: 2, name: 'Test rule 2', unread: 50, @@ -80,7 +82,7 @@ describe('rule actions', () => { url: 'https://xkcd.com/atom.xml', favicon: null, }, - }; + ]; const expectedAction = { type: actions.RECEIVE_RULES, @@ -211,8 +213,8 @@ describe('rule actions', () => { unread: 0, }; - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Ars Technica', url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', @@ -220,7 +222,7 @@ describe('rule actions', () => { category: 1, unread: 200, }, - 2: { + { id: 2, name: 'Hacker News', url: 'https://news.ycombinator.com/rss', @@ -228,10 +230,10 @@ describe('rule actions', () => { category: 1, unread: 350, }, - }; + ]; fetchMock.getOnce('/api/categories/1/rules/', { - body: Object.values({ ...rules }), + body: rules, headers: { 'content-type': 'application/json' }, }); diff --git a/src/newsreader/js/tests/homepage/reducers/category.test.js b/src/newsreader/js/tests/homepage/reducers/category.test.js index 2eeed65..f5c27ae 100644 --- a/src/newsreader/js/tests/homepage/reducers/category.test.js +++ b/src/newsreader/js/tests/homepage/reducers/category.test.js @@ -1,5 +1,7 @@ import { categories as reducer } from '../../../pages/homepage/reducers/categories.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/categories.js'; import * as postActions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; @@ -25,19 +27,15 @@ describe('category reducer', () => { }); it('should return state after receiving multiple categories', () => { - const receivedCategories = { - 0: { id: 9, name: 'Tech', unread: 291 }, - 1: { id: 2, name: 'World news', unread: 444 }, - }; + const receivedCategories = [ + { id: 9, name: 'Tech', unread: 291 }, + { id: 2, name: 'World news', unread: 444 }, + ]; + const action = { type: actions.RECEIVE_CATEGORIES, categories: receivedCategories }; - const items = {}; - - Object.values({ ...receivedCategories }).forEach(category => { - items[category.id] = category; - }); - - const expectedState = { ...defaultState, items }; + const expectedCategories = objectsFromArray(receivedCategories, 'id'); + const expectedState = { ...defaultState, items: expectedCategories }; expect(reducer(undefined, action)).toEqual(expectedState); }); diff --git a/src/newsreader/js/tests/homepage/reducers/post.test.js b/src/newsreader/js/tests/homepage/reducers/post.test.js index b54c3a1..ef4234a 100644 --- a/src/newsreader/js/tests/homepage/reducers/post.test.js +++ b/src/newsreader/js/tests/homepage/reducers/post.test.js @@ -1,5 +1,7 @@ import { posts as reducer } from '../../../pages/homepage/reducers/posts.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; import * as constants from '../../../pages/homepage/constants.js'; @@ -45,8 +47,8 @@ describe('post actions', () => { }); it('should return state after receiving posts', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -59,7 +61,7 @@ describe('post actions', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -71,7 +73,7 @@ describe('post actions', () => { rule: 4, read: false, }, - }; + ]; const action = { type: actions.RECEIVE_POSTS, @@ -79,10 +81,11 @@ describe('post actions', () => { posts, }; + const expectedPosts = objectsFromArray(posts, 'id'); const expectedState = { ...defaultState, isFetching: false, - items: posts, + items: expectedPosts, }; expect(reducer(undefined, action)).toEqual(expectedState); diff --git a/src/newsreader/js/tests/homepage/reducers/rule.test.js b/src/newsreader/js/tests/homepage/reducers/rule.test.js index 67e1f4c..171c301 100644 --- a/src/newsreader/js/tests/homepage/reducers/rule.test.js +++ b/src/newsreader/js/tests/homepage/reducers/rule.test.js @@ -1,5 +1,7 @@ import { rules as reducer } from '../../../pages/homepage/reducers/rules.js'; +import { objectsFromArray } from '../../../utils.js'; + import * as actions from '../../../pages/homepage/actions/rules.js'; import * as postActions from '../../../pages/homepage/actions/posts.js'; import * as selectedActions from '../../../pages/homepage/actions/selected.js'; @@ -49,8 +51,8 @@ describe('category reducer', () => { }); it('should return state after receiving multiple rules', () => { - const rules = { - 1: { + const rules = [ + { id: 1, name: 'Test rule', unread: 100, @@ -58,7 +60,7 @@ describe('category reducer', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - 2: { + { id: 2, name: 'Another Test rule', unread: 444, @@ -66,11 +68,12 @@ describe('category reducer', () => { url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml', favicon: 'https://cdn.arstechnica.net/favicon.ico', }, - }; + ]; const action = { type: actions.RECEIVE_RULES, rules }; - const expectedState = { ...defaultState, items: { ...rules } }; + const mappedRules = objectsFromArray(rules, 'id'); + const expectedState = { ...defaultState, items: { ...mappedRules } }; expect(reducer(undefined, action)).toEqual(expectedState); }); diff --git a/src/newsreader/js/tests/homepage/reducers/selected.test.js b/src/newsreader/js/tests/homepage/reducers/selected.test.js index 22a5e7e..215c6e1 100644 --- a/src/newsreader/js/tests/homepage/reducers/selected.test.js +++ b/src/newsreader/js/tests/homepage/reducers/selected.test.js @@ -211,8 +211,8 @@ describe('selected reducer', () => { }); it('should return state after receiving posts', () => { - const posts = { - 2067: { + const posts = [ + { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', title: @@ -225,7 +225,7 @@ describe('selected reducer', () => { rule: 4, read: false, }, - 2141: { + { id: 2141, remoteIdentifier: 'https://arstechnica.com/?p=1648757', title: 'The most complete brain map ever is here: A fly’s “connectome”', @@ -237,7 +237,7 @@ describe('selected reducer', () => { rule: 4, read: false, }, - }; + ]; const action = { type: postActions.RECEIVE_POSTS, @@ -254,7 +254,7 @@ describe('selected reducer', () => { expect(reducer(undefined, action)).toEqual(expectedState); }); - it('should return state after receiving a post', () => { + it('should return state after receiving a post which is selected', () => { const post = { id: 2067, remoteIdentifier: 'https://arstechnica.com/?p=1648607', @@ -280,6 +280,32 @@ describe('selected reducer', () => { expect(reducer(state, action)).toEqual(expectedState); }); + it('should return state after receiving a post with none selected', () => { + const post = { + id: 2067, + remoteIdentifier: 'https://arstechnica.com/?p=1648607', + title: + 'This amazing glitch puts Star Fox 64 ships in an unmodified Zelda cartridge', + body: + '"Stale-reference manipulation," 300-character file names, and a clash between worlds.', + author: 'Kyle Orland', + publicationDate: '2020-01-24T19:50:12Z', + url: 'https://arstechnica.com/?p=1648607', + rule: 4, + read: false, + }; + + const action = { + type: postActions.RECEIVE_POST, + post: { ...post, rule: 6 }, + }; + + const state = { ...defaultState, post: {} }; + const expectedState = { ...defaultState, post: {} }; + + expect(reducer(state, action)).toEqual(expectedState); + }); + it('should return state after selecting a post', () => { const post = { id: 2067, diff --git a/src/newsreader/js/utils.js b/src/newsreader/js/utils.js index 0794a1a..9db723e 100644 --- a/src/newsreader/js/utils.js +++ b/src/newsreader/js/utils.js @@ -12,3 +12,13 @@ export const formatDatetime = dateString => { return date.toLocaleDateString(locale, dateOptions); }; + +export const objectsFromArray = (array, key) => { + const arrayEntries = array + .filter(object => key in object) + .map(object => { + return [object[key], { ...object }]; + }); + + return Object.fromEntries(arrayEntries); +};