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.

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
: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.

#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

Creating a User

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

Keeping a User Logged In

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))
    }
    })
  }

Logging In and Out of Best Books

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)
    })
  }

Handling Book Clubs

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.

#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

Difficulty Updating Book Club Users

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

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

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

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

Adding Questions and Comments
adding comments and questions

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.

14