0.3.11
This commit is contained in:
parent
66edc1e8dd
commit
18dbf2d312
39 changed files with 708 additions and 128 deletions
|
|
@ -1,5 +1,10 @@
|
|||
# Changelog
|
||||
|
||||
## 0.3.11
|
||||
|
||||
- Add saved posts section
|
||||
- Bump django version
|
||||
|
||||
## 0.3.10
|
||||
|
||||
- Add custom color for confirm buttons
|
||||
|
|
|
|||
|
|
@ -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
6
poetry.lock
generated
|
|
@ -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"},
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
this.props.fetchPostsBySection(this.props.selected, this.props.next);
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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: {} },
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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")),
|
||||
),
|
||||
)
|
||||
]
|
||||
|
|
|
|||
14
src/newsreader/news/core/migrations/0008_post_saved.py
Normal file
14
src/newsreader/news/core/migrations/0008_post_saved.py
Normal 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)
|
||||
)
|
||||
]
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ class PostSerializer(serializers.ModelSerializer):
|
|||
"url",
|
||||
"rule",
|
||||
"read",
|
||||
"saved",
|
||||
"publicationDate",
|
||||
"remoteIdentifier",
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
96
src/newsreader/news/core/tests/endpoints/post/list/tests.py
Normal file
96
src/newsreader/news/core/tests/endpoints/post/list/tests.py
Normal 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)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1 +0,0 @@
|
|||
@import './category';
|
||||
|
|
@ -21,7 +21,6 @@
|
|||
@import './integrations/index';
|
||||
|
||||
@import './rules/index';
|
||||
@import './category/index';
|
||||
|
||||
@import './post/index';
|
||||
@import './post-message/index';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -12,3 +12,4 @@
|
|||
@import './small/index';
|
||||
@import './select/index';
|
||||
@import './checkbox/index';
|
||||
@import './saved-icon/index';
|
||||
|
|
|
|||
15
src/newsreader/scss/elements/saved-icon/_saved-icon.scss
Normal file
15
src/newsreader/scss/elements/saved-icon/_saved-icon.scss
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
.saved-icon {
|
||||
@include font-awesome;
|
||||
|
||||
&:before {
|
||||
content: "\f0c7";
|
||||
}
|
||||
|
||||
&:hover {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
&--saved {
|
||||
color: var(--confirm-color);
|
||||
}
|
||||
}
|
||||
1
src/newsreader/scss/elements/saved-icon/index.scss
Normal file
1
src/newsreader/scss/elements/saved-icon/index.scss
Normal file
|
|
@ -0,0 +1 @@
|
|||
@import './saved-icon';
|
||||
|
|
@ -9,3 +9,8 @@
|
|||
@mixin button-padding {
|
||||
padding: 5px 20px;
|
||||
}
|
||||
|
||||
@mixin font-awesome {
|
||||
font-family: "Font Awesome 5 Free";
|
||||
font-weight: 900;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue