Ecosyste.ms: Awesome
An open API service indexing awesome lists of open source software.
https://github.com/stevepolitodesign/rails-remote-elements-tutorial
Create a single page app in Rails from scratch using remote elements and Stimulus.
https://github.com/stevepolitodesign/rails-remote-elements-tutorial
rails ruby-on-rails stimulusjs
Last synced: about 17 hours ago
JSON representation
Create a single page app in Rails from scratch using remote elements and Stimulus.
- Host: GitHub
- URL: https://github.com/stevepolitodesign/rails-remote-elements-tutorial
- Owner: stevepolitodesign
- Created: 2021-10-18T01:09:45.000Z (almost 3 years ago)
- Default Branch: main
- Last Pushed: 2021-11-09T10:50:31.000Z (almost 3 years ago)
- Last Synced: 2023-03-06T00:21:59.543Z (over 1 year ago)
- Topics: rails, ruby-on-rails, stimulusjs
- Language: Ruby
- Homepage: https://stevepolito.design/blog/rails-remote-elements-tutorial/
- Size: 980 KB
- Stars: 0
- Watchers: 1
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
Awesome Lists containing this project
README
# Rails Remote Elements Tutorial
Do you need to create real-time features in your Rails app, but either can't use Turbo or don't want to use a front end framework like React? Fortunately older versions of Rails actually provide this functionality of the box. In this tutorial I'll show you how to create a single page app in Rails from scratch using remote elements and Stimulus.
![Demo](./public/demo.gif)
## Formula### Stimulus Controller
```javascript
import { Controller } from "@hotwired/stimulus";export default class extends Controller {
static targets = ["error", "form"];
static values = {
target: String,
action: { type: String, default: "replace" },
};connect() {
this.target =
this.hasTargetValue && document.querySelector(this.targetValue);
this.action = this.hasActionValue && this.actionValue;
this.actions = [
"afterbegin",
"afterend",
"beforebegin",
"beforeend",
"remove",
"replace",
"update",
];
this.element.addEventListener("ajax:error", (event) =>
this.handleError(event)
);
this.element.addEventListener("ajax:success", (event) =>
this.handleSuccess(event)
);
}actionIsPermitted(action) {
if (this.actions.indexOf(action) == -1) {
throw `data-request-action-value="${action}" is not one of ${this.actions.join(
", "
)}`;
} else {
return true;
}
}clearForm() {
this.hasFormTarget && this.formTarget.reset();
}clearErrors() {
this.hasErrorTarget && (this.errorTarget.innerHTML = "");
}handleError(event) {
const { response } = event.detail[2];this.errorTarget.innerHTML = response;
}handleSuccess(event) {
const { response } = event.detail[2];this.clearErrors();
this.clearForm();
this.actionIsPermitted(this.action) &&
this.updateTarget(this.action, response);
}updateTarget(action, response) {
switch (action) {
case "remove":
this.target.remove();
break;
case "replace":
const parser = new DOMParser();
const doc = parser.parseFromString(response, "text/html");
this.target.replaceWith(doc.body.firstChild);
break;
case "update":
this.target.innerHTML = response;
break;
default:
this.target.insertAdjacentHTML(this.action, response);
}
}
}
```> **What's Going On Here?**
>
> - Rails-ujs dispatches [custom events](https://guides.rubyonrails.org/working_with_javascript_in_rails.html#rails-ujs-event-handlers) on the element creating the request. In our case we specifically listen for `ajax:error` and `ajax:success`. Those responses are returned via `event.detail[2]`.
> - If the request was successful we simply update the DOM with the response according to the value of `this.actionValue`. We limit what actions can be used with `this.actionIsPermitted()`. These values are inspired by [Turbo's seven actions](https://turbo.hotwired.dev/reference/streams#the-seven-actions) and are handled via `this.updateTarget()`.
> - Since a request can come from a button, link, or form we need to conditionally handle rendering errors and clearing form data via `this.clearErrors()` and `this.clearForm()`.### Remote Element Markup
#### Forms
```html+ruby
<%= form_with(
local: false,
data: {
controller: "request",
request_target: "form",
request_target_value: "#some_dom_id",
request_action_value: "afterbegin | afterend | beforebegin | beforeend | remove | replace | update"
}) do |form| %>
...
<% end %>
```> **What's Going On Here?**
` so we can render any errors in the form if the object is not valid.
>
> - We need to add `local: false` to ensure the form will make an AJAX request.
> - The `request_target: "form"` data attribute ensures the form will be cleared via `this.clearForm()`.
> - The `request_target_value` data attribute references an element on the page that will be updated when the response from the server is successful.
> - The `request_action_value` data attribute determines how the `request_target_value` element will be updated when the response from the server is successful.
> - We add `#### Buttons
```html+ruby
<%= button_to(
remote: true,
form: {
data: {
controller: "request",
request_target_value: "#some_dom_id",
request_action_value: "afterbegin | afterend | beforebegin | beforeend | remove | replace | update"
}
}
) do %>
...
<% end %>
```> **What's Going On Here?**
>
> - We need to add `remote: true` to ensure the form will make an AJAX request.
> - The `request_target_value` data attribute references an element on the page that will be updated when the response from the server is successful. Note that this is wrapped in `form: { data: {} }` since we need [this value to be set on the form and not the button](https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-button_to).
> - The `request_action_value` data attribute determines how the `request_target_value` element will be updated when the response from the server is successful. Note that this is wrapped in `form: { data: {} }` since we need [this value to be set on the form and not the button](https://api.rubyonrails.org/classes/ActionView/Helpers/UrlHelper.html#method-i-button_to).#### Links
```html+ruby
<%= link_to(
task.title,
task_path(task),
remote: true,
data: {
controller: "request",
request_target_value: "#some_dom_id",
request_action_value: "afterbegin | afterend | beforebegin | beforeend | remove | replace | update"
}
) %>
```> **What's Going On Here?**
>
> - We need to add `remote: true` to ensure the request will make an AJAX request.
> - The `request_target_value` data attribute references an element on the page that will be updated when the response from the server is successful.
> - The `request_action_value` data attribute determines how the `request_target_value` element will be updated when the response from the server is successful.### Controller Responses
#### When Responding With a Partial
```ruby
class TasksController < ApplicationController
def create
@task = Task.new(task_params)
if @task.save
render @task
else
render partial: "layouts/form_errors", locals: {object: @task}, status: :unprocessable_entity
end
end
end
```> **What's Going On Here?**
>
> - This action only returns [partials](https://guides.rubyonrails.org/layouts_and_rendering.html#using-partials) instead of a full layout. If the `@task` is saved the server will respond with `app/views/tasks/_task.html.erb`. Otherwise it will respond with `layouts/_form_errors.html.erb`. In either case just the markup from the partial is returned instead of the full document.#### When Responding With a Layout
```ruby
class TasksController < ApplicationController
def show
render layout: !request.xhr?
end
end
```> **What's Going On Here?**
>
> - This action will return the full page layout (in this case `app/views/tasks/show.html.erb`) unless the request was an [xhr](https://api.rubyonrails.org/classes/ActionDispatch/Request.html#method-i-xhr-3F) request. If the request was made with xhr then only the content of `app/views/tasks/show.html.erb` will be returned instead of the full document. We could have set `layout: false` but there could be a case where we actually visit the route (https://www.example.com/tasks/1). If we set `layout: false` then the response would be missing the actually page layout.## Example
Below is a real world example of how you can use remote elements to create a single page application in Rails.
**Notes**
- Since it's a single page app, all requests are coming from the root path (`tasks#index`).
- The back button won't work as expected. For example, if you click on a task and then click the back button, you won't be brought back to the `tasks#index` since you're technically already there. Instead you'll be brought back to whatever page you were on last. This is why there are client side routing libraries such as [React Router](https://reactrouter.com/).
- In order to DRY up our code, we set the data attributes for some of the form partials in our controllers since we sometimes respond with a form partial. A good example of this is `tasks#edit` and `app/views/tasks/_form.html.erb`.### Routes
```ruby
# config/routes.rb
Rails.application.routes.draw do
root to: "tasks#index"
resources :tasks, except: [:new] do
resources :items do
collection do
put "mark_all_as_complete"
put "mark_all_as_incomplete"
end
end
end
end
```### Layout
```html+ruby
Rails Remote Elements Tutorial
<%= csrf_meta_tags %>
<%= csp_meta_tag %><%= stylesheet_link_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
<%= yield %>
```
```html+ruby
<% object.errors.full_messages.each do |error| %>
- <%= error %>
<% end %>
```### Task Controller and Views
```ruby
# app/controllers/tasks_controller.rb
class TasksController < ApplicationController
before_action :set_task, except: [:create, :index]
before_action :set_new_item_data_attributes, only: [:show]
before_action :set_edit_task_data_attributes, only: [:edit]
before_action :set_new_task_data_attributes, only: [:index]def create
@task = Task.new(task_params)
if @task.save
render @task
else
render partial: "layouts/form_errors", locals: {object: @task}, status: :unprocessable_entity
end
enddef destroy
@task.destroy
enddef edit
render partial: "form", locals: {data_attributes: @edit_task_data_attributes}
enddef index
@tasks = Task.all.order(created_at: :desc)
@task = Task.new
render layout: !request.xhr?
enddef show
@items = @task.items.order(created_at: :desc)
render layout: !request.xhr?
enddef update
if @task.update(task_params)
render @task
else
render partial: "layouts/form_errors", locals: {object: @task}, status: :unprocessable_entity
end
endprivate
def set_new_item_data_attributes
@new_item_data_attributes = {
controller: "request",
request_target: "form",
request_target_value: "#items",
request_action_value: "afterbegin"
}
enddef set_new_task_data_attributes
@new_task_data_attributes = {
controller: "request",
request_target: "form",
request_target_value: "#tasks",
request_action_value: "afterbegin"
}
enddef set_edit_task_data_attributes
@edit_task_data_attributes = {
controller: "request",
request_target: "form",
request_target_value: "#task_#{@task.id}",
request_action_value: "replace"
}
enddef set_task
@task = Task.find(params[:id])
enddef task_params
params.require(:task).permit(:title)
end
end
``````html+ruby
<%= form_with model: @task, local: false, data: data_attributes, class: "row align-items-center" do |form| %>
<%= render partial: "layouts/form_errors", locals: { object: form.object } if form.object.errors.any? %>
<%= form.text_field :title, class: "form-control" %>
<%= form.submit class: "btn btn-primary" %>
<% end %>
``````html+ruby
<%= link_to task.title, task_path(task), class: "fs-5 link-dark", remote: true, data: { controller: "request", request_target_value: "#content", request_action_value: "update" } %>
<%= link_to "Edit", edit_task_path(task), class: "link-secondary", remote: true, data: { controller: "request", request_target_value: "##{dom_id(task)}", request_action_value: "update" } %>
<%= link_to "Delete", task_path(task), class: "link-secondary", method: :delete, remote: true, data: { controller: "request", request_target_value: "##{dom_id(task)}", request_action_value: "remove" } %>
```
```html+ruby
<%= render partial: "form", locals: { data_attributes: @new_task_data_attributes } %>
<%= render @tasks %>
```
```html+ruby
<%= link_to "Back to Tasks", tasks_path, remote: true, data: { controller: "request", request_target_value: "#content", request_action_value: "update" } %>
<%= @task.title %>
<%= render partial: "items/form", locals: { object: [@task, @task.items.build], data_attributes: @new_item_data_attributes } %>
<%= button_to "Mark All As Complete", mark_all_as_complete_task_items_path(@task), method: :put, remote: true, form_class: "col-4", class: "btn btn-sm btn-outline-secondary", form: { data: { controller: "request", request_target_value: "#items-container", request_action_value: "update" } } %>
<%= button_to "Mark All As Incomplete", mark_all_as_incomplete_task_items_path(@task), method: :put, remote: true, form_class: "col-4", class: "btn btn-sm btn-outline-secondary", form: { data: { controller: "request", request_target_value: "#items-container", request_action_value: "update" } } %>
<%= render partial: "items/items" %>
```
### Item Controller and Views
```ruby
# app/controllers/items_controller.rb
class ItemsController < ApplicationController
before_action :set_item, only: [:destroy, :edit, :update]
before_action :set_task
before_action :set_edit_item_data_attributes, only: [:edit]
def create
@item = @task.items.build(item_params)
if @item.save
render @item
else
render partial: "layouts/form_errors", locals: {object: @item}, status: :unprocessable_entity
end
end
def destroy
@item.destroy
end
def edit
render partial: "form", locals: {object: [@task, @item], data_attributes: @edit_item_data_attributes}
end
def mark_all_as_complete
@items = @task.items.order(created_at: :desc)
@items.update_all(complete: true)
render partial: "items"
end
def mark_all_as_incomplete
@items = @task.items.order(created_at: :desc)
@items.update_all(complete: false)
render partial: "items"
end
def update
if @item.update(item_params)
render @item
else
render partial: "layouts/form_errors", locals: {object: @item}, status: :unprocessable_entity
end
end
private
def item_params
params.require(:item).permit(:title, :complete)
end
def set_edit_item_data_attributes
@edit_item_data_attributes = {
controller: "request",
request_target: "form",
request_target_value: "#item_#{@item.id}",
request_action_value: "replace"
}
end
def set_item
@item = Item.find(params[:id])
end
def set_task
@task = Task.find(params[:task_id])
end
end
```
```html+ruby
<%= form_with model: object, local: false, data: data_attributes, class: "row align-items-center" do |form| %>
<%= render partial: "layouts/form_errors", locals: { object: form.object } if form.object.errors.any? %>
<%= form.text_field :title, class: "form-control" %>
<%= form.submit class: "btn btn-primary"%>
<% end %>
```
```html+ruby
<% unless item.new_record? %>
<%= item.title %>
<%= button_to [item.task, item], class: "btn btn-link", method: :put, remote: true, params: { item: { complete: !item.complete } }, form: { data: { controller: "request", request_target_value: "##{dom_id(item)}", request_action_value: "replace" } } do %>
<%= item.complete? ? "Mark as incomplete" : "Mark as complete" %>
<% end %>
<%= link_to "Edit", edit_task_item_path(item.task, item), class: "link-secondary", remote: true, data: { controller: "request", request_target_value: "##{dom_id(item)}", request_action_value: "update" } %>
<%= link_to "Delete", task_item_path(item.task, item), class: "link-secondary", method: :delete, remote: true, data: { controller: "request", request_target_value: "##{dom_id(item)}", request_action_value: "remove" } %>
<% end %>
```
```html+ruby
<%= render @items %>
```