This commit is contained in:
Sonny Bakker 2021-02-27 15:59:50 +01:00
parent 66edc1e8dd
commit 18dbf2d312
39 changed files with 708 additions and 128 deletions

View file

@ -1,5 +1,10 @@
# Changelog
## 0.3.11
- Add saved posts section
- Bump django version
## 0.3.10
- Add custom color for confirm buttons

View file

@ -1,6 +1,6 @@
{
"name": "newsreader",
"version": "0.3.10",
"version": "0.3.11",
"description": "Application for viewing RSS feeds",
"main": "index.js",
"scripts": {

6
poetry.lock generated
View file

@ -216,7 +216,7 @@ toml = ["toml"]
[[package]]
name = "django"
version = "3.1.6"
version = "3.1.7"
description = "A high-level Python Web framework that encourages rapid development and clean, pragmatic design."
category = "main"
optional = false
@ -949,8 +949,8 @@ coverage = [
{file = "coverage-5.4.tar.gz", hash = "sha256:6d2e262e5e8da6fa56e774fb8e2643417351427604c2b177f8e8c5f75fc928ca"},
]
django = [
{file = "Django-3.1.6-py3-none-any.whl", hash = "sha256:169e2e7b4839a7910b393eec127fd7cbae62e80fa55f89c6510426abf673fe5f"},
{file = "Django-3.1.6.tar.gz", hash = "sha256:c6c0462b8b361f8691171af1fb87eceb4442da28477e12200c40420176206ba7"},
{file = "Django-3.1.7-py3-none-any.whl", hash = "sha256:baf099db36ad31f970775d0be5587cc58a6256a6771a44eb795b554d45f211b8"},
{file = "Django-3.1.7.tar.gz", hash = "sha256:32ce792ee9b6a0cbbec340123e229ac9f765dff8c2a4ae9247a14b2ba3a365a7"},
]
django-axes = [
{file = "django-axes-5.13.0.tar.gz", hash = "sha256:96469de7b10d1152e8bee92edd6325f27ff64b13f58f1d875f7de9ad5c502491"},

View file

@ -1,6 +1,6 @@
[tool.poetry]
name = "newsreader"
version = "0.3.10"
version = "0.3.11"
description = "Webapplication for reading RSS feeds"
authors = ["Sonny <sonnyba871@gmail.com>"]
license = "GPL-3.0"

View file

@ -31,6 +31,7 @@ class App extends React.Component {
post={this.props.post}
rule={this.props.rule}
category={this.props.category}
selectedType={this.props.selectedType}
feedUrl={this.props.feedUrl}
subredditUrl={this.props.subredditUrl}
timelineUrl={this.props.timelineUrl}
@ -62,6 +63,7 @@ const mapStateToProps = state => {
error,
rule,
post: state.selected.post,
selectedType: state.selected.item.type,
};
}

View file

@ -11,27 +11,32 @@ export const REQUEST_POSTS = 'REQUEST_POSTS';
export const MARK_POST_READ = 'MARK_POST_READ';
export const MARKING_POST = 'MARKING_POST';
export const requestPosts = () => ({ type: REQUEST_POSTS });
export const MARK_POST_SAVED = 'MARK_POST_SAVED';
export const MARK_POST_UNSAVED = 'MARK_POST_UNSAVED';
export const TOGGLING_POST = 'TOGGLING_POST';
export const TOGGLED_POST = 'TOGGLED_POST';
export const requestPosts = () => ({ type: REQUEST_POSTS });
export const receivePost = post => ({ type: RECEIVE_POST, post });
export const receivePosts = (posts, next) => ({
type: RECEIVE_POSTS,
posts,
next,
});
export const receivePost = post => ({ type: RECEIVE_POST, post });
export const selectPost = post => ({ type: SELECT_POST, post });
export const unSelectPost = () => ({ type: UNSELECT_POST });
export const markingPostRead = () => ({ type: MARKING_POST });
export const postRead = (post, section) => ({
type: MARK_POST_READ,
post,
section,
});
export const markingPostRead = () => ({ type: MARKING_POST });
export const togglingPost = () => ({ type: TOGGLING_POST });
export const postToggled = post => ({ type: TOGGLED_POST, post });
export const markPostRead = (post, token) => {
return (dispatch, getState) => {
@ -64,6 +69,49 @@ export const markPostRead = (post, token) => {
};
};
export const toggleSaved = (post, token) => {
return (dispatch, getState) => {
dispatch(togglingPost());
const url = `/api/posts/${post.id}/`;
const options = {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': token,
},
body: JSON.stringify({ saved: !post.saved }),
};
return fetch(url, options)
.then(response => response.json())
.then(updatedPost => {
dispatch(receivePost({ ...updatedPost }));
dispatch(postToggled({ ...updatedPost }));
})
.catch(error => {
dispatch(receivePost({}));
dispatch(handleAPIError(error));
});
};
};
export const fetchSavedPosts = (next = false) => {
return dispatch => {
dispatch(requestPosts());
const url = next ? next : '/api/posts/?saved=true';
return fetch(url)
.then(response => response.json())
.then(posts => dispatch(receivePosts(posts.results, posts.next)))
.catch(error => {
dispatch(receivePosts([]));
dispatch(handleAPIError(error));
});
};
};
export const fetchPostsBySection = (section, next = false) => {
return dispatch => {
if (section.unread === 0) {

View file

@ -4,6 +4,9 @@ import { receiveRule, requestRule } from './rules.js';
import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js';
export const MARK_SECTION_READ = 'MARK_SECTION_READ';
export const SELECT_SAVED = 'SELECT_SAVED';
export const selectSaved = () => ({ type: SELECT_SAVED });
export const markSectionRead = section => ({
type: MARK_SECTION_READ,

View file

@ -2,10 +2,11 @@ import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import { unSelectPost, markPostRead } from '../actions/posts.js';
import { unSelectPost, markPostRead, toggleSaved } from '../actions/posts.js';
import {
CATEGORY_TYPE,
RULE_TYPE,
SAVED_TYPE,
FEED,
SUBREDDIT,
TWITTER_TIMELINE,
@ -21,7 +22,7 @@ class PostModal extends React.Component {
const markPostRead = this.props.markPostRead;
const token = Cookies.get('csrftoken');
if (this.props.autoMarking && !post.read) {
if (this.props.autoMarking && this.props.selectedType != SAVED_TYPE && !post.read) {
this.readTimer = setTimeout(markPostRead, 3000, post, token);
}
@ -51,9 +52,12 @@ class PostModal extends React.Component {
const token = Cookies.get('csrftoken');
const publicationDate = formatDatetime(post.publicationDate);
const titleClassName = post.read ? 'post__title post__title--read' : 'post__title';
const readButtonDisabled = post.read || this.props.isMarkingPost;
const readButtonDisabled =
post.read || this.props.isUpdating || this.props.selectedType === SAVED_TYPE;
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
let ruleUrl = '';
switch (this.props.rule.type) {
case SUBREDDIT:
ruleUrl = `${this.props.subredditUrl}/${this.props.rule.id}/`;
@ -114,6 +118,10 @@ class PostModal extends React.Component {
>
<i className="fas fa-external-link-alt" />
</a>
<span
className={savedIconClass}
onClick={() => this.props.toggleSaved(post, token)}
/>
</div>
</div>
@ -128,8 +136,11 @@ class PostModal extends React.Component {
const mapDispatchToProps = dispatch => ({
unSelectPost: () => dispatch(unSelectPost()),
markPostRead: (post, token) => dispatch(markPostRead(post, token)),
toggleSaved: (post, token) => dispatch(toggleSaved(post, token)),
});
const mapStateToProps = state => ({ isMarkingPost: state.posts.isMarking });
const mapStateToProps = state => ({
isUpdating: state.posts.isUpdating,
});
export default connect(mapStateToProps, mapDispatchToProps)(PostModal);

View file

@ -1,26 +1,31 @@
import React from 'react';
import { connect } from 'react-redux';
import Cookies from 'js-cookie';
import {
CATEGORY_TYPE,
RULE_TYPE,
SAVED_TYPE,
FEED,
SUBREDDIT,
TWITTER_TIMELINE,
} from '../../constants.js';
import { selectPost } from '../../actions/posts.js';
import { selectPost, toggleSaved } from '../../actions/posts.js';
import { formatDatetime } from '../../../../utils.js';
class PostItem extends React.Component {
render() {
const rule = { ...this.props.post.rule };
const post = { ...this.props.post, rule: rule.id };
const token = Cookies.get('csrftoken');
const publicationDate = formatDatetime(post.publicationDate);
const titleClassName = post.read
? 'posts__header posts__header--read'
: 'posts__header';
let ruleUrl = '';
const savedIconClass = post.saved ? 'saved-icon saved-icon--saved' : 'saved-icon';
let ruleUrl = '';
if (rule.type === SUBREDDIT) {
ruleUrl = `${this.props.subredditUrl}/${rule.id}/`;
} else if (rule.type === TWITTER_TIMELINE) {
@ -43,7 +48,7 @@ class PostItem extends React.Component {
<span className="posts-info__date" title={publicationDate}>
{publicationDate} {this.props.timezone} {post.author && `By ${post.author}`}
</span>
{this.props.selected.type == CATEGORY_TYPE && (
{[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && (
<span className="badge">
<a href={ruleUrl} target="_blank" rel="noopener noreferrer">
{rule.name}
@ -58,6 +63,10 @@ class PostItem extends React.Component {
>
<i className="fas fa-external-link-alt"></i>
</a>
<span
className={savedIconClass}
onClick={() => this.props.toggleSaved(post, token)}
/>
</div>
</li>
);
@ -66,6 +75,7 @@ class PostItem extends React.Component {
const mapDispatchToProps = dispatch => ({
selectPost: post => dispatch(selectPost(post)),
toggleSaved: (post, token) => dispatch(toggleSaved(post, token)),
});
export default connect(null, mapDispatchToProps)(PostItem);

View file

@ -2,7 +2,8 @@ import React from 'react';
import { connect } from 'react-redux';
import { isEqual } from 'lodash';
import { fetchPostsBySection } from '../../actions/posts.js';
import { fetchPostsBySection, fetchSavedPosts } from '../../actions/posts.js';
import { SAVED_TYPE } from '../../constants.js';
import { filterPosts } from './filters.js';
import LoadingIndicator from '../../../../components/LoadingIndicator.js';
@ -33,11 +34,15 @@ class PostList extends React.Component {
}
paginate() {
if (this.props.selected.type === SAVED_TYPE) {
this.props.fetchSavedPosts(this.props.next);
} else {
this.props.fetchPostsBySection(this.props.selected, this.props.next);
}
}
render() {
const postItems = this.props.postsBySection.map((item, index) => {
const postItems = this.props.postsByType.map((item, index) => {
return (
<PostItem
key={index}
@ -55,7 +60,7 @@ class PostList extends React.Component {
return (
<div className="post-message">
<div className="post-message__block">
<i class="fas fa-arrow-left" />
<i className="fas fa-arrow-left" />
<p className="post-message__text">Select an item to show its unread posts</p>
</div>
</div>
@ -83,7 +88,7 @@ class PostList extends React.Component {
const mapStateToProps = state => ({
isFetching: state.posts.isFetching,
postsBySection: filterPosts(state),
postsByType: filterPosts(state),
next: state.selected.next,
lastReached: state.selected.lastReached,
selected: state.selected.item,
@ -91,6 +96,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = dispatch => ({
fetchPostsBySection: (rule, next = false) => dispatch(fetchPostsBySection(rule, next)),
fetchSavedPosts: (next = false) => dispatch(fetchSavedPosts(next)),
});
export default connect(mapStateToProps, mapDispatchToProps)(PostList);

View file

@ -1,4 +1,4 @@
import { CATEGORY_TYPE, RULE_TYPE } from '../../constants.js';
import { CATEGORY_TYPE, RULE_TYPE, SAVED_TYPE } from '../../constants.js';
const isEmpty = (object = {}) => {
return Object.keys(object).length === 0;
@ -17,6 +17,10 @@ const sortOrdering = (firstPost, secondPost) => {
return dateOrdering;
};
const savedOrdering = (firstPost, secondPost) => {
return new Date(firstPost.publicationDate) < new Date(secondPost.publicationDate);
};
export const filterPostsByRule = (rule = {}, posts = []) => {
const filteredPosts = posts.filter(post => {
return post.rule === rule.id;
@ -45,15 +49,24 @@ export const filterPostsByCategory = (category = {}, rules = [], posts = []) =>
return sortedPosts;
};
export const filterPostsBySaved = (rules = [], posts = []) => {
const filteredPosts = posts.filter(post => post.saved);
return filteredPosts
.map(post => ({ ...post, rule: { ...rules.find(rule => rule.id === post.rule) } }))
.sort(savedOrdering);
};
export const filterPosts = state => {
const posts = Object.values({ ...state.posts.items });
const rules = Object.values({ ...state.rules.items });
switch (state.selected.item.type) {
case CATEGORY_TYPE:
const rules = Object.values({ ...state.rules.items });
return filterPostsByCategory({ ...state.selected.item }, rules, posts);
case RULE_TYPE:
return filterPostsByRule({ ...state.selected.item }, posts);
case SAVED_TYPE:
return filterPostsBySaved(rules, posts);
}
return [];

View file

@ -26,7 +26,9 @@ class CategoryItem extends React.Component {
render() {
const chevronClass = this.state.open ? 'fas fa-chevron-down' : 'fas fa-chevron-right';
const selected = isSelected(this.props.category, this.props.selected, CATEGORY_TYPE);
const className = selected ? 'category category--selected' : 'category';
const className = selected
? 'sidebar__container sidebar__container--selected'
: 'sidebar__container';
const ruleItems = this.props.rules.map(rule => {
return <RuleItem key={rule.id} rule={rule} selected={this.props.selected} />;
@ -35,13 +37,13 @@ class CategoryItem extends React.Component {
return (
<li className="sidebar__item">
<div className={className}>
<div className="category__menu" onClick={() => this.toggleRules()}>
<span className="sidebar__icon" onClick={() => this.toggleRules()}>
<i className={chevronClass} />
</div>
</span>
<div className="category__info" onClick={() => this.handleSelect()}>
<span className="category__name">{this.props.category.name}</span>
<span className="badge category__badge">{this.props.category.unread}</span>
<div className="sidebar__text" onClick={() => this.handleSelect()}>
<span>{this.props.category.name}</span>
<span className="badge">{this.props.category.unread}</span>
</div>
</div>

View file

@ -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 (
<li className="sidebar__item">
<div className={className}>
<span className="sidebar__icon saved-icon" />
<div className="sidebar__text" onClick={() => this.handleSelect()}>
<span>Saved posts</span>
</div>
</div>
</li>
);
}
}
const mapDispatchToProps = dispatch => ({
selectSaved: () => dispatch(selectSaved()),
fetchSavedPosts: () => dispatch(fetchSavedPosts()),
});
export default connect(null, mapDispatchToProps)(SavedItem);

View file

@ -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 (
<div className="sidebar">
{(this.props.categories.isFetching || this.props.rules.isFetching) && (
<LoadingIndicator />
)}
<ul className="sidebar__nav">{items}</ul>
<ul className="sidebar__nav">
<SavedItem selected={this.props.selected.item} />
{categoryItems}
</ul>
{!isEqual(this.props.selected.item, {}) && <ReadButton />}
{showReadButton && <ReadButton />}
</div>
);
}

View file

@ -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';

View file

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

View file

@ -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,

View file

@ -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 flys “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: {} },
});

View file

@ -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 = {

View file

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

View file

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

View file

@ -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

View file

@ -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")),
),
)
]

View file

@ -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)
)
]

View file

@ -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"

View file

@ -19,6 +19,7 @@ class PostSerializer(serializers.ModelSerializer):
"url",
"rule",
"read",
"saved",
"publicationDate",
"remoteIdentifier",
)

View file

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

View file

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

View file

@ -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/<int:pk>/", DetailPostView.as_view(), name="posts-detail"),
path("categories/", ListCategoryView.as_view(), name="categories-list"),
path(

View file

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

View file

@ -1 +0,0 @@
@import './category';

View file

@ -21,7 +21,6 @@
@import './integrations/index';
@import './rules/index';
@import './category/index';
@import './post/index';
@import './post-message/index';

View file

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

View file

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

View file

@ -12,3 +12,4 @@
@import './small/index';
@import './select/index';
@import './checkbox/index';
@import './saved-icon/index';

View file

@ -0,0 +1,15 @@
.saved-icon {
@include font-awesome;
&:before {
content: "\f0c7";
}
&:hover {
cursor: pointer;
}
&--saved {
color: var(--confirm-color);
}
}

View file

@ -0,0 +1 @@
@import './saved-icon';

View file

@ -9,3 +9,8 @@
@mixin button-padding {
padding: 5px 20px;
}
@mixin font-awesome {
font-family: "Font Awesome 5 Free";
font-weight: 900;
}