DEV Community

Cover image for Best Books:  My Fullstack React & Ruby On Rails App
Christine Contreras
Christine Contreras

Posted on

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Creating a User

create 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
Enter fullscreen mode Exit fullscreen mode
#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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Keeping a User Logged In

persistent logged in user
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

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Logging In and Out of Best Books

user logout
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

#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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

Handling Book Clubs

creating a book club
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.

#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
Enter fullscreen mode Exit fullscreen mode

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>
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Difficulty Updating Book Club Users

updating a book club
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.

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
Enter fullscreen mode Exit fullscreen mode

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

Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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.

Discussion (1)

Collapse
jenna7899 profile image
Jenna7899

I am studying programming right now and it is really hard to do without additional knowlege and professional books. Being a student is not so easy and I don't have enough money on buying books so bookscouter.com/rental helps to rent college books online. I find this way really convenient, because you can always share your books with other students and to save money on it.