-
this.toggleRules()}>
+ this.toggleRules()}>
-
+
-
this.handleSelect()}>
-
{this.props.category.name}
-
{this.props.category.unread}
+
this.handleSelect()}>
+ {this.props.category.name}
+ {this.props.category.unread}
diff --git a/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js b/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js
new file mode 100644
index 0000000..31b865a
--- /dev/null
+++ b/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js
@@ -0,0 +1,38 @@
+import React from 'react';
+import { connect } from 'react-redux';
+
+import { fetchSavedPosts } from '../../actions/posts.js';
+import { selectSaved } from '../../actions/selected.js';
+import { SAVED_TYPE } from '../../constants.js';
+
+class SavedItem extends React.Component {
+ handleSelect() {
+ this.props.selectSaved();
+ this.props.fetchSavedPosts();
+ }
+
+ render() {
+ const className =
+ this.props.selected.type === SAVED_TYPE
+ ? 'sidebar__container sidebar__container--selected'
+ : 'sidebar__container';
+
+ return (
+
+
+
+
this.handleSelect()}>
+ Saved posts
+
+
+
+ );
+ }
+}
+
+const mapDispatchToProps = dispatch => ({
+ selectSaved: () => dispatch(selectSaved()),
+ fetchSavedPosts: () => dispatch(fetchSavedPosts()),
+});
+
+export default connect(null, mapDispatchToProps)(SavedItem);
diff --git a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js
index 3780afb..88a69f2 100644
--- a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js
+++ b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js
@@ -4,14 +4,16 @@ import { isEqual } from 'lodash';
import { filterCategories, filterRules } from './filters.js';
+import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
import CategoryItem from './CategoryItem.js';
+import SavedItem from './SavedItem.js';
import ReadButton from './ReadButton.js';
// TODO: show empty category message
class Sidebar extends React.Component {
render() {
- const items = this.props.categories.items.map(category => {
+ const categoryItems = this.props.categories.items.map(category => {
const rules = this.props.rules.items.filter(rule => {
return rule.category === category.id;
});
@@ -26,15 +28,22 @@ class Sidebar extends React.Component {
);
});
+ const showReadButton =
+ this.props.selected.item &&
+ [CATEGORY_TYPE, RULE_TYPE].includes(this.props.selected.item.type);
+
return (
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
)}
-
+
- {!isEqual(this.props.selected.item, {}) &&
}
+ {showReadButton &&
}
);
}
diff --git a/src/newsreader/js/pages/homepage/constants.js b/src/newsreader/js/pages/homepage/constants.js
index 22184b9..0f5629b 100644
--- a/src/newsreader/js/pages/homepage/constants.js
+++ b/src/newsreader/js/pages/homepage/constants.js
@@ -1,5 +1,6 @@
export const RULE_TYPE = 'RULE';
export const CATEGORY_TYPE = 'CATEGORY';
+export const SAVED_TYPE = 'SAVED';
export const SUBREDDIT = 'subreddit';
export const FEED = 'feed';
diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js
index 608deb2..dd795a0 100644
--- a/src/newsreader/js/pages/homepage/reducers/posts.js
+++ b/src/newsreader/js/pages/homepage/reducers/posts.js
@@ -4,18 +4,19 @@ import { objectsFromArray } from '../../../utils.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
import {
- SELECT_POST,
MARKING_POST,
MARK_POST_READ,
RECEIVE_POST,
RECEIVE_POSTS,
REQUEST_POSTS,
+ TOGGLING_POST,
+ TOGGLED_POST,
} from '../actions/posts.js';
import { SELECT_CATEGORY } from '../actions/categories.js';
import { SELECT_RULE } from '../actions/rules.js';
import { MARK_SECTION_READ } from '../actions/selected.js';
-const defaultState = { items: {}, isFetching: false, isMarking: false };
+const defaultState = { items: {}, isFetching: false, isUpdating: false };
export const posts = (state = { ...defaultState }, action) => {
switch (action.type) {
@@ -65,9 +66,13 @@ export const posts = (state = { ...defaultState }, action) => {
},
};
case MARKING_POST:
- return { ...state, isMarking: true };
+ return { ...state, isUpdating: true };
+ case TOGGLING_POST:
+ return { ...state, isUpdating: true };
case MARK_POST_READ:
- return { ...state, isMarking: false };
+ return { ...state, isUpdating: false };
+ case TOGGLED_POST:
+ return { ...state, isUpdating: false };
default:
return state;
}
diff --git a/src/newsreader/js/pages/homepage/reducers/selected.js b/src/newsreader/js/pages/homepage/reducers/selected.js
index babcb82..b1f1f98 100644
--- a/src/newsreader/js/pages/homepage/reducers/selected.js
+++ b/src/newsreader/js/pages/homepage/reducers/selected.js
@@ -9,8 +9,9 @@ import {
UNSELECT_POST,
} from '../actions/posts.js';
-import { MARK_SECTION_READ } from '../actions/selected.js';
+import { MARK_SECTION_READ, SELECT_SAVED } from '../actions/selected.js';
import { MARK_POST_READ } from '../actions/posts.js';
+import { SAVED_TYPE } from '../constants.js';
const defaultState = { item: {}, next: false, lastReached: false, post: {} };
@@ -47,6 +48,13 @@ export const selected = (state = { ...defaultState }, action) => {
next: false,
lastReached: false,
};
+ case SELECT_SAVED:
+ return {
+ ...state,
+ item: { type: SAVED_TYPE },
+ next: false,
+ lastReached: false,
+ };
case RECEIVE_POSTS:
return {
...state,
diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js
index ce2ffdc..d30e549 100644
--- a/src/newsreader/js/tests/homepage/actions/post.test.js
+++ b/src/newsreader/js/tests/homepage/actions/post.test.js
@@ -26,6 +26,12 @@ describe('post actions', () => {
expect(actions.markingPostRead()).toEqual(expectedAction);
});
+ it('should create an action to toggle post saved state', () => {
+ const expectedAction = { type: actions.TOGGLING_POST };
+
+ expect(actions.togglingPost()).toEqual(expectedAction);
+ });
+
it('should create an action receive a post', () => {
const post = {
id: 2067,
@@ -39,6 +45,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
};
const expectedAction = {
@@ -62,6 +69,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
};
const expectedAction = {
@@ -91,6 +99,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
};
const rule = {
@@ -111,6 +120,30 @@ describe('post actions', () => {
expect(actions.postRead(post, rule)).toEqual(expectedAction);
});
+ it('should create an action toggling post saved', () => {
+ 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: 5,
+ read: false,
+ saved: false,
+ };
+
+ const expectedAction = {
+ type: actions.TOGGLED_POST,
+ post,
+ };
+
+ expect(actions.postToggled(post)).toEqual(expectedAction);
+ });
+
it('should create multiple actions to mark post read', () => {
const post = {
id: 2067,
@@ -124,6 +157,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
};
const rule = {
@@ -143,7 +177,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: {
item: rule,
next: false,
@@ -170,6 +204,65 @@ describe('post actions', () => {
});
});
+ it('should create multiple actions to toggle a post saved', () => {
+ 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: 5,
+ read: false,
+ saved: false,
+ };
+
+ const rule = {
+ id: 1,
+ name: 'Test rule',
+ unread: 100,
+ category: 1,
+ url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
+ favicon: 'https://cdn.arstechnica.net/favicon.ico',
+ };
+
+ fetchMock.patchOnce('/api/posts/2067/', {
+ body: { ...post, saved: true },
+ headers: { 'content-type': 'application/json' },
+ });
+
+ const store = mockStore({
+ categories: { items: {}, isFetching: false },
+ rules: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
+ selected: {
+ item: rule,
+ next: false,
+ lastReached: false,
+ post: {},
+ },
+ });
+
+ const expectedActions = [
+ { type: actions.TOGGLING_POST },
+ {
+ type: actions.RECEIVE_POST,
+ post: { ...post, saved: true },
+ },
+ {
+ type: actions.TOGGLED_POST,
+ post: { ...post, saved: true },
+ },
+ ];
+
+ return store.dispatch(actions.toggleSaved(post, 'TOKEN')).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
it('should create multiple actions to fetch posts by rule', () => {
const posts = [
{
@@ -184,6 +277,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
+ saved: false,
},
{
id: 2141,
@@ -196,6 +290,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
+ saved: false,
},
];
@@ -212,7 +307,7 @@ describe('post actions', () => {
fetchMock.getOnce('/api/rules/4/posts/?read=false', {
body: {
count: 2,
- next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
+ next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar&read=false',
previous: null,
results: posts,
},
@@ -222,7 +317,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
@@ -230,7 +325,7 @@ describe('post actions', () => {
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
- next: 'https://durp.com/api/rules/4/posts/?page=2&read=false',
+ next: 'https://durp.com/api/rules/4/posts/?cursor=jabadabar&read=false',
posts,
},
];
@@ -254,6 +349,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
+ saved: false,
},
{
id: 2141,
@@ -266,6 +362,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
+ saved: false,
},
];
@@ -279,7 +376,7 @@ describe('post actions', () => {
fetchMock.getOnce('/api/categories/1/posts/?read=false', {
body: {
count: 2,
- next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
+ next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar&read=false',
previous: null,
results: posts,
},
@@ -289,7 +386,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
@@ -297,7 +394,7 @@ describe('post actions', () => {
{ type: actions.REQUEST_POSTS },
{
type: actions.RECEIVE_POSTS,
- next: 'https://durp.com/api/categories/4/posts/?page=2&read=false',
+ next: 'https://durp.com/api/categories/4/posts/?cursor=jabadabar&read=false',
posts,
},
];
@@ -307,6 +404,67 @@ describe('post actions', () => {
});
});
+ it('should create multiple actions to fetch posts by saved state', () => {
+ const posts = [
+ {
+ 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,
+ saved: true,
+ },
+ {
+ id: 2141,
+ remoteIdentifier: 'https://arstechnica.com/?p=1648757',
+ title: 'The most complete brain map ever is here: A fly’s “connectome”',
+ body:
+ 'It took 12 years and at least $40 million to chart a region about 250µm across.',
+ author: 'WIRED',
+ publicationDate: '2020-01-25T11:06:46Z',
+ url: 'https://arstechnica.com/?p=1648757',
+ rule: 4,
+ read: false,
+ saved: true,
+ },
+ ];
+
+ fetchMock.getOnce('/api/posts/?saved=true', {
+ body: {
+ next: 'https://durp.com/api/posts/?cursor=jabadabar&saved=true',
+ previous: null,
+ results: posts,
+ },
+ headers: { 'content-type': 'application/json' },
+ });
+
+ const store = mockStore({
+ categories: { items: {}, isFetching: false },
+ rules: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
+ selected: { item: {}, next: false, lastReached: false, post: {} },
+ });
+
+ const expectedActions = [
+ { type: actions.REQUEST_POSTS },
+ {
+ type: actions.RECEIVE_POSTS,
+ next: 'https://durp.com/api/posts/?cursor=jabadabar&saved=true',
+ posts,
+ },
+ ];
+
+ return store.dispatch(actions.fetchSavedPosts()).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
it('should create no actions when fetching posts and section is read', () => {
const rule = {
id: 4,
@@ -320,7 +478,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: { item: {}, next: false, lastReached: false, post: {} },
});
@@ -344,6 +502,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
};
const rule = {
@@ -364,7 +523,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: { item: { ...rule }, next: false, lastReached: false, post: {} },
});
@@ -379,6 +538,55 @@ describe('post actions', () => {
});
});
+ it('should handle exceptions when toggling a post saved/unsaved', () => {
+ 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: 5,
+ read: false,
+ saved: false,
+ };
+
+ const rule = {
+ id: 4,
+ name: 'Ars Technica',
+ unread: 100,
+ category: 1,
+ url: 'http://feeds.arstechnica.com/arstechnica/index?fmt=xml',
+ favicon: 'https://cdn.arstechnica.net/favicon.ico',
+ };
+
+ const errorMessage = 'Permission denied';
+
+ fetchMock.patch(`/api/posts/${post.id}/`, () => {
+ throw new Error(errorMessage);
+ });
+
+ const store = mockStore({
+ categories: { items: {}, isFetching: false },
+ rules: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
+ selected: { item: { ...rule }, next: false, lastReached: false, post: {} },
+ });
+
+ const expectedActions = [
+ { type: actions.TOGGLING_POST },
+ { type: actions.RECEIVE_POST, post: {} },
+ { type: errorActions.RECEIVE_API_ERROR, error: Error(errorMessage) },
+ ];
+
+ return store.dispatch(actions.toggleSaved(post, 'FAKE_TOKEN')).then(() => {
+ expect(store.getActions()).toEqual(expectedActions);
+ });
+ });
+
it('should handle exceptions when fetching posts by section', () => {
const rule = {
id: 4,
@@ -399,7 +607,7 @@ describe('post actions', () => {
const store = mockStore({
categories: { items: {}, isFetching: false },
rules: { items: {}, isFetching: false },
- posts: { items: {}, isFetching: false },
+ posts: { items: {}, isFetching: false, isUpdating: false },
selected: { item: { ...rule }, next: false, lastReached: false, post: {} },
});
diff --git a/src/newsreader/js/tests/homepage/actions/selected.test.js b/src/newsreader/js/tests/homepage/actions/selected.test.js
index b0f163c..cac7509 100644
--- a/src/newsreader/js/tests/homepage/actions/selected.test.js
+++ b/src/newsreader/js/tests/homepage/actions/selected.test.js
@@ -32,6 +32,14 @@ describe('selected actions', () => {
expect(actions.markSectionRead(category)).toEqual(expectedAction);
});
+ it('should create an action to select saved items', () => {
+ const expectedAction = {
+ type: actions.SELECT_SAVED,
+ };
+
+ expect(actions.selectSaved()).toEqual(expectedAction);
+ });
+
it('should mark a category as read', () => {
const category = { id: 1, name: 'Test category', unread: 100 };
const rules = {
diff --git a/src/newsreader/js/tests/homepage/reducers/post.test.js b/src/newsreader/js/tests/homepage/reducers/post.test.js
index 6fe728f..adb8983 100644
--- a/src/newsreader/js/tests/homepage/reducers/post.test.js
+++ b/src/newsreader/js/tests/homepage/reducers/post.test.js
@@ -12,7 +12,7 @@ describe('post actions', () => {
it('should return state after requesting posts', () => {
const action = { type: actions.REQUEST_POSTS };
- const expectedState = { ...defaultState, isFetching: true, isMarking: false };
+ const expectedState = { ...defaultState, isFetching: true, isUpdating: false };
expect(reducer(undefined, action)).toEqual(expectedState);
});
@@ -30,6 +30,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
+ saved: false,
};
const action = {
@@ -40,7 +41,7 @@ describe('post actions', () => {
const expectedState = {
...defaultState,
isFetching: false,
- isMarking: false,
+ isUpdating: false,
items: { [post.id]: post },
};
@@ -61,6 +62,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 4,
read: false,
+ saved: false,
},
{
id: 2141,
@@ -73,6 +75,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648757',
rule: 4,
read: false,
+ saved: false,
},
];
@@ -86,7 +89,7 @@ describe('post actions', () => {
const expectedState = {
...defaultState,
isFetching: false,
- isMarking: false,
+ isUpdating: false,
items: expectedPosts,
};
@@ -131,6 +134,7 @@ describe('post actions', () => {
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
+ saved: false,
},
4638: {
id: 4638,
@@ -143,6 +147,7 @@ describe('post actions', () => {
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
+ saved: false,
},
};
@@ -189,6 +194,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648607',
rule: 5,
read: false,
+ saved: false,
},
2141: {
id: 2141,
@@ -201,6 +207,7 @@ describe('post actions', () => {
url: 'https://arstechnica.com/?p=1648757',
rule: 5,
read: false,
+ saved: false,
},
4637: {
id: 4637,
@@ -213,6 +220,7 @@ describe('post actions', () => {
url: 'https://www.bbc.co.uk/news/world-asia-china-51299195',
rule: 4,
read: false,
+ saved: false,
},
4638: {
id: 4638,
@@ -225,6 +233,7 @@ describe('post actions', () => {
url: 'https://www.bbc.co.uk/news/world-europe-51294305',
rule: 4,
read: false,
+ saved: false,
},
4589: {
id: 4589,
@@ -238,6 +247,7 @@ describe('post actions', () => {
'https://tweakers.net/nieuws/162878/analyse-nintendo-verdiende-miljard-dollar-aan-mobiele-games.html',
rule: 7,
read: false,
+ saved: false,
},
4594: {
id: 4594,
@@ -251,6 +261,7 @@ describe('post actions', () => {
'https://tweakers.net/nieuws/162870/samsung-kondigt-eerste-tablet-met-5g-aan.html',
rule: 7,
read: false,
+ saved: false,
},
};
diff --git a/src/newsreader/js/tests/homepage/reducers/selected.test.js b/src/newsreader/js/tests/homepage/reducers/selected.test.js
index 215c6e1..40561a3 100644
--- a/src/newsreader/js/tests/homepage/reducers/selected.test.js
+++ b/src/newsreader/js/tests/homepage/reducers/selected.test.js
@@ -52,6 +52,19 @@ describe('selected reducer', () => {
expect(reducer(undefined, action)).toEqual(expectedState);
});
+ it('should return state after selecting saved items', () => {
+ const action = {
+ type: actions.SELECT_SAVED,
+ };
+
+ const expectedState = {
+ ...defaultState,
+ item: { type: constants.SAVED_TYPE },
+ };
+
+ expect(reducer(undefined, action)).toEqual(expectedState);
+ });
+
it('should return state after selecting a category twice', () => {
const category = { id: 9, name: 'Tech', unread: 291 };
diff --git a/src/newsreader/news/core/endpoints.py b/src/newsreader/news/core/endpoints.py
index ab47cca..b224024 100644
--- a/src/newsreader/news/core/endpoints.py
+++ b/src/newsreader/news/core/endpoints.py
@@ -13,11 +13,19 @@ from rest_framework.response import Response
from newsreader.accounts.permissions import IsPostOwner
from newsreader.core.pagination import CursorPagination
from newsreader.news.collection.serializers import RuleSerializer
-from newsreader.news.core.filters import ReadFilter
+from newsreader.news.core.filters import ReadFilter, SavedFilter
from newsreader.news.core.models import Category, Post
from newsreader.news.core.serializers import CategorySerializer, PostSerializer
+class ListPostView(ListAPIView):
+ queryset = Post.objects.all()
+ serializer_class = PostSerializer
+ permission_classes = (IsAuthenticated, IsPostOwner)
+ pagination_class = CursorPagination
+ filter_backends = [ReadFilter, SavedFilter]
+
+
class DetailPostView(RetrieveUpdateAPIView):
queryset = Post.objects.all()
serializer_class = PostSerializer
diff --git a/src/newsreader/news/core/filters.py b/src/newsreader/news/core/filters.py
index d322d83..ba3ea48 100644
--- a/src/newsreader/news/core/filters.py
+++ b/src/newsreader/news/core/filters.py
@@ -30,3 +30,30 @@ class ReadFilter(filters.BaseFilterBackend):
),
)
]
+
+
+class SavedFilter(filters.BaseFilterBackend):
+ query_param = "saved"
+
+ def filter_queryset(self, request, queryset, view):
+ key = request.query_params.get(self.query_param, None)
+ available_values = {"True": True, "true": True, "False": False, "false": False}
+
+ if not key or key not in available_values.keys():
+ return queryset
+
+ value = available_values[key]
+ return queryset.filter(saved=value)
+
+ def get_schema_fields(self, view):
+ return [
+ coreapi.Field(
+ name=self.query_param,
+ required=False,
+ location="query",
+ schema=coreschema.String(
+ title=force_text(self.query_param),
+ description=force_text(_("Wether posts should be saved or not")),
+ ),
+ )
+ ]
diff --git a/src/newsreader/news/core/migrations/0008_post_saved.py b/src/newsreader/news/core/migrations/0008_post_saved.py
new file mode 100644
index 0000000..08ae2a8
--- /dev/null
+++ b/src/newsreader/news/core/migrations/0008_post_saved.py
@@ -0,0 +1,14 @@
+# Generated by Django 3.1.5 on 2021-02-19 20:50
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [("core", "0007_auto_20200706_2312")]
+
+ operations = [
+ migrations.AddField(
+ model_name="post", name="saved", field=models.BooleanField(default=False)
+ )
+ ]
diff --git a/src/newsreader/news/core/models.py b/src/newsreader/news/core/models.py
index ff44c81..2f7d571 100644
--- a/src/newsreader/news/core/models.py
+++ b/src/newsreader/news/core/models.py
@@ -14,6 +14,7 @@ class Post(TimeStampedModel):
url = models.URLField(max_length=1024, blank=True, null=True)
read = models.BooleanField(default=False)
+ saved = models.BooleanField(default=False)
rule = models.ForeignKey(
CollectionRule, on_delete=models.CASCADE, editable=False, related_name="posts"
diff --git a/src/newsreader/news/core/serializers.py b/src/newsreader/news/core/serializers.py
index d4353c9..38619a1 100644
--- a/src/newsreader/news/core/serializers.py
+++ b/src/newsreader/news/core/serializers.py
@@ -19,6 +19,7 @@ class PostSerializer(serializers.ModelSerializer):
"url",
"rule",
"read",
+ "saved",
"publicationDate",
"remoteIdentifier",
)
diff --git a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py
index 2d25a89..92444cc 100644
--- a/src/newsreader/news/core/tests/endpoints/post/detail/tests.py
+++ b/src/newsreader/news/core/tests/endpoints/post/detail/tests.py
@@ -22,8 +22,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["id"], post.pk)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["id"], post.pk)
self.assertTrue("title" in data)
self.assertTrue("body" in data)
@@ -37,8 +37,8 @@ class PostDetailViewTestCase(TestCase):
response = self.client.get(reverse("api:news:core:posts-detail", args=[100]))
data = response.json()
- self.assertEquals(response.status_code, 404)
- self.assertEquals(data["detail"], "Not found.")
+ self.assertEqual(response.status_code, 404)
+ self.assertEqual(data["detail"], "Not found.")
def test_post(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -49,8 +49,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 405)
- self.assertEquals(data["detail"], 'Method "POST" not allowed.')
+ self.assertEqual(response.status_code, 405)
+ self.assertEqual(data["detail"], 'Method "POST" not allowed.')
def test_patch(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -63,8 +63,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["title"], "This title is very accurate")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["title"], "This title is very accurate")
def test_identifier_cannot_be_changed(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -77,8 +77,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["id"], post.pk)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["id"], post.pk)
def test_rule_cannot_be_changed(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -98,7 +98,7 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
+ self.assertEqual(response.status_code, 200)
self.assertTrue(data["rule"], rule.pk)
@@ -113,8 +113,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["title"], "This title is very accurate")
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["title"], "This title is very accurate")
def test_delete(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -125,8 +125,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 405)
- self.assertEquals(data["detail"], 'Method "DELETE" not allowed.')
+ self.assertEqual(response.status_code, 405)
+ self.assertEqual(data["detail"], 'Method "DELETE" not allowed.')
def test_post_with_unauthenticated_user_without_category(self):
self.client.logout()
@@ -138,7 +138,7 @@ class PostDetailViewTestCase(TestCase):
reverse("api:news:core:posts-detail", args=[post.pk])
)
- self.assertEquals(response.status_code, 403)
+ self.assertEqual(response.status_code, 403)
def test_post_with_unauthenticated_user_with_category(self):
self.client.logout()
@@ -150,7 +150,7 @@ class PostDetailViewTestCase(TestCase):
reverse("api:news:core:posts-detail", args=[post.pk])
)
- self.assertEquals(response.status_code, 403)
+ self.assertEqual(response.status_code, 403)
def test_post_with_unauthorized_user_without_category(self):
other_user = UserFactory()
@@ -161,7 +161,7 @@ class PostDetailViewTestCase(TestCase):
reverse("api:news:core:posts-detail", args=[post.pk])
)
- self.assertEquals(response.status_code, 403)
+ self.assertEqual(response.status_code, 403)
def test_post_with_unauthorized_user_with_category(self):
other_user = UserFactory()
@@ -172,7 +172,7 @@ class PostDetailViewTestCase(TestCase):
reverse("api:news:core:posts-detail", args=[post.pk])
)
- self.assertEquals(response.status_code, 403)
+ self.assertEqual(response.status_code, 403)
def test_post_with_different_user_for_category_and_rule(self):
other_user = UserFactory()
@@ -183,7 +183,7 @@ class PostDetailViewTestCase(TestCase):
reverse("api:news:core:posts-detail", args=[post.pk])
)
- self.assertEquals(response.status_code, 403)
+ self.assertEqual(response.status_code, 403)
def test_mark_read(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -196,8 +196,8 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["read"], True)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["read"], True)
def test_mark_unread(self):
rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
@@ -210,5 +210,33 @@ class PostDetailViewTestCase(TestCase):
)
data = response.json()
- self.assertEquals(response.status_code, 200)
- self.assertEquals(data["read"], False)
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["read"], False)
+
+ def test_mark_saved(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+ post = FeedPostFactory(rule=rule, saved=False)
+
+ response = self.client.patch(
+ reverse("api:news:core:posts-detail", args=[post.pk]),
+ data=json.dumps({"saved": True}),
+ content_type="application/json",
+ )
+ data = response.json()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["saved"], True)
+
+ def test_mark_unsaved(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+ post = FeedPostFactory(rule=rule, saved=True)
+
+ response = self.client.patch(
+ reverse("api:news:core:posts-detail", args=[post.pk]),
+ data=json.dumps({"saved": False}),
+ content_type="application/json",
+ )
+ data = response.json()
+
+ self.assertEqual(response.status_code, 200)
+ self.assertEqual(data["saved"], False)
diff --git a/src/newsreader/news/core/tests/endpoints/post/list/__init__.py b/src/newsreader/news/core/tests/endpoints/post/list/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/src/newsreader/news/core/tests/endpoints/post/list/tests.py b/src/newsreader/news/core/tests/endpoints/post/list/tests.py
new file mode 100644
index 0000000..37f83b0
--- /dev/null
+++ b/src/newsreader/news/core/tests/endpoints/post/list/tests.py
@@ -0,0 +1,96 @@
+from datetime import datetime
+
+from django.test import TestCase
+from django.urls import reverse
+
+import pytz
+
+from newsreader.accounts.tests.factories import UserFactory
+from newsreader.news.collection.tests.factories import FeedFactory
+from newsreader.news.core.tests.factories import CategoryFactory, FeedPostFactory
+
+
+class PostListViewTestCase(TestCase):
+ def setUp(self):
+ self.user = UserFactory(is_staff=True, password="test")
+ self.client.force_login(self.user)
+
+ def test_simple(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+ FeedPostFactory.create_batch(size=3, rule=rule)
+
+ response = self.client.get(reverse("api:news:core:posts-list"))
+ data = response.json()
+
+ self.assertEquals(response.status_code, 200)
+ self.assertEquals(len(data["results"]), 3)
+
+ def test_ordering(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+
+ posts = [
+ FeedPostFactory(
+ title="I'm the first post",
+ rule=rule,
+ publication_date=datetime(2019, 5, 20, 16, 7, 38, tzinfo=pytz.utc),
+ ),
+ FeedPostFactory(
+ title="I'm the second post",
+ rule=rule,
+ publication_date=datetime(2019, 5, 20, 16, 7, 37, tzinfo=pytz.utc),
+ ),
+ FeedPostFactory(
+ title="I'm the third post",
+ rule=rule,
+ publication_date=datetime(2019, 5, 20, 16, 7, 36, tzinfo=pytz.utc),
+ ),
+ ]
+
+ response = self.client.get(reverse("api:news:core:posts-list"))
+ data = response.json()
+
+ self.assertEquals(response.status_code, 200)
+
+ for index, post in enumerate(posts, start=0):
+ with self.subTest(post=post):
+ self.assertEqual(data["results"][index]["id"], post.pk)
+
+ def test_read_posts(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+
+ FeedPostFactory.create_batch(size=20, rule=rule, read=False)
+ FeedPostFactory.create_batch(size=10, rule=rule, read=True)
+
+ response = self.client.get(
+ reverse("api:news:core:posts-list"), {"read": "true"}
+ )
+
+ data = response.json()
+ posts = data["results"]
+
+ self.assertEquals(response.status_code, 200)
+ self.assertEquals(len(data["results"]), 10)
+
+ for post in posts:
+ with self.subTest(post=post):
+ self.assertEqual(post["read"], True)
+
+ def test_saved_posts(self):
+ rule = FeedFactory(user=self.user, category=CategoryFactory(user=self.user))
+
+ FeedPostFactory.create_batch(size=20, rule=rule, saved=False)
+ FeedPostFactory.create_batch(size=10, rule=rule, saved=True)
+
+ response = self.client.get(
+ reverse("api:news:core:posts-list"), {"saved": "true"}
+ )
+
+ data = response.json()
+ posts = data["results"]
+
+ self.assertEquals(response.status_code, 200)
+ self.assertEquals(len(data["results"]), 10)
+
+ for post in posts:
+ with self.subTest(post=post):
+ self.assertEqual(post["saved"], True)
diff --git a/src/newsreader/news/core/urls.py b/src/newsreader/news/core/urls.py
index 21db59d..8096cf8 100644
--- a/src/newsreader/news/core/urls.py
+++ b/src/newsreader/news/core/urls.py
@@ -6,6 +6,7 @@ from newsreader.news.core.endpoints import (
DetailCategoryView,
DetailPostView,
ListCategoryView,
+ ListPostView,
NestedPostCategoryView,
NestedRuleCategoryView,
)
@@ -32,6 +33,7 @@ urlpatterns = [
]
endpoints = [
+ path("posts/", ListPostView.as_view(), name="posts-list"),
path("posts/
/", DetailPostView.as_view(), name="posts-detail"),
path("categories/", ListCategoryView.as_view(), name="categories-list"),
path(
diff --git a/src/newsreader/scss/components/category/_category.scss b/src/newsreader/scss/components/category/_category.scss
deleted file mode 100644
index 8f5e109..0000000
--- a/src/newsreader/scss/components/category/_category.scss
+++ /dev/null
@@ -1,41 +0,0 @@
-.category {
- display: flex;
- align-items: center;
-
- padding: 5px;
-
- &__info {
- display: flex;
- justify-content: space-between;
-
- width: 100%;
- padding: 0 0 0 20px;
-
- overflow: hidden;
- white-space: nowrap;
-
-
- &:hover {
- cursor: pointer;
- }
- }
-
- &__name {
- overflow: hidden;
- white-space: nowrap;
- text-overflow: ellipsis;
- }
-
- &__menu {
- display: flex;
- align-items: center;
-
- &:hover {
- cursor: pointer;
- }
- }
-
- &--selected, &:hover {
- background-color: var(--lighter-accent-color);
- }
-}
diff --git a/src/newsreader/scss/components/category/index.scss b/src/newsreader/scss/components/category/index.scss
deleted file mode 100644
index d434e4f..0000000
--- a/src/newsreader/scss/components/category/index.scss
+++ /dev/null
@@ -1 +0,0 @@
-@import './category';
diff --git a/src/newsreader/scss/components/index.scss b/src/newsreader/scss/components/index.scss
index 0240ee1..d0419ac 100644
--- a/src/newsreader/scss/components/index.scss
+++ b/src/newsreader/scss/components/index.scss
@@ -21,7 +21,6 @@
@import './integrations/index';
@import './rules/index';
-@import './category/index';
@import './post/index';
@import './post-message/index';
diff --git a/src/newsreader/scss/components/post/_post.scss b/src/newsreader/scss/components/post/_post.scss
index 5254363..dc5f829 100644
--- a/src/newsreader/scss/components/post/_post.scss
+++ b/src/newsreader/scss/components/post/_post.scss
@@ -39,12 +39,6 @@
}
}
- &__link {
- & i {
- padding: 0 0 0 7px;
- }
- }
-
&__date {
font-size: small;
}
@@ -103,6 +97,6 @@
align-items: center;
margin: 15px 0;
- gap: 5px;
+ gap: 10px;
}
}
diff --git a/src/newsreader/scss/components/sidebar/_sidebar.scss b/src/newsreader/scss/components/sidebar/_sidebar.scss
index c70594a..1650a40 100644
--- a/src/newsreader/scss/components/sidebar/_sidebar.scss
+++ b/src/newsreader/scss/components/sidebar/_sidebar.scss
@@ -16,8 +16,37 @@
list-style: none;
- &__item {
- padding: 2px 10px 5px 10px;
+ }
+
+ &__container {
+ display: flex;
+ align-items: center;
+
+ padding: 5px;
+
+ &--selected, &:hover {
+ background-color: var(--lighter-accent-color);
+ }
+ }
+
+ &__icon {
+ &:hover {
+ cursor: pointer;
+ }
+ }
+
+ &__text {
+ display: flex;
+ justify-content: space-between;
+
+ width: 100%;
+ padding: 0 0 0 20px;
+
+ overflow: hidden;
+ white-space: nowrap;
+
+ &:hover {
+ cursor: pointer;
}
}
diff --git a/src/newsreader/scss/elements/index.scss b/src/newsreader/scss/elements/index.scss
index 0c30aff..718b562 100644
--- a/src/newsreader/scss/elements/index.scss
+++ b/src/newsreader/scss/elements/index.scss
@@ -12,3 +12,4 @@
@import './small/index';
@import './select/index';
@import './checkbox/index';
+@import './saved-icon/index';
diff --git a/src/newsreader/scss/elements/saved-icon/_saved-icon.scss b/src/newsreader/scss/elements/saved-icon/_saved-icon.scss
new file mode 100644
index 0000000..21fea31
--- /dev/null
+++ b/src/newsreader/scss/elements/saved-icon/_saved-icon.scss
@@ -0,0 +1,15 @@
+.saved-icon {
+ @include font-awesome;
+
+ &:before {
+ content: "\f0c7";
+ }
+
+ &:hover {
+ cursor: pointer;
+ }
+
+ &--saved {
+ color: var(--confirm-color);
+ }
+}
diff --git a/src/newsreader/scss/elements/saved-icon/index.scss b/src/newsreader/scss/elements/saved-icon/index.scss
new file mode 100644
index 0000000..db05603
--- /dev/null
+++ b/src/newsreader/scss/elements/saved-icon/index.scss
@@ -0,0 +1 @@
+@import './saved-icon';
diff --git a/src/newsreader/scss/lib/_mixins.scss b/src/newsreader/scss/lib/_mixins.scss
index d7b8b8e..4667660 100644
--- a/src/newsreader/scss/lib/_mixins.scss
+++ b/src/newsreader/scss/lib/_mixins.scss
@@ -9,3 +9,8 @@
@mixin button-padding {
padding: 5px 20px;
}
+
+@mixin font-awesome {
+ font-family: "Font Awesome 5 Free";
+ font-weight: 900;
+}