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.
Admin Panel
Forum Handling
Post Handling
Profile Handling
Planning out the different tables, their fields, and their various associations beforehand helps a lot in guiding and keeping your database focused.
Chart built using website: LucidChart.com
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)
Level 2 (Moderator)
Level 1 (Forums Moderator)
Level 0 (Basic User)
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.
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.
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.
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.
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.
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.
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.
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.
/package.json
found within the base directory of your project folderYour 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*
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).
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
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.
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.
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.
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".
User Model
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+\-.]+@[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.
allows us to set up model associations; in this case, one(1) user can have many posts and comments. The has_many
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 dependent: :destroy
post.rb
modelThe 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.
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.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:
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.
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.
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.
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
-> ....
$ 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.
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.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.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.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.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
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.Go to cloudinary.com and sign up for a new free account or log in to your already made account.
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
directoryproduction:
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+\-.]+@[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.json_response
method on the selected_user
variable.Don't forget to install Rails Active Storage with:
$ rails active_storage:install
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).
Merge recent changes to the git hub master branch.
$ heroku create
Then push changes to Heroku master using:
$ git push heroku master
Then generate database on Heroku application:
$ heroku run rails db:migrate
$ heroku addons:create sendgrid:starter
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.
Click the find more add-ons button...
Then scroll down until you see the SendGrid app or select the Email/SMS category from the side menu and select it.
Lastly, you will select which of your Heroku apps you wish to provision the SendGrid app for, then click Submit the Order Form.
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 codeconfig.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.
Firstly, I went to Sendgrid.com and signed up for an account. Requiring verification through two-factor authentification (2fa).
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.
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.
Here is where we will create our API Key and access the necessary SMTP configuration variables.
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.
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.
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.
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.
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
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
function to store the user's token.window.sessionStorage
creates and sets a session item containing the user token and other relevant information returned from the API.userLogin
uses the data stored in the session to see if the token stored is still valid.userLoggedIn
And Lastly,
removes the stored session information out of storage while also sending a request to the API to render the current user token invalid.userLogout
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
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 get()
along with the new password.changePasswordWithToken
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 redirect ? loginRedirect : renderMain;
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.
src/components/functional/users/profilePage.js
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.
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.
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.
Administrator Panel (On another user's profile page)
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.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.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...
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.
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).
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.
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.
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.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.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.
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.
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)
.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.
"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.
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.
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.
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).
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.
src/components/functional/comments/paginateComments.js
It follows the same structural pattern of the paginatePosts.js component mentioned up above.
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.
After creating/logging into your Netlify account, we will attempt to create a new Netlify app from our GitHub repository.
Netlify has a feature called Continous Development, which updates and rebuilds the Netlify app whenever the GitHub repository is updated.
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.
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
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)