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.

    25

    This website collects cookies to deliver better user experience

    Best Books: My Fullstack React & Ruby On Rails App