Skip to content

Rails app featuring Dynamic nested forms, all AJAX, Drag & Drop, advanced PG_search

Notifications You must be signed in to change notification settings

ndrean/Dynamic-Ajax-forms

Repository files navigation

Overview

A toy Rails app deployed on Heroku: https://dynamic-ajax-forms.herokuapp.com/

  • backed by Postgres,
  • Puma configured with unix/sockets for speed
  • reverse proxied with nginx. Leave the gzip compression to nginx for static files only as BREACH vulnerability...). Note: rake can use gem 'rack-brotli'.
  • using plain Javascript with Webpacker (no React).
  • all queries are Ajax in multiple forms
  • using search pg_search
  • implementing dynamic forms (add fields 'on-the-fly')
  • implementing full ajax Kaminari pagination
  • implementing dynamic 'Drag-drop' (all ajax)

No cache strategy implemented (nor fragment/page nor conditional Get nor in model cache.fetch). TODO https://medium.com/better-programming/cache-and-serve-rails-static-assets-with-nginx-reverse-proxy-dfcd49319547

Nginx: installed via https://denji.github.io/homebrew-nginx/#modules and nginx brew reinstall nginx-full --with-gzip-static --with-brotli-module to Brotli compress data and let nginx serve static files (after rails assets:precompile with config.public_file_server.enabled = true). See below 'nginx.conf' running using tcp/ports (possible unix/socket, configure Puma).

Unix/socket or TCP/port: modify '/config/puma.rb' and '/nginx.conf':

  • TCP/port : in '/config.puma.rb', set
port ENV.fetch("PORT") { 3000 }

only and in 'nginx.conf', set the directive

upstream app_server {
  server localhost:3000;
}
  • unix/sockets: in '/config.puma.rb', set
app_dir =  File.expand_path("../..", __FILE__);
bind "unix://#{app_dir}/tmp/sockets/nginx.socket";

and in 'nginx.conf', set

upstream app_server {
  server unix:///Users/utilisateur/code/rails/dynamic-ajax-forms/tmp/sockets/nginx.socket fail_timeout=0;
}

where 'app_dir = Users/utilisateur/code/rails/dynamic-ajax-forms'

Nginx.conf

With the flag config.public_file_server.enabled = false in '/config/environment/dev || prod', we can configure Nginx to run as reverse proxy to serve static files (CSS, JPG, JS) from the /public/assets or /public/packs folders

https://www.linode.com/docs/web-servers/nginx/slightly-more-advanced-configurations-for-nginx/

https://medium.com/@joatmon08/using-containers-to-learn-nginx-reverse-proxy-6be8ac75a757

worker_processes  auto; # depend on cpu cores, ram

error_log  tmp/logs/error.log;
# error_log  logs/error.log  notice;
# error_log  logs/error.log  info;

# pid        logs/nginx.pid;

# daemon off;

events {
    worker_connections  1024;
}


http {
    include           mime.types;
    default_type      application/octet-stream;
    sendfile          on;
    keepalive_timeout 65;
    add_header    X-XSS-Protection "1; mode=block";
    add_header    X-Content-Type-Options nosniff;
    add_header    X-Frame-Options SAMEORIGIN;

    upstream app_server {
      server          localhost:3000;
      # server unix:///Users/utilisateur/code/rails/godwd/tmp/sockets/nginx.socket fail_timeout=0;
    }

    gzip                  on;
    gzip_comp_level       6;
    gzip_min_length       512;
    gzip_static           on;
    gzip_proxied          no-cache no-store private expired auth;
    gzip_types
      #"application/json;charset=utf-8" application/json
      "application/javascript;charset=utf-8" application/javascript text/javascript
      "application/xml;charset=utf-8" application/xml text/xml
      "text/css;charset=utf-8" text/css
      "text/plain;charset=utf-8" text/plain;


    server {
      listen          8080;
      listen          [::]:8080;
      #server_name localhost;

      root             /public

      # serve static (compiled) assets directly if they exist (for rails monolith production)
      # if Rails API, do not use !!!

      location ~ ^/(assets|packs) {
        try_files $uri @rails;
        access_log off;
        gzip_static on;
        # to serve pre-gzipped version
        expires max;
        add_header Cache-Control public;
        add_header Last-Modified "";
        add_header ETag "";
        break;
      }

      location / {
        try_files $uri @rails;
      }

      location @rails {
      proxy_set_header  X-Real-IP  $remote_addr;
      proxy_set_header  X-Forwarded-For $proxy_add_x_forwarded_for;
      proxy_set_header  Host $http_host;
      proxy_redirect    off;
      proxy_pass        http://app_server;

      proxy_http_version  1.1;
      proxy_set_header    Connection ‘’;
      proxy_buffering     off;
      proxy_cache         off;
      chunked_transfer_encoding off;
      # proxy_set_header    X-Accel-Buffering: no;

   }

      error_page   500 502 503 504  /50x.html;
      location = /50x.html {
          root   html;
      }

      location /favicon.ico {
        log_not_found off;
      }
    }


    # another virtual host using mix of IP-, name-, and port-based configuration
    #
    #server {
    #    listen       8000;
    #    listen       somename:8080;
    #    server_name  somename  alias  another.alias;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}


    # HTTPS server
    #
    #server {
    #    listen       443 ssl;
    #    server_name  localhost;

    #    ssl_certificate      cert.pem;
    #    ssl_certificate_key  cert.key;

    #    ssl_session_cache    shared:SSL:1m;
    #    ssl_session_timeout  5m;

    #    ssl_ciphers  HIGH:!aNULL:!MD5;
    #    ssl_prefer_server_ciphers  on;

    #    location / {
    #        root   html;
    #        index  index.html index.htm;
    #    }
    #}
    include servers/*;
}

Local testing with Nginx reverse-proxy & 'rails s'

The database.yml should be set with host: localhost. The Rails app will run on port:3000. To run with nginx, then nginx should be started on the machine (brew services start nginx). Modify nginx's config in '/etc/local/nginx/nginx.conf', remove daemon off, set listen 8080 and set upstream { server localhost:3000 }.

Run the app in local Docker container without nginx

Set host: db in '/config/database.yml' where dbis the Postgres. Run docker-compose up -f docker-compose-no-nginx.yml. Navigate to localhost:3000.

Run the app in a local container with nginx in it.

  • folder structure:
- app
  - config
    database.yml #put 'host: db'
    puma.rb # choose port: 3000
- db
- docker
  - app
    Dockerfile (rails app with node)
  - web
    Dockerfile (nginx)
    nginx.conf
docker-compose.yml

Note: the gem 'web-console' allows you to create an interactive Ruby session in your browser. Those sessions are launched automatically in case of an error and can also be launched manually in any page. Since we whare The config.web_console.permissions lets you control which IP's have access to the console.https://stackoverflow.com/questions/29417328/how-to-disable-cannot-render-console-from-on-rails

Docker container Ip adress

https://www.freecodecamp.org/news/how-to-get-a-docker-container-ip-address-explained-with-examples/ docker network ls

Error with ENTRYPOINT

Got error standard_init_linux.go:211: exec user process caused "no such file or directory"because of this file.

Heroku deploy

For Heroku:

  • in 'app/config/nginx.conf.erb', set daemon off
  • in Procfile, set: web: bin/start-nginx bundle exec puma --config config/puma.rb
rails assets:precompile
rails assets:clobber

heroku ps:scale web=1 --app dynamic-ajax-forms

heroku run rack db:schema:load --app dynamic-ajax-forms
heroku run rack db:seed --app dynamic-ajax-forms

Continuous update of the logs with heroku logs --tail in a terminal.

To be done: favicon

https://github.com/lewagon/product/blob/master/checklist/04_favicon_tag_is_set.md

Links:

Import js libraries into .js.erb

To add Erb support in your JS templates, run:

bundle exec rails webpacker:install:erb

on a Rails app already setup with Webpacker.

With this setting, we then can create a .js.erb file in the folder /javascript/packs/. Then we can use ERB (Ruby parses the file first) and import external libraries with import { myFunction } from '../components/myJsFile.js.

In other words, we can import .js libraries into .js.erb files.

This can save on data-attributes (the data-something="<%= Post.first.id%>" in the HTML file with it's searching document.querySelector('[data-something]')can be replaced simply by eg `const id = <%= Post.first.id%> in the .js.erb file)

Note 1: A 'standard' view rendering file .js.erb located in the views does not have access to import, only those located in the folder /javascript/packs/ do (after running webpacker:install:erb).

Note 2: To use a JS library inside a view .html.erb we need to:

  • import the library in a someFile.js.erb file in the folder /javascript/packs/

  • import the someFile.js.erb file in the view with <t%= javascript_pack_tag 'someFile' %>

Note 3: we need to have Turbolinks loaded to access to the DOM, so all the code in the someFile.js.erb file is wrapped as a callback: document.addEventListener("turbolinks:load", myFunction}), and declare const myFunction = ()=> {[...]} after.

Javascript setup

Standard Webpack settings:

# views/layout/application
 <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload', defer: true %>

An example to ensure that Turbolinks is loaded before calling any JS function:

# javacsript/packs/applications.js
import { createComment } from "../components/createComment.js";
// for Turbolinks to work with Javascript
document.addEventListener("turbolinks:load", () => {
  const createCommentButton = document.getElementById("newComment");
    if (createCommentButton) {
      createComment();
    }
  [... all other methods called here ....]
});

The models

We have a simple three model one-to-many with Type, Restaurant and Client and a joint table Comment between Restaurant and Client (with fields resp. name, name, name and comment ). Database

class Genre < ApplicationRecord
  has_many :restos, -> { order(name: :asc)}
    has_many :comments, through: :restos
    has_many :clients, through: :comments
    validates :name, uniqueness: true, presence: true
    accepts_nested_attributes_for :restos
end

class Resto < ApplicationRecord
  belongs_to :genre, optional: true
  has_many :comments, dependent: :destroy
  has_many :clients, through: :comments
  validates :name, uniqueness: true, presence: true
  accepts_nested_attributes_for :comments
end

class Comment < ApplicationRecord
  belongs_to :resto, counter_cache: true
  belongs_to :client
  validates :comment, length: {minimum: 2}
  accepts_nested_attributes_for :client
  default_scope {joins(:resto).order('name ASC') } # to sort the table 'comments' with the resto name ASC
end

class Client < ApplicationRecord
    has_many :comments
    has_many :restos, through: :comments
    has_many :genres, through: :comments, source: :resto

end

The method accept_nested_attributes_for works with has_manyand belongs_to

Dynamic forms

We build a form which permits to add four nested inputs: genre (1>n) restos (1)>n) comments (n<1) client.

  • use accepts_nested_attributes_for in the models, both for has_many and belongs_to
  • build nested records in the controller's method new with @genre.restos.build for a simple nested association or @genre.restos.build.comments.build for a triple nested association, and build_model for the belongs_to association (where model is client here), so we have @resto.comments.build.build_client in the new method.
  • use the form builder fields_for (both simple_form or form_with)
  • adapt the strong params method (see below)

All this will make Rails accept an array of nested attributes of any length, and the formbuilder will render a block for each element in the association.

The controller is

#restos_controllers.rb
def new4
  @genre = Genre.new
  @genre.restos.build.comments.build.build_client

# with strong params method:

def resto_params
      params.require(:resto).permit(:name,:genre_id,
        comments_attributes: [:id, :comment,
          client_attributes: [:client_id
          ]
        ]
      )
    end

We obtain the following hash params for example: (we put : instead of =>):

#Parameters:
{"genre":{
  "name":"German",
   "restos_attributes":{
      "0":{
        "name":"The Best",
        "comments_attributes":{
          "0":{
            "comment":"Cool",
            "client_attributes":{
              "name":"John"
            }
          },
        "1":{
          "comment":"Bueno",
          "client_attributes":{
            "name":"Mary"
            }
          }
        }
      }
    }
  }, "commit":"Create!"
}

The code written in /views/genres/new4.html.erb calls the partial /genres/_nested_dyn_form.html.erb:

<%= simple_form_for genre, url: 'create4', remote: true do |f| %>
    <%= f.error_notification%>
    <%= f.input :name, label:"Genre/Type of restaurant" %>
    <%= f.simple_fields_for :restos do |r| %>
        <%= r.input :name, label:"Restaurant's name" %>
        <%= r.simple_fields_for :comments do |c| %>
        <fieldset data-fields-id="<%= c.index %>">
            <%= c.input :comment, label:"Add a comment" %>
            <%= c.simple_fields_for :client do |cl| %>
                <%= cl.input :name, label: "Join client's name"%>
            <% end %>
        </fieldset>
        <% end %>
    <% end %>
    <%= f.button :submit, "Create!", class:"btn btn-primary", id:"submit-nested" %>
<% end %>

We have wrapped the dynamic HTML fragment inside a fielset tag to easily select it. Furthermore:

we added a dataset which fills in with the index of the formbuilder object 'comment', <%= c.index %> which is automatically updated by Rails.

The HTML fragment is:

# HTML fragment copied from the console
<div id="select_comment">
  <fieldset data-fields-id="0">
    <div class="form-group string optional genre_restos_comments_comment">
      <label
        class="string optional"
        for="genre_restos_attributes_0_comments_attributes_${newId}_comment"
        >Add a comment</label
      >
      <input
        class="form-control string optional"
        type="text"
        name="genre[restos_attributes][0][comments_attributes][0][comment]"
        id="genre_restos_attributes_0_comments_attributes_0_comment"
      />
    </div>

    <div class="form-group string optional genre_restos_comments_client_name">
      <label
        class="string optional"
        for="genre_restos_attributes_0_comments_attributes_${newId}_client_attributes_name"
        >Join client's name</label
      >
      <input
        class="form-control string optional"
        type="text"
        name="genre[restos_attributes][0][comments_attributes][0][client_attributes][name]"
        id="genre_restos_attributes_0_comments_attributes_0_client_attributes_name"
      />
    </div>
  </fieldset>
</div>

We use JS in a js.erb file ot inject the HTML code. We want a button to add new input fields and assign a unique id, and a form submit button. The following Javascript method does the following:

  • copies the first HTML fragment wrapped in the tag 'fieldset'
  • finds the last formbuilder index, which is in a dataset,
  • we find and replace by a simple regex the desired indexes (here, it's basically replace one out of two '0' with 'newId' to get the unique Id)
  • finally, inject inot the DOM
// # restos/new4.js.erb
function dynAddNestedComment() {
  document.getElementById("addNestedComment").addEventListener("click", (e) => {
    e.preventDefault();
    const lastID = document.querySelector("#fieldset: last-child").dataset
      .fieldsId;
    // calculate the new Id
    // const arrayComments = [...document.querySelectorAll("fieldset")];
    // const lastId = arrayComments[arrayComments.length - 1].dataset.fieldsId
    // we have put a dataset in the fieldset tag where data-fieldsid = c.index
    //  where Rails gives the index of the formbuilder object
    const newId = parseInt(lastId, 10) + 1;

    // set new ID at special location in the new injected HTML fragment
    let dynField = document
      .querySelector('[data-fields-id="0"]')
      .outerHTML.toString();
    let nb = 0; // counter
    dynField = dynField.replace(
      /0/g, // global flag 'g' to get ALL
      (matched, offset) => {
        // we are going to replace every odd index of '0' to 'newId' (see original fieldset)
        nb += 1;
        if ((offset = 0)) {
          return matched;
        }
        if (nb % 2 === 1) {
          // every odd to change 'xxx-0-xxx-0' to 'xxx-0-xxx-1'
          return newId;
        } else {
          return matched;
        }
      }
    );

    // inject the new updated fragment into the DOM
    document
      .querySelector("#submit-nested")
      .insertAdjacentHTML("beforebegin", dynField);
  });
}

We have another form with dynamical injection. This time, we create a restaurant given the 'genre' and will dynamically add new comments with a given collection of clients. The code below is the HTML fragment of the 'fielset' (this has been created for this purpose) to be injected by Javascript. We passed the index of the form object with the .index Ruby method and passed it into a dataset so that the Ruby parsing for the HTML will set the correct value. We grab it with JS and use outerHTML to get the serialized HTML fragment of the fieldset including its descendants. Since we wrapped the fieldsets into a div, we can easily grab the last child element to get the last index. Then we replace the index (since it has to have a unique 'name') by a regex replace(/regex/, new value) where the new value is given by searching the formbuilder's last index and incrementing it.

# HTML fragment copied from the console
<div id="select">
  <fieldset data-fields-id="0">
    <div class="form-group string optional resto_comments_comment">
      <label
        class="string optional"
        for="resto_comments_attributes_${newID}_comment"
        >Comment</label
      >
      <input
        class="form-control string optional"
        type="text"
        name="resto[comments_attributes][${newID}][comment]"
        id="resto_comments_attributes_${newID}_comment"
      />
    </div>
  </fieldset>
</div>
function dynAddComment() {
  const createCommentButton = document.getElementById("addComment");
  createCommentButton.addEventListener("click", (e) => {
    e.preventDefault();
    const lastId = document.querySelector("#select").lastElementChild.dataset
      .fieldsId;
    // const arrayComments = [...document.querySelectorAll("fieldset")];
    // const lastId = arrayComments[arrayComments.length - 1].dataset.fieldsId
    const newId = parseInt(lastId, 10) + 1;
    const changeFieldsetId = document
      .querySelector("[data-fields-id]")
      .outerHTML.replace("0", "${newId}");
    document
      .querySelector("#new_resto")
      .insertAdjacentHTML("beforeend", changeFieldsetId);
  });
}

When the button _ create comment_ is clicked, we want to inject by Javascript a new input block used for comment We need a unique id for the input field. Since we have access to the formbuilder index, we save this id in a dataset, namely add it to the fieldset that englobes our label/input block. By JS, we can attribute a unique id to the new input by reading the last block.

Queries

Some ActiveRecord queries

  • WHERE needs table name and JOINS needs the association name.

Given a client = CLIENT.find_by(name: "myfavorite"), we can find the restaurants on which he commented with client.restos, and the genres he commented on with client.genres.

Conversely: given a resto = RESTO.find_by('restos.name ILIKE ?', "%Sweet%"), we can find the clients that gave a comment with the equivalent queries:

  Resto.joins(comments: :resto).where('clients.name ILIKE ?', '%coralie%')

Given a genre = Genre.find_by(name: "thai"), we can find the clients gave a comment with:

Genre.joins(restos: {comments: :client}).where(clients: {name: "Coralie Effertz"}).uniq

Genre.joins(restos: {comments: :client}).merge(Client.where("clients.name= ?",  "Coralie Effertz")).uniq

Genre.joins(restos: {comments: :client}).merge(Client.where("clients.name ILIKE ?",  "%Coralie%")).uniq

Genre.joins(restos: {comments: :client}).where("clients.name ILIKE ?","%Coralie%").uniq

Given a client = Client.first, we want to render it's comments sorted by restaurant (where comment: belongs_to :resto, client: has_many :comments), then we write:

#clients_controller.rb <br>
  client.includes(comments: :resto)

#views.clients.html.erb <br>
client.comments do |c|
  c.resto.name

and if we want to further include the genre (where resto: belongs_to :genre) which makes the bullet gem happy:

#clients_controler.rb <br>
  client.includes(comments: {resto: :genre})

#views/clients/index.html.erb <br>
  c.resto.genre.name

Search pg_Search

We implemented only a full-text pg_search in the page comments on two columns of associated tables (restos and comments).

# /views/restos/index.html.erb
<%= simple_form_for :search, method: 'GET' do |f| %> (note: a form is 'POST' by default)
<div class="input-field">
  <%= f.input_field :g, required: false, placeholder: "blank or any 'type'"  %>
  <%= f.input_field :r, required: false, placeholder: "blank or any 'type'"  %>
  <%= f.input_field :pg, required: false, placeholder: "blank or any 'type'"  %>
  <%= button_tag(type: 'submit', class: "btn btn-outline-success btn-lg", style:"padding: .8rem 1rem") do %>
    <i class="fas fa-search" id="i-search">&lt/i>
  <% end %>
</div>
<% end %>
# model Comment
class Comment < ActiveRecord
  # Usage of question mark "?" to SANITIZE against SQL injection
  scope :find_by_genre, ->(name) {joins(resto: :genre).where("genres.name ILIKE ?", "%#{name}%")}
  scope :find_by_resto, ->(name) {joins(:resto).where("restos.name ILIKE ?", "%#{name}%")}

  include PgSearch::Model
      multisearchable against: :comment

  pg_search_scope :search_by_word, against: [:comment],
      associated_against: {
          resto: :name
          # !! use the association name
      },
      using: {
          tsearch: { prefix: true }
      }

  # helper to avoid repeating comments = Comment.find_by_xxx(qurey[:x])
  def self.sendmethod(m,q)
    comments = self.send(m, q)
    return  comments.any? ? comments :  self.all
  end


  def self.search_for_comments(query)
    # page load
    return Comment.all if !query.present? || (query.present? && query[:r]=="" && query[:g]=="" && query[:pg]=="")

    if !(query[:r]== "")
      return self.sendmethod(:find_by_resto, query[:r])

    elsif query[:g] != ""
      return self.sendmethod(:find_by_genre, query[:g])

    elsif query[:pg] != ""
      return self.sendmethod(:search_by_word, query[:pg])
    end
  end
end

and the mode Resto needs also:

#model Resto
class Resto < ActiveRecord
[•••]
include PgSearch::Model
  multisearchable against: :name
[•••]
end

Then, the controller's index method includes the search results (and avoids N+1 with 'includes' and uses Kaminari's pagination)

#comments_controller.rb
def index
  @comments = Comment.includes(:resto).order('restos.name').search_for_comments(params[:search]).page(params[:page])
end

Fetch GET with query string

For a GET request, there is no need for CORS. We used:

  • new FormData on e.target as we listened to the submit of the form, and then
  • new URLSearchParams().toString()

to convert the input of a form into a query string added to the end point /restos?.

This produces for example /restos?search%5Bg%5D=burgers&search%5Br%5D=&button= if params[:search][:g]="burgers",params[:search][:r]="",params[:search][:r]="")

async function getSearchRestos() {
  const searchForm = document.querySelector('[action="/restos"]');
  searchForm.addEventListener("submit", async (e) => {
    e.preventDefault();

    const data = new FormData(e.target);
    const uri = new URLSearchParams(data).toString();
    console.log(uri);
    try {
      const request = await fetch("/restos?" + uri, {
        method: "GET",
        headers: {
          "Content-Type": "application/json",
          Accept: "application/json",
        },
      });
      const response = await request.json();
      console.log(response);
    } catch (error) {
      console.warn(error);
    }
  });
}

Editable cell on the fly

Back to Contents

We can edit directly the name of the restaurant and save. Firstly we need to use the attribute contenteditable = true. Then, there is a hidden form under the button submit. When Rails renders the HTML view, the (hidden) form will be initially populated with the object values, as a standard edit mode form. We use the form helper form_with and provide the object model: @esto. Then, the save method will automatically be patch/put.

We attached a listener on the body since we have a Kaminari pagination. It captures the event input. We build a Javascript function such that every event triggers a copy of the innerText into the input of the hidden form. When we validate, the form is submitted to the database with PATCH / UPDATE, so this happens in the background.

The JS helper that copies directly from the cell to the form input every change in the cell.

const copyActive = (tag) => {
  document.querySelectorAll("td").forEach((td) => {
    document.body.addEventListener("input", (e) => {
      if (e.target.dataset.editable === undefined) {
        return;
      } else {
      const id = e.target.dataset.editable;
      document.querySelector(tag + id).value = e.target.innerText;
    });
  });
};

Create or Select on the fly

In the settings of Resto, belongs_to :genre and Genre, has_many :restos, when we create a form with Resto.new, we can create a new restaurant and select-or-create it's genre with the belongs_to method create_genre as a before_save action in the model).

To create a new genre or select one for a new restaurant we can create an instance variable attr_accessor :new_genre_name in the model Resto and permits(... :new_genre_name)in the controller. Then we have 2 possible methods, with before_save in the model, or with find_or_create_by in the controller:

  • just declare @resto = Resto.new(resto_params) in controller and set in the model a before_action :create_genre_from_resto(the belongs_to method makes the following create_genre method available ( Rails guide )) ("The create_association method returns a new object of the associated type. This object will be instantiated from the passed attributes, the link through this object's foreign key will be set, and, once it passes all of the validations specified on the associated model, the associated object will be saved").

The model:

# model Resto
def create_genre_from_resto
  create_genre(name: new_genre ) unless new_genre.blank?
end

or the controller (where the hash params is available):

@resto = Rest.new(resto_params)
if params[:resto][:new_genre_name] != ""
  @resto.genre = Genre.find_or_create_by(name: params[:resto][:new_genre_name] )
end

# Note: ligne 2 is equivalent to the following:
if params[:resto][:new_genre_name].blank?
  @genre = Genre.find(params[:resto][:genre_id])
else
  @genre = Genre.create(name: params[:resto][:new_genre_name])
end
@resto.genre = @genre

For the setting of the 'New comment on restaurant by client' query in the same page, the model Comment has 2 foreign keys, and the method create_client does not work. The use the controller's method:

def create
  @comment = Comment.new(comment_params)
  if params[:comment][:client_new] != ""
    @comment.client = Client.find_or_create_by(name: params[:comment][:client_new])
  end
  @comment.save
  respond_to :js
end

Delete Ajax

Back to Contents

The Delete method is Ajax rendered. The link calls the restos#destroy method. It reads the query string with the params hash, then querries the database with the found ID and delete it from the database.

We declared dependent: :destroy in the model; this is similar to @resto.comments.destroy_all so all associated objects will be deleted together with the parent.

Then the link has the attribute remote: true, so the method will respond to with destroy.js.erb to render dynamically the view in the browser. To update the view, namely delete a row, we need to select it with Javascript so we need to pass the ID information from Rails to Javascript to be able to remove the correct row. We use datasets for this. When Rails renders the HTML, Rails will write the IDs given by the database in a dataset for every object, with the HTML.ERB code:

<tr data-resto-id = '&lt%=resto.id%>'>

(we used a <table> to present the data above).

Since we use the file format js.erb, this file will be firstly parsed by Rails and then Javascript. The code of this file is:

document.querySelector('[data-resto-id = &lt%= @resto.id %>"]').remove();

In the first parse, Rails restos#destroy knows the instance @resto and will put the 'real' value for &lt%= @resto.id %>, say "13" for example. Then Javascript reads the string data-resto-id = "13", finds the correct &lttr> in the DOM, and acts with .remove(). Et voilà.

fetch with response text

The list of clients is rendered by the controler clients#index in the view /views/clients/index.html.erb. On page load, we ask the controller to return an empty array, and there is a button to display all of them. To do so, we used a dummy query string pointing to http://localhost:3000/clients with params ?=c=" " so that the controller can respond with Client.all.includes(comments: {resto: :genre}) when the button is trigger. We ask the controller to serve the collection of clients in format text with a partial with no layout, with render partial: 'clients/client', collection: @clients, layout: false so Rails sends a prefilled text response to the browser. Then we have a Javascript method fetch() that reads the response and parses it into text format, and inserts inot the DOM.

we can use the method .innerHTML here (otherwise, only .insertAdjacentHTML)

const fetchClients = (tag) => {
  document.querySelector(tag).addEventListener("click", async (e) => {
    e.preventDefault();
    try {
      const query = await fetch('/clients?c=""', {
        method: "GET",
        headers: {
          "Content-Type": "text/html",
          Accept: "text/html",
        },
        credentials: "same-origin", // default value
      });
      if (query.ok) {
        const content = await query.text();
        return (document.querySelector("#client_articles").innerHTML = content);
      }
    } catch (error) {
      throw error;
    }
  });
};

Back to Contents

Drag Drop

Back to Contents

  • we need to add the draggable attribute to the node we want to make draggable
  • we add a listener on the dragstart event to capture the start of the drag and capture data in the DataTansfer object. The dataTransfer.setData() method sets the data type and the value of the dragged data. We can only pass a string in it so we stringify the object we pass.
document.addEventListener("dragstart", (e) => {
    // we define the data that wil lbe transfered with the dragged node
    const draggedObj = {
      idSpan: e.target.id,
      resto_id: e.target.dataset.restoId,
    };
    e.dataTransfer.setData("text", JSON.stringify(draggedObj));

By default, data/elements cannot be dropped in other elements. To allow a drop on an element, it needs:

  • to listen to the dragover event to prevent the default handling of the element,
document.addEventListener("dragover", (e) => {
  e.preventDefault();
});
  • listen to the drop event: here we accept to drop on an element that has the class "drop-zone". We can then use the data contained in the DataTransfer object with the dataTransfer.setData() method.

Then, for a drop event, we construct an object data={resto:{genre_id:"value", id:"value}} and transmit it to the Rails backend with a fetch() with POST. This will update the dragged element with it's property (resto_id with correct genre_id) and persist to the database. This is done by calling a dedicated method of a Rails controller.

document.addEventListener("drop", async (e) => {
    e.preventDefault();
    // permits drop only in elt with class 'drop-zone'
    if (e.target.classList.contains("drop-zone")) {
      const transferedData = JSON.parse(e.dataTransfer.getData("text"));

      const data = {
        resto: {
          genre_id: e.target.parentElement.dataset.genreId,
          id: transferedData.resto_id,
        },
      };
      // the method `postGenreToResto` is a `fetch`with a Rails ended-point defined after
      await postGenreToResto(data).then((data) => {
        if (data) {
          // status: ok
          e.target.appendChild(document.getElementById(transferedData.idSpan));
        }
      });
    }
  });
}

Fetch POST

We need to get the csrf token from the session given by Rails since it is an internal request and Rails serve as an API.

We define a custom route that likes to a method that updates the params sent by the fetch() Javascript method.

# routes
patch 'updateGenre', to:'restos#updateGenre'

The method updateGenre simply reads the params hash (formatted as {resto:{id: value, name: value}}) and saves it to the database.

import { csrfToken } from "@rails/ujs";

const postGenreToResto = async (obj = {}) => {
  try {
    const response = await fetch("/updateGenre", {
      method: "PATCH",
      headers: {
        Accept: "application/json",
        "X-CSRF-Token": csrfToken(), // for CORS since it is an internal request
        //document.getElementsByName("csrf-token")[0].getAttribute("content"),
        "Content-Type": "application/json",
      },
      credentials: "same-origin", //if authnetification with cookie
      body: JSON.stringify(obj),
    });
    return await response.json();
  } catch (err) {
    console.log(err);
  }
};

Fetch DELETE and tabindex attribute

To delete a 'type', we first need to read/find it, and then transmit the data to a Rails end-point by a fetch()DELETE method. Then the Rails bakcned will try to delete it (depending upon the validations), and then upon success, the Javascript will remove (or not) the element from the DOM.

We add a tabindex attribute tabindex="0" to make the 'type" element clickable.

We can then listen to a clic event with the document.activeElement

We use activeElement because the list of clickable items can change, so we couldn't use a querySelector which acts on a fixed list. With an document.activeElement that corresponds to a certain 'type' (we added a class genre_tag to find the 'type' items only), we have access to the properties of the item.

function listenToGenres() {
  document.querySelector("#tb-genres").addEventListener("click", () => {
    const item = document.activeElement;
    if (item.classList.contains("genre_tag")) {
      document.querySelector("#hiddenId").value =
        item.parentElement.parentElement.dataset.genreId;
      document.querySelector("#genre_to_delete").value = item.textContent;
    }
  });
}

Then we save the id in a hidden input. On submit, the fetch() DELETE query is triggered. Tthe Rails end-point is defined by a custom route:

delete 'deleteFetch/:id', to: 'genres#deleteFetch'

The deleteFetch method renders: render json: {status: :ok} so the fetchmethod will process it and react upon success.

function destroyType() {
  document
    .querySelector("#genreDeleteForm")
    .addEventListener("submit", async (e) => {
      e.preventDefault();
      const id = document.querySelector("#hiddenId").value;
      try {
        const query = await fetch("/deleteFetch/" + id, {
          method: "DELETE",
          headers: {
            Accept: "application/json",
            "X-CSRF-Token": csrfToken(),
            "Content-Type": "application/json",
          },
          credentials: "same-origin",
        });
        const response = await query.json();
        if (response.status === "ok") {
          document.querySelector(`[data-genre-id="${id}"]`).remove();
          document.querySelector("#genreDeleteForm").reset();
        }
      } catch {
        (err) => console.log("impossible", err);
      }
    });

Error rendering

Back to Contents

Browser validation required: true with the setup config.browser_validations = true used with simple_form_for in #config/initializers/simple_form.rb

With Simple_Form, we just need:

<%= simple_form_for @comment do |f|>
 <%= f.error_notification %>
 ....

but with form_with, we may need to add:

<%= form_with model: @comment do |f| %>
<%= render 'shared/errors', myvar: f.object %>
...

where:

#shared/_erros.html.erb
<% if myvar.errors.any? %>
    <div>
      <h2><%= pluralize(myvar.errors.count, "error") %> prohibited this item from being saved:</h2>

      <ul>
        <% myvar.errors.full_messages.each do |message| %>
          <li><%= message %></li>
        <% end %>
      </ul>
    </div>
  <% end %>

To render errors when the form is AJAX submitted, we can do:

if (<%= @myobject.errors.any? %>) {
    const myDivAboveTheForm = document.querySelector("#myDivAboveTheForm")
    myDivAboveTheForm.innerHTML = ""
    myDivAboveTheForm.insertAdjacentHTML('beforeend',`<%= j render 'restos/form' %>`)

} else {
    ....do something
}

Kaminari AJAX

Back to Contents

Installation: put gem kaminari in gemfile, bundle, and run rails g kaminari:config: this generates the default configuration file into config/initializers directory. We set here:

#/config/initializers/kaminari_config.rb
Kaminari.configure do |config|
  config.default_per_page = 5
end

We tweaked the pagination helper with Bootstrap4 template them, running rails g kaminari:view bootstrap4.

class CommentsController < ApplicationController

  # GET /comments
  def index
    @comments = Comment.includes([:resto]).page(params[:page])
    respond_to do |format|
      format.js
      format.html
    end
  end

In the index.html.erb views of Comments, we add the pagination link and extract a partial of the data that will be paginated (namely the <tbody> part)

# views/comments/index.html.erb
<div id="paginator">
    <%= paginate(@comments, remote: true)  %>
</div>

<table>
  <thead>
  ...
  <tbody id="tb-comments">
    <%= render 'comments/table_comments', comments: @comments %>
  </tbody>
  </thead>
</table>

We extract in a partial the body of the table that will be paginated where the partial that iterates oever @comments = Comment.all:

# /views/comments/_table_comments.html.erb
<% comments.each do |comment| %>
    <tr data-comment-id= "<%= comment.id %>" >
    <td contenteditable="true" data-editable="<%= comment.id %>"><%= comment.comment %> </td>
    <td><%= comment.resto.name %></td>
    ...

And we create a file index.js.erb that contains:

document.querySelector("#tb-comments").innerHTML = "";
document.querySelector(
  "#tb-comments"
).innerHTML = `<%= j render 'comments/table_comments', comments: @comments %>`;
document.querySelector(
  "#paginator"
).innerHTML = `<%= j paginate(@comments, remote: true)%>`;

Et voilà.

Setup

Back to Contents

Database model

Database

> rails g model genre name
> rails g model resto name comments_count:integer genre:references
> rails g model comment comment resto:references
> rails g model client name comment:references
> rails db:create db:migrate
#postgresql
Table Genres as G {
  id int [pk, increment] // auto-increment
  name varchar
  created_at timestamp
}

Table Restos as R {
  id int [pk]
  name varchar
  comments_count integer
  genre_id int [ref:> G.id]
  created_at timestamp // inline relationship (many-to-one)
}



Table Comments as Co {
  id int [pk]
  comment varchar
  resto_id int [ref: > R.id]
  client_id int [ref: > Cl.id]
  created_at_at timestamp
 }

Table Clients as Cl {
  id int [pk]
  name varchar
  create_at timestamp
}

Counter cache

In the view #views/restos/index.html.erb, we have an iteration with a counting output <td> <%= resto.comments.size %></td>. If we use count, we fire an SQL query. We can use counter_cache to persist the count in the database and Rails will update the counter for us whenever a comment is added or removed.

Add counter_cache in the child model (Commenthere).

class Comment < ApplicationRecord
  belongs_to :resto, counter_cache: true
  # requires a field comments_count to the Resto model
  validates :comment, length: {minimum: 2}
end

Add a field comments_count to the parent model (Restomodel here).

rails g migration AddCommentsCountToRestos comments_count:integer
rails db:migrate

Note: to count the number of comments by restaurant with SQL/Ruby, we do:

JOINS( 'restos' )
.SELECT ("restos.*, 'COUNT("comments.id") AS comments_count')
.GROUP('restos.id')

https://blog.appsignal.com/2018/06/19/activerecords-counter-cache.html

Fontawesome

# gemfile
gem 'font-awesome-sass', '~> 5.12'
#application.scss (respect the order)
@import "font-awesome-sprockets";
@import "font-awesome";

Bootstrap

yarn add bootstrap
#application.scss
@import "bootstrap/scss/bootstrap";

and

rails generate simple_form:install --bootstrap

Faker

#gemfile
group :development do
  gem 'faker', :git => 'https://github.com/faker-ruby/faker.git', :branch => 'master'
end

Back to Contents

Docker

https://www.codewithjason.com/dockerize-rails-application/

Misc

  • generate Rails new app with:
rails new nompapp --webpack --database:postgresql
  • change application.css to a SCSS file so that I can use @import directive

  • to present a select ordered alphabetically:

Resto.all.order(name: :asc)
  • list of pids using port 5432: lsof -i :5432
  • kill this
kill -9 $(lsof -i tcp:3000 -t)
  • PostgreSQL Run this command to manually start the server:
brew services start/stop/restart postgresql
pg_ctl -D /usr/local/var/postgres -l /usr/local/var/postgres/server.log start

Start manually:

pg_ctl -D /usr/local/var/postgres start

Stop manually:

pg_ctl -D /usr/local/var/postgres stop

Start automatically:

"To have launchd start postgresql now and restart at login:"

brew services start postgresql
RBENV_VERSION=2.6.5 gem install irb
  • !!! Prefer PostgresApp, no automatic launch of Postgres.