Best Books:  My Fullstack React & Ruby On Rails App
Tags: ruby, react, beginners, flatiron
Published: 2021-12-13 19:58:03

This is my second to last project for Flatiron and this phase was all about Ruby on Rails. From what I’ve read, Ruby on Rails isn’t as popular as it was 5 years ago, however it is still a good language to understand and helped me learn back-end web development.

##What I Used In My Project
- React framework for my front-end
- React Router for my front-end routes
- Mui for styling
- Ruby on Rails for my backend
- ActiveRecord to handle my models and communication with my database


##Project Overview
I created a book club app called Best Books. It lets you create book clubs with your friends where you can track goals, create discussion questions, and comment on the discussion questions.

###Best Book Models
####User
- Has many book club users
- Has many comments

####Book Club User
- Belongs to a user
- Belongs to a book club

####Book Club
- Belongs to a book
- Belongs to a book club
- Has many goals
- Has many guide questions

####Goal
- Belongs to a book club book

####Guide Question
- Belongs to a book club book
- Has many comments

####Comment
- Belongs to a user
- Belongs to a guide question

```rb
                                                          :deadline
                                                          :pages
                                                          :priority
                                                          :complete
                                                          :notes
                                                          :meetingURL
                                                          :bookclub_book_id
                                                          Goal
                                                          V
                                                          |
User --------------< BookClubUser >---- BookClub ----< BookClubBook >-------- Book
:email               :user_id           :name          :bookclub_id           :imageURL
:password_digest     :bookclub_id                      :book_id               :title
:first_name          :isAdmin                          :archived              :series
:last_name                                             :status                :author
:location                                              :suggested_by          :description
:profile_color                                         :current               :pages
|                                                       |                     :publicationDate
|                                                       |                     :genres
|                                                       |
|                                                       |
|                                                       ^
  -------------------< Comment >----------------- GuideQuestion
                       :user_id                   :bookclub_book_id 
                       :guide_question_id         :chapter
                       :comment                   :question
```

##Hurdles in Project

###Handling User Creation and Persistent Login
This was my first project where I was able to create user functionality: ability to create an account, log in and out, and persistently stay logged in using cookies. I used bcrypt gem to create protective passwords and enabled cookies in RoR so I could track sessions to keep the user logged in.

####User and Cookies Implementation
**Enabling Cookies**
Since I was using RoR as an API I had to re-enable the ability to use cookies.
```rb
#application.rb

require_relative "boot"
require "rails"
 
module BestBooksApi
 class Application < Rails::Application
   config.load_defaults 6.1
   config.api_only = true
 
   # Adding back cookies and session middleware
   config.middleware.use ActionDispatch::Cookies
   config.middleware.use ActionDispatch::Session::CookieStore
 
   # Use SameSite=Strict for all cookies to help protect against CSRF
   config.action_dispatch.cookies_same_site_protection = :strict
 end
end

```

**Routes For Sessions and Users**
```rb
#routes.rb

Rails.application.routes.draw do
  namespace :api do
	resources :users, only: [:index, :destroy, :update]
	post "/signup", to: "users#create"
	get "/me", to: "users#show"
 
	post "/login", to: "sessions#create"
	delete "/logout", to: "sessions#destroy"
  end
 
  get "*path", to: "fallback#index", constraints: ->(req) { !req.xhr? && req.format.html? }
 
end
```
####Creating a User
![create a user](https://media4.giphy.com/media/20kVvmkR68jWfq3TtJ/giphy.gif?rid=giphy.gif&ct=g)

When a new user is created it creates a session cookie to keep the user logged in. Once the user is entered into the database, the user information is set to the front-end.

**Backend**
```rb
#user_controller.rb
class Api::UsersController < ApplicationController
 
   skip_before_action :authorize, only: :create
 
   def create
   	user = User.create(user_params)
 
   	if user.valid?
       	session[:user_id] = user.id
       	render json: user, status: :created
   	else
       	render json: { errors: user.errors.full_messages }, status: :unprocessable_entity
   	end
   end
 
   def show
   	user = @current_user
   	render json: user, include: ['bookclubs', 'bookclubs.users', 'bookclubs.bookclub_books', 'bookclubs.bookclub_books.book', 'bookclubs.bookclub_books.goals', 'bookclubs.bookclub_books.guide_questions', 'bookclubs.bookclub_books.guide_questions.comments']
   	# render json: user
   end
 
   def update
   	user = @current_user
   	user.update(user_params)
   	render json: user, status: :accepted
   end
 
   def destroy
   	@current_user.destroy
   	head :no_content
   end
 
   private
 
   def user_params
   	params.permit(:email, :first_name, :last_name, :location, :profile_color, :password, :password_confirmation, :bookclubs)
   end
end
```

```rb
#user_serializer.rb

class UserSerializer < ActiveModel::Serializer
  attributes :id, :email, :first_name, :last_name, :full_name, :location, :profile_color
 
  has_many :bookclubs
 
  def full_name
	"#{self.object.first_name} #{self.object.last_name}"
  end
end
```
**Front-end**
```js
import * as React from 'react'
import { Button, TextField, Alert, Stack } from '@mui/material'
import { useNavigate } from 'react-router'
 
const FormSignup = ({ onLogin }) => {
  const [firstName, setFirstName] = React.useState('')
  const [lastName, setLastName] = React.useState('')
  const [email, setEmail] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [passwordConfirmation, setPasswordConfirmation] = React.useState('')
  const [location, setLocation] = React.useState('')
  const [errors, setErrors] = React.useState([])
 
  let navigate = useNavigate()
 
  const handleSubmit = (e) => {
	e.preventDefault()
	setErrors([])
	fetch('/api/signup', {
  	method: 'POST',
  	headers: {
    	'Content-Type': 'application/json',
  	},
  	body: JSON.stringify({
    	first_name: firstName,
  	  last_name: lastName,
    	password,
    	password_confirmation: passwordConfirmation,
    	email,
    	location,
    	profile_color: '#004d40',
  	}),
	}).then((response) => {
  	if (response.ok) {
    	response
      	.json()
      	.then((user) => onLogin(user))
      	.then(navigate('/'))
  	} else {
    	response.json().then((err) => setErrors(err.errors || [err.error]))
  	}
	})
  }
 
  return (
	<form onSubmit={handleSubmit} className='form'> …
	</form>
  )
}
 
export default FormSignup
```


####Keeping a User Logged In
![persistent logged in user](https://media2.giphy.com/media/u2KeeA9xbC6vkLKUzq/giphy.gif?rid=giphy.gif&ct=g)
When the app initially loads for a user, it makes a fetch request  to `/me` to see if the session cookie already exists. If the cookie isn’t present, an unauthorized error is sent back to the front-end. The authorized method is set up in the `application_controller.rb` file.

**Backend**
```rb
class ApplicationController < ActionController::API
   include ActionController::Cookies
   rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity
 
 
   before_action :authorize
	private
	def authorize
 	@current_user = User.find_by_id(session[:user_id])
  	render json: { errors: ["Not Authorized"] }, status: :unauthorized unless @current_user
   end
	def render_unprocessable_entity(exception)
 	render json: { errors: exception.record.errors.full_messages }, status: :unprocessable_entity
   end
 end
```
**Front-end**
```js
 React.useEffect(() => {
	// auto-login
	handleCheckLogin()
 
	//fetch list recommendations
	handleFetchRecommendations()
  }, [])
 
  const handleCheckLogin = () => {
	fetch('/api/me').then((response) => {
  	if (response.ok) {
    	response.json().then((user) => {
      	setUser(user)
    	})
  	} else {
    	response.json().then((err) => console.log(err))
  	}
	})
  }

```



####Logging In and Out of Best Books
![user logout](https://media1.giphy.com/media/8fYt6t17ONAOQpYbLE/giphy.gif?rid=giphy.gif&ct=g)
The `/login` and `/logout` routes are sent to the Sessions controller. If the user and password our found, a session is created and the user’s info is sent to the front-end. When a user logs out, the session cookie is destroyed.

**Backend**
```rb
#sessions_controller.rb

class Api::SessionsController < ApplicationController
   skip_before_action :authorize, only: :create
 
   def create
   	user = User.find_by(email: params[:email])
 
   	if user&.authenticate(params[:password])
       	session[:user_id] = user.id
       	render json: user, status: :created
   	else
       	render json: { errors: ["Invalid username or password"] }, status: :unauthorized
   	end
   end
 
   def destroy
   	session.delete :user_id
   	head :no_content
   end
end
```
**Front-end**
```js
 import * as React from 'react'
import { Button, TextField, Alert, Stack } from '@mui/material'
import { useNavigate } from 'react-router'
 
//login
const FormLogin = ({ onLogin }) => {
  const [email, setEmail] = React.useState('')
  const [password, setPassword] = React.useState('')
  const [errors, setErrors] = React.useState([])
 
  let navigate = useNavigate()
 
  const handleSubmit = (e) => {
	e.preventDefault()
	setErrors([])
	fetch('/api/login', {
  	method: 'POST',
  	headers: {
    	'Content-Type': 'application/json',
  	},
  	body: JSON.stringify({
    	password,
    	email,
  	}),
	}).then((response) => {
  	if (response.ok) {
    	response
      	.json()
      	.then((user) => onLogin(user))
     	 .then(navigate('/'))
  	} else {
    	response.json().then((err) => setErrors(err.errors || [err.error]))
  	}
	})
  }
 
  return (
	<form onSubmit={handleSubmit} className='form'>
	</form>
  )
}
 
export default FormLogin

//logout
  const handleLogout = () => {
	fetch('/api/logout', {
  	method: 'DELETE',
	}).then((response) => {
  	if (response.ok) setUser(null)
	})
  }

```



###Handling Book Clubs
![creating a book club](https://media0.giphy.com/media/sU3Nz3mM6LDelrrlnF/giphy.gif?rid=giphy.gif&ct=g)
A user can create new book clubs, update book club info, add books to a book club, and delete a book club if they are the admin. Whenever a book club page is visited, a fetch is made to the backend and the book club information is sent back.

####Book Club Implementation
**Backend**
Most database information is sent whenever a GET request is made to retrieve a book club. When a book club is created an automatic book club user is created with the current logged in user and makes them the book club admin.
```rb
#bookclubs_controller.rb

class Api::BookclubsController < ApplicationController
	rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response
	before_action :set_bookclub, only: [:show, :destroy]
	skip_before_action :authorize, only: [:index, :show]
 
	def index
    	bookclubs = Bookclub.all
    	render json: bookclubs, status: :ok
	end
 
	def show
    	bookclub = @bookclub
    	render json: bookclub, include: ['users', 'bookclub_books', 'bookclub_books.book', 'bookclub_books.goals', 'bookclub_books.guide_questions', 'bookclub_books.guide_questions.comments'], status: :ok
	end
 
	def create
    	user = @current_user
    	bookclub = user.bookclubs.create(bookclub_params)
    	bookclub_user = user.bookclub_users.find_by(bookclub_id: bookclub.id)
    	bookclub_user.isAdmin = true
    	bookclub_user.save
 
       
    	render json: bookclub, include:  ['users', 'bookclub_books', 'bookclub_books.book', 'bookclub_books.goals', 'bookclub_books.guide_questions', 'bookclub_books.guide_questions.comments'], status: :created
 
	end
 
	def destroy
    	@bookclub.destroy
    	head :no_content
	end
 
	private
 
	def bookclub_params
    	params.permit(:name)
	end
 
	def set_bookclub
    	@bookclub = Bookclub.find(params[:id])
	end
 
	def render_not_found_response
    	render json: { error: 'Book Club Not Found' }, status: :not_found
	end
 
end
```

**Front-End**
routes with React Router
```js
<Route path='bookclub' element={<BookClubPage />}>
            	<Route
              	path=':id'
              	element={
                	<BookClub
                  	user={user}
                  	loading={loading}
                  	bookclub={currentBookclub}
                  	handleFetchBookClub={handleFetchBookClub}
                	/>
              	}>
              	<Route
                	path='admin-dashboard'
                	element={
                  	<BookClubDashboard
                    	bookclub={currentBookclub}
                    	setCurrentBookclub={setCurrentBookclub}
                    	fetchUser={handleCheckLogin}
                    	user={user}
                  	/>
                	}
              	/>
              	<Route
                	path='current-book'
                	element={
                  	<BookClubCurrenBook
                    	bookclub={currentBookclub}
                    	user={user}
                    	loading={loading}
                    	handleFetchBookClub={handleFetchBookClub}
                  	/>
             	   }
              	/>
              	<Route
                	path='wishlist'
                	element={
                  	<BookClubWishlist
                    	bookclub={currentBookclub}
                    	user={user}
       	             setCurrentBookclub={setCurrentBookclub}
                    	setCurrentBook={setCurrentBook}
                    	handleFetchBookClub={handleFetchBookClub}
                  	/>
                	}
              	/>
        	      <Route
                	path='history'
                	element={
                  	<BookClubHistory
                    	bookclub={currentBookclub}
                    	user={user}
                    	setCurrentBookclub={setCurrentBookclub}
                    	handleFetchBookClub={handleFetchBookClub}
                  	/>
                	}
              	/>
            	</Route>
</Route>
```

fetching a book club with the id param
```js
 const handleFetchBookClub = (bookClubId) => {
	setCurrentBookclub(null)
	setLoading(true)
	fetch(`/api/bookclubs/${bookClubId}`)
  	.then((response) => response.json())
  	.then((data) => {
    	setLoading(false)
    	setCurrentBookclub(data)
  	})
  	.catch((err) => {
    	console.error(err)
  	})
  }
 
import * as React from 'react'
import { Grid, Typography } from '@mui/material'
import BookClubMenu from '../../components/nav/BookClubMenu'
import Loading from '../../components/Loading'
import { useParams, Outlet } from 'react-router'
 
const Bookclub = ({ user, handleFetchBookClub, loading, bookclub }) => {
  let params = useParams()
 
  React.useEffect(() => {
	handleFetchBookClub(params.id)
  }, [])
 
  return loading ? (
	<Grid container alignItems='center' justifyContent='center'>
  	<Loading />
	</Grid>
  ) : (
	<>
  	{bookclub &&
    	(bookclub.error || bookclub.errors ? (
      	<Grid
        	item
        	container
        	flexDirection='column'
        	wrap='nowrap'
        	alignItems='center'>
        	<Typography component='h1' variant='h4' align='center'>
          	{bookclub.error ? bookclub.error : bookclub.errors}
        	</Typography>
      	</Grid>
    	) : (
      	<>
        	<Grid item xs={12} md={4} lg={3}>
          	<BookClubMenu user={user} bookclub={bookclub} />
        	</Grid>
 
        	<Grid
          	item
          	container
          	flexDirection='column'
          	spacing={3}
          	xs={12}
          	md={8}
          	lg={9}
          	sx={{ pl: 4 }}>
          	<Outlet />
        	</Grid>
      	</>
    	))}
	</>
  )
}
 
export default Bookclub
```

####Difficulty Updating Book Club Users
![updating a book club](https://media4.giphy.com/media/GqGgIHIJgQEKchmjDj/giphy.gif?rid=giphy.gif&ct=g)
One of my biggest struggles with this app was updating the book club users. I nearly wanted to give up on this app a few times during this process. The ability to add other users to a book club is essential to my app being functional. After all, what is a book club if only one person is in it?
 
As you could see from my project overview, I had to create 3 joint tables with many-to-many relationships. It was my first time tackling joint tables, and I had difficulty on where to make updates and calls.

**Routes**
I decided to handle all book club user-related calls in the book club controller rather than creating a controller for book club users. I’m still not sure if this was the best way to implement calls for changes but it felt like the most efficient way to get the information I needed on the front-end once a request was made.
```rb
Rails.application.routes.draw do
  # For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
  # Routing logic: fallback requests for React Router.
  # Leave this here to help deploy your app later!
  namespace :api do
	patch "/bookclubs/:id/current-book", to: "bookclubs#current_book"
	resources :bookclubs
   
	resources :books, only: [:show, :create, :destroy]
   
	resources :bookclub_books, only: [:index, :destroy, :update]
 
	resources :goals, only: [:show, :create, :update, :destroy]
 
	resources :guide_questions, only: [:show, :create, :update, :destroy]
 
	resources :comments, only: [:create, :destroy]
   
  end
 
  get "*path", to: "fallback#index", constraints: ->(req) { !req.xhr? && req.format.html? }
 
end
```

**Front-End**
If a user is the admin for a book club, they will be able to access the admin dashboard. Here, the user can update the book club name; view, add, and delete users; and change the admin of the book club.
 
When the admin dashboard form is loaded, it makes a fetch to the backend to receive all users. This gives the admin the ability to add anyone that already has an account with Best Books. An admin has the ability to set a new admin, but is not able to delete the admin. (If they have access to the admin dashboard, they are the admin.)

```js
import * as React from 'react'
import '../../css/Form.css'
import { useNavigate } from 'react-router-dom'
 
const FormBookClub = ({ bookclub, setCurrentBookclub, fetchUser }) => {
  let navigate = useNavigate()
  const [name, setName] = React.useState(bookclub ? bookclub.name : '')
  const [adminId, setAdminId] = React.useState(
	bookclub ? bookclub.admin.id : null
  )
  const [currentUsers, setCurrentUsers] = React.useState(
	bookclub ? bookclub.users : []
  )
  const [deleteUsers, setDeleteUsers] = React.useState([])
  const [allUsers, setAllUsers] = React.useState([])
 
  const [newUsers, setNewUsers] = React.useState([])
  const [errors, setErrors] = React.useState([])
  const [updated, setUpdated] = React.useState(false)
  const [loading, setLoading] = React.useState(false)
 
  React.useEffect(() => {
	setName(bookclub ? bookclub.name : '')
	setAdminId(bookclub ? bookclub.admin.id : null)
	setCurrentUsers(bookclub ? bookclub.users : [])
 
	fetch('/api/users')
  	.then((response) => response.json())
  	.then((data) => setAllUsers(data))
  	.catch((err) => {
    	console.error(err)
  	})
  }, [bookclub])
 
  const handleSubmit = (e) => {
	e.preventDefault()
	setErrors([])
	setLoading(true)
	setUpdated(false)
 
	const deleteUserIds = deleteUsers ? deleteUsers.map((user) => user.id) : []
	const addUserIds = newUsers ? newUsers.map((user) => user.id) : []
 
	fetch(`/api/bookclubs/${bookclub.id}`, {
  	method: 'PATCH',
  	headers: {
    	'Content-Type': 'application/json',
    	Accept: 'application/json',
  	},
  	body: JSON.stringify({
    	name,
    	admin_id: adminId,
    	delete_users: deleteUserIds,
    	add_users: addUserIds,
  	}),
	}).then((response) => {
  	setLoading(false)
  	setDeleteUsers([])
  	setNewUsers([])
  	if (response.ok) {
    	setUpdated(true)
    	response.json().then((data) => {
      	setCurrentBookclub(data)
      	fetchUser()
    	})
  	} else {
    	response.json().then((err) => {
      	if (err.exception) {
        	fetchUser()
 	       navigate('/profile/my-bookclubs')
      	} else {
        	setErrors(err.errors || [err.error])
      	}
    	})
  	}
	})
  }
 
  const handleDeleteCurrentMemberClick = (user) => {
	setDeleteUsers((prevUsers) => [...prevUsers, user])
  }
 
  const handleAddCurrentMemberClick = (user) => {
	const newDeltedUsers = deleteUsers.filter((u) => u.id !== user.id)
	setDeleteUsers(newDeltedUsers)
  }
 
  let filteredOptions = () => {
	const currentUserIds = currentUsers
  	? currentUsers.map((user) => user.id)
  	: []
 
	const allUserIds = allUsers ? allUsers.map((user) => user.id) : []
 
	const filteredIds = allUserIds.filter((id) => currentUserIds.includes(id))
 
	const filteredUsers =
  	filteredIds.length === 0
    	? []
    	: allUsers.filter((user) => !filteredIds.includes(user.id))
	return filteredUsers
  }
 
  return (
	<form onSubmit={handleSubmit} className='form'>
	</form>
  )
}
 
export default FormBookClub
 
```
**Backend**
```rb
#bookclub_controller.rb

class Api::BookclubsController < ApplicationController
	rescue_from ActiveRecord::RecordNotFound, with: :render_not_found_response
	before_action :set_bookclub, only: [:show, :destroy]
	skip_before_action :authorize, only: [:index, :show]
 
	def update
    	bookclub = Bookclub.find(params[:id])
    	bookclub.update(bookclub_params)
 
    	#check if admin is changed
    	admin_bookclub_user = bookclub.bookclub_users.find {|user| user.isAdmin == true }
    	admin_id = admin_bookclub_user.user_id
 
    	if params[:admin_id] != admin_id
        	admin_bookclub_user.update(isAdmin: false)
        	new_admin_bookclub_user = bookclub.bookclub_users.find_by(user_id: params[:admin_id])
        	new_admin_bookclub_user.update(isAdmin: true)
    	end
 
       
    	# delete users if needed
    	if !params[:delete_users].empty?
        	users = params[:delete_users].each do |user_id|
            	bookclub_user = bookclub.bookclub_users.find_by(user_id: user_id)
            	bookclub_user.destroy
        	end
    	end
 
    	# add users if needed
    	if !params[:add_users].empty?
 	       params[:add_users].each do |user_id|
            	bookclub.bookclub_users.create(user_id: user_id, isAdmin: false)
        	end
    	end
 
    	render json: bookclub, include: ['users', 'bookclub_books', 'bookclub_books.book', 'bookclub_books.goals', 'bookclub_books.guide_questions', 'bookclub_books.guide_questions.comments'], status: :accepted
	end
 
	private
 
	def bookclub_params
    	params.permit(:name)
	end
 
	def set_bookclub
    	@bookclub = Bookclub.find(params[:id])
	end
 
	def render_not_found_response
    	render json: { error: 'Book Club Not Found' }, status: :not_found
	end
 
end
 

```

##Other Best Book Capabilities

###Adding a Book To a Book Club
I used the Good Reads API to be able to search and get book information so a user can add it to their book club.
![adding a book to a book club](https://media2.giphy.com/media/aTTkg14yqwBHgtw4QQ/giphy.gif?rid=giphy.gif&ct=g)

###Move Books in Book Club
A user is able to add a book to a book club wishlist, make it the book club’s current book, and archive a book if they are finished with it.
![making a book the currently reading book](https://media3.giphy.com/media/oruQrH3EvRCTKxXWJs/giphy.gif?rid=giphy.gif&ct=g)

###Add Goals, Questions, and Comments to a Book Club
A user has the ability to add goals for the current books, add questions, and comment on the guide questions for book clubs they belong to.
**Adding A Goal**
![adding a goal](https://media1.giphy.com/media/hkJD4LXM44B73SE5Dh/giphy.gif?rid=giphy.gif&ct=g)

**Adding Questions and Comments**
![adding comments and questions](https://media0.giphy.com/media/udF2LlmpVbTZwvrijk/giphy.gif?rid=giphy.gif&ct=g)

##Final Thoughts
I am proud of this app’s capabilities. I didn’t get to cover all of the app’s abilities (including updating and deleting your profile) in this post, but I did try to use all CRUD actions for each model where it made sense.
 
I do still want to add a feature to this app that lets users search all book clubs and request to join them. When the admin logs in they would then be able to approve or reject the request. Right now, you can only join a book club after receiving an invitation from a book club admin.
 
As always, thank you for going through this post. I hope it helped you understand my process a little more. I am on to my final phase and project for Flatiron.