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
This commit is contained in:
Andy Zhao 2018-04-05 14:41:28 -04:00 committed by Ben Halpern
parent 1bf80860ed
commit deb63ab643
16 changed files with 415 additions and 47 deletions

View file

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

View file

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

View file

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

View file

@ -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 (
<OnboardingArticles
articles={this.state.articles}
savedArticles={this.state.savedArticles}
handleSaveAllArticles={this.handleSaveAllArticles}
handleSaveArticle={this.handleSaveArticle}
/>
);
} else if (this.state.pageNumber === 5) {
return (
<OnboardingWelcomeThread />
);
@ -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 (
<button className="button cta" onClick={this.handleNextButton} onMouseOver={this.handleNextHover}>
{this.state.pageNumber < 4 ? 'NEXT' : "LET'S GO"}
<button
className="button cta"
onClick={this.handleNextButton}
onMouseOver={this.handleNextHover}
onFocus={this.handleNextHover}
>
{this.state.pageNumber < 5 ? 'NEXT' : "LET'S GO"}
</button>
);
}
@ -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];
}

View file

@ -117,14 +117,17 @@ describe('<Onboarding />', () => {
// 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

View file

@ -36,6 +36,7 @@ FindWrapper {
"attributes": Object {
"className": "button cta",
"onClick": [Function],
"onFocus": [Function],
"onMouseOver": [Function],
},
"children": Array [
@ -51,8 +52,12 @@ FindWrapper {
exports[`<Onboarding /> 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 [

View file

@ -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 (
<div className="onboarding-user-list-row" key={article.user.id} >
<div className="onboarding-user-list-key article">
<h3>{article.title}</h3>
<img
src={article.user.profile_image_url}
alt={article.user.name}
/>
<span>{article.user.name}</span>
<p>{article.description.length < 75 ? article.description : `${article.description.substring(0, 75)}...`}</p>
<div className="onboarding-article-engagement">
<img
src="https://practicaldev-herokuapp-com.freetls.fastly.net/assets/reactions-stack-4bb9c1e4b3e71b7aa135d6f9a5ef29a6494141da882edd4fa971a77abe13dbe7.png"
alt="Reactions"
/> {article.positive_reactions_count}
<img
src="https://practicaldev-herokuapp-com.freetls.fastly.net/assets/comments-bubble-7448082accd39cfe9db9b977f38fa6e8f8d26dc43e142c5d160400d6f952ee47.png"
alt="Comments"
/> {article.comments_count}
</div>
</div>
<div className="onboarding-user-list-checkbox article">
<button
onClick={this.props.handleSaveArticle.bind(this, article)}
className={`article save-single ${this.props.savedArticles.indexOf(article) > -1 ? 'saved' : ''}`}
>
{this.props.savedArticles.indexOf(article) > -1 ? 'SAVED' : 'SAVE'}
</button>
</div>
</div>
);
});
return (
<div className="onboarding-user-container">
<div className="onboarding-user-cta">
When you see an interesting post, you can <strong className="purple">SAVE</strong> it. To get started, here are pre-selected suggestions.
</div>
<div className="onboarding-user-list">
<div className="onboarding-user-list-header onboarding-user-list-row">
<div className="onboarding-user-list-key">
Save All
</div>
<div className="onboarding-user-list-checkbox">
<button
onClick={this.handleAllClick}
className={`article save-all ${this.props.savedArticles.length === this.props.articles.length ? 'saved' : ''}`}
>
{this.props.savedArticles.length === this.props.articles.length ? '✓' : '+'}
</button>
</div>
</div>
<div className="onboarding-user-list-body">{articles}</div>
</div>
</div>
);
}
}
OnboardingArticles.propTypes = {
handleSaveArticle: PropTypes.func.isRequired,
handleSaveAllArticles: PropTypes.func.isRequired,
};
export default OnboardingArticles;

View file

@ -30,11 +30,21 @@ class OnboardingUsers extends Component {
</div>
);
});
const renderLoadingOrList = () => {
if (this.props.users.length === 0) {
return (
<div className="onboarding-user-loading">
Loading...
</div>
);
}
return followList;
};
return (
<div className="onboarding-user-container">
<div className="onboarding-user-cta">
Here are some folks from the community you might want to follow <span class="yellow">based on your interests</span>
Here are some folks from the community you might want to follow <strong className="yellow">based on your interests.</strong>
</div>
<div className="onboarding-user-list">
<div className="onboarding-user-list-header onboarding-user-list-row">
@ -47,7 +57,9 @@ class OnboardingUsers extends Component {
</button>
</div>
</div>
<div className="onboarding-user-list-body">{followList}</div>
<div className="onboarding-user-list-body">
{renderLoadingOrList()}
</div>
</div>
</div>
);

View file

@ -13,7 +13,7 @@ const OnboardingWelcome = () => {
return (
<div>
{messages.map(item => (<p>{item}</p>))}
<p><strong className="yellow">{specialMessage}</strong>.</p>
<p><strong className="yellow">{specialMessage}.</strong></p>
</div>
);
};

View file

@ -8,11 +8,11 @@ exports[`<OnboardingFollowUsers /> renders properly when given users 1`] = `
class="onboarding-user-cta"
>
Here are some folks from the community you might want to follow
<span
<strong
class="yellow"
>
based on your interests
</span>
based on your interests.
</strong>
</div>
<div
class="onboarding-user-list"

View file

@ -8,11 +8,11 @@ exports[`<OnboardingUsers /> when given users to follow renders correctly 1`] =
class="onboarding-user-cta"
>
Here are some folks from the community you might want to follow
<span
<strong
class="yellow"
>
based on your interests
</span>
based on your interests.
</strong>
</div>
<div
class="onboarding-user-list"

View file

@ -15,9 +15,8 @@ exports[`<OnboardingWelcome /> renders correctly 1`] = `
<strong
class="yellow"
>
Let's get started
Let's get started.
</strong>
.
</p>
</div>
`;

View file

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

View file

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

View file

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

View file

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