From 21ba53960e183e6d07f6df81af8c5d94897b6e9b Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Fri, 19 Feb 2021 22:20:23 +0100 Subject: [PATCH 1/9] Add posts list endpoint --- src/newsreader/news/core/endpoints.py | 10 +- src/newsreader/news/core/filters.py | 27 ++++++ .../news/core/migrations/0008_post_saved.py | 14 +++ src/newsreader/news/core/models.py | 1 + src/newsreader/news/core/serializers.py | 1 + .../core/tests/endpoints/post/detail/tests.py | 76 ++++++++++----- .../tests/endpoints/post/list/__init__.py | 0 .../core/tests/endpoints/post/list/tests.py | 96 +++++++++++++++++++ src/newsreader/news/core/urls.py | 2 + 9 files changed, 202 insertions(+), 25 deletions(-) create mode 100644 src/newsreader/news/core/migrations/0008_post_saved.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/list/__init__.py create mode 100644 src/newsreader/news/core/tests/endpoints/post/list/tests.py 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( From 5ca819903ca819f1f9c8b57e47bc67e940ca3105 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Fri, 19 Feb 2021 22:31:46 +0100 Subject: [PATCH 2/9] Initial javascript changes --- .../js/pages/homepage/actions/posts.js | 37 +++++++++++++++++++ .../js/pages/homepage/reducers/posts.js | 14 +++++-- 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 826512f..6f6f250 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -11,6 +11,10 @@ export const REQUEST_POSTS = 'REQUEST_POSTS'; export const MARK_POST_READ = 'MARK_POST_READ'; export const MARKING_POST = 'MARKING_POST'; +export const MARK_POST_SAVED = 'MARK_POST_SAVED'; +export const MARK_POST_UNSAVED = 'MARK_POST_UNSAVED'; +export const TOGGLING_POST = 'TOGGLING_POST'; + export const requestPosts = () => ({ type: REQUEST_POSTS }); export const receivePosts = (posts, next) => ({ @@ -64,6 +68,39 @@ export const markPostRead = (post, token) => { }; }; +// TODO add missing methods (postSaved & postUnsaved) +export const toggleSaved = (post, token) => { + return (dispatch, getState) => { + dispatch(togglingPostSaved()); + + 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 })); + + if (updatedPost.saved) { + dispatch(postSaved({ ...updatedPost })); + } else { + dispatch(postUnsaved({ ...updatedPost })); + } + }) + .catch(error => { + dispatch(receivePost({})); + dispatch(handleAPIError(error)); + }); + }; +}; + export const fetchPostsBySection = (section, next = false) => { return dispatch => { if (section.unread === 0) { diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 608deb2..771d4f2 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -10,13 +10,15 @@ import { RECEIVE_POST, RECEIVE_POSTS, REQUEST_POSTS, + TOGGLING_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 }; +// TODO isMarking -> isUpdating export const posts = (state = { ...defaultState }, action) => { switch (action.type) { case REQUEST_POSTS: @@ -65,9 +67,15 @@ 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 MARK_POST_SAVED: + return { ...state, isUpdating: false }; + case MARK_POST_UNSAVED: + return { ...state, isUpdating: false }; default: return state; } From 3818c7190ea0090d15726ca1094a3dc8afb87a66 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 20 Feb 2021 22:40:53 +0100 Subject: [PATCH 3/9] Update javascript tests --- .../js/pages/homepage/actions/posts.js | 21 +-- .../js/pages/homepage/reducers/posts.js | 5 +- .../js/tests/homepage/actions/post.test.js | 152 +++++++++++++++++- .../js/tests/homepage/reducers/post.test.js | 17 +- 4 files changed, 170 insertions(+), 25 deletions(-) diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 6f6f250..23bd3f6 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -13,29 +13,30 @@ export const MARKING_POST = 'MARKING_POST'; 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) => { @@ -68,10 +69,9 @@ export const markPostRead = (post, token) => { }; }; -// TODO add missing methods (postSaved & postUnsaved) export const toggleSaved = (post, token) => { return (dispatch, getState) => { - dispatch(togglingPostSaved()); + dispatch(togglingPost()); const url = `/api/posts/${post.id}/`; const options = { @@ -87,12 +87,7 @@ export const toggleSaved = (post, token) => { .then(response => response.json()) .then(updatedPost => { dispatch(receivePost({ ...updatedPost })); - - if (updatedPost.saved) { - dispatch(postSaved({ ...updatedPost })); - } else { - dispatch(postUnsaved({ ...updatedPost })); - } + dispatch(postToggled({ ...updatedPost })); }) .catch(error => { dispatch(receivePost({})); diff --git a/src/newsreader/js/pages/homepage/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 771d4f2..3f9067c 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -11,6 +11,7 @@ import { 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'; @@ -72,9 +73,7 @@ export const posts = (state = { ...defaultState }, action) => { return { ...state, isUpdating: true }; case MARK_POST_READ: return { ...state, isUpdating: false }; - case MARK_POST_SAVED: - return { ...state, isUpdating: false }; - case MARK_POST_UNSAVED: + case TOGGLED_POST: return { ...state, isUpdating: false }; default: 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..9d78aab 100644 --- a/src/newsreader/js/tests/homepage/actions/post.test.js +++ b/src/newsreader/js/tests/homepage/actions/post.test.js @@ -39,6 +39,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, + saved: false, }; const expectedAction = { @@ -62,6 +63,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, + saved: false, }; const expectedAction = { @@ -91,6 +93,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, + saved: false, }; const rule = { @@ -111,6 +114,29 @@ 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.TOGGLING_POST, + }; + + expect(actions.togglingPost(post)).toEqual(expectedAction); + }); + it('should create multiple actions to mark post read', () => { const post = { id: 2067, @@ -124,6 +150,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, + saved: false, }; const rule = { @@ -143,7 +170,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 +197,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 +270,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, + saved: false, }, { id: 2141, @@ -196,6 +283,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, + saved: false, }, ]; @@ -222,7 +310,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: {} }, }); @@ -254,6 +342,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 4, read: false, + saved: false, }, { id: 2141, @@ -266,6 +355,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648757', rule: 4, read: false, + saved: false, }, ]; @@ -289,7 +379,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: {} }, }); @@ -320,7 +410,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 +434,7 @@ describe('post actions', () => { url: 'https://arstechnica.com/?p=1648607', rule: 5, read: false, + saved: false, }; const rule = { @@ -364,7 +455,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 +470,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 +539,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/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, }, }; From 20ef9b06a58e7ca8ac57e72ed214c372c07c43cd Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 20 Feb 2021 23:23:18 +0100 Subject: [PATCH 4/9] Add saved icon --- .../js/pages/homepage/components/PostModal.js | 15 ++++++++++++--- .../js/pages/homepage/reducers/posts.js | 1 - src/newsreader/scss/components/post/_post.scss | 8 +------- src/newsreader/scss/elements/index.scss | 1 + .../scss/elements/saved-icon/_saved-icon.scss | 15 +++++++++++++++ .../scss/elements/saved-icon/index.scss | 1 + src/newsreader/scss/lib/_mixins.scss | 5 +++++ 7 files changed, 35 insertions(+), 11 deletions(-) create mode 100644 src/newsreader/scss/elements/saved-icon/_saved-icon.scss create mode 100644 src/newsreader/scss/elements/saved-icon/index.scss diff --git a/src/newsreader/js/pages/homepage/components/PostModal.js b/src/newsreader/js/pages/homepage/components/PostModal.js index f9b7b5e..7ee5261 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -2,7 +2,7 @@ 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, @@ -51,9 +51,11 @@ 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; + 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 +116,10 @@ class PostModal extends React.Component { > + this.props.toggleSaved(post, token)} + /> @@ -128,8 +134,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/reducers/posts.js b/src/newsreader/js/pages/homepage/reducers/posts.js index 3f9067c..bb06f3d 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -19,7 +19,6 @@ import { MARK_SECTION_READ } from '../actions/selected.js'; const defaultState = { items: {}, isFetching: false, isUpdating: false }; -// TODO isMarking -> isUpdating export const posts = (state = { ...defaultState }, action) => { switch (action.type) { case REQUEST_POSTS: 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/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; +} From 2c8c5e56354227f7523fcaf1d8996bb340dc07c7 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sun, 21 Feb 2021 22:29:37 +0100 Subject: [PATCH 5/9] Initial saved items --- .../components/sidebar/CategoryItem.js | 14 ++++--- .../homepage/components/sidebar/SavedItem.js | 19 +++++++++ .../homepage/components/sidebar/Sidebar.js | 8 +++- .../scss/components/category/_category.scss | 41 ------------------- .../scss/components/category/index.scss | 1 - src/newsreader/scss/components/index.scss | 1 - .../scss/components/sidebar/_sidebar.scss | 33 ++++++++++++++- 7 files changed, 64 insertions(+), 53 deletions(-) create mode 100644 src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js delete mode 100644 src/newsreader/scss/components/category/_category.scss delete mode 100644 src/newsreader/scss/components/category/index.scss 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..96c4573 --- /dev/null +++ b/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js @@ -0,0 +1,19 @@ +import React from 'react'; +import { connect } from 'react-redux'; + +class SavedItem extends React.Component { + render() { + return ( +
  • +
    + +
    + Saved posts +
    +
    +
  • + ); + } +} + +export default connect()(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..706e90b 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js @@ -6,12 +6,13 @@ import { filterCategories, filterRules } from './filters.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; }); @@ -32,7 +33,10 @@ class Sidebar extends React.Component { )} -
      {items}
    +
      + + {categoryItems} +
    {!isEqual(this.props.selected.item, {}) && } 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/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; } } From 5bec9ce5ee26536edd3e18953b4661f3760362ce Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 27 Feb 2021 13:13:51 +0100 Subject: [PATCH 6/9] Add front-end bits --- src/newsreader/js/pages/homepage/App.js | 2 ++ .../js/pages/homepage/actions/posts.js | 17 +++++++++++++ .../js/pages/homepage/actions/selected.js | 3 +++ .../js/pages/homepage/components/PostModal.js | 6 +++-- .../homepage/components/postlist/PostItem.js | 3 ++- .../homepage/components/postlist/PostList.js | 16 ++++++++---- .../homepage/components/postlist/filters.js | 17 +++++++++++-- .../homepage/components/sidebar/SavedItem.js | 25 ++++++++++++++++--- .../homepage/components/sidebar/Sidebar.js | 2 +- src/newsreader/js/pages/homepage/constants.js | 1 + .../js/pages/homepage/reducers/posts.js | 1 - .../js/pages/homepage/reducers/selected.js | 10 +++++++- 12 files changed, 87 insertions(+), 16 deletions(-) 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 23bd3f6..49858fa 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -96,6 +96,23 @@ export const toggleSaved = (post, token) => { }; }; +// TODO add tests +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 7ee5261..6da8044 100644 --- a/src/newsreader/js/pages/homepage/components/PostModal.js +++ b/src/newsreader/js/pages/homepage/components/PostModal.js @@ -6,6 +6,7 @@ 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,7 +52,8 @@ 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.isUpdating; + 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 = ''; diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js index 58637bf..fe63a68 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -4,6 +4,7 @@ import { connect } from 'react-redux'; import { CATEGORY_TYPE, RULE_TYPE, + SAVED_TYPE, FEED, SUBREDDIT, TWITTER_TIMELINE, @@ -43,7 +44,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} 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/SavedItem.js b/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js index 96c4573..31b865a 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/SavedItem.js @@ -1,13 +1,27 @@ 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
    @@ -16,4 +30,9 @@ class SavedItem extends React.Component { } } -export default connect()(SavedItem); +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 706e90b..b435b7d 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js @@ -34,7 +34,7 @@ class Sidebar extends React.Component { )}
      - + {categoryItems}
    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 bb06f3d..dd795a0 100644 --- a/src/newsreader/js/pages/homepage/reducers/posts.js +++ b/src/newsreader/js/pages/homepage/reducers/posts.js @@ -4,7 +4,6 @@ import { objectsFromArray } from '../../../utils.js'; import { CATEGORY_TYPE, RULE_TYPE } from '../constants.js'; import { - SELECT_POST, MARKING_POST, MARK_POST_READ, RECEIVE_POST, 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, From eb4ad8612b6c2477961b067319f2833295bfd334 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 27 Feb 2021 15:16:44 +0100 Subject: [PATCH 7/9] Hide read button when selecting saved items --- .../js/pages/homepage/components/sidebar/Sidebar.js | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js index b435b7d..88a69f2 100644 --- a/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js +++ b/src/newsreader/js/pages/homepage/components/sidebar/Sidebar.js @@ -4,6 +4,7 @@ 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'; @@ -27,6 +28,10 @@ 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) && ( @@ -38,7 +43,7 @@ class Sidebar extends React.Component { {categoryItems} - {!isEqual(this.props.selected.item, {}) && } + {showReadButton && }
    ); } From 2630d67b76d274e3ce55bef75dfc2f473738e37c Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 27 Feb 2021 15:24:35 +0100 Subject: [PATCH 8/9] Add saved button to post list view --- .../pages/homepage/components/postlist/PostItem.js | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js index fe63a68..83833f5 100644 --- a/src/newsreader/js/pages/homepage/components/postlist/PostItem.js +++ b/src/newsreader/js/pages/homepage/components/postlist/PostItem.js @@ -1,5 +1,6 @@ import React from 'react'; import { connect } from 'react-redux'; +import Cookies from 'js-cookie'; import { CATEGORY_TYPE, @@ -9,19 +10,22 @@ import { 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) { @@ -59,6 +63,10 @@ class PostItem extends React.Component { >
    + this.props.toggleSaved(post, token)} + />
  • ); @@ -67,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); From 0bc27a5ff61e02de66552bce73ca07f80a2d1417 Mon Sep 17 00:00:00 2001 From: Sonny Bakker Date: Sat, 27 Feb 2021 15:45:33 +0100 Subject: [PATCH 9/9] Add new tests --- .../js/pages/homepage/actions/posts.js | 1 - .../js/tests/homepage/actions/post.test.js | 80 +++++++++++++++++-- .../tests/homepage/actions/selected.test.js | 8 ++ .../tests/homepage/reducers/selected.test.js | 13 +++ 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/src/newsreader/js/pages/homepage/actions/posts.js b/src/newsreader/js/pages/homepage/actions/posts.js index 49858fa..6a0cd7a 100644 --- a/src/newsreader/js/pages/homepage/actions/posts.js +++ b/src/newsreader/js/pages/homepage/actions/posts.js @@ -96,7 +96,6 @@ export const toggleSaved = (post, token) => { }; }; -// TODO add tests export const fetchSavedPosts = (next = false) => { return dispatch => { dispatch(requestPosts()); diff --git a/src/newsreader/js/tests/homepage/actions/post.test.js b/src/newsreader/js/tests/homepage/actions/post.test.js index 9d78aab..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, @@ -131,10 +137,11 @@ describe('post actions', () => { }; const expectedAction = { - type: actions.TOGGLING_POST, + type: actions.TOGGLED_POST, + post, }; - expect(actions.togglingPost(post)).toEqual(expectedAction); + expect(actions.postToggled(post)).toEqual(expectedAction); }); it('should create multiple actions to mark post read', () => { @@ -300,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, }, @@ -318,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, }, ]; @@ -369,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, }, @@ -387,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, }, ]; @@ -397,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, 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/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 };