Creating a Forum CMS with React.js and Ruby on Rails by@Aaron Rory

Creating a Forum CMS with React.js and Ruby on Rails

React.js and Ruby on Ruby on Rails are creating a Forum CMS for the site. We will be using React-Quill, Ruby on. Rails, React-.js, Cloudinary, and Action Mailer to build a forum whose content is manageable by the site's users. The CMS is based on React.JS, Ruby. on. and React.js. It will be used to create a forum for administrators to post and comment on posts. The admin_view_only field determines whether forum can be viewed only by administrators.
image
Aaron Newbold Hacker Noon profile picture

Aaron Newbold

Full-Stack Developer - JavaScript, React, Ruby, Rails. Portfolio: https://aaronrory.com

For this project, we will be using React.js, Ruby on Rails, React-Quill, Cloudinary, and Action Mailer to build a forum whose content is manageable by the site's users.

Features included in this Forum CMS:

Admin Panel

  • Can disable a user's ability to post
  • Can disable a user's ability to comment on posts
  • Can promote a user to administrator (3 levels)

Forum Handling

  • Create forums
  • Create Subforums (at least 1 level deep)
  • Rename forums and subforums
  • Remove forums and subforums (and all associated posts)
  • Create administrative only forums
    - Only administrators of a certain level can post on these forums
    - Allow the forum to be viewed only by administrators

Post Handling

  • Create Format-Rich Topics using React-Quill
  • Edit Topics
  • Pin Topics
  • Lock Posts to prevent additional comments
  • Comment on Topics
  • Comment on Comments
  • Remove Topics

Profile Handling

  • Profile image uploads using Cloudinary
  • Account Activation by email confirmation
  • Password changing
  • Password resetting by email confirmation

    Entity Relationship Diagram (ERD) And Association Planning

    Planning out the different tables, their fields, and their various associations beforehand helps a lot in guiding and keeping your database focused.

    image

    Chart built using website: LucidChart.com

    Users Table

    image

    Fields needed are,

    username 
    (string)
    ,
    password_digest 
    (string),
    email 
    (string)
    ,
    is_activated 
    (bool)
    ,
    activation_key 
    (string)
    ,
    token 
    (string)
    ,
    admin_level 
    (integer)
    ,
    can_post_date 
    (datetime)
    , and
    can_comment_date 
    (datetime)
    . The fields
    id 
    (integer)
    and
    created_at 
    (timestamps)
    are automatically created by Rails.

    Our user will need a username as a means of identification. The email address will be used to contact them to send over an activation_key which will hold a URL link to set the is_activated flag on the user account (Account verification by email).

    The token field will hold a random string which we will use for session persistence and checking if the user is still logged in.

    The password_reset_token field will also hold a random string which we will use for resetting a user's password once requested.

    Both the token_date and password_reset_date fields will be used to check whether a token is valid or has expired.

    The admin_level field will span between 4 integers, 0 - 3.

    Level 3 (Site Owner)

    • Cannot be modified by any other moderator
    • Can remove or add Moderators
    • Can disable a user's ability to comment and create topics
    • Can create and edit Forums and subForums

    Level 2 (Moderator)

    • Can remove or add Moderators
    • Can create and edit Forums and subForums
    • Can disable a user's ability to comment and create topics

    Level 1 (Forums Moderator)

    • Can create and edit Forums and subForum
    • Can disable a user's ability to comment and create topics

    Level 0 (Basic User)

    • Can create and edit their own posts
    • Can comment on posts

    Fields can_post_date and can_comment_date will hold DateTime values that determine the expiration date of a user's communications suspension.

    For example, if the date and time stored in any of the fields are greater than the current date and time then that user is unable to communicate using such a medium, and vice versa, if the date and time stored in these fields have already passed then the user's communications are no longer suspended.

    Forums Table

    image

    Fields needed are,

    name 
    (string),
      admin_only 
    (bool),
     
    and 
    admin_view_only 
    (bool)
    .

    The name field is simply an identifier for the main forum.

    The admin_only field determines whether or not this forum only allows posts by administrators.

    The admin_view_only field once check is coupled with the admin_only field, setting both to true and this prevents this forum from being seen or viewed by basic user accounts.

    Subforums Table

    image

    Fields needed are

    name 
    (string), and foreign key 
    forum_id
    .

    The name field will hold the string identifier of the subforum, and the forum_id holds the id of the forum this subforum is a child of.

    Posts Table

    image

    Fields needed are

    title 
    (string), 
    body 
    (text), 
    is_pinned 
    (bool), and 
    is_locked 
    (bool)
    , along with foreign keys
    forum_id
    , 
    subforum_id
    ,
     
    and 
    author_id
    .

    The title field holds the title of the post and the body field holds the text content of the post.

    The field is_pinned determines whether or not a post is sectioned as a pinned Post and takes top priority in the order of presentation when posts are listed.

    The is_locked field determines whether or not a post can be commented on.

    This table is linked to the Forum's Table through the foreign key forum_id and also to the Subforums' Table through the foreign key subforum_id. These fields hold a direct record object association in Ruby on Rails allowing for some pretty useful commands, which we will get into later when setting up the Back-end.

    This table is linked to the User's Table through the foreign key author_id.

    Comments Table

    image

    Fields needed in this table are

    body 
    (text) and foreign keys 
    author_id
    , 
    comment_id
    , and 
    post_id
    .

    The body field simply holds the text of the comment, the author_id holds the id of the user who wrote the comment, and post_id references the post the comment was written under.

    The comment_id field allows comments to be written in response to another comment, essentially commenting on comments. The limit in our case is that we will allow commenting on comments but replying to comment only links the initial comment that was replied to.

    For example, Jane comments on a Post, and John replies to Jane's comment. Ron then replies to John's comment, which is in response to Jane's comment. Let's say Jane's comment has an id of 1 (*id not comment_id), then John's comment_id will be 1 referencing Jane's comment, and Ron's comment_id will also be 1, referencing Jane's comment.

    Setting up the Front-end

    We will build a stand-alone front-end with dummy data in the form of .js files to provide a demo of the features and allow client-based logic testing of these features.

    However, ultimately once the back-end is built, we will switch out the dummy data for the actual API queried database data.

    Create React App

    Since we will be creating a React application, we will use the quick start guide provided by Create React App.

    $ npx create-react-app forum-cms
    $ cd forum-cms

    "forum-cms" is just what I decided to call my app, you can name it whatever you want.

    Install Needed Dependencies

    PropTypes
    Runtime type checking for React props and similar objects.

    You can use prop-types to document the intended types of properties passed to components. React will check props passed to your components against those definitions, and warn in development if they don’t match.

    $ npm i prop-types

    React-Router-DOM
    Allows the use of URL routes to load the different App components

    $ npm i react-router-dom
    

    Axios
    Used for API fetch requests

    $ npm i axios

    React-Quill
    A simple free-to-use rich-text editor, this is what handles runtime text editing. (Bolding, italicizes, underlings, heading, etc...)

    $ npm i react-quill

    Code Linter - ESLint
    This isn't a necessary dependency, but I like to use code linters to catch various errors that may break the program, errors I may have overlooked. Code Linters also enforced code standardization.

    Inside The package.json File

    /package.json
    found within the base directory of your project folder

    Your file should now look similar to this:

    {
      "name": "react-cmsblog",
      "version": "0.1.0",
      "private": true,
      "dependencies": {
        "@testing-library/jest-dom": "^4.2.4",
        "@testing-library/react": "^9.5.0",
        "@testing-library/user-event": "^7.2.1",
        "axios": "^0.20.0",
        "prop-types": "^15.7.2",
        "react": "^16.14.0",
        "react-dom": "^16.14.0",
        "react-quill": "^1.3.5",
        "react-router-dom": "^5.2.0",
        "react-scripts": "3.4.3"
      },

    React and Dependency versions used in build noted above*

    Organizing Folder and File Structure

    I tend to create sub-folders to organize my apps based on relativity. So here's what a basic layout of what my create react app folder structure looks like within the Visual Studio Code Integrated Development Environment (IDE).

    image

    Within the "src" folder, I created an "assets" folder with two sub-folders, "CSS" and "images".

    Then I created a "components" folder with two sub-folders, "functional" and "presentational".

    Lastly, I created a folder titled "tests" and afterward, I moved all files into their relative folders.

    After moving all the files around, you may have to change the import locations of the files within the index.js and App.js files

    index.js

    change from:

    import './index.css'; // line 3

    to:

    import './assets/css/index.css'; // line 3

    App.js

    change from:

    import logo from './logo.svg'; // line 2
    import './App.css'; // line 3

    to:

    import logo from './assets/images/logo.svg'; // line 2
    import './assets/css/App.css'; // line 3

    Now the application should be able to run without errors.

    Using History To Ensure The Page Scroll resets on each new page link

    Okay, I've noticed after creating a few React pages and clicking through between the pages, upon entrance into a new page, the scroll position from the previous page is transferred over to the new page.

    In order to fix that, we will create a Scroll reset(ScrollToTop) component and place that component within the initial render of the ReactDOM function alongside the App component.

    src/components/misc/pageScrollReset.js

    import { useEffect } from 'react';
    import { withRouter } from 'react-router-dom';
    
    function ScrollToTop({ history }) {
      useEffect(() => {
        const unlisten = history.listen(() => {
          window.scrollTo(0, 0);
        });
        return () => {
          unlisten();
        };
      });
    
      return (null);
    }
    
    export default withRouter(ScrollToTop);
    

    The history prop passed to the ScrollToTop component is one of React-Router-DOM's dependencies that allows viewing React-Apps browsing history.

    So within this ScrollToTop component, we call the history.listen action, which is a callback method that listens to location changes, and we supply the

    window.scrollTo(0, 0)
    code, which occurs on each location change noted.

    We then wrap this code within a useEffect Hook, which on render/mount, checks the browsing location history, and on unmount cancels out history listening by calling the unlisten function expression.

    src/index.js

    import React, { StrictMode } from 'react';
    import ReactDOM from 'react-dom';
    import { BrowserRouter as Router } from 'react-router-dom';
    import './assets/css/index.css';
    import App from './App';
    import ScrollToTop from './components/misc/pageScrollReset'; // Scrolls page to top on route switch
    
    ReactDOM.render(
      <Router>
        <ScrollToTop />
        <StrictMode>
          <App />
        </StrictMode>
      </Router>,
      document.getElementById('root'),
    );

    I created a stand-alone version of the Front-end before moving on and developing the Back-end API, but for smooth transitional purposes, I will come back to the Front-end after I explain the making of the Back-end API.

    Setting up the Back-end API

    Now for the easy part...( ͡° ͜ʖ ͡°)

    We will start by creating a new rails application.

    $ rails new forum-cms --database=postgresql --api -T

    Take note whenever you see the $ symbol, we are typing the presented text into the command line.

    This line creates a new rails application named "forum-cms" using PostgreSQL as the database (including the "pg" gem), setting the is API flag and removing MiniTest as the default testing framework.

    Gems to Install

    All the below gems should be added or already included in your Gemfile:

    # Use Active Model has_secure_password
    gem 'bcrypt', '~> 3.1.7'
    gem 'cloudinary'
    gem 'rubocop', '~>0.81.0'
    
    # Use Rack CORS for handling Cross-Origin Resource Sharing (CORS), making cross-origin AJAX possible
    gem 'rack-cors'
    
    group :development, :test do
      gem 'rspec-rails', '~> 3.5'
    end
    
    group :test do
      gem 'database_cleaner'
      gem 'factory_bot_rails', '~> 4.0'
      gem 'faker'
      gem 'shoulda-matchers', '~> 3.1'
    end

    After adding all the necessary gems into the Gemfile (Generally found at the root directory). Run the "bundle install" command into the terminal:

    $ bundle

    "bundle" is the shorthand for "bundle install".

    Creating the Models

    User Model

    image

    Let's start by generating a model and its migrations using the rails generator.

    $ rails g model user username:string password_digest:string email:string is_activated:boolean activation_key:string token:string token_date:datetime password_reset_token:string password_reset_date:datetime admin_level:integer can_post_date:datetime can_comment_date:datetime

    Now in the user.rb model

    /app/models/user.rb
    we will add the following code for validations and associations.

    class User < ApplicationRecord
      has_secure_password
      has_many :posts, inverse_of: 'author', dependent: :destroy
      has_many :comments, dependent: :destroy
      validates :username, length: { in: 4..32 }, presence: true,
                           uniqueness: { case_sensitive: false }
      validates :password, length: { minimum: 8 }
      VALID_EMAIL_REGEX = /\A[\w+\-.][email protected][a-z\d\-.]+\.[a-z]+\z/i.freeze
      validates :email, presence: true, length: { maximum: 255 },
                        format: { with: VALID_EMAIL_REGEX },
                        uniqueness: { case_sensitive: false }
      validates :admin_level, numericality: { only_integer: true,
                                              less_than_or_equal_to: 3 }
      before_save { username.downcase! }
      before_save { email.downcase! }
    end
    

    has_secure_password
     
    is a method supplied by the bcrypt gem used to set and authenticate a password attribute.

    has_many
    allows us to set up model associations; in this case, one(1) user can have many posts and comments. The
    dependent: :destroy
    flag causes all associated posts and comments to be destroyed along with the user. And inverse_of will allow us to rename our association when coupled with the belongs_to code we will place in the
    post.rb
    model

    The regular expression set as a frozen constant, VALID_EMAIL_REGEX is explained here: REGEXR.

    Also, take note of the before_save functions used. Before saving each user record, I take the values for both username and email and make sure they are entirely downcased. This preventing needing the use of ILIKE (POSTGRESQL search method for finding records without checking for case sensitivity) coupled with the where function later on when designing the login authentication system.

    Check out the Rails Guide for an overview of all of the validations used.

    Forum Model

    image

    Generate forum.rb model:

    $ rails g model forum name:string admin_only:boolean admin_only_view:boolean

    Now in the forum.rb we will add validations and associations

    app/models/forum.rb
    .

    class Forum < ApplicationRecord
      has_many :subforums, dependent: :destroy
      has_many :posts, dependent: :destroy
      validates :name, length: { in: 3..32 }, presence: true,
                       uniqueness: { case_sensitive: false }
      before_save { name.downcase! }
    
      # Grabs all posts without a subforum, while also limiting the amount posts retrieved
      def subforum_posts(per_page = 10, page = 1)
        offset = (page * per_page) - per_page
        retrieved_posts = posts.where(subforum_id: nil)
                               .offset(offset).limit(per_page)
    
        Forum.truncate_posts(retrieved_posts)
      end
    
      # Truncates posts title and body attribute returning a new array
      def self.truncate_posts(posts)
        returned_posts = []
        posts.each do |post|
          new_post = post.as_json(only: %i[id user_id is_pinned created_at])
          new_post['title'] = post.title.slice(0..30)
          new_post['body'] = post.body.slice(0..32)
          new_post['author'] = post.author.username
          new_post['subforum'] = post.subforum.name if post.subforum.present?
          new_post['forum'] = post.forum.name
          returned_posts.push(new_post)
        end
    
        returned_posts
      end
    
      def self.forum_all_json
        returned_json = []
        Forum.all.each do |forum|
          new_forum = forum.as_json
          new_forum['subforums'] = forum.subforums.as_json(only: %i[id name])
    
          returned_json.push(new_forum)
        end
    
        returned_json
      end
    end

    The

    subforum_posts(subform)
    method grabs all posts linked to the current forum without a subforum_id. These posts are then paginated and truncated.

    The

    self.truncate_posts(posts)
    method simply returns a new Hash object with a shortened title and body of a post record along with the name of the author, subforum, and forum. The self. part of the method name ensures that it is a Class Method accessible by invoking the name of the class first to call it. EG.
    Forum.truncate_posts(posts)

    The

    self.forum_all_json
    method simply grabs the resulting array of records from the
    Forum.all
    query and returns a new array with custom packed Hashes including the forum's subforum associations as keys.

    Subforum Model

    image

    Generate forum.rb model:

    $ rails g model subforum name:string forum:belongs_to

    Now in the subforum.rb we will add validations and associations

    app/models/subforum.rb
    .

    class Subforum < ApplicationRecord
      belongs_to :forum
      has_many :posts, dependent: :destroy
      validates :name, length: { in: 3..32 }, presence: true,
                       uniqueness: { case_sensitive: false }
      before_save { name.downcase! }
    
      # Grabs all posts by subforum, while also limiting the amount of posts retrieved
      def subforum_posts(per_page = 10, page = 1)
        offset = (page * per_page) - per_page
        retrieved_posts = posts.offset(offset).limit(per_page)
    
        Forum.truncate_posts(retrieved_posts)
      end
    end
    

    The same logic used in the Forum model is used here:

    Post Model

    image

    Generate model and migrations:

    $ rails g model post title:string body:text forum:belongs_to subforum:belongs_to is_pinned:boolean is_locked:boolean user:belongs_to

    After this migration is generated, we will need to change a line relating to the subforums belongs_to data type.

    class CreatePosts < ActiveRecord::Migration[6.0]
      def change
        create_table :posts do |t|
          t.string :title
          t.text :body
          t.belongs_to :forum, null: false, foreign_key: true
          t.belongs_to :subforum, null: true
          t.boolean :is_pinned, default: false
          t.boolean :is_locked, default: false
          t.belongs_to :user, null: false, foreign_key: true
    
          t.timestamps
        end
      end
    end
    

    We set

    null: true
    and remove the
    foreign_key: true
    argument allowing us to accept null values for the subforum_id foreign key and eliminating the Database Level Foreign Key CHECK constraint.

    Code added in post.rb

    /app/models/post.rb
    .

    class Post < ApplicationRecord
      belongs_to :forum
      belongs_to :subforum, optional: true
      belongs_to :author, class_name: 'User', foreign_key: 'user_id'
      has_many :comments, dependent: :destroy
      validates :title, length: { in: 3..48 }, presence: true
      validates :body, length: { in: 8..20_000 }, presence: true
      scope :pins, -> { where('is_pinned = true') }
      scope :not_pinned, -> { where('is_pinned = false') }
    
      def post_json
        new_post = attributes
        new_post['author'] = author.username
        new_post['subforum'] = subforum.name if subforum.present?
        new_post['forum'] = forum.name
        new_post['admin_only'] = forum.admin_only
        new_post['admin_only_view'] = forum.admin_only_view
        new_post
      end
    
      def self.author_posts_json(posts_array)
        returned_posts = []
        posts_array.each do |post|
          new_post = post.as_json(only: %i[id user_id is_pinned created_at])
          new_post['title'] = post.title.slice(0..30)
          new_post['body'] = post.body.slice(0..32)
          new_post['author'] = post.author.username
          new_post['subforum'] = post.subforum.name if post.subforum.present?
          new_post['forum'] = post.forum.name
          new_post['admin_only'] = post.forum.admin_only
          new_post['admin_only_view'] = post.forum.admin_only_view
          returned_posts.push(new_post)
        end
    
        returned_posts
      end
    
      def self.author_comments_json(comments_array)
        returned_comments = []
        comments_array.each do |comment|
          new_comment = comment.as_json
          new_comment['author'] = comment.author.username
          new_comment['admin_only'] = comment.post.forum.admin_only
          new_comment['admin_only_view'] = comment.post.forum.admin_only_view
          new_comment['server_date'] = DateTime.now
          returned_comments.push(new_comment)
        end
    
        returned_comments
      end
    
      def self.pins_json
        results = []
        all_pins = Post.pins
        all_pins.each do |p|
          new_post = p.post_json
          results.push(new_post)
        end
    
        results
      end
    end

    The

    belongs_to
     
    :author
    line is the inverse of the
    has_many
    line in the user.rb model.

    The

    belongs_to :subforum, optional: true
    method line allows a post to have a null id for its subforum_id without an error.

    The defined scopes allow us to grab all like records from the controller that match the criteria presented in the where method clause.

    All of the methods in the Post model follow the same logic as the methods in the Forum model.

    Comment Model

    image

    Generate comment.rb model:

    $ rails g model comment body:text user:references post:references comment:references

    Now in the comment.rb file

    app/models/comment.rb

    class Comment < ApplicationRecord
      belongs_to :author, class_name: 'User', foreign_key: 'user_id'
      belongs_to :post
      belongs_to :comment, optional: true
      has_many :comments, dependent: :destroy
      validates :body, length: { in: 2..400 }, presence: true
    
      def comment_json
        new_comment = attributes
        new_comment['author'] = author.username
        new_comment
      end
    
      def self.author_comments_json(comments_array)
        returned_comments = []
        comments_array.each do |comment|
          new_comment = comment.as_json
          new_comment['post_author'] = comment.post.author.username
          new_comment['post_title'] = comment.post.title
          new_comment['forum'] = comment.post.forum.name
          new_comment['subforum'] = comment.post.subforum if comment.post.subforum.present?
          new_comment['author'] = comment.author.username
          returned_comments.push(new_comment)
        end
    
        returned_comments
      end
    end

    Nothing new here, so moving along.

    Creating the Controllers

    Registrations Controller

    This controller will handle user creation when a user signs up for a new account. I won't go over every single method created in these controllers but just touch on why and what some of the more interesting ones were made to do (Check out the GitHub repository for the entire codebase).

    Let's define some general helper methods first that we will use in a few of this controller's methods.

      private
    
      def register_params
        # whitelist params
        params.require(:user)
              .permit(:username, :email, :password, :password_confirmation)
      end
    
      def password_params
        # whitelist params
        params.require(:user)
              .permit(:password, :password_confirmation)
      end

    These methods essentially create a hash based on the permitted symbol values while also grabbing these values from client-submitted parameters.

    EG. register_params
    generates...

    { username: params[:user][:username],
      email: params[:user][:email],
      password: params[:user][:password],
      password_confirmation: [:user][:password_confirmation] }

    And this method simply returns that hash.

    Create method

    The create method in the registrations controller will handle new user registrations.

      # Register a new user account
      def create
        user = User.create!(register_params)
        new_activation_key = generate_token(user.id, 62)
        user.update_attribute(:admin_level, 3) if User.all.size <= 1
        if user.update_attribute(:activation_key, new_activation_key)
          ActivationMailer.with(user: user).welcome_email.deliver_now
        end
        json_response({ message: 'Account registered but activation required' },
                      :created)
      end
    

    The basic idea of the create route and how it should function is, a user wants to create an account, so we receive their preferred account credentials through the register_params method and create a new User record.

    Since we want email confirmation (scroll down below to section about setting up Action mailer for more info on that), upon creating this new User record, we also generate an email_confirmation_token/activation_key (check down below for setting up the Application controller) then we store this value on the User's record and also send the value along with a route for account activation to the User's related email address given.

    The line where it updates the User's

    :admin_level
    to 3 is set to happen only if this is the first user created in the database to give this user administrative rights.

    Then ultimately, a JSON response is rendered, and the message: 'Account registered by activation required' is sent to the client.

    Activate Account method

    This method works in tandem with the create method, a GET route leading to this method is sent in the email to confirm a User's account.

      # Link used in account activation email
      def activate_account
        # Set url variable to the front-end url
        url = 'https://arn-forum-cms.netlify.app/login'
        user = User.find(params[:id])
    
        if user.activation_key == params[:activation_key]
          user.update_attribute(:is_activated, true)
        end
    
        # json_response(message: 'Successfully activated account')
        redirect_to url
      end

    It checks the

    :activation_key
    parameter supplied is the same as the one saved in the User's record, then updates the
    :is_activated
    attribute, and afterward redirects the user to the login page on the client end.

    Forgot Password method

    Now let's look at a similar chain of methods that also deals and uses tokens.

      # Generate password reset token and send to account's associated email
      def forgot_password
        user = User.find_by(email: params[:email])
        if user
          new_token = generate_token(user.id, 32, true)
          if user.update_attribute(:password_reset_token, new_token)
            user.update_attribute(:password_reset_date, DateTime.now)
            ActivationMailer.with(user: user).password_reset_email.deliver_now
          else
            json_response({ errors: user.errors.full_messages }, 401)
          end
        end
        json_response({ message: 'Password reset information sent to associated account.' })
      end

    Follows the same logic as the

    email_confirm_token
    /
    activation_key
    . A new token is generated and stored on the User record; then, an email is sent out to the confirmed email address containing a link to the route that verifies the token.

      # Link used in account password reset email
      def password_reset_account
        # Set url variable to the front-end url
        reset_token = params[:password_reset_token]
        url = "https://arn-forum-cms.netlify.app/reset_password?token=#{reset_token}"
    
        redirect_to url
      end

    This is the method used in the password reset email, which redirects to the

    /reset_password
    page on the client (front-end) while also supplying the reset_token as a parameter.

    Now for the final method used in the password_reset chain

      # Change a user's password if they have a password reset token
      def change_password_with_token
        token = params[:password_reset_token]
        user = User.find_by(password_reset_token: token) if token.present?
        if user
          # Check if token is still valid
          return json_response({ message: 'Token expired' }, 400) if user.password_token_expired?
    
          if user.update(password_params)
            user.update_attribute(:password_reset_token, nil)
            json_response({ message: 'Password changed successfully' })
          else
            json_response({ errors: user.errors.full_messages }, 400)
          end
        else
          json_response({ errors: 'Invalid Token' }, 401)
        end
      end

    Once the reset_token is received by the front-end client, and the user is redirected to the reset_password page through the email link. It is used here in this method to allow the user to reset their password using the password_params method to gather their new password values. The password_reset token is checked for date validity through the User model method added here:

    Now in the user.rb file

    app/models/user.rb

      def password_token_expired?
        offset = (Time.zone.now - password_reset_date).round
        offset / 1.hours >= 1 # Token expires after 1 hour
      end

    After all the checks run through, the user's password is updated to the newly given password parameters.

    Application Controller

    Here is where we will store helper methods used frequently in multiple controllers.

    class ApplicationController < ActionController::API
      include Response
      include ExceptionHandler
      include TokenGenerator
      include CompareDates
    
      # Determine if user is authenticated
      def authorized_user?
        json_response({ errors: 'Account not Authorized' }, 401) unless current_user
      end
    
      # Determine if user is authenticated administrator
      def authorized_admin?
        authorized_user?
        json_response({ errors: 'Insufficient Administrative Rights' }, 401) unless @current_user.admin_level.positive?
      end
    
      private
    
      # Sets a global @current_user variable if possible
      def current_user
        return nil unless access_token.present?
    
        @current_user ||= User.find_by(token: access_token)
        return nil unless @current_user
        return nil if token_expire?(@current_user.token_date)
    
        @current_user
      end
    
      # Determines if token is expired based on the amount of time between the token_date and server date
      # Default expiration date is 1 day after creation
      def token_expire?(token_date, days = 1, hours = 24, minutes = 0, seconds = 0)
        date_diff = compare_dates(token_date)
    
        if date_diff[:days] >= days && date_diff[:hrs] >= hours &&
           date_diff[:mins] >= minutes && date_diff[:secns] >= seconds
          true
        end
    
        false
      end
    
      # Grabs the token placed in the HTTP Request Header, "Authorization"
      def access_token
        request.headers[:Authorization]
      end
    end

    Pretty straightforward here, but notice the included modules at the top of the Application Controller?

    Let's take a look at each one...

    Include Response

    module Response
      def json_response(object, status = :ok)
        render json: object, status: status
      end
    end

    A simple module that takes two(2) arguments, an object, and a status, and then returns the rails render method in JSON format using the arguments given as its options.

    Include ExceptionHandler

    module ExceptionHandler
      extend ActiveSupport::Concern
    
      included do
        rescue_from ActiveRecord::RecordNotFound do |e|
          json_response({ errors: e.message }, :not_found)
        end
    
        rescue_from ActiveRecord::RecordInvalid do |e|
          json_response({ errors: e.message }, :unprocessable_entity)
        end
      end
    end
    

    Some information here on ActiveSupport::Concern (Here's some additional info Here).

    In short, extending

    ActiveSupport::Concern
    allows us access to the included method which essentially evaluates the given code block in the context of the base class.

    So here's an inheritance chain of what happens in each controller since all controllers inherit from the Application Controller.

    EG.

    RegistrationsController 
    -> 
    ApplicationController 
    -> 
    CompareDates 
    -> 
    TokenGenerator 
    -> 
    ExceptionHandler 
    -> 
    Response 
    -> 
    ActionController::API
      -> ....


    You can see the entire inheritance link by typing in the rails console:

    $ RegistrationsController.ancestors

    Include CompareDates

    # Compares two dates and returns a hash with the difference in seconds, minutes, hours, and days
    module CompareDates
      def compare_dates(date1, date2 = DateTime.now)
        diff_secns = date2.to_time - date1.to_time
        diff_mins = (diff_secns / 60).round
        diff_hrs = (diff_mins / 60).round
        diff_days = (diff_hrs / 24).round
        diff_text = "#{diff_days} day/s, #{diff_hrs % 24} hour/s, #{diff_mins % 60} minute/s, #{(diff_secns % 60).round} second/s"
    
        { diff_string: diff_text,
          days: diff_days, hrs: diff_hrs,
          mins: diff_mins, secns: diff_secns }
      end
    end
    

    This method shows how much time is left for a person that has had their communications suspended by an administrator before the suspension is retracted.

    Include TokenGenerator

    module TokenGenerator
      def generate_token(user_id, token_size = 32, url_safe = false)
        random_ascii = [
          0, 1, 2, 3, 4, 5, 6, 7, 8, 9, '!', '@', '#', '$', '%', '^', '&',
          '*', '(', ')', '-', '_', '+', '|', '~', '=',
          'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
          'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
          'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
          'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
        ]
        url_safe_ascii = [
          0, 1, 2, 3, 4, 5, 6, 7, 8, 9, '!', '@', '#', '$',
          '*', '(', ')', '-', '_', '~',
          'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l', 'm',
          'n', 'o', 'p', 'q', 'r', 's', 't', 'u', 'v', 'w', 'x', 'y', 'z',
          'A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M',
          'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W', 'X', 'Y', 'Z'
        ]
    
        token = [user_id]
    
        (1..token_size - 1).each do
          token.push(random_ascii.sample) unless url_safe
          token.push(url_safe_ascii.sample) if url_safe
        end
    
        token.join('')
      end
    end
    

    The method we use and will use to generate the tokens for email confirmation, password reset confirmation, and user authentication after logging in.

    In order to ensure the tokens are always unique, instead of using a loop that checks if the randomly generated token already exists within the database, I prepend the user's id to each generated token.

    Sessions Controller

    This controller will handle allowing a user who has already signed up and confirmed their email address to log in.

    class SessionsController < ApplicationController
      before_action :authorized_user?, except: :create
    
      # When a user attempts to log in
      def create
        user = User.where(username: params[:user][:username].downcase)
                   .or(User.where(email: params[:user][:email].downcase))
                   .first
    
        return json_response({ errors: 'Incorrect login credentials' }, 401) unless user
    
        authenticate_user(user)
      end
    
      # When a user logs out
      def destroy
        @current_user.update(token: nil)
        json_response(user: { logged_in: false })
      end
    
      # Checks if a user is still logged in
      def logged_in
        json_response(user: user_status(@current_user))
      end
    
      private
    
      # Returns a Hash with additional keys for Front-End use
      def user_status(user)
        user_with_status = user.as_json(only: %i[id username is_activated
                                                 token admin_level can_post_date
                                                 can_comment_date])
        user_with_status['logged_in'] = true
        user_with_status['can_post'] = DateTime.now > user.can_post_date
        user_with_status['can_comment'] = DateTime.now > user.can_comment_date
    
        user_with_status
      end
    
      # Returns user Hash after successful authentication
      def authenticate_user(user)
        if user.try(:authenticate, params[:user][:password])
          return unless activated(user)
    
          new_token = generate_token(user.id)
          if user.update_attribute(:token, new_token)
            user.update_attribute(:token_date, DateTime.now)
            json_response(user: user_status(user))
          else
            json_response({ errors: user.errors.full_messages }, 401)
          end
        else
          json_response({ errors: 'Incorrect login credentials' }, 401)
        end
      end
    
      # Checks to make sure a user has confirmed their email address
      def activated(user)
        unless user.is_activated
          json_response({ errors: ['Account not activated'] }, 401)
          return false
        end
    
        true
      end
    end

    Fairly straightforward authentication procedure here...

    The Create method ensures that a user exists based on the given username or email.

    Afterward, the

    authenticate_user(user)
    method ensures that the password given is linked to the user found, while also calling upon the
    activated(user)
    method to ensure that the user has also confirmed their email address.

    Then, if all checks out, the user is given a new login token which is saved to their record, and a JSON string is returned.

    I would also like to point out the

    before_action :authorized_user?
    line. This simply calls the
    authorized_user?
    method we created in the Application Controller before every method within the Sessions Controller.

    This method and its variant

    (
    authorized_admin?
    )
    will be used in all of the controllers to ensure that the person calling these routes/methods are users who are actually logged into the forum.

    Forum Controller

    Holds all of the forum handling methods for creation, updating, deletion, and displaying.

    class ForumsController < ApplicationController
      before_action :authorized_admin?, only: %i[create update destroy]
      before_action :set_forum, only: %i[update destroy]
      before_action :set_page_params, only: %i[index show_by_forum show_by_subforum]
    
      # Shows all Forum records and appends their posts and subforums by
      # adding new keys after converting to a hash
      def index
        all_forums = []
        Forum.all.each do |forum|
          new_forum = forum.attributes
          new_forum['posts'] = forum.subforum_posts(@per_page, @page)
          new_forum['subforums'] = return_subforums(forum, @per_page, @page)
          all_forums.push new_forum
        end
    
        json_response(results: { forums: all_forums, pinned_posts: Post.pins_json,
                                 per_page: @per_page, page: @page })
      end
    
      # Shows all Forum records and their related subforums
      def index_all
        json_response(forums: Forum.forum_all_json)
      end
    
      # Similar process to the index method but only shows one Forum record
      # along with its Subforums and their posts
      def show_by_forum
        forum = Forum.find_by(name: params[:forum])
        selected_forum = forum.attributes
        selected_forum['posts'] = forum.subforum_posts(@per_page, @page)
        selected_forum['subforums'] = return_subforums(forum, @per_page, @page)
    
        json_response(results: { forum: selected_forum,
                                 per_page: @per_page, page: @page })
      end
    
      # Shows not only the Forum record but the posts for a specific Subforum
      def show_by_subforum
        forum = Forum.find_by(name: params[:forum])
        selected_forum = forum.attributes
    
        subforum = Subforum.find_by(name: params[:subforum])
        selected_forum['posts'] = []
        new_subforum = { id: subforum.id,
                         subforum: subforum.name,
                         posts: subforum.subforum_posts(@per_page, @page) }
        selected_forum['subforums'] = [new_subforum]
    
        json_response(results: { forum: selected_forum,
                                 per_page: @per_page, page: @page })
      end
    
      # Creates a Forum record and populates its Subforums through association
      # building. The params[:forum][:subforums] is an array of names for the
      # Subforums to be created.
      def create
        forum = Forum.create!(forum_params)
        all_subforums = params[:forum][:subforums]
        new_subforums = []
        all_subforums.each do |sub|
          new_hash = { name: sub }
          new_subforums.push(new_hash)
        end
        forum.subforums.create!(new_subforums)
        json_response(forums: Forum.forum_all_json)
      end
    
      def update
        if @forum.update(forum_params)
          json_response(forums: Forum.forum_all_json)
        else
          json_response({ errors: @forum.errors.full_messages }, 401)
        end
      end
    
      def destroy
        @forum.destroy
        json_response(forums: Forum.forum_all_json)
      end
    
      private
    
      def set_forum
        @forum = Forum.find(params[:id])
      end
    
      def set_page_params
        @per_page = params[:per_page].present? ? params[:per_page].to_i : 5
        @page = params[:page].present? ? params[:page].to_i : 1
      end
    
      def return_subforums(forum, per_page, page)
        all_subforums = []
        forum.subforums.each do |subforum|
          new_subforum = { id: subforum.id,
                           subforum: subforum.name,
                           posts: subforum.subforum_posts(per_page, page) }
          all_subforums.push(new_subforum)
        end
    
        all_subforums
      end
    
      def forum_params
        params.require(:forum)
              .permit(:name, :admin_only, :admin_only_view)
      end
    end

    Nothing much new going on here, the variables with the @ symbol in the front, @forum, for example, are simply global variables accessible from within the entire class. These variables are then set when used in combination with private methods such as

    set_forum
    and
    set_page_params
    combined with the
    before_action
    method.

    The

    set_page_params
    method plays a part in helping out with paginating the results of a search. This method works in tandem with the
    subforum_posts
    methods found in the Subforum and Forum models.

    Subforums Controller

    class SubforumsController < ApplicationController
      before_action :authorized_admin?, only: %i[create update destroy]
      before_action :set_forum, only: %i[create]
      before_action :set_subforum, only: %i[update destroy]
    
      def create
        @forum.subforums.create!(subforum_params)
        json_response(forums: Forum.forum_all_json)
      end
    
      def update
        if @subforum.update(subforum_params)
          json_response(forums: Forum.forum_all_json)
        else
          json_response({ errors: @forum.errors.full_messages }, 401)
        end
      end
    
      def destroy
        @subforum.destroy
        json_response(forums: Forum.forum_all_json)
      end
    
      private
    
      def set_forum
        @forum = Forum.find(params[:subforum][:forum_id])
      end
    
      def set_subforum
        @subforum = Subforum.find(params[:id])
      end
    
      def subforum_params
        params.require(:subforum).permit(:name)
      end
    end

    Nothing new here but I want to touch the method

    authorized_admin?
    which is run as a
    before_action
    for the methods
    create
    , 
    update
    , and 
    destroy
    .

    As mentioned earlier when creating the methods used across multiple controllers, we added them into the Application Controller. And since all controllers inherit from the Application Controller we have access to those methods.

    authorized_admin?
    essentially just checks that the user calling any of the routes mentioned, has administrative rights, meaning that their account's
    admin_level
    attribute is greater than 0.

    Posts Controller

    The posts controller holds all relevant post-related functions like

    pin_post
    , 
    lock_post
    , and 
    suspended(date)
    .

      def lock_post
        if @post.update(is_locked: [email protected]_locked)
          json_response(post: @post.post_json)
        else
          json_response({ errors: @post.errors.full_messages }, 401)
        end
      end
    
      def pin_post
        if @post.update(is_pinned: [email protected]_pinned)
          json_response(post: @post.post_json)
        else
          json_response({ errors: @post.errors.full_messages }, 401)
        end
      end
    
      def suspended(date)
        if date > DateTime.now
          json_response(errors: ['Your posting communications are still suspended'])
          return true
        end
    
        false
      end

    Both methods

    lock_post
    and
    pin_post
    simply toggle the related field of the @post record grabbed.

    While the

    suspended(date)
    method runs a check to see if the @current_user attempting to create a post doesn't have their communications suspended by an administrator.

    Comments Controller

    Handles all commenting-related features, and allowing users to comment on comments is as simple as including that comment's id in the create request method.

      def comment_params
        params.require(:comment).permit(:body, :comment_id, :user_id)
      end

    Users Controller

    This controller handles everything related to the user other than registration and logging in. That entails returning user records, to populate their show pages, and housing methods used to suspend user communications, upload profile images, and set administrative rights.

      def suspend_comms(user, comms, attr)
        comms_i = comms.map(&:to_i)
        d = DateTime.now
        ban_date = DateTime.new(comms_i[0], comms_i[1], comms_i[2], comms_i[3], comms_i[4], 0, d.offset);
        user.update_attribute(attr, ban_date)
      end
    

    The

    suspend_comms
    method is used as a helper method within the main suspend_communication method. The comms attribute passed to this helper method is an array value that is broken down and passed from the front-end. This is done since the
    DateTime()
    method accepts multiple arguments, the year, month, day, hour, minute, second, and offset.

    Setting up Active Storage

    Go to cloudinary.com and sign up for a new free account or log in to your already made account.

    image

    Take note of your Cloud name (which you can change), API Key, and API Secret.

    Now back to your rails app...

    Create a "

    cloudinary.yml
    " file under the
    /config
    directory

    production:
      cloud_name: your_cloud_name
      api_key: <%= ENV['CLOUDINARY_KEY'] %>
      api_secret: <%= ENV['CLOUDINARY_SECRET'] %>
      enhance_image_tag: true
      static_file_support: true

    Your ENVIRONMENT variables will be handled by Heroku when you launch your back-end application to heroku.com.

    Next, you will declare the Cloudinary service in your "

    storage.yml
    " in the
    /config/storage.yml
    directory.

    test:
      service: Disk
      root: <%= Rails.root.join("tmp/storage") %>
    
    local:
      service: Disk
      root: <%= Rails.root.join("storage") %>
    
    cloudinary:      
      service: Cloudinary

    Here's what the top half of your "

    storage.yml
    " file may look like after adding the Cloudinary service.

    Finally, in the

    /config/environments/production.rb

      # Store uploaded files on the local file system (see config/storage.yml for options).
      config.active_storage.service = :local

    Replace "

    :local
    " with "
    :cloudinary
    "

      # Store uploaded files on the local file system (see config/storage.yml for options).
      config.active_storage.service = :cloudinary

    Later on, when we create the models and controllers, we will use these active storage helpers to manage and access the user's profile image.

    class User < ApplicationRecord
      has_secure_password
      has_many :posts, inverse_of: 'author', dependent: :destroy
      has_many :comments, dependent: :destroy
      validates :username, length: { in: 4..32 }, presence: true,
                           uniqueness: { case_sensitive: false }
      validates :password, length: { minimum: 8 }
      VALID_EMAIL_REGEX = /\A[\w+\-.][email protected][a-z\d\-.]+\.[a-z]+\z/i.freeze
      validates :email, presence: true, length: { maximum: 255 },
                        format: { with: VALID_EMAIL_REGEX },
                        uniqueness: { case_sensitive: false }
      validates :admin_level, numericality: { only_integer: true,
                                              less_than_or_equal_to: 3 }
      has_one_attached :profile_image
      before_save { username.downcase! }
      before_save { email.downcase! }
    end

    Added the line "

    has_one_attached :profile_image
    "

    and

      def show
        json_response(user: user_with_image(@user))
      end
    
      private
    
      # Returns a hash object of a user with their profile_image included
      def user_with_image(user)
        user_with_attachment = user.attributes
        user_with_attachment['profile_image'] = nil
    
        unless user.profile_image_attachment.nil?
          user_with_attachment['profile_image'] = url_for(user.profile_image)
        end
    
        user_with_attachment
      end

      def show
        selected_user = User.find(params[:id])
        json_response(user: user_with_image(selected_user))
      end
    
      private
    
      def user_with_image(user)
        user_with_attachment = { id: user.id, username: user.username,
                                 email: user.email, profile_image: nil,
                                 can_post_date: user.can_post_date,
                                 can_comment_date: user.can_comment_date,
                                 created_at: user.created_at}
        unless user.profile_image_attachment.nil?
          user_with_attachment['profile_image'] = url_for(user.profile_image)
        end
    
        user_with_attachment
      end

    Added the private method

    user_with_image(user)
    , which returns a user hash object with their profile_image attached.

    Also called that method under the
    show method within the
    json_response 
    method on the
    selected_user
    variable.

    Don't forget to install Rails Active Storage with:

    $ rails active_storage:install

    Using Action Mailer for account confirmation

    Registrations Controller

    Now, in the registrations controller, after the user's activation_key attribute is updated, send out the Activation Email:

    app/controllers/registrations_controller.rb

      def create
        user = User.create!(register_params)
        new_activation_key = generate_token(user.id, 52)
        user.update_attribute(:admin_level, 3) if User.all.size <= 1
        if user.update_attribute(:activation_key, new_activation_key)
          ActivationMailer.with(user: user).welcome_email.deliver_later
        end
        json_response({ user: user }, :created)
      end

    The method called activate_account within the registrations controller handles account activation by updating the is_activated attribute on a record after comparing the given activation_key parameter with the saved value.

    app/controllers/registrations_controller.rb

      def activate_account
        user = User.find(params[:id])
    
        if user.activation_key == params[:activation_key]
          user.update_attribute(:is_activated, true)
        end
    
        json_response(message: 'Successfully activated account')
      end

    activate_account method accepts two parameters id and activation_key.

    Here is how we will shape the route for this method

    config/routes.rb

      get '/activate_account',
          to: 'registrations#activate_account',
          as: 'activate_account'

    Generate a new rails mailer, I called mine "Activation Mailer".

    $ rails generate mailer ActivationMailer

    In the file created,

    app/mailers/activation_mailer.rb
    add the following code.

    class ActivationMailer < ApplicationMailer
      default from: '[email protected]'
    
      def welcome_email
        @user = params[:user]
        mail(to: @user.email, subject: 'Welcome to the React.js Forum-CMS Demo')
      end
    end

    default from: '[email protected]' is simply the email address where the mail will have appeared to have been sent from.

    The welcome_email method will allow us to link global variables like, @user, with a mailer template that we will be creating next.

    Now in the directory,

    app/views/activation_mailer
    we will create two .erb files.

    The first one is welcome_email.html.erb,

    app/views/activation_mailer/welcome_email.html.erb
    , with the following provided template (You can change it up as you please, of course).

    <!DOCTYPE html>
    <html>
      <head>
        <meta content='text/html; charset=UTF-8' http-equiv='Content-Type' />
      </head>
      <body>
        <h1><%= @user.username %>,</h1>
        <h1>Welcome to the React.js Forum-CMS Demo</h1>
        <p>
          You have successfully signed up but you need to activate your account.<br>
        </p>
        <p>
          To activate your account and login to the site, just follow this link: 
          <%= link_to "Confirmation link", activate_account_url(:id => @user.id, :activation_key => @user.activation_key) %>
        </p>
        <p>Thanks for joining and have a great day!</p>
      </body>
    </html>

    The URL helper activate_account_url is the route we assigned to the activate_account method that we created in the registrations controller

    The second one is welcome_email.text.erb,

    app/views/activation_mailer/welcome_email.text.erb
    , with the following provided template.

    Welcome to the React.js Forum-CMS Demo, <%= @user.username %>
    ===============================================
     
    You have successfully signed up but you need to activate your account.
     
    To activate your account and login to the site, just follow this link: 
    <%= link_to "Confirmation link", activate_account_url(:id => @user.id, :activation_key => @user.activation_key) %>
     
    Thanks for joining and have a great day!

    The completed version of this mailer within the source code also contains code for the forgot_password mailer along with its routes and integration into the registrations controller (Explained above).

    Deployment to Heroku

    Merge recent changes to the git hub master branch.

    Create a new Heroku application:

    $ heroku create

    Then push changes to Heroku master using:

    $ git push heroku master

    Then generate database on Heroku application:

    $ heroku run rails db:migrate

    Adding Sendgrid to your Heroku App to handle mailing

    Documentation

    Using the Commandline

    $ heroku addons:create sendgrid:starter

    Using Browser

    Open up your Heroku dashboard in your browser of choice and select your forum app.

    Next, switch over from the Overview tab to the Resources tab.

    image

    Click the find more add-ons button...

    image

    Then scroll down until you see the SendGrid app or select the Email/SMS category from the side menu and select it.

    image

    Lastly, you will select which of your Heroku apps you wish to provision the SendGrid app for, then click Submit the Order Form.

    Setting SMTP settings for ActionMailer

    Create a new file called smtp.rb and place it within the

    config/initializers/smtp.rb
    folder.

    ActionMailer::Base.smtp_settings = {
      address: 'smtp.sendgrid.net',
      port: 587,
      domain: 'YourAppName.herokuapp.com',
      user_name: ENV['SENDGRID_USERNAME'],
      password: ENV['SENDGRID_PASSWORD'],
      authentication: :login,
      enable_starttls_auto: true
    }

    Replace "YourAppName" with the name of your Heroku app. The SENDGRID_USERNAME and SENDGRID_PASSWORD are environment variables that are automatically created when you install the SendGrid add-on to your Heroku app.

    Also, in the

    config/environments/production.rb
    file, add these two lines of code

    config.action_mailer.delivery_method = :smtp
    config.action_mailer.default_url_options = { host: "https://YourHerokuApp.herokuapp.com" }

    Once again, replace "YourHerokuApp" with the name of your actual Heroku app.

    Afterward, your Heroku app should be able to send out emails using SendGrid.

    However, for me, I was getting an authentication error stating that my account was disabled, so I decided to go a step further and generate a SendGrid API key.

    Creating a SendGrid API Key

    Firstly, I went to Sendgrid.com and signed up for an account. Requiring verification through two-factor authentification (2fa).

    image

    After creating the account, I authorized the use of a Single Sender which verified me as the owner of an email account that I will use to send the emails from. I decided to create a new email for this project.

    image

    Once the email you provide is verified you can then integrate using the WEB API or SMTP Relay configurations. We will be using SMTP Relay configurations with Action Mailer.

    image

    Here is where we will create our API Key and access the necessary SMTP configuration variables.

    image

    Now we will configure our Heroku app's environment variables and add a new environment variable called "SENDGRID_API_KEY"

    $ heroku config:set SENDGRID_API_KEY=YOURAPIKEY

    "YOURAPIKEY" will of course be the same API Key you generated through SendGrid a few moments ago.

    Now go back to the file called smtp.rb that we added earlier located in

    config/initializers/smtp.rb
    and configure it so.

    ActionMailer::Base.smtp_settings = {
      address: 'smtp.sendgrid.net',
      port: 587,
      domain: 'YourHerokuApp.herokuapp.com',
      user_name: 'apikey',
      password: ENV['SENDGRID_API_KEY'],
      authentication: :plain
    }

    "YourHerokuApp" will be the name of your Heroku app.

    Now you should be set to send out emails through your Heroku App.

    Set Heroku config variables

    Documentation

    Using terminal Set Cloudinary Key

    $ heroku config:set CLOUDINARY_KEY=YOURCLOUDINARYKEY
    

    Set Cloudiny Secret

    $ heroku config:set CLOUDINARY_SECRET=YOURCLOUDINARYSECRET

    Now, time to reconfigure the Front-End to use the Back-End API.

    Creating the User related components

    src/components/functional/users/*

    The User components will consist of Registration, Logging In, User Profile Page, Forgot Password/Reset Password, New Users Notifications (As seen near the bottom of the page), and Administrator Panel.

    Creating the Registration Component

    The idea behind the registration component is simple, receive input from the user such as username, email, and password, then send this information to the Back-end API's user registration route.

    src/components/functional/users/register.js

    import React, { useEffect, useState } from 'react';
    import propTypes from 'prop-types';
    import PinnedPostDisplay from '../presentational/blogPage/pinnedPostDisplay';
    import PostDisplay from '../presentational/blogPage/postDisplay';
    
    const BlogPage = ({ allPosts }) => {
      const [pinnedPosts, setPinnedPosts] = useState([]);
    
      const populatePins = () => pinnedPosts.map(post => (
        <PinnedPostDisplay key={post.id} post={post} />
      ));
    
      const populatePosts = () => allPosts.map(post => (
        <PostDisplay key={post.id} post={post} />
      ));
    
      // Grab all pinned Post on Component Load
      useEffect(() => {
        const postPins = allPosts.filter(post => post.is_pinned);
        setPinnedPosts(postPins);
      });
    
      return (
        <div className="bg-main pt-1">
          <div className="container">
            <div>
              <h2>Pinned Posts</h2>
              <div>{populatePins()}</div>
            </div>
            <div>
              <h2>All Posts</h2>
              <div>{populatePosts()}</div>
            </div>
          </div>
        </div>
      );
    };
    
    BlogPage.propTypes = {
      allPosts: propTypes.instanceOf(Array).isRequired,
    };
    
    export default BlogPage;
    
    import React, { useState } from 'react';
    import propTypes from 'prop-types';
    import { userRegister } from '../../misc/apiRequests';
    import ConfirmPage from '../confirmPage';
    
    const Register = ({ handleModal, handleLoader }) => {
      const [username, setUsername] = useState('');
      const [email, setEmail] = useState('');
      const [password, setPassword] = useState('');
      const [passwordConfirm, setPasswordConfirm] = useState('');
      const [message, setMessage] = useState('');
      const [userCreds, setUserCreds] = useState({});
      const [emailConfirm, setEmailConfirm] = useState(false);
    
      const handleSubmit = e => {
        e.preventDefault();
        if (password !== passwordConfirm) {
          return handleModal(["Password doesn't Match Confirmation!"]);
        }
    
        const user = {
          username: username.trim(),
          email: email.trim(),
          password,
          password_confirmation: passwordConfirm,
        };
    
        setUserCreds({ username: username.trim(), email: email.trim() });
    
        handleLoader(true);
        userRegister(user)
          .then(response => {
            if (response.success) { setMessage(response.message); setEmailConfirm(true); }
            if (!response.success) handleModal(response.errors);
            handleLoader(false);
          });
    
        return null;
      };
    
      return emailConfirm
        ? <ConfirmPage user={userCreds} />
        : (
          <div id="LoginPage" className="bg-main pt-1">
            <div className="container-md">
              <h2 className="text-center mb-1">Register New User</h2>
              <form className="login-form" onSubmit={handleSubmit}>
                <h4>Username</h4>
                <input
                  type="text"
                  value={username}
                  onChange={e => setUsername(e.target.value)}
                  minLength="3"
                  required
                />
                <h4>Email</h4>
                <input
                  type="text"
                  value={email}
                  onChange={e => setEmail(e.target.value)}
                  minLength="3"
                  required
                />
                <h4>Password</h4>
                <input
                  type="password"
                  value={password}
                  onChange={e => setPassword(e.target.value)}
                  required
                />
                <h4>Password Confirmation</h4>
                <input
                  type="password"
                  value={passwordConfirm}
                  onChange={e => setPasswordConfirm(e.target.value)}
                  required
                />
                <button type="submit">Register</button>
              </form>
    
              <h4 className="text-center p-1">{message}</h4>
            </div>
          </div>
        );
    };
    
    Register.propTypes = {
      handleModal: propTypes.func.isRequired,
      handleLoader: propTypes.func.isRequired,
    };
    
    export default Register;
    

    Take note of the incoming props,

    handleModal
    , and
    handleLoader
    . These two functions will be passed to all components that make API requests. The loader function simply determines when the visual cue for a pending request to the API is in progress. The modal function deals with displaying the returned responses from the API (success messages, errors...etc.)

    Both of these props are passed from the main component, App.js

    src/App.js

    import React, { useState, useEffect, useCallback } from 'react';
    import {
      Switch,
      Route,
    } from 'react-router-dom';
    import './assets/css/App.css';
    
    import Register from './components/functional/users/register';
    import Modal from './components/functional/modal';
    import Loader from './components/presentational/loader';
    
    const App = () => {
      const [errors, setErrors] = useState([]);
      const [isLoading, setIsLoading] = useState(true);
      const [showModal, setShowModal] = useState(false);
    
      // Toggle modal and clear status
      const handleModal = useCallback((errors = []) => {
        setErrors(errors);
      }, [setErrors]);
    
      const handleLoader = useCallback((loading = true) => {
        setIsLoading(loading);
      }, [setIsLoading]);
    
      // open Modal to show errors
      useEffect(() => {
        setShowModal(errors.length > 0);
      }, [errors]);
    
      return (
        <div className="App">
          {/* ...Some code not shown */}
          <main className="bg-navbar pt-1">
            {/* ...Some code not shown */}
            <Switch>
              <Route
                exact
                path="/sign_up"
                render={() => <Register handleModal={handleModal} handleLoader={handleLoader} />}
              />
            </Switch>
          </main>
          <div className="blend-main-footer" />
          <footer className="footer">
            {/* ...Some code not shown */}
          </footer>
          {showModal && <Modal errors={errors} handleModal={handleModal} />}
          {isLoading && <Loader />}
        </div>
      );
    };
    
    export default App;
    

    There are three parts to the

    handleModal
    process, the Modal component, the two states (errors and showModal), and the
    useEffect
    which determines the state value of showModal.

    The

    handleModal 
    and
    handleLoader
    functions are wrapped with the
    useCallback
    function which is used to prevent an infinite rendering loop. This loop occurs because of the child component calling the function which updates the main component which will end up re-rendering the child component.

    The showModal and isLoading states are what determine whether or not the respective components are shown, and the

    handleLoader
    and
    handleModal 
    functions are used to set values to these states.

    src/components/functional/users/register.js

      const handleSubmit = e => {
        e.preventDefault();
        if (password !== passwordConfirm) {
          return handleModal(["Password doesn't Match Confirmation!"]);
        }
    
        const user = {
          username: username.trim(),
          email: email.trim(),
          password,
          password_confirmation: passwordConfirm,
        };
    
        setUserCreds({ username: username.trim(), email: email.trim() });
    
        handleLoader(true);
        userRegister(user)
          .then(response => {
            if (response.success) { setMessage(response.message); setEmailConfirm(true); }
            if (!response.success) handleModal(response.errors);
            handleLoader(false);
          });
    
        return null;
      };

    The

    handleSubmit
    function, which is set to execute on submission of the user registration form, also holds the asynchronous function
    userRegister
    , which sends the request to the API to register a new user. The request is treated as an
    Async
     
    Promise
    , and the return value from the API server determines whether an error modal opens or the presentational confirmation page displays.

    Creating the Login/Sign-in component

    src/components/functional/users/login.js

    After registration comes logging in, this component takes the same input values from the User as in the registration component (username, email, password) and uses it as verification.

    The Login component structure is similar to the Registration component layout, having a form,

    handleSubmit
    function,
     
    asynchronous
    request
    (
    userLogin
    )
    , and
    handleModal 
    and
    handleLoader 
    functions.

    The login page is accessed by clicking on the LoginBtn component.

    src/components/presentational/users/loginBtn.js

    image

    And just as with the previous components, the Registration, Login, and LoginBtn components are all called within the App component.

    There are three API request functions that work together in maintaining a user's login token validity,

    UserLogin
    , 
    UserLoggedIn
    , and 
    UserLogout
    .

    The three of these functions utilize the built-in

    window.sessionStorage
    function to store the user's token.

    userLogin
    creates and sets a session item containing the user token and other relevant information returned from the API.

    userLoggedIn
    uses the data stored in the session to see if the token stored is still valid.

    And Lastly,

    userLogout
    removes the stored session information out of storage while also sending a request to the API to render the current user token invalid.

    Creating the Forgot/Reset Password components

    src/components/functional/users/forgotPassword.js

    Once again, this is a form component following a similar format to the above form components. This time, the only input required from the User is their email.

    And as previously mentioned, while we were building the backend API request for this component, this component initiates a request which generates a reset password token and attaches it to the user account related to the supplied email address. Then afterward, an email is sent to the email address with a clickable link containing the generated token as a URL parameter.

    Then that's where the resetPassword component comes in.

    src/components/functional/users/resetPassword.js

    import React, { useEffect, useState } from 'react';
    import { Redirect, useLocation } from 'react-router-dom';
    import propTypes from 'prop-types';
    import { changePasswordWithToken } from '../../misc/apiRequests';
    
    const ResetPassword = ({ handleModal, handleLoader }) => {
      const [redirect, setRedirect] = useState(false);
      const [passwordReset, setPasswordReset] = useState(false);
      const [message, setMessage] = useState('');
      const [password, setPassword] = useState('');
      const [passwordConfirm, setPasswordConfirm] = useState('');
    
      const loginRedirect = (<Redirect to="/login" />);
    
      function useQuery() {
        return new URLSearchParams(useLocation().search);
      }
      const query = useQuery();
    
      const handleSubmit = e => {
        e.preventDefault();
        if (password !== passwordConfirm) {
          return handleModal(["Password doesn't Match Confirmation!"]);
        }
        const token = query.get('token');
        const user = { password, passwordConfirm };
    
        handleLoader(true);
        changePasswordWithToken(token, user)
          .then(response => {
            if (response.success) { setMessage(response.message); setPasswordReset(true); }
            if (!response.success) handleModal(response.errors);
            handleLoader(false);
          });
      };
    
      useEffect(() => {
        let timer;
        if (passwordReset) {
          timer = setTimeout(() => {
            setRedirect(true);
          }, 5000);
        }
        return () => clearTimeout(timer);
      }, [passwordReset]);
    
      const renderMain = passwordReset
        ? (
          <div className="bg-main pt-1">
            <div className="text-center container-md">
              <h2>{message}</h2>
              <h4>You will be redirected to the login page in a few seconds...</h4>
            </div>
          </div>
        )
        : (
          <div id="LoginPage" className="bg-main pt-1">
            <div className="container-md">
              <h2 className="text-center mb-1">Set a new Password</h2>
              <form className="login-form" onSubmit={handleSubmit}>
                <h4>New Password</h4>
                <input
                  type="password"
                  value={password}
                  onChange={e => setPassword(e.target.value)}
                  required
                />
                <h4>Password Confirmation</h4>
                <input
                  type="password"
                  value={passwordConfirm}
                  onChange={e => setPasswordConfirm(e.target.value)}
                  required
                />
                <button type="submit">Change Password</button>
              </form>
            </div>
          </div>
        );
    
      return redirect ? loginRedirect : renderMain;
    };
    
    ResetPassword.propTypes = {
      handleModal: propTypes.func.isRequired,
      handleLoader: propTypes.func.isRequired,
    };
    
    export default ResetPassword;

    The

    useQuery
    function utilizes the built-in JS function
    URLSearchParams
    the values supplied to this function are the query parameters of the URL in string format. The
     
    react-router-dom
    dependent function
    useLocation()
     
    returns the URL of the react-app, and its search property returns the query parameters within that URL.

    Then a variable called query is used to access the returned URLSearchParams function to

    get()
    the value of the parameter named 'token' and on form submit this token value is sent to the API through the use of the API request method
    changePasswordWithToken
    along with the new password.

    Then on a successful response from the server, the setPasswordReset state is set to true, which activates the condition for the useEffect to redirect the user to the login page (The loginRedirect JSX variable is returned instead of the renderMain variable).

      useEffect(() => {
        let timer;
        if (passwordReset) {
          timer = setTimeout(() => {
            setRedirect(true);
          }, 5000);
        }
        return () => clearTimeout(timer);
      }, [passwordReset]);

    loginRedirect JSX return variable...

      const loginRedirect = (<Redirect to="/login" />);

    Return statement...

      return redirect ? loginRedirect : renderMain;

    Creating the New Users/All Users components

    src/components/functional/users/newUsers.js

    The newUsers component makes an API request which returns all of the latest users signed up. This component then displays the names of the 8 latest new users along with a count of the total number of users signed up.

    The display of the count of the total number of users signed up is a Link to the allUsers component.

    src/components/functional/users/allUsers.js

    This component displays a list of all users ordered from newest to oldest, their name, account type(member/administrator), communications status(posting/commenting bans), and profile image.

    image

    Creating the Profile Page component

    src/components/functional/users/profilePage.js

    image

    The profile component displays information about the selected user like their name, account status, communications status, latest posts, and latest comments.

    If the user is looking at their own profile page they are allowed to upload a profile image and sign out from here.

    Also, the Profile Page component houses the Administrative Panel views. The allowed administrative actions are relevant to whether or not the administrator is looking at their own profile page or the profile page of another user.

    Creating the Administrative Panel components

    src/components/functional/users/admin/adminPanel.js

    The administrative panel consists of multiple modal components...

    import React, { useEffect, useState } from 'react';
    import propTypes from 'prop-types';
    import RenameForumModal from './modals/renameForumModal';
    import NewSubforumModal from './modals/newSubforumModal';
    import NewforumModal from './modals/newForum';
    import RenameSubforumModal from './modals/renameSubforum';
    import SuspendUser from './modals/suspendUser';
    import PromoteUser from './modals/promoteUser';
    import { fetchAllForums, forumRemove } from '../../../misc/apiRequests';

    The prop passed to this component, normally known as

    handleModal 
    has been renamed to
    handleMainModal 
    (Passed from profilePage component) to allow this component its own function that handles its various modals.

    const AdminPanel = ({
      user, selectedUser, handleSelectedUser, handleLoader, handleMainModal,
    }) => {
      const [allForums, setForums] = useState([]);
      const [selectedForum, setSelectedForum] = useState({});
      const [selectedSubforum, setSelectedSubforum] = useState({});
      const [showModal, setShowModal] = useState(false);
      const [modalType, setModalType] = useState('renameForum');
    
      const handleModal = (forum, formType = 'renameForum', subforum = {}) => {
        setSelectedSubforum(subforum);
        setSelectedForum(forum);
        setModalType(formType);
        setShowModal(true);
      };
    .......................

    The newly defined

    handleModal
    function is used to determine which modal component should be displayed. It accepts arguments for forum, formType, and subforum. These parameters, once supplied, are used to update the different component states, like selectedForum, modalType, and selectedSubforum, some of which are then supplied to the various modal components (as seen below).

            {showModal && (
            <div className="modal">
              <button type="button" className="modal-bg" onClick={handleFormReset}>x</button>
              <div className="modal-content">
                <div className="container-md">
                  {modalType === 'renameForum' && (
                    <RenameForumModal
                      forum={selectedForum}
                      handleForums={handleForums}
                      handleFormReset={handleFormReset}
                      handleLoader={handleLoader}
                      handleModal={handleMainModal}
                    />
                  )}
                  {modalType === 'renameSubforum' && (
                    <RenameSubforumModal
                      forum={selectedForum}
                      subforum={selectedSubforum}
                      handleForums={handleForums}
                      handleFormReset={handleFormReset}
                      handleLoader={handleLoader}
                      handleModal={handleMainModal}
                    />
                  )}
                  {modalType === 'newSubforum' && (
                    <NewSubforumModal
                      forum={selectedForum}
                      handleForums={handleForums}
                      handleFormReset={handleFormReset}
                      handleLoader={handleLoader}
                      handleModal={handleMainModal}
                    />
                  )}
                  {modalType === 'newForum' && (
                    <NewforumModal
                      handleFormReset={handleFormReset}
                      handleForums={handleForums}
                      handleLoader={handleLoader}
                      handleModal={handleMainModal}
                    />
                  )}
                </div>
              </div>
            </div>
            )}

    The newForum, newSubforum, renameForum, renameSubforum components are all form submission components with similar component structures (the main difference being the API request they each send out to the back-end server).

    As mentioned above, from a UI perspective, these administrative actions are only accessible if an administrator clicks onto their own profile page.

    image

    Administrator Panel (On your own profile page)

    However, the administrative actions for suspending communications and promoting/demoting a user's account status are only viewable on that user's profile page in question.

    image

    Administrator Panel (On another user's profile page)

    Creating the Suspend User component

    src/components/functional/users/admin/modal/suspendUser.js

    import React, { useState } from 'react';
    import propTypes from 'prop-types';
    import { userSuspendComms } from '../../../../misc/apiRequests';
    import { convertRailsDate, convertToRubyDate } from '../../../../misc/convertDate';
    
    const SuspendUser = ({
      user, selectedUser, handleSelectedUser, handleFormReset, handleLoader, handleMainModal,
    }) => {
      // eslint-disable-next-line camelcase
      const { can_post_date, can_comment_date } = selectedUser;
      const [suspendPostsExpiryDate, setSuspendPostExpiry] = useState(convertRailsDate(can_post_date));
      const [
        suspendCommentsExpiryDate,
        setSuspendCommentsExpiry,
      ] = useState(convertRailsDate(can_comment_date));
    // ... Some code not shown

    Take note of the two imported helper functions,

    convertRailsDate,
    and
    convertToRubyDate
    found within
    convertDate.js
    .

    Both states,

    suspendPostsExpiryDate
    , and
    suspendCommentsExpiryDate 
    are values populated on component mount/load. The DateTime format stored by the Rails PostgreSQL database is different than the formatted value used and accepted by the datetime-local input type. This is why the
    convertRailsDate
    function is used on the can_comment_date, and the can_post_date, which are DateTime values passed from the Rails Database.

          <h4>Suspend Posting abilities until</h4>
          <input
            type="datetime-local"
            value={suspendPostsExpiryDate}
            onChange={e => setSuspendPostExpiry(e.target.value)}
            required
          />
          <h4>Suspend Commenting abilities until</h4>
          <input
            type="datetime-local"
            value={suspendCommentsExpiryDate}
            onChange={e => setSuspendCommentsExpiry(e.target.value)}
            required
          />

    Then in the handleSubmit function called on form submission...

      // Handle modification of User's suspended activities
      const handleSubmit = e => {
        e.preventDefault();
        const suspendUser = {
          id: selectedUser.id,
          can_post_date: convertToRubyDate(suspendPostsExpiryDate),
          can_comment_date: convertToRubyDate(suspendCommentsExpiryDate),
          admin_id: user.id,
        };
    // ...Some code not shown

    the

    convertToRubyDate
    function is called on the returned value from the datetime-local type input fields.

    src/components/misc/convertDate.js

    // ...Some code above not shown
    
    const convertRailsDate = date => date.substring(0, date.length - 1);
    
    const convertToRubyDate = date => {
      const dateArray = date.split('-');
      let timeArray = dateArray[2].substring(dateArray[2].indexOf('T'));
      timeArray = timeArray.split(':');
      timeArray[0] = timeArray[0].substring(1);
      dateArray[2] = dateArray[2].substring(0, dateArray[2].indexOf('T'));
    
      return dateArray.concat(timeArray.slice(0, 2));
    };
    
    export { convertDate, convertRailsDate, convertToRubyDate };

    The

    convertRailsDate
    function simply removes the "Z" from the end of the DateTime string value returned from the Rails Database.

    image

    And then, the

    convertToRubyDate 
    function breaks down the returned value from the HTML input of type date-time local into an array and supplies this array to the backend as arguments for Ruby's
    DateTime.new(year, month, date, hour, minute)
    function.

    image

    The promoteUser component is a fairly straightforward form submission type component with a select box input type, so no need for an in-depth explanation...

    Creating the Forum related components

    src/components/functional/blogPage/*

    The Forum components will consist of the landing page BlogPage, New Post, Edit Post Page, Show Post Page, Topic Forum Page, Page Pagination, and Comments.

    Creating the Blog Page Component

    The Blog Page component is the default page (landing page) for the forum, meaning it's the first page a user sees upon entering the website. This page shows the Pinned Posts, All the Forums, and their Subforums, along with any related posts (paginated).

    image

    src/components/functional/blogPage.js

    const BlogPage = ({
      user, handlePostSelect, handleLoader, handleModal,
    }) => {
      const [pinnedPosts, setPinnedPosts] = useState([]);
      const [forumTopics, setForumTopics] = useState([]);
      // ...Some code not shown
    
      // Grab all pinned Posts, and sort all other posts by forum on Component Load
      useEffect(() => {
        handleLoader(true);
        const forum = { per_page: 5, page: 1 };
        fetchAllForumPosts(forum.per_page, forum.page)
          .then(response => {
            if (response.success) {
              setPinnedPosts(response.pinned_posts.filter(post => !post.admin_only_view));
              setForumTopics(response.forums);
            }
            if (!response.success) handleModal(response.errors);
            handleLoader(false);
          });
      }, [handleLoader, handleModal]);
    // ...Some code not shown

    The BlogPage component calls the fetchAllForumPosts API request immediately on component load and stores the returned response values into the two states pinnedPosts and forumTopics.

    import React, { useEffect, useState } from 'react';
    import propTypes from 'prop-types';
    import PinnedPostDisplay from '../presentational/blogPage/pinnedPostDisplay';
    import ForumDisplay from '../presentational/blogPage/forumDisplay';
    import '../../assets/css/blogPage.css';
    import { fetchAllForumPosts } from '../misc/apiRequests';
    
    const BlogPage = ({
      user, handlePostSelect, handleLoader, handleModal,
    }) => {
      const [pinnedPosts, setPinnedPosts] = useState([]);
      const [forumTopics, setForumTopics] = useState([]);
    
      // Populate Pinned Posts
      const populatePins = () => pinnedPosts.map(post => (
        <button type="button" key={post.id} className="bare-btn" onClick={() => handlePostSelect(post)}>
          <PinnedPostDisplay post={post} />
        </button>
      ));
    
      // Populate all subforums and related posts paginated by 5 posts per page
      const populateAllForums = () => forumTopics.map(forumData => (
        <ForumDisplay
          key={forumData.name}
          user={user}
          forum={forumData}
          handlePostSelect={handlePostSelect}
          postsPages={5}
        />
      ));

    The values stored in the two states are then used in tandem with the two methods populatePins and populateAllForums.

    PopulatePins

    The PopulatePins method grabs the data from the array value stored within the pinnedPosts state and for each index of the array, which would be an object containing various properties, the imported PinnedPostDisplay component template is used to create selectable post buttons.

    image

    Take note that the prop handlePostSelect, which is passed to the BlogPage component, is then called onClick for the button encasing the PinnedPostDisplay component template.

      const App = () => {
      const [selectedPost, setSelectedPost] = useState(null);
      const [redirect, setRedirect] = useState(null);
    
      // Handles selection of post when post is clicked
      const handlePostSelect = post => {
        setSelectedPost(post);
      };
    
      // Follow up redirect after a post is selected
      useEffect(() => {
        if (selectedPost) {
          const { forum, subforum, id } = selectedPost;
          setRedirect(<Redirect to={`/${forum}${subforum ? `/${subforum}` : ''}/posts/${id}/show`} />);
        }
      }, [selectedPost]);
    
      useEffect(() => { setRedirect(null); setSelectedPost(null); }, [redirect]);
    
      return redirect || (
        <div className="App">
          <header className="bg-navbar">
            <nav className="container">
              <div className="flex-row">
    // ...Some code not shown

    src/App.js

    Now in the App component, the main component, the

    handlePostSelect
    function, is used to give the child components the ability to set the selectedPost state of the parent component, App.js.

    The first useEffect hook causes a re-render whenever the selectedPost state is updated, and upon component mount/load, the redirect state is fed a link made up of the object properties saved in the selectedPost state. This Redirect simply sends the user to the relevant forum or subforum which had been clicked previously within the confines of one of the child components of the main App.js component.

    The second useEffect hook clears the saved state values for redirect and selectedPost.

    Also, note that the return statement for the App.js component, which renders the JSX, first checks if there is a value in the redirect state. If there so happens to be a redirect link stored in the redirect state, then the page is redirected to the given link.

    So, that being said, the

    handlePostSelect
    function essentially provides the means to redirect a user to the selected post or topic from within the various child components.

    PopulateAllForums method

    The

    PopulateAllForums
    method grabs the data from the array value stored within the forumTopics state and for each index of the array, which would be an object containing various properties, the imported ForumDisplay component template is used to create selectable forums, subforums, and their posts.

    image
    import React, { useEffect, useState } from 'react';
    import propTypes from 'prop-types';
    import { Link } from 'react-router-dom';
    import Paginate from '../../functional/blogPage/paginatePosts';
    import populatePosts from './populatePosts';
    import SubForumDisplay from './subForumDisplay';
    
    const ForumDisplay = ({
      user, forum, postsPages, handlePostSelect, isSubforum,
    }) => {
      // ...Some code not shown
    
      const populateSubForums = () => subForums.map(subforumData => (
        <SubForumDisplay
          key={subforumData.subforum}
          forum={{ id: forum.id, name: forum.name, isSubforum }}
          subforum={subforumData}
          handleIcon={handleIcon}
          handlePostSelect={handlePostSelect}
          checkForumContraints={checkForumContraints}
        />
      ));

    src/components/presentational/blogPage/forumDisplay.js

    The ForumDisplay component imports multiple components for templated use. Including the PaginatePosts, and SubForumDisplay components coupled with the populatePosts function.

    Since the populatePosts function is a supplemental function used by the paginatePosts component and a few other components, let's run through its structure first.

    PopulatePosts method

    import React from 'react';
    import PostDisplay from './postDisplay';
    
    const PopulatePosts = (postsArray, handlePostSelect, isPinned = false) => postsArray.map(post => (
      <button type="button" key={post.id} className="bare-btn row" onClick={() => handlePostSelect(post)}>
        <PostDisplay post={post} isPinned={isPinned} />
      </button>
    ));
    
    export default PopulatePosts;

    src/components/presentational/blogPage/populatePosts.js

    This function takes 3 arguments,

    postsArray
    (an array),
     handlePostSelect
    (a function), 
    isPinned
    (a boolean)
    .

    The postsArray array is used to generate HTML buttons, which onClick accesses the

    handlePostSelect
    function stored in the App.js main component. The
    isPinned
    boolean is a visual factor that determines if the red star is shown before the title of the post.

    The PostDisplay component stored within the button wrapper simply displays truncated information about the posts related to a particular forum/subforum.

    image

    PaginatePosts method

    import React, { useEffect, useState } from 'react';
    import propTypes from 'prop-types';
    
    const Paginate = ({
      posts, populatePosts, postsPages, handlePostSelect,
    }) => {
      const [pinnedPosts, setPinnedPosts] = useState([]);
      const [selectedPosts, setPosts] = useState([]);
      const [postsPerPage] = useState(postsPages);
      const [page, setPage] = useState(1);
      const [maxPages, setMaxPages] = useState(1);
    
      const handlePrev = () => {
        if (page > 1) {
          setPage(page - 1);
        }
      };
    
      const handleNext = () => {
        if (page < maxPages) {
          setPage(page + 1);
        }
      };
    
      // Calculates the max amount of pages using the length of the posts and postsPages props
      useEffect(() => {
        const pageMax = Math.ceil(posts.length / postsPerPage);
        setMaxPages(pageMax || 1);
      }, [posts, postsPerPage]);
    
      // Filters and stores the posts prop array into pinned and unpinned posts.
      // Unpinned posts are then paginated and stored into the selectedPosts state
      useEffect(() => {
        const postsPinned = posts.filter(post => post.is_pinned);
        const unPinnedPosts = posts.filter(post => !post.is_pinned);
        const startingIndex = (page * postsPerPage) - postsPerPage;
        const endingIndex = (page * postsPerPage) - 1;
        const paginatedPosts = unPinnedPosts.filter((post, index) => {
          if (index >= startingIndex && index <= endingIndex) {
            return post;
          }
          return null;
        });
        setPinnedPosts(postsPinned);
        setPosts(paginatedPosts);
      }, [page, posts, postsPerPage]);
    
      return (
        <div>
          {populatePosts(pinnedPosts, handlePostSelect, true)}
          {populatePosts(selectedPosts, handlePostSelect)}
          <div className="paginate">
            <button type="button" onClick={handlePrev}>Prev</button>
            <span>
              {page}
              /
              {maxPages}
            </span>
            <button type="button" onClick={handleNext}>Next</button>
          </div>
        </div>
      );
    };
    // Some code not shown...

    src/components/functional/blogPage/paginatePosts.js

    This component accepts 4 props,

    posts
    (array of posts), 
    populatePosts
    (function previously defined), 
    postsPages
    (determines amount of posts shown per page), and 
    handlePostSelect
    (function previously defined)
    .

    SubForumDisplay method

    import Paginate from '../../functional/blogPage/paginatePosts';
    import populatePosts from './populatePosts';
    
    const SubForumDisplay = ({
      forum, subforum, handleIcon, handlePostSelect, checkForumContraints, postsPerPage,
    }) => {
      const [forumTitle, setForumTitle] = useState('');
      const [posts, setPosts] = useState([]);
      const [showForum, setShowForum] = useState(false);
    
      // Some code not shown...
      return (
        <div className="forum-section ml-1">
          <div className="header-title">
            <Link to={`/${forum.name}/${forumTitle}`}>
              <h4 className="text-camel">{forumTitle}</h4>
            </Link>
            <button type="button" onClick={() => handleShowForum(showForum)}>
              {handleIcon(showForum)}
            </button>
          </div>
          {showForum && (
            <div>
              {checkForumContraints() && (
                <Link
                  to={`/${forum.name}/${forumTitle}/posts/new?forum_id=${forum.id}&&subforum_id=${subforum.id}`}
                  className="new-post-btn"
                >
                  New Topic
                </Link>
              )}
              <div className="post-section">
                <Paginate
                  posts={posts}
                  handlePostSelect={handlePostSelect}
                  populatePosts={populatePosts}
                  postsPages={postsPerPage}
                />
              </div>
            </div>
          )}
        </div>
      );
    };

    src/components/presentational/blogPage/subForumDisplay.js

    Both forumDisplay and subForumDisplay components are presentational components used to manage how forums and subforums are structured and shown to the user, thus they both employ the same child components.

    image

    "Announcements" as shown in the above photo, would be the result of the forumDisplay component, and "Rules" would be the result of the subForumDisplay component. "Rules of Engagement By Aaron Rory" would be the result of the paginatePosts component.

    NewBlogPost Component

    import React, { useState } from 'react';
    import propTypes from 'prop-types';
    import { Link, Redirect, useLocation } from 'react-router-dom';
    import ReactQuill from 'react-quill';
    import { postNew } from '../../misc/apiRequests';
    import { modules, formats } from '../../misc/presets/quillModules';
    import 'react-quill/dist/quill.snow.css';
    
    const NewBlogPost = ({
      match, user, handlePostSelect, handleLoader, handleModal,
    }) => {
      const [newPostTitle, setPostTitle] = useState('');
      const [newPostBody, setPostBody] = useState('');
      const { forum, subforum } = match.params;
    
      function useQuery() {
        return new URLSearchParams(useLocation().search);
      }
      const query = useQuery();
    
      const handleChangeTitle = e => {
        const elem = e.target;
        setPostTitle(elem.value);
      };
    
      const handleSubmitPost = e => {
        e.preventDefault();
        if (!user.can_post) return;
    
        const formData = new FormData();
        formData.append('post[title]', newPostTitle.trim());
        formData.append('post[body]', newPostBody);
        formData.append('post[forum_id]', query.get('forum_id'));
        formData.append('post[subforum_id]', query.get('subforum_id'));
        formData.append('post[user_id]', user.id);
    
        handleLoader(true);
        postNew(formData)
          .then(response => {
            if (response.success) handlePostSelect(response.post);
            if (!response.success) handleModal(response.errors);
            handleLoader(false);
          });
      };
    
      const renderMain = (
        <div id="BlogPage" className="bg-main">
          <div className="container-md">
            <form className="newPost" onSubmit={handleSubmitPost} encType="multipart/form-data">
              <Link to={`/${forum}${subforum ? `/${subforum}` : ''}`}>
                <i className="fas fa-chevron-circle-left pr-01" />
                Back
              </Link>
              <h4 className="text-grey">Forum</h4>
              <h3 className="text-camel">{`New ${forum}/${subforum} Topic`}</h3>
              <input
                name="postTitle"
                type="text"
                value={newPostTitle}
                onChange={handleChangeTitle}
                placeholder="Post Title"
                minLength="6"
                maxLength="32"
                required
              />
              <ReactQuill
                theme="snow"
                modules={modules}
                formats={formats}
                value={newPostBody}
                onChange={setPostBody}
              />
              <button type="submit" className="submit-btn">Submit</button>
            </form>
          </div>
        </div>
      );
    
      return user.logged_in ? renderMain : <Redirect to="/login" />;
    };

    src/components/functional/blogPage/newPost.js

    The match prop is passed along from the App.js main component, and match.params is used to grab the subforum and forum variable parameter values.

    https://arn-forum-cms.netlify.app/announcements/rules/posts/new?forum_id=1&&subforum_id=1

    Example URL address

    The forum parameter would be "announcements", and the subforum parameter would be "rules".

    The built-in function,

    URLSearchParams
    , was first used while creating the Forgot/Reset Password component, is used here to grab the parameter values for both forum_id and subforum_id from the current URL web address.

    This component is essentially just another form type component, except this time the form is submitted as a

    multipart/form-data
    encoded FormData object. The reason being, initially, the idea was to allow image uploads through this form.

    // Create New Post
    const postNew = async post => {
      let login;
      if (sessionStorage.getItem('user')) login = JSON.parse(sessionStorage.getItem('user'));
      return axios.post(`${URL}posts`, post,
        { headers: { 'Content-Type': 'multipart/form-data', Authorization: login.token } })
        .then(response => {
          const { post } = response.data;
    
          return { post, success: true };
        })
        .catch(error => errorCatch(error));
    };

    src/components/misc/apiRequests.js

    When sending over FormData through Axios the request changes up slightly too. As seen in the postNew apiRequest an additional header, Content-Type is required and the object data format sent is slightly different (No need to encase the object within an object).

    The ReactQuill component is used just as any other component would be, and the modules and formats imported were created using the documentation provided by the ReactQuill npm package.

    // Some code not shown...
      <Route
        exact
        path="/:forum/posts/new"
        render={props => (
          <NewPost
            match={props.match}
            user={user}
            handlePostSelect={handlePostSelect}
            handleLoader={handleLoader}
            handleModal={handleModal}
          />
        )}
      />
      <Route
        exact
        path="/:forum/:subforum/posts/new"
        render={props => (
          <NewPost
            match={props.match}
            user={user}
            handlePostSelect={handlePostSelect}
            handleLoader={handleLoader}
            handleModal={handleModal}
          />
        )}
      />
    // Some code not shown...

    src/App.js

    As with all components, don't forget to add a new Route into the App.js main component to allow proper access to it.

    EditBlogPost Component

    src/components/functional/blogPage/editPost.js

    The editPost.js component is another form type component, and just like the newBlogPost.js component, the match prop is passed along from App.js.

    EG.

    https://arn-forum-cms.netlify.app/announcements/rules/posts/3/edit

      useEffect(() => {
        let isMounted = true;
    
        if (match.params.id) {
          const postID = parseInt(match.params.id, 10);
          handleLoader(true);
          fetchPost(postID)
            .then(response => {
              if (response.success) {
                if (isMounted) setSelectedPost(response.post);
              }
              if (!response.success) handleModal(response.errors);
              handleLoader(false);
            });
        }
        return () => { isMounted = false; };
      }, [match.params.id, handleLoader, handleModal]);

    The

    useEffect
    react Hook, which runs as soon as the component is loaded, is used to fetch the post data from the API using the ID(would be 3 using the above example link) retrieved from the address URL through
    match.params
    .

    Here's what the routed link looks like in App.js:

              <Route
                exact
                path="/:forum/posts/:id/edit"
                render={props => (
                  <EditPost
                    match={props.match}
                    user={user}
                    handlePostSelect={handlePostSelect}
                    handleLoader={handleLoader}
                    handleModal={handleModal}
                  />
                )}
              />
              <Route
                exact
                path="/:forum/:subforum/posts/:id/edit"
                render={props => (
                  <EditPost
                    match={props.match}
                    user={user}
                    handlePostSelect={handlePostSelect}
                    handleLoader={handleLoader}
                    handleModal={handleModal}
                  />
                )}
              />

    The words with the colon (:) in front are all considered parameters. These parameters, :forum, :id, and :subforum would be the values retrieved using match.params.

    ShowPostPage Component

    src/components/functional/blogPage/postPage.js

    A functional component more akin to a presentational component, but its functionality lies in allowing the creator of the post to remove and allowing administrators to either pinning or locking a post (Disallowing commenting on the post).

    Comments Component

    src/components/functional/comments/commentSection.js

    The commentSection.js component follows a similar coding structure to the blogPage.js component. It is ultimately a form submission type of component that relies on a pagination component and a display component.

    import React, { useEffect, useRef, useState } from 'react';
    import propTypes from 'prop-types';
    import { Link } from 'react-router-dom';
    import CommentDisplay from './commentDisplay';
    import PaginateComments from './paginateComments';
    import { commentNew, commentEdit, commentRemove } from '../../misc/apiRequests';

    The pagination component used is called paginateComments.js, and the display component used is called commentDisplay.js.

      const populateComments = commentsArray => commentsArray.map(comment => (
        <CommentDisplay
          key={comment.id}
          user={user}
          allComments={postComments}
          comment={comment}
          handleSelectComment={handleSelectComment}
          handleEditComment={handleEditComment}
          handleRemoveComment={handleRemoveComment}
        />
      ));

    Similar to previously discussed functions like

    populatePosts
    , 
    populateSubForums
    , and 
    populateSubForums
    , the
     populateComments 
    function uses the 
    commentDisplay
    component to structure the fetched comments grabbed on the mount.

    Overall, the comments component functions very similarly to all the previously mentioned form submission type components.

    PaginateComments Component

    src/components/functional/comments/paginateComments.js

    It follows the same structural pattern of the paginatePosts.js component mentioned up above.

    Deployment to Netlify

    Now that we have finished the setup for our front-end application let's get this boy over onto Netlify so that we can share a live interactive version of this project with others.

    image

    After creating/logging into your Netlify account, we will attempt to create a new Netlify app from our GitHub repository.

    image

    Netlify has a feature called Continous Development, which updates and rebuilds the Netlify app whenever the GitHub repository is updated.

    image

    After selecting GitHub as the Git provider, you will be prompted to log into your GitHub account and allow access to the repository for Netlify App.

    Then from there, you will be shown a list of all your GitHub repositories, and here is where you select the relevant repository to build the Netlify app from.

    image

    After you select the repository, you will need to input the build command and the publish directory for your project.

    {
      "name": "react-cmsblog",
      "version": "0.1.0",
      "private": true,
      "dependencies": {
        "@testing-library/jest-dom": "^4.2.4",
        "@testing-library/react": "^9.5.0",
        "@testing-library/user-event": "^7.2.1",
        "axios": "^0.20.0",
        "prop-types": "^15.7.2",
        "react": "^16.14.0",
        "react-dom": "^16.14.0",
        "react-quill": "^1.3.5",
        "react-router-dom": "^5.2.0",
        "react-scripts": "3.4.3"
      },
      "scripts": {
        "server": "react-scripts start",
        "start": "react-scripts build",
        "build": "react-scripts build",
        "test": "react-scripts test",
        "eject": "react-scripts eject"
      },
    //... Some code not shown
    }

    /package.json

    The

    build
    command would be defined in the
    package.json
    under the "scripts" tag, and the folder generated by this build command would be under the directory of
    /public/

    Then from there, you should just be able to hit the deploy site button.

    Also, note that you can rename your Netlify app after creation by clicking on the App (Found on the team overview tab after logging in, then hitting Site settings -> Change site name)

    Live Netlify Site Of Project

    image

    Tags

    Join Hacker Noon

    Create your free account to unlock your custom reading experience.