17
Best Books: My Fullstack React & Ruby On Rails App
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.
- 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
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.
- Has many book club users
- Has many comments
- Belongs to a user
- Belongs to a book club
- Belongs to a book
- Belongs to a book club
- Has many goals
- Has many guide questions
- Belongs to a book club book
- Belongs to a book club book
- Has many comments
- Belongs to a user
- Belongs to a guide question
: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
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.
Enabling Cookies
Since I was using RoR as an API I had to re-enable the ability to use cookies.
#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
#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
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
#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
#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
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
Backend
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
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))
}
})
}
Backend
#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
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)
})
}
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.
#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
<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
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
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.
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.)
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
#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
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.
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.
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
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.
17