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 (afterrails assets:precompile
withconfig.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'
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/*;
}
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 }
.
Set host: db
in '/config/database.yml' where db
is the Postgres.
Run docker-compose up -f docker-compose-no-nginx.yml
. Navigate to localhost:3000.
- 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
https://www.freecodecamp.org/news/how-to-get-a-docker-container-ip-address-explained-with-examples/
docker network ls
Got error standard_init_linux.go:211: exec user process caused "no such file or directory"
because of this file.
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.
https://github.com/lewagon/product/blob/master/checklist/04_favicon_tag_is_set.md
-
Drag & Drop with
fetch()
'POST' andDELETE
andcsrfToken()
- Fetch POST
- Fetch DELETE and tabindex attribute
-
Error rendering & form validation for browser & backend
-
Kaminari setup with Ajax rendering pagination
-
- Database model
- Counter cache quick setup, child model and parent model
- Fontawsome setup with a gem and
@import
- Bootstrap setup with yarn and
@import
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 searchingdocument.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 runningwebpacker: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 declareconst myFunction = ()=> {[...]}
after.
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 ....]
});
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 ).
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 withhas_many
andbelongs_to
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 forhas_many
andbelongs_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, andbuild_model
for thebelongs_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.
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
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"></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
For a GET
request, there is no need for CORS
. We used:
new FormData
one.target
as we listened to the submit of the form, and thennew 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);
}
});
}
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;
});
});
};
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 abefore_action :create_genre_from_resto
(thebelongs_to
method makes the followingcreate_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
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 = '<%=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 = <%= @resto.id %>"]').remove();
In the first parse, Rails restos#destroy knows the instance @resto
and will put the 'real' value for <%= @resto.id %>
, say "13" for example. Then Javascript reads the string data-resto-id = "13"
, finds the correct <tr>
in the DOM, and acts with .remove()
. Et voilà.
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;
}
});
};
- 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));
}
});
}
});
}
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);
}
};
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 fetch
method 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);
}
});
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
}
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à.
> 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
}
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 (Comment
here).
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 (Resto
model 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
# gemfile
gem 'font-awesome-sass', '~> 5.12'
#application.scss (respect the order)
@import "font-awesome-sprockets";
@import "font-awesome";
yarn add bootstrap
#application.scss
@import "bootstrap/scss/bootstrap";
and
rails generate simple_form:install --bootstrap
#gemfile
group :development do
gem 'faker', :git => 'https://github.com/faker-ruby/faker.git', :branch => 'master'
end
https://www.codewithjason.com/dockerize-rails-application/
- 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.