From deb63ab643edb62a3ae8e58e49a12591732126e9 Mon Sep 17 00:00:00 2001 From: Andy Zhao Date: Thu, 5 Apr 2018 14:41:28 -0400 Subject: [PATCH] Add Save Articles Page to Onboarding (#182) * Add MVP of onboarding articles * Lint and fix unfollow all button bug * Update onboarding tests * Fix typo and adjust when saveAll request gets sent * Set default to always save all articles * Fix when request is sent for follows/saves * Check for article length when for initial request * Add a loading indicator for follows page * Prevent multiple follow/save requests * Update tests * Add some hover and click effects * Adjust onboarding test and suggested articles --- .../stylesheets/preact/onboarding-modal.scss | 131 +++++++++++++++++- app/controllers/api/v0/articles_controller.rb | 14 ++ .../api/v0/reactions_controller.rb | 13 ++ app/javascript/src/Onboarding.jsx | 130 ++++++++++++++--- .../src/__tests__/Onboarding.test.jsx | 15 +- .../__snapshots__/Onboarding.test.jsx.snap | 5 + .../src/components/OnboardingArticles.jsx | 81 +++++++++++ .../src/components/OnboardingUsers.jsx | 16 ++- .../src/components/OnboardingWelcome.jsx | 2 +- .../OnboardingFollowUsers.test.jsx.snap | 6 +- .../OnboardingUsers.test.jsx.snap | 6 +- .../OnboardingWelcome.test.jsx.snap | 3 +- app/labor/classic_article.rb | 6 +- .../api/v0/articles/onboarding.json.jbuilder | 14 ++ app/views/api/v0/users/index.json.jbuilder | 8 +- config/routes.rb | 12 +- 16 files changed, 415 insertions(+), 47 deletions(-) create mode 100644 app/javascript/src/components/OnboardingArticles.jsx create mode 100644 app/views/api/v0/articles/onboarding.json.jbuilder diff --git a/app/assets/stylesheets/preact/onboarding-modal.scss b/app/assets/stylesheets/preact/onboarding-modal.scss index fe015f306..3cfe04379 100644 --- a/app/assets/stylesheets/preact/onboarding-modal.scss +++ b/app/assets/stylesheets/preact/onboarding-modal.scss @@ -42,6 +42,9 @@ .yellow{ background: $yellow; } + .purple{ + background: $purple; + } .modal-header { height: 10%; @@ -194,15 +197,56 @@ color: $black; margin: 20px auto 25px; } + .onboarding-article-container{ + color: $black; + .onboarding-article-header{ + padding: 5px 10px 5px 5px; + background: $purple; + font-size: 1.1em; + font-weight: bold; + border: 1px solid darken($purple, 20%); + box-shadow: 4px 5px 0px darken($purple, 20%); + } + .onboarding-article-header-checkbox{ + width: 80%; + display: inline-block; + text-align: center; + .onboarding-article-save-all-btn{ + border: 2px solid transparent; + border-radius: 3px; + text-align: center; + float: right; + font-size: 0.75em; + background: inherit; + color: $black; + &:hover { + background: darken($purple, 10%); + } + &.saved { + background: darken($purple, 26%); + color: $white; + &:hover { + background: darken($purple, 30%); + } + } + } + } + } .onboarding-user-list{ color: $black; font-size: 0.7em; - overflow: scroll; border: 1px solid darken($purple, 20%); box-shadow: 4px 5px 0px darken($purple, 20%); .onboarding-user-list-body{ height: calc(39vh - 20px); - overflow: scroll; + overflow-y: scroll; + .onboarding-user-loading { + display: flex; + align-items: center; + justify-content: center; + margin-top: 15%; + color: $medium-gray; + } } .onboarding-user-list-row{ padding: 10px 0px; @@ -214,6 +258,21 @@ vertical-align: -0.5em; margin-right: 10px; } + .onboarding-article-engagement { + font-size: 16px; + font-weight: bold; + font-family: $helvetica-condensed; + color: $medium-gray; + img { + height: 20px; + min-width: 27px; + width: initial; + vertical-align: -5px; + margin-right: 3px; + border-radius: initial; + padding-left: 7px; + } + } &:nth-child(odd){ background: $light-gray; } @@ -226,6 +285,24 @@ width: calc(100% - 80px); display: inline-block; padding-left: 10px; + h3 { + -webkit-margin-before: 0.2em; + -webkit-margin-after: 0.3em; + font-size: 30px; + } + &.article { + width: calc(100% - 100px); + span { + // article author's name + color: $medium-gray; + } + p { + // article description + -webkit-margin-before: 0.5em; + -webkit-margin-start: 10px; + font-size: 0.85em; + } + } } .onboarding-user-list-checkbox{ width: 70px; @@ -233,11 +310,55 @@ text-align: center; button{ border: 0px; + border-radius: 3px; background: transparent; font-size:1.1em; &.checked{ background: $green; color: white; + &:hover { + background: darken($green, 10%); + } + &:active { + background: darken($green, 20%); + } + } + &:hover { + background: darken($purple, 10%); + } + &:active { + background: darken($purple, 20%); + } + &.article{ + &:hover{ + background: darken($purple, 10%); + } + &:active { + background: darken($purple, 20%); + } + &.save-single{ + background: darken($purple, 2%); + font-size: 0.8em; + font-family: $helvetica-condensed; + width: inherit; + height: 35px; + &:hover { + background: darken($purple, 10%); + } + &:active { + background: darken($purple, 20%); + } + } + &.saved { + background: darken($purple, 26%); + color: white; + &:hover { + background: darken($purple, 35%); + } + &:active { + background: darken($purple, 50%); + } + } } } } @@ -274,6 +395,12 @@ a { color: black; } + &:hover { + background: darken($green, 20%); + } + &:active { + background: darken($green, 35%); + } } } diff --git a/app/controllers/api/v0/articles_controller.rb b/app/controllers/api/v0/articles_controller.rb index fb00b1ea2..12ae41754 100644 --- a/app/controllers/api/v0/articles_controller.rb +++ b/app/controllers/api/v0/articles_controller.rb @@ -19,6 +19,20 @@ module Api @article = Article.includes(:user).find(params[:id]).decorate not_found unless @article.published end + + def onboarding + tag_list = params[:tag_list].split(",") + @articles = [] + 4.times do + @articles << ClassicArticle.new.get(tag_list) + end + Article.tagged_with(tag_list, any: true). + order("published_at DESC"). + where("positive_reactions_count > ? OR comments_count > ?", 10, 3).limit(15).each do |article| + @articles << article + end + @articles = @articles.uniq + end end end end diff --git a/app/controllers/api/v0/reactions_controller.rb b/app/controllers/api/v0/reactions_controller.rb index bc4d7e353..091c264c0 100644 --- a/app/controllers/api/v0/reactions_controller.rb +++ b/app/controllers/api/v0/reactions_controller.rb @@ -18,6 +18,19 @@ module Api render json: { reaction: @reaction.to_json } end + def onboarding + verify_authenticity_token + reactable_ids = JSON.parse(params[:articles]).map { |article| article["id"] } + reactable_ids.each do |article_id| + Reaction.delay.create( + user_id: current_user.id, + reactable_id: article_id, + reactable_type: "Article", + category: "readinglist", + ) + end + end + private def valid_user diff --git a/app/javascript/src/Onboarding.jsx b/app/javascript/src/Onboarding.jsx index 89686fb5b..f3a15478d 100644 --- a/app/javascript/src/Onboarding.jsx +++ b/app/javascript/src/Onboarding.jsx @@ -4,6 +4,7 @@ import OnboardingFollowTags from './components/OnboardingFollowTags'; import OnboardingFollowUsers from './components/OnboardingFollowUsers'; import OnboardingWelcomeThread from './components/OnboardingWelcomeThread'; import cancelSvg from '../../assets/images/cancel.svg'; +import OnboardingArticles from './components/OnboardingArticles'; class Onboarding extends Component { constructor() { @@ -17,6 +18,8 @@ class Onboarding extends Component { this.getUserTags = this.getUserTags.bind(this); this.handleCheckAllUsers = this.handleCheckAllUsers.bind(this); this.handleCheckUser = this.handleCheckUser.bind(this); + this.handleSaveAllArticles = this.handleSaveAllArticles.bind(this); + this.handleSaveArticle = this.handleSaveArticle.bind(this); this.getUsersToFollow = this.getUsersToFollow.bind(this); this.state = { pageNumber: 1, @@ -25,6 +28,10 @@ class Onboarding extends Component { allTags: [], users: [], checkedUsers: [], + followRequestSent: false, + articles: [], + savedArticles: [], + saveRequestSent: false, }; } @@ -76,8 +83,29 @@ class Onboarding extends Component { }); } + getSuggestedArticles() { + const followedTags = []; + for (let i = 0; i < this.state.allTags.length; i += 1) { + if (this.state.allTags[i].following) { + followedTags.push(this.state.allTags[i].name); + } + } + + fetch(`/api/articles/onboarding?tag_list=${followedTags.join(',')}`, { + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + }, + credentials: 'same-origin', + }) + .then(response => response.json()) + .then((json) => { + this.setState({ articles: json, savedArticles: json }); + }); + } + handleBulkFollowUsers(users) { - if (this.state.checkedUsers.length > 0) { + if (this.state.checkedUsers.length > 0 && !this.state.followRequestSent) { const csrfToken = document.querySelector("meta[name='csrf-token']").content; const formData = new FormData(); @@ -90,7 +118,32 @@ class Onboarding extends Component { }, body: formData, credentials: 'same-origin', - }).then(() => { + }).then((response) => { + if (response.ok) { + this.setState({ followRequestSent: true }); + } + }); + } + } + + handleBulkSaveArticles(articles) { + if (this.state.savedArticles.length > 0 && !this.state.saveRequestSent) { + const csrfToken = document.querySelector("meta[name='csrf-token']").content; + + const formData = new FormData(); + formData.append('articles', JSON.stringify(articles)); + + fetch('/api/reactions/onboarding', { + method: 'POST', + headers: { + 'X-CSRF-Token': csrfToken, + }, + body: formData, + credentials: 'same-origin', + }).then((response) => { + if (response.ok) { + this.setState({ saveRequestSent: true }); + } }); } } @@ -122,8 +175,6 @@ class Onboarding extends Component { credentials: 'same-origin', }) .then((response) => { - // change allTags state - // this.setState({ allTags: [] }); return response.json().then((json) => { this.setState({ allTags: this.state.allTags.map((currentTag) => { @@ -144,7 +195,7 @@ class Onboarding extends Component { handleCheckAllUsers() { - if (this.state.checkedUsers.length < 50) { + if (this.state.checkedUsers.length < this.state.users.length) { this.setState({ checkedUsers: this.state.users.slice() }); } else { this.setState({ checkedUsers: [] }); @@ -162,13 +213,47 @@ class Onboarding extends Component { this.setState({ checkedUsers: newCheckedUsers }); } - handleNextButton() { + handleSaveAllArticles() { + if (this.state.savedArticles.length < this.state.articles.length) { + this.setState({ savedArticles: this.state.articles.slice() }); + } else { + this.setState({ savedArticles: [] }); + } + } + + handleSaveArticle(article) { + const newSavedArticles = this.state.savedArticles.slice(); + if (this.state.savedArticles.indexOf(article) > -1) { + const index = newSavedArticles.indexOf(article); + newSavedArticles.splice(index, 1); + } else { + newSavedArticles.push(article); + } + this.setState({ savedArticles: newSavedArticles }); + } + + handleNextHover() { if (this.state.pageNumber === 2 && this.state.users.length === 0) { this.getUsersToFollow(); + this.getSuggestedArticles(); } - if (this.state.pageNumber < 4) { + } + + handleNextButton() { + if (this.state.pageNumber === 2 && + this.state.users.length === 0 && + this.state.articles.length === 0) { + this.getUsersToFollow(); + this.getSuggestedArticles(); + } + if (this.state.pageNumber < 5) { this.setState({ pageNumber: this.state.pageNumber + 1 }); - } else if (this.state.pageNumber === 4) { + if (this.state.pageNumber === 4 && this.state.checkedUsers.length > 0) { + this.handleBulkFollowUsers(this.state.checkedUsers); + } else if (this.state.pageNumber === 5 && this.state.savedArticles.length > 0) { + this.handleBulkSaveArticles(this.state.savedArticles); + } + } else if (this.state.pageNumber === 5) { this.closeOnboarding(); } } @@ -181,7 +266,6 @@ class Onboarding extends Component { closeOnboarding() { document.getElementsByTagName('body')[0].classList.remove('modal-open'); - this.handleBulkFollowUsers(this.state.checkedUsers); const csrfToken = document.querySelector("meta[name='csrf-token']").content; const formData = new FormData(); formData.append('saw_onboarding', true); @@ -227,6 +311,15 @@ class Onboarding extends Component { /> ); } else if (this.state.pageNumber === 4) { + return ( + + ); + } else if (this.state.pageNumber === 5) { return ( ); @@ -241,17 +334,15 @@ class Onboarding extends Component { } } - handleNextHover() { - if (this.state.pageNumber === 2 && this.state.users.length === 0) { - this.getUsersToFollow(); - } - } - renderNextButton() { - const onclick = this.handleNextButton; return ( - ); } @@ -261,7 +352,8 @@ class Onboarding extends Component { 1: 'WELCOME!', 2: 'FOLLOW TAGS!', 3: 'FOLLOW SOME DEVS!', - 4: 'GET INVOLVED!', + 4: 'SAVE SOME POSTS!', + 5: 'GET INVOLVED!', }; return messages[this.state.pageNumber]; } diff --git a/app/javascript/src/__tests__/Onboarding.test.jsx b/app/javascript/src/__tests__/Onboarding.test.jsx index 89cb2ecff..09f72b29f 100644 --- a/app/javascript/src/__tests__/Onboarding.test.jsx +++ b/app/javascript/src/__tests__/Onboarding.test.jsx @@ -117,14 +117,17 @@ describe('', () => { // going to page four context.find('.button').at(1).simulate('click'); expect(context.state('pageNumber')).toEqual(4); - fetch.mockResponse(JSON.stringify({ outcome: 'onboarding closed' })); - // going back to page three - context.find('.button').at(0).simulate('click'); - expect(context.state('pageNumber')).toEqual(3); - // going back to page four + // going to page five context.find('.button').at(1).simulate('click'); + expect(context.state('pageNumber')).toEqual(5); + fetch.mockResponse(JSON.stringify({ outcome: 'onboarding closed' })); + // going back to page four + context.find('.button').at(0).simulate('click'); expect(context.state('pageNumber')).toEqual(4); - // evaluting the button text on page 4 + // going back to page five + context.find('.button').at(1).simulate('click'); + expect(context.state('pageNumber')).toEqual(5); + // evaluting the button text on page five expect(context.find('[onClick]').at(2).text()).toEqual("LET'S GO"); context.find('.button').at(1).simulate('click'); // clicking the final button diff --git a/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap b/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap index cd3ed4168..b94cf5600 100644 --- a/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap +++ b/app/javascript/src/__tests__/__snapshots__/Onboarding.test.jsx.snap @@ -36,6 +36,7 @@ FindWrapper { "attributes": Object { "className": "button cta", "onClick": [Function], + "onFocus": [Function], "onMouseOver": [Function], }, "children": Array [ @@ -51,8 +52,12 @@ FindWrapper { exports[` display if there is current user 1`] = ` Object { "allTags": Array [], + "articles": Array [], "checkedUsers": Array [], + "followRequestSent": false, "pageNumber": 1, + "saveRequestSent": false, + "savedArticles": Array [], "showOnboarding": true, "userData": Object { "followed_tag_names": Array [ diff --git a/app/javascript/src/components/OnboardingArticles.jsx b/app/javascript/src/components/OnboardingArticles.jsx new file mode 100644 index 000000000..1f57322e5 --- /dev/null +++ b/app/javascript/src/components/OnboardingArticles.jsx @@ -0,0 +1,81 @@ +import { h, render, Component } from 'preact'; +import PropTypes from 'prop-types'; + +class OnboardingArticles extends Component { + constructor(props) { + super(props); + this.handleAllClick = this.handleAllClick.bind(this); + } + + + handleAllClick() { + this.props.handleSaveAllArticles(); + } + + render() { + const articles = this.props.articles.map((article) => { + return ( +
+
+

{article.title}

+ {article.user.name} + {article.user.name} +

{article.description.length < 75 ? article.description : `${article.description.substring(0, 75)}...`}

+
+ Reactions {article.positive_reactions_count} + Comments {article.comments_count} +
+
+
+ +
+
+ ); + }); + + return ( +
+
+ When you see an interesting post, you can SAVE it. To get started, here are pre-selected suggestions. +
+
+
+
+ Save All +
+
+ +
+
+
{articles}
+
+
+ ); + } +} + +OnboardingArticles.propTypes = { + handleSaveArticle: PropTypes.func.isRequired, + handleSaveAllArticles: PropTypes.func.isRequired, +}; + +export default OnboardingArticles; diff --git a/app/javascript/src/components/OnboardingUsers.jsx b/app/javascript/src/components/OnboardingUsers.jsx index 542692a73..31a9ae212 100644 --- a/app/javascript/src/components/OnboardingUsers.jsx +++ b/app/javascript/src/components/OnboardingUsers.jsx @@ -30,11 +30,21 @@ class OnboardingUsers extends Component { ); }); + const renderLoadingOrList = () => { + if (this.props.users.length === 0) { + return ( +
+ Loading... +
+ ); + } + return followList; + }; return (
- Here are some folks from the community you might want to follow based on your interests + Here are some folks from the community you might want to follow based on your interests.
@@ -47,7 +57,9 @@ class OnboardingUsers extends Component {
-
{followList}
+
+ {renderLoadingOrList()} +
); diff --git a/app/javascript/src/components/OnboardingWelcome.jsx b/app/javascript/src/components/OnboardingWelcome.jsx index e6395aaca..a25f7e83f 100644 --- a/app/javascript/src/components/OnboardingWelcome.jsx +++ b/app/javascript/src/components/OnboardingWelcome.jsx @@ -13,7 +13,7 @@ const OnboardingWelcome = () => { return (
{messages.map(item => (

{item}

))} -

{specialMessage}.

+

{specialMessage}.

); }; diff --git a/app/javascript/src/components/__tests__/__snapshots__/OnboardingFollowUsers.test.jsx.snap b/app/javascript/src/components/__tests__/__snapshots__/OnboardingFollowUsers.test.jsx.snap index 23c720f66..dbae3079c 100644 --- a/app/javascript/src/components/__tests__/__snapshots__/OnboardingFollowUsers.test.jsx.snap +++ b/app/javascript/src/components/__tests__/__snapshots__/OnboardingFollowUsers.test.jsx.snap @@ -8,11 +8,11 @@ exports[` renders properly when given users 1`] = ` class="onboarding-user-cta" > Here are some folks from the community you might want to follow - - based on your interests - + based on your interests. +
when given users to follow renders correctly 1`] = class="onboarding-user-cta" > Here are some folks from the community you might want to follow - - based on your interests - + based on your interests. +
renders correctly 1`] = ` - Let's get started + Let's get started. - .

`; diff --git a/app/labor/classic_article.rb b/app/labor/classic_article.rb index b021a6937..e7730358d 100644 --- a/app/labor/classic_article.rb +++ b/app/labor/classic_article.rb @@ -5,11 +5,11 @@ class ClassicArticle @not_ids = options[:not_ids] end - def get - if rand(5) == 1 + def get(tag_names = random_supported_tag_names) + if rand(8) == 1 random_high_quality_article else - qualifying_articles(random_supported_tag_names).where.not(id: not_ids).compact.sample || + qualifying_articles(tag_names).where.not(id: not_ids).compact.sample || random_high_quality_article end end diff --git a/app/views/api/v0/articles/onboarding.json.jbuilder b/app/views/api/v0/articles/onboarding.json.jbuilder new file mode 100644 index 000000000..e0814a0c1 --- /dev/null +++ b/app/views/api/v0/articles/onboarding.json.jbuilder @@ -0,0 +1,14 @@ +json.array! @articles do |article| + json.id article.id + json.title article.title + json.description article.description + json.published_at article.published_at + json.tag_list article.cached_tag_list + json.comments_count article.comments_count + json.positive_reactions_count article.positive_reactions_count + + json.user do + json.name article.user.name + json.profile_image_url ProfileImage.new(article.user).get(90) + end +end diff --git a/app/views/api/v0/users/index.json.jbuilder b/app/views/api/v0/users/index.json.jbuilder index 7af1cc43b..6c6b0b84f 100644 --- a/app/views/api/v0/users/index.json.jbuilder +++ b/app/views/api/v0/users/index.json.jbuilder @@ -1,6 +1,6 @@ json.array! @users.each do |user| - json.id user.id - json.name user.name - json.username user.username - json.profile_image_url ProfileImage.new(user).get(90) + json.id user.id + json.name user.name + json.username user.username + json.profile_image_url ProfileImage.new(user).get(90) end diff --git a/config/routes.rb b/config/routes.rb index d257ee08c..96cc50888 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -38,10 +38,18 @@ Rails.application.routes.draw do namespace :api, defaults: {format: 'json'} do scope module: :v0, constraints: ApiConstraints.new(version: 0, default: true) do - resources :articles + resources :articles, only: [:index, :show] do + collection do + get "/onboarding", to: "articles#onboarding" + end + end resources :comments resources :podcast_episodes - resources :reactions, only: [:create] + resources :reactions, only: [:create] do + collection do + post "/onboarding", to: "reactions#onboarding" + end + end resources :users, only: [:index] resources :tags, only: [:index] do collection do