Add users to signup modal (and other adjustments) (#124)

* Add a missing bounty hunter

* Add a msg about updating social usernames

* Add mailto: link for yo@dev in terms

* Use render partial for help tag

* Lint a file

* Add first draft of onboarding follow users

* WIP onboarding

* Change users api controller

* Create FollowSuggester to return suggested follows for user

* Create initial follow users flow in onboarding

* Move follow user logic to top lvl

* Add follow api end point for onboarding

* Add profile img and update follow action

* Remove checkbox and use a tag instead

* Lint and update tests

* Remove incomplete test for now

* Final adjustments to signup modal

* Update jest snapshot

* Add more jest tests and fix typo
This commit is contained in:
Ben Halpern 2018-03-22 18:29:22 -04:00 committed by GitHub
parent f5a1dcb1da
commit 8a1f9991f1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
23 changed files with 699 additions and 21 deletions

View file

@ -178,7 +178,7 @@
font-size:28px;
font-weight:400;
}
a{
a.big{
display:block;
border: 3px solid $black;
font-size:2.2em;
@ -191,6 +191,7 @@
img{
width: 90%;
max-width: 270px;
min-height: 350px;
}
}
}

View file

@ -97,6 +97,7 @@
padding-left: calc(3% + 2px);
max-width: 730px;
float:left;
font-size: calc(0.8em + 6px);
}
.tags-slide {
@ -107,6 +108,10 @@
height: 40vh;
overflow-x: hidden;
overflow-y: auto;
font-size: calc(0.77em + 6px);
@media screen and (min-width: 900px) {
font-size: 27px;
}
.col-1{
float: left;
width: 50%;
@ -165,6 +170,9 @@
font-weight:900;
line-height:26px;
font-size:1.1em;
@media screen and (min-width: 900px) {
top: 6px;
}
&.following-butt {
font-family: "Helvetica Neue", Helvetica, Arial, "Lucida Grande", sans-serif;
top:8px;
@ -178,6 +186,65 @@
}
.onboarding-user-container{
margin-top: calc(0.5vw + 8px);
text-align: left;
.onboarding-user-cta{
font-size: 0.9em;
color: $black;
margin: 20px auto 25px;
}
.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;
}
.onboarding-user-list-row{
padding: 10px 0px;
img{
height: 33px;
width: 33px;
border-radius: 50%;
display: inline-block;
vertical-align: -0.5em;
margin-right: 10px;
}
&:nth-child(odd){
background: $light-gray;
}
&.onboarding-user-list-header{
background: $purple;
font-size:1.1em;
font-weight: bold;
}
.onboarding-user-list-key{
width: calc(100% - 80px);
display: inline-block;
padding-left: 10px;
}
.onboarding-user-list-checkbox{
width: 70px;
display: inline-block;
text-align: center;
button{
border: 0px;
background: transparent;
font-size:1.1em;
&.checked{
background: $green;
color: white;
}
}
}
}
}
}
.modal-footer {
height:10%;
display:block;
@ -203,7 +270,7 @@
cursor: pointer;
display: inline-block;
font-size: calc(1vw + 20px);
padding: 4px 6px;
padding: 4px 10px;
a {
color: black;
}

View file

@ -0,0 +1,15 @@
module Api
module V0
class FollowsController < ApplicationController
def create
return unless user_signed_in?
users = JSON.parse(params[:users])
users.each do |user_hash|
followable = User.find(user_hash['id'])
current_user.delay.follow(followable)
end
render json: { outcome: "followed 50 users" }
end
end
end
end

View file

@ -0,0 +1,15 @@
module Api
module V0
class UsersController < ApplicationController
def index
unless user_signed_in?
@users = []
return
end
if params[:state] == "follow_suggestions"
@users = UserFollowSuggester.new(current_user).suggestions
end
end
end
end
end

View file

@ -43,11 +43,11 @@ class UsersController < ApplicationController
current_user.saw_onboarding = true
if current_user.save!
respond_to do |format|
format.json { render json: { outcome: 'onboarding closed' } }
format.json { render json: { outcome: "onboarding closed" } }
end
else
respond_to do |format|
format.json { render json: { outcome: 'onboarding opened' } }
format.json { render json: { outcome: "onboarding opened" } }
end
end
end

View file

@ -1,6 +1,7 @@
import { h, render, Component } from 'preact';
import OnboardingWelcome from './components/OnboardingWelcome';
import OnboardingFollowTags from './components/OnboardingFollowTags';
import OnboardingFollowUsers from './components/OnboardingFollowUsers';
import OnboardingWelcomeThread from './components/OnboardingWelcomeThread';
import cancelSvg from '../../assets/images/cancel.svg';
@ -11,13 +12,19 @@ class Onboarding extends Component {
this.handleBackButton = this.handleBackButton.bind(this);
this.closeOnboarding = this.closeOnboarding.bind(this);
this.handleFollowTag = this.handleFollowTag.bind(this);
this.handleNextHover = this.handleNextHover.bind(this);
this.updateUserData = this.updateUserData.bind(this);
this.getUserTags = this.getUserTags.bind(this);
this.handleCheckAllUsers = this.handleCheckAllUsers.bind(this);
this.handleCheckUser = this.handleCheckUser.bind(this);
this.getUsersToFollow = this.getUsersToFollow.bind(this);
this.state = {
pageNumber: 1,
showOnboarding: false,
userData: null,
allTags: [],
users: [],
checkedUsers: [],
};
}
@ -45,11 +52,47 @@ class Onboarding extends Component {
const updatedJSON = checkFollowingStatus(followedTagNames, json);
this.setState({ allTags: updatedJSON });
})
.catch((huh) => {
console.log(huh);
.catch((error) => {
console.log(error);
});
}
getUsersToFollow() {
fetch('/api/users?state=follow_suggestions', {
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
credentials: 'same-origin',
})
.then(response => response.json())
.then((json) => {
this.setState({ users: json, checkedUsers: json });
})
.catch((error) => {
console.log(error);
});
}
handleBulkFollowUsers(users) {
if (this.state.checkedUsers.length > 0) {
const csrfToken = document.querySelector("meta[name='csrf-token']").content;
const formData = new FormData();
formData.append('users', JSON.stringify(users));
fetch('/api/follows', {
method: 'POST',
headers: {
'X-CSRF-Token': csrfToken,
},
body: formData,
credentials: 'same-origin',
}).then(() => {
});
}
}
updateUserData() {
this.setState({ userData: JSON.parse(document.body.getAttribute('data-user')) });
if (this.state.userData.saw_onboarding === true) {
@ -97,10 +140,35 @@ class Onboarding extends Component {
});
}
handleCheckAllUsers() {
if (this.state.checkedUsers.length < 50) {
this.setState({ checkedUsers: this.state.users.slice() });
} else {
this.setState({ checkedUsers: [] });
}
}
handleCheckUser(user) {
const newCheckedUsers = this.state.checkedUsers.slice();
if (this.state.checkedUsers.indexOf(user) > -1) {
const index = newCheckedUsers.indexOf(user);
newCheckedUsers.splice(index, 1);
} else {
newCheckedUsers.push(user);
}
this.setState({ checkedUsers: newCheckedUsers });
}
handleNextButton() {
if (this.state.pageNumber < 3) {
if (this.state.pageNumber === 2 && this.state.users.length === 0) {
this.getUsersToFollow();
}
if (this.state.pageNumber < 4) {
this.setState({ pageNumber: this.state.pageNumber + 1 });
} else if (this.state.pageNumber === 3) {
} else if (this.state.pageNumber === 4) {
this.closeOnboarding();
}
}
@ -112,6 +180,7 @@ class Onboarding extends Component {
}
closeOnboarding() {
this.handleBulkFollowUsers(this.state.checkedUsers);
const csrfToken = document.querySelector("meta[name='csrf-token']").content;
document.getElementsByTagName('body')[0].classList.remove('modal-open');
const formData = new FormData();
@ -149,6 +218,15 @@ class Onboarding extends Component {
/>
);
} else if (this.state.pageNumber === 3) {
return (
<OnboardingFollowUsers
users={this.state.users}
checkedUsers={this.state.checkedUsers}
handleCheckUser={this.handleCheckUser}
handleCheckAllUsers={this.handleCheckAllUsers}
/>
);
} else if (this.state.pageNumber === 4) {
return (
<OnboardingWelcomeThread />
);
@ -163,11 +241,17 @@ class Onboarding extends Component {
}
}
handleNextHover() {
if (this.state.pageNumber === 2) {
this.getUsersToFollow();
}
}
renderNextButton() {
const onclick = this.handleNextButton;
return (
<button className="button cta" onClick={this.handleNextButton}>
{this.state.pageNumber < 3 ? 'NEXT' : <a href="/welcome" data-no-instant>LET'S GO</a>}
<button className="button cta" onClick={this.handleNextButton} onMouseOver={this.handleNextHover}>
{this.state.pageNumber < 4 ? 'NEXT' : "LET'S GO"}
</button>
);
}
@ -176,7 +260,8 @@ class Onboarding extends Component {
const messages = {
1: 'WELCOME!',
2: 'FOLLOW TAGS!',
3: 'GET INVOLVED!',
3: 'FOLLOW SOME DEVS!',
4: 'GET INVOLVED!',
};
return messages[this.state.pageNumber];
}

View file

@ -94,16 +94,19 @@ describe('<Onboarding />', () => {
expect(context.state('pageNumber')).toEqual(2);
expect(context.find('[onClick]')).toMatchSnapshot();
// going to page three
context.find('.button').at(1).simulate('click');
expect(context.state('pageNumber')).toEqual(3);
// going to page four
context.find('.button').at(1).simulate('click');
expect(context.state('pageNumber')).toEqual(4);
fetch.mockResponse(JSON.stringify({ outcome: 'onboarding closed' }));
context.find('.button').at(1).simulate('click');
expect(context.state('pageNumber')).toEqual(3);
// going back to page two
context.find('.button').at(0).simulate('click');
expect(context.state('pageNumber')).toEqual(2);
// going back to page three
context.find('.button').at(1).simulate('click');
context.find('.button').at(0).simulate('click');
expect(context.state('pageNumber')).toEqual(3);
// evaluting the button text on page 3
// going back to page four
context.find('.button').at(1).simulate('click');
expect(context.state('pageNumber')).toEqual(4);
// evaluting the button text on page 4
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],
"onMouseOver": [Function],
},
"children": Array [
"NEXT",
@ -50,6 +51,7 @@ FindWrapper {
exports[`<Onboarding /> display if there is current user 1`] = `
Object {
"allTags": Array [],
"checkedUsers": Array [],
"pageNumber": 1,
"showOnboarding": true,
"userData": Object {
@ -68,5 +70,6 @@ Object {
},
],
},
"users": Array [],
}
`;

View file

@ -0,0 +1,13 @@
import { h } from 'preact';
import OnboardingUsers from './OnboardingUsers';
const OnboardingFollowUsers = ({ users, checkedUsers, handleCheckUser, handleCheckAllUsers }) => (
<OnboardingUsers
users={users}
checkedUsers={checkedUsers}
handleCheckUser={handleCheckUser}
handleCheckAllUsers={handleCheckAllUsers}
/>
);
export default OnboardingFollowUsers;

View file

@ -16,7 +16,7 @@ class OnboardingTags extends Component {
return (
<div className="tags-slide">
<p>
<strong class="yellow">What topics are you interested in?</strong> Here are a few tags to follow (There are plenty more you can choose from later)
<strong class="yellow">What topics are you interested in?</strong> Here are a few tags to follow (There are plenty more you can choose from at any time)
</p>
<div className="tags-col-container">
<div className="col-1">

View file

@ -0,0 +1,63 @@
import { h, render, Component } from 'preact';
import PropTypes from 'prop-types';
class OnboardingUsers extends Component {
constructor(props) {
super(props);
this.handleAllClick = this.handleAllClick.bind(this);
}
handleAllClick() {
this.props.handleCheckAllUsers();
}
render() {
const followList = this.props.users.map((user) => {
return (
<div className="onboarding-user-list-row" key={user.id} >
<div className="onboarding-user-list-key">
<img
src={user.profile_image_url}
alt={user.name}
/>
{user.name}
</div>
<div className="onboarding-user-list-checkbox">
<button onClick={this.props.handleCheckUser.bind(this, user)} className={this.props.checkedUsers.indexOf(user) > -1 ? 'checked' : ''}>
{this.props.checkedUsers.indexOf(user) > -1 ? '✓' : '+'}
</button>
</div>
</div>
);
});
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>
</div>
<div className="onboarding-user-list">
<div className="onboarding-user-list-header onboarding-user-list-row">
<div className="onboarding-user-list-key">
Follow All
</div>
<div className="onboarding-user-list-checkbox">
<button id="onboarding-user-follow-all-btn" onClick={this.handleAllClick} className={this.props.checkedUsers.length === this.props.users.length ? 'checked' : ''}>
{this.props.checkedUsers.length === this.props.users.length ? '✓' : '+'}
</button>
</div>
</div>
<div className="onboarding-user-list-body">{followList}</div>
</div>
</div>
);
}
}
OnboardingUsers.propTypes = {
handleCheckUser: PropTypes.func.isRequired,
handleCheckAllUsers: PropTypes.func.isRequired,
};
export default OnboardingUsers;

View file

@ -0,0 +1,51 @@
import { h } from 'preact';
import render from 'preact-render-to-json';
import OnboardingFollowUsers from '../OnboardingFollowUsers';
describe('<OnboardingFollowUsers />', () => {
it('renders properly when given users', () => {
const users = [
{
id: 1,
name: 'Ben Halpern',
profile_image_url: 'ben.jpg',
},
{
id: 2,
name: 'Krusty the Clown',
profile_image_url: 'clown.jpg',
},
{
id: 3,
name: 'dev.to staff',
profile_image_url: 'dev.jpg',
},
];
const checkedUsers = [
{
id: 1,
name: 'Ben Halpern',
profile_image_url: 'ben.jpg',
},
{
id: 2,
name: 'Krusty the Clown',
profile_image_url: 'clown.jpg',
},
{
id: 3,
name: 'dev.to staff',
profile_image_url: 'dev.jpg',
},
];
const handleCheckUser = jest.fn();
const handleCheckAllUsers = jest.fn();
const tree = render(<OnboardingFollowUsers
users={users}
checkedUsers={checkedUsers}
handleCheckUser={handleCheckUser}
handleCheckAllUsers={handleCheckAllUsers}
/>);
expect(tree).toMatchSnapshot();
});
});

View file

@ -0,0 +1,67 @@
import { h } from 'preact';
import { shallow } from 'preact-render-spy';
import { render } from 'preact-render-to-json';
import OnboardingUsers from '../OnboardingUsers';
describe('<OnboardingUsers />', () => {
const users = [
{
id: 1,
name: 'Ben Halpern',
profile_image_url: 'ben.jpg',
},
{
id: 2,
name: 'Krusty the Clown',
profile_image_url: 'clown.jpg',
},
{
id: 3,
name: 'dev.to staff',
profile_image_url: 'dev.jpg',
},
];
const checkedUsers = [
{
id: 1,
name: 'Ben Halpern',
profile_image_url: 'ben.jpg',
},
{
id: 2,
name: 'Krusty the Clown',
profile_image_url: 'clown.jpg',
},
{
id: 3,
name: 'dev.to staff',
profile_image_url: 'dev.jpg',
},
];
const handleCheckUser = jest.fn();
const handleCheckAllUsers = jest.fn();
describe('when given users to follow', () => {
it('renders correctly', () => {
const context = render(<OnboardingUsers
users={users}
checkedUsers={checkedUsers}
handleCheckUser={handleCheckUser}
handleCheckAllUsers={handleCheckAllUsers}
/>);
expect(context).toMatchSnapshot();
});
it('responds to clicking Follow All', () => {
const context = shallow(<OnboardingUsers
users={users}
checkedUsers={checkedUsers}
handleCheckUser={handleCheckUser}
handleCheckAllUsers={handleCheckAllUsers}
/>);
expect(context.find('#onboarding-user-follow-all-btn').text()).toEqual('✓');
context.find('#onboarding-user-follow-all-btn').simulate('click');
expect(handleCheckAllUsers).toHaveBeenCalledTimes(1);
});
});
});

View file

@ -10,7 +10,7 @@ exports[`<OnboardingFollowTags /> renders properly when given a tag 1`] = `
>
What topics are you interested in?
</strong>
Here are a few tags to follow (There are plenty more you can choose from later)
Here are a few tags to follow (There are plenty more you can choose from at any time)
</p>
<div
class="tags-col-container"

View file

@ -0,0 +1,112 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OnboardingFollowUsers /> renders properly when given users 1`] = `
<div
class="onboarding-user-container"
>
<div
class="onboarding-user-cta"
>
Here are some folks from the community you might want to follow
<span
class="yellow"
>
based on your interests
</span>
</div>
<div
class="onboarding-user-list"
>
<div
class="onboarding-user-list-header onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
Follow All
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
class="checked"
id="onboarding-user-follow-all-btn"
onClick={[Function]}
>
</button>
</div>
</div>
<div
class="onboarding-user-list-body"
>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="Ben Halpern"
src="ben.jpg"
/>
Ben Halpern
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="Krusty the Clown"
src="clown.jpg"
/>
Krusty the Clown
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="dev.to staff"
src="dev.jpg"
/>
dev.to staff
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,112 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<OnboardingUsers /> when given users to follow renders correctly 1`] = `
<div
class="onboarding-user-container"
>
<div
class="onboarding-user-cta"
>
Here are some folks from the community you might want to follow
<span
class="yellow"
>
based on your interests
</span>
</div>
<div
class="onboarding-user-list"
>
<div
class="onboarding-user-list-header onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
Follow All
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
class="checked"
id="onboarding-user-follow-all-btn"
onClick={[Function]}
>
</button>
</div>
</div>
<div
class="onboarding-user-list-body"
>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="Ben Halpern"
src="ben.jpg"
/>
Ben Halpern
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="Krusty the Clown"
src="clown.jpg"
/>
Krusty the Clown
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
<div
class="onboarding-user-list-row"
>
<div
class="onboarding-user-list-key"
>
<img
alt="dev.to staff"
src="dev.jpg"
/>
dev.to staff
</div>
<div
class="onboarding-user-list-checkbox"
>
<button
onClick={[Function]}
>
+
</button>
</div>
</div>
</div>
</div>
</div>
`;

View file

@ -0,0 +1,41 @@
class UserFollowSuggester
attr_accessor :user
def initialize(user)
@user = user
end
def suggestions
user_ids = tagged_article_user_ids
if user.decorate.cached_followed_tag_names.any?
group_1 = User.where(id: user_ids).
order("reputation_modifier DESC").limit(25).to_a
group_2 = User.where(id: user_ids).
order("twitter_following_count DESC").limit(25).to_a
group_3 = User.where(id: user_ids).
order("articles_count DESC").limit(25).to_a
group_4 = User.order("reputation_modifier DESC").limit(30).to_a
group_5 = User.order("comments_count DESC").limit(25).to_a
users = (group_1 + group_2 + group_3 + group_4 + group_5 - [user]).
uniq.shuffle.first(50)
else
group_1 = User.order("reputation_modifier DESC").limit(100).to_a
group_2 = User.where("articles_count > ?", 5).
order("twitter_following_count DESC").limit(100).to_a
group_3 = User.order("comments_count DESC").limit(100).to_a
users = (group_1 + group_2 + group_3 - [user]).
uniq.shuffle.first(50)
end
users
end
def tagged_article_user_ids
Article.
tagged_with(user.decorate.cached_followed_tag_names, any: true).
where(published: true).
where("positive_reactions_count > ?", 1).pluck(:user_id).
each_with_object(Hash.new(0)) { |value, counts| counts[value] += 1 }.
sort_by { |_key, value| value }.
map { |arr| arr[0] }
end
end

View file

@ -0,0 +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)
end

View file

@ -80,7 +80,7 @@
<% else %>
<div class="no-articles">
<h3>This is where you can manage your posts, but you haven't written anything yet.</h3>
<a href="/new" class="cta">
<a href="/new" class="cta big">
Write your first post now
</a>
<%=image_tag(cl_image_path(asset_path("/assets/sloan.png"),
@ -91,6 +91,8 @@
:flags => "progressive",
:fetch_format => "auto",
:sign_url => true), class:"sloan", alt:"sloth-mascot") %>
<br/>
<h3>Also check out the <a href="/welcome" data-no-instant>Welcome Thread</a> to introduce yourself!</h3>
</div>
<% end %>
</div>

View file

@ -33,6 +33,7 @@
<div class="field">
<%= f.label :username %>
<%= f.text_field :username, maxlength: 30 %>
Signing out and back in will update your Twitter/Github username.
</div>
<div class="field">
<%= f.label :name %>

View file

@ -17,6 +17,7 @@
"latest",
"contact",
"merch",
"onboarding_update",
"rlygenerator",
"orlygenerator",
"rlyslack",

View file

@ -42,11 +42,13 @@ Rails.application.routes.draw do
resources :comments
resources :podcast_episodes
resources :reactions, only: [:create]
resources :users, only: [:index]
resources :tags, only: [:index] do
collection do
get "/onboarding", to: "tags#onboarding"
end
end
resources :follows, only: [:create]
end
end

View file

@ -0,0 +1,18 @@
require "rails_helper"
RSpec.describe UserFollowSuggester, vcr: {} do
let(:user) { create(:user) }
it "does not include calling user" do
create(:user)
create(:user)
create(:user)
expect(described_class.new(user).suggestions).not_to include(user)
end
it "does not include calling user" do
create(:user)
create(:user)
create(:user)
expect(described_class.new(user).suggestions.size).to eq(3)
end
end