diff --git a/CHANGELOG.md b/CHANGELOG.md index d75fa14..036c931 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.3.11 + +- Add saved posts section +- Bump django version + ## 0.3.10 - Add custom color for confirm buttons diff --git a/package.json b/package.json index 4a090a4..9ba71f8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "newsreader", - "version": "0.3.10", + "version": "0.3.11", "description": "Application for viewing RSS feeds", "main": "index.js", "scripts": { diff --git a/poetry.lock b/poetry.lock index e63e93c..5ec46ab 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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"}, diff --git a/pyproject.toml b/pyproject.toml index 2d6ca29..89c7d2e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "newsreader" -version = "0.3.10" +version = "0.3.11" description = "Webapplication for reading RSS feeds" authors = ["Sonny "] license = "GPL-3.0" diff --git a/src/newsreader/js/pages/homepage/App.js b/src/newsreader/js/pages/homepage/App.js index 0b2aedb..01ca773 100644 --- a/src/newsreader/js/pages/homepage/App.js +++ b/src/newsreader/js/pages/homepage/App.js @@ -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, }; } diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 826512f..6a0cd7a 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -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) { diff --git a/src/newsreader/js/pages/homepage/actions/selected.js b/src/newsreader/js/pages/homepage/actions/selected.js index 189cad6..44fe79d 100644 --- a/src/newsreader/js/pages/homepage/actions/selected.js +++ b/src/newsreader/js/pages/homepage/actions/selected.js @@ -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, diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index f9b7b5e..6da8044 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -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 { > + this.props.toggleSaved(post, token)} + /> @@ -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); diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js index 58637bf..83833f5 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -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 { {publicationDate} {this.props.timezone} {post.author && `By ${post.author}`} - {this.props.selected.type == CATEGORY_TYPE && ( + {[CATEGORY_TYPE, SAVED_TYPE].includes(this.props.selected.type) && ( {rule.name} @@ -58,6 +63,10 @@ class PostItem extends React.Component { > + this.props.toggleSaved(post, token)} + /> ); @@ -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); diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostList.js b/src/newsreader/js/pages/homepage/components/postlist/PostList.js index 282300b..82617f8 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostList.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostList.js @@ -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 (
- +

Select an item to show its unread posts

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