We write a social network on Ruby on Rails. Part 1

Film frame "Social network"
Frame from the film “The Social Network”

Why and for whom is this article?

Hello everyone! I’m a Ruby on Rails Developer and I’ve only recently started my journey in this area. I have already gone through the first steps (I wrote about them in this article), like choosing a language, learning its basics, getting to know the framework, the first pet projects, the first interviews, the first offer, the first company. But many have just begun to follow this path, and this article is for them. I remember from experience how difficult it is to find guides (most of them are about creating bookstores, personal blogs, etc.), so I hope many people like the idea of ​​​​creating a social network.

Why social network and how will the process go?

Firstly, I myself would be interested in the implementation of this project. Secondly, I was inspired by the book Practical Rails Social Networking Sites by Alan Bradburne. It would be possible to do everything according to the book, you say. But why then me and my articles? The book is from 2007 and the ruby ​​version is 1.8, so most of the solutions won’t be relevant these days. In addition, I am not going to do everything according to the book, but only be guided by it (I will use the design from it with the addition bootsrap). During development, I will use many gems that will be useful for novice developers. But I will say right away: this series of articles is not for the very start. Some basic stuff (setting ruby, railswhat’s happened MVC, git and the like) I will skip, so I recommend postponing this series of articles and returning to it a little later. If you are an experienced developer and reading this article, I would really appreciate to hear your opinion. Regarding the frequency of publication of articles and exactly how many of them I can’t say, since I plan to do a project in my free time from work and write articles in parallel. But I will try not to procrastinate and do everything at a good pace.

Create a project and make initial settings

Before we start, install the following versions:

  • Ruby 3.0.3

  • Rails 6.1.4.6

  • MySQL 8.0

  • Node 10.19

  • Yarn 1.22.17

I strongly advise you to use github during the development of this project. A little later we will set up CI/CD on our project, which will be an extremely useful experience for you. Now let’s create our project. I will call him g_connectbut you can use any other name (if you choose another, wherever I will use g_connectwrite your own).

rails new g_connect -d mysql

Now go to the folder with the project and take care of the primary settings. I always start with Gemfile and some gems that I will definitely use during development.

#Gemfile

source 'https://rubygems.org'
git_source(:github) { |repo| "https://github.com/#{repo}.git" }

ruby '3.0.3'

gem 'aasm'
gem 'bootsnap', '>= 1.4.4', require: false
gem 'bootstrap'
gem 'devise'
gem 'jbuilder', '~> 2.7'
gem 'mysql2', '~> 0.5.2'
gem 'puma', '~> 5.0'
gem 'rails', '~> 6.1.4.6'
gem 'sass-rails', '>= 6'
gem 'slim'
gem 'turbolinks', '~> 5'
gem 'webpacker', '~> 5.0'

group :development, :test do
  gem 'better_errors'
  gem 'binding_of_caller'
  gem 'byebug', platforms: %i[mri mingw x64_mingw]
  gem 'factory_bot_rails'
  gem 'faker'
  gem 'rails-controller-testing'
  gem 'rspec-rails'
  gem 'rubocop'
  gem 'rubocop-rails'
  gem 'rubocop-rspec'
end

group :development do
  gem 'annotate'
  gem 'listen', '~> 3.3'
  gem 'rack-mini-profiler', '~> 2.0'
  gem 'spring'
  gem 'web-console', '>= 4.1.0'
end

group :test do
  gem 'capybara', '>= 3.26'
  gem 'rspec_junit_formatter'
  gem 'selenium-webdriver'
  gem 'simplecov', require: false
  gem 'webdrivers'
end

gem 'tzinfo-data', platforms: %i[mingw mswin x64_mingw jruby]

Let me explain what some of these gems are for (I recommend reading the documentation before starting in more detail):

Now we need to install all the gems and their dependencies, so we run bundle in our terminal (by the way, yes, I forgot to say that I use Ubuntuso for MacOS/Windows(it’s better not to touch Windows at all when developing on ruby, but if you really want to) see some points yourself). We can also delete the folder testit is not needed (after all, we will write rspec-and).

After that, we will set up our database. In file config/database.yml specify your username and password (I did the standard root/root). After that, we run the following (if someone does not know, then this one command immediately executes db:create, db:schema:load and db:seed):

rails db:setup

Some of our gems require additional configuration. We will deal with them nowdevise also requires additional configuration, but we will deal with this later when we do authentication). Let’s start with bootstrap. Let’s go to the file app/assets/stylesheets/application.scss (the file may initially have the extension .cssso fix it to .scss) and add the following line:

/*app/assets/stylesheets/application.scss*/

@import "bootstrap";

Now let’s set up annotate. To do this, run the following command in a terminal:

rails g annotate:install

Now you need to set up rspec:

rails g rspec:install

Also for our tests we will need a setup factory_bot_rails and simplecov. in our folder spec create a folder supportand in it we create a file factory_bot.rb with the following code:

#spec/support/factory_bot.rb

RSpec.configure do |config|
  config.include FactoryBot::Syntax::Methods
end

Now let’s go to our spec/rails_helper.rb. Here we will include our file for factory_botand also connect simplecov (two lines to connect simplecov should be at the very beginning of the file).

#spec/rails_helper.rb

require 'simplecov'
SimpleCov.start 'rails'
require_relative './support/factory_bot'

We’re done with setting up the gems for now. If you want to check the test coverage percentage, you can run xdg-open coverage/index.html and that’s the magic. But I would like to add a couple more points. First – shared_context.rb to test our models. In the folder spec/support create shared_context.rb

#spec/support/shared_context.rb

RSpec.shared_examples 'creates_object_for' do |model_name|
  subject { FactoryBot.build(model_name) }

  it 'creates object' do
    expect { subject.save }.to change { described_class.count }.by(1)
  end
end

RSpec.shared_examples 'not_create_object_for' do |model_name, parameter|
  subject { described_class.create(attributes) }

  let(:attributes) { FactoryBot.attributes_for(model_name, parameter) }

  it 'does not create object' do
    expect { subject.save }.to change { described_class.count }.by(0)
  end

  it 'raise RecordInvalid error' do
    expect { subject.save! }.to raise_error(ActiveRecord::RecordInvalid)
  end
end

Our shared_context will act as a template for model testing. In it, we describe what should happen if the data is valid and the object is created, and vice versa, what will happen if some data is invalid or missing and the object will not be created. This will greatly simplify writing tests for models, then in practice you will see this. Now let’s plug it into our spec_helper.rb

#spec/spec_helper.rb

require_relative './support/shared_context'

And the last thing about the settings before the start: a touch of design. Go to app/views/layouts/application.html.erb. Change extension .erb on the .slim and do like this:

#app/views/layouts/application.html.slim

doctype html
html
  head
    meta content="text/html; charset=UTF-8" http-equiv='Content-Type'
    title G-Connect
    = csrf_meta_tags
    = csp_meta_tag
    = stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload'
    = javascript_pack_tag 'application', 'data-turbolinks-track': 'reload'
  body
    #container
      #header
        
      #sidemenu
        = render 'application/sidemenu'
      #content
        = yield

I think it looks much better than .html.erb. If you have never used .slimthen use here this resource for translation from .html.erb v .html.slim. Further in the folder app/views create an application folder, and in it a file _sidemenu.html.slim and inside it so far only the following lines:

#app/views/layouts/_sidemenu.html.slim

ul
  li
    = link_to 'Home', '/', class: 'btn btn-sm btn-light'

Then we go to app/assets/stylesheets/application.scss and add the following:

/*app/assets/stylesheets/application.scss*/

body {
   margin: 0;
   padding: 0;
   background-color: #f0ffff;
   font-family: Arial, Helvetica, sans-serif;
 }

 #header {
   background-color: #f0ffff;
   height: 60px;
   margin-top: 10px;
   text-align: left;
   padding-top: 1px;
 }

 #container {
   width: 760px;
   min-width: 760px;
   margin: 0 auto;
   padding: 0px;
 }

 #sidemenu {
   font-size: 80%;
   float: left;
   width: 100px;
   padding: 0px;
 }

 #sidemenu ul {
   list-style: none;
   margin-left: 0px;
   padding: 0px;
 }

 a {
   color: #b00;
 }

 a:hover {
   background-color: #b00; color: #f0ffff;
 }

 #content {
   float: right;
   width: 650px;
 }

 th {
   background-color: #933;
   color: #f0ffff;
 }

 tr.odd {
   background-color: #fcc;
 }

 tr.even {
   background-color: #ecc;
 }

While quite simple (css taken from the book), but, as I said earlier, I focus on the backing. When the initial work is over, we can move on. Can pour everything we’ve done to you on github and do new things already in another branch. I recommend checking before doing this. rubocop-om (you can add a special extension for your IDE and it will immediately highlight files that have flaws for you).

Creating a page model

We will make the first model for pages. It will have the following fields

Field name

A type

id

integer

title

string

permalink

string

body

text

Permalink will be made up of our titleonly will have a more beautiful look to use as a url. Let’s create a model.

rails g model Page

In addition to the model, we have generated a migration and two files for tests, we will return to them a little later. The migration is in the folder db/migrate. Let’s start with her:

#db/migrate/date_time_create_pages.rb

class CreatePages < ActiveRecord::Migration[6.1]
  def change
    create_table :pages do |t|
      t.string :title,          null: false
      t.string :permalink
      t.text :body,             null: false
    end
  end
end

After that we run rails db:migrate (our annotate immediately shows which files have been annotated) and go to our model. Here we will write our validations, as well as a method for obtaining our permalink (we will call it using the callback after_create). In this method I will use regular expressions, to help with composing them I always use Rubular.

#app/model/page.rb

class Page < ApplicationRecord
  after_create :clean_url

  validates_presence_of :title, :body
  validates :title, length: { in: 3..250 }
  validates :body, length: { in: 3..100_00 }

  private

  def clean_url
    return unless self.permalink.nil?
    url = title.downcase.gsub(/s+/, '_').gsub(/[^a-zA-Z0-9_]+/, '')
    self.permalink = url
    save
  end
end

Now I propose to do our tests. Let’s start with a file spec/factories/pages.rb

#spec/factories/pages.rb

FactoryBot.define do
  factory :page do
    title { 'Test' }
    body { 'Test' }
  end
end

After that, we can start writing tests directly. It is in this file that our previously written spec/supprot/shared_context.rb.

#spec/models/page_spec.rb

require 'rails_helper'

RSpec.describe Page, type: :model do
  describe '.create' do
    context 'with valid attributes' do
      include_examples 'creates_object_for', :page
    end

    context 'with invalid attributes' do
      context 'with short title' do
        include_examples 'not_create_object_for', :page, title: 'te'
      end

      context 'with too long title' do
        include_examples 'not_create_object_for', :page, title: Faker::String.random(length: 253)
      end

      context 'with short body' do
        include_examples 'not_create_object_for', :page, body: 'te'
      end

      context 'with too long body' do
        include_examples 'not_create_object_for', :page, body: Faker::String.random(length: 100_02)
      end
    end

    context 'with missing attributes' do
      context 'with missing title' do
        include_examples 'not_create_object_for', :page, title: nil
      end

      context 'with missing body' do
        include_examples 'not_create_object_for', :page, body: nil
      end
    end
  end
end

I think no explanation is needed here, just note that private methods do not need to be tested, so there are no tests for our clean_url. We can launch rspec in our terminal and make sure all our tests pass without errors. When we figured out the model and tests for them, I propose to deal with the controller. I don’t use a generator for controllers, so we create a file pages_controller.rb in our folder app/controllers. Here we will write the following:

#app/controllers/pages_controller.rb

class PagesController < ApplicationController
  before_action :find_page, only: %i[show edit update destroy]

  def index
    @pages = Page.all
  end

  def show; end

  def new
    @page = Page.new
  end

  def create
    @page = Page.create(page_params)

    if @page.save
      redirect_to pages_path, notice: 'Page created'
    else
      render :new
    end
  end

  def edit; end

  def update
    if @page.update(page_params)
      redirect_to page_path(@page), notice: 'Page updated'
    else
      render :edit
    end
  end

  def destroy
    @page.destroy
    redirect_to pages_path, notice: 'Page deleted'
  end

  private

  def find_page
    @page = Page.find(params[:id])
  end

  def page_params
    params.require(:page).permit(:title, :permalink, :body)
  end
end

Here everything is more absolutely standard, so I will not focus on this either. Now we need to add routes for our controller:

#config/routes.rb

Rails.application.routes.draw do
  root 'pages#index'

  resources :pages
end

Now let’s cover our controller with tests. For it’s in a folder spec create a folder controllers and in the same place immediately create a file pages_controller_spec.rb

#spec/controllers/pages_controller_spec.rb

require 'rails_helper'

RSpec.describe PagesController, type: :controller do
  describe 'GET #index' do
    let(:pages) { [FactoryBot.create(:page)] }

    it 'returns all pages' do
      get :index

      expect(response).to render_template('index')
      expect(response).to have_http_status(:ok)
      expect(assigns(:pages)).to eq(pages)
    end
  end

  describe 'GET #show' do
    let(:page) { FactoryBot.create(:page) }

    it 'assigns page' do
      get :show, params: { id: page.id }

      expect(response).to render_template('show')
      expect(response).to have_http_status(:ok)
      expect(assigns(:page)).to eq(page)
    end
  end

  describe 'GET #new' do
    it 'returns render form for creating new page' do
      get :new

      expect(response).to render_template('new')
      expect(response).to have_http_status(:success)
    end
  end

  describe 'POST #create' do
    let(:page_params) { FactoryBot.attributes_for(:page) }

    it 'creates new page' do
      get :create, params: { page: page_params }

      expect(response).to redirect_to('/pages')
      expect(response).to have_http_status(:found)
    end

    it 'doesn`t create new page' do
      get :create, params: { page: page_params.except(:title) }

      expect(response).to render_template('new')
    end
  end

  describe 'PUT #update' do
    let(:page) { FactoryBot.create(:page) }

    it 'updates the requested page' do
      put :update, params: { id: page.id, page: { title: 'brbrbr' } }

      expect(response).to redirect_to("/pages/#{page.id}")
      expect(response).to have_http_status(:found)
    end

    it 'doesn`t update page' do
      put :update, params: { id: page.id, page: { title: '' } }

      expect(response).to render_template('edit')
    end
  end

  describe 'DELETE #destroy' do
    let(:page) { FactoryBot.create(:page) }

    it 'destroys page' do
      delete :destroy, params: { id: page.id }

      expect(response).to redirect_to('/pages')
    end
  end
end

Now I propose to make a visual for our controller. Go to app/views and create a folder there pagesand it has 5 files: _form.htm.slim, new.html.slim, edit.html.slim, show.html.slim and index.html.slim. Now let’s go through each of them. In our _form.htm.slim there will be a form that we will fill out to create or change our Pages. We will render this form in our new and edit respectively.

#app/views/pages/_form.html.slim

= form_with(model: page, local: true) do |f|
  .form-group
    = f.label :title
    = f.text_field :title
  .form-group
    = f.label :body
    = f.text_area :body
  .form-group
    = f.submit 'Submit', class: 'btn btn-success'
#app/views/pages/new.html.slim

h1 New Page

= render 'form', page: @page

= link_to 'Back', :back, class: 'btn btn-sm btn-primary'
#app/views/pages/edit.html.slim

h1 Edit Page

= render 'form', page: @page

= link_to 'Back', :back, class: 'btn btn-sm btn-primary'

Now let’s get busy show and index:

#app/views/pages/show.html.slim

p
  strong Title: 
  = @page.title
p
  strong Body: 
  = @page.body

= link_to 'Edit', edit_page_path(@page), class: 'btn btn-sm btn-success'
'
= link_to 'Delete', page_path(@page), method: :delete, class: 'btn btn-sm btn-danger', data: { confirm: 'Are you sure?' }
'
= link_to 'Back', :back, class: 'btn btn-sm btn-primary'
#app/views/pages/index.html.slim

h2 Pages
ul
  - @pages.each do |page|
    li
      = page.permalink
      |: 
      = page.title
      |
      p  
        = link_to 'Show', page_path(page), class: 'btn btn-sm btn-info'

p
  = link_to 'Create new page', new_page_url, class: 'btn btn-sm btn-primary'

And the last thing for today – a little tweak _sidemenu.html.slim

#app/views/application/_sidemenu.html.slim 

ul
  li
    = link_to 'Home', root_path, class: 'btn btn-sm btn-light'

I think that’s enough for today. So this is a pretty big article. Check everything rubocop-om, correct the shortcomings, if necessary, and you can safely upload to your github. In the next article, we will add users, devise and set up CI/CD. I hope you all enjoyed reading this article. If you have any comments or suggestions – feel free to write in the comments. I wish everyone less errors in the code and more interesting projects!

Similar Posts

Leave a Reply