51
loading...
This website collects cookies to deliver better user experience
rails new hotwire_modals -T
cd hotwire_modals
bundle add hotwire-rails
rails hotwire:install
yarn add tailwindcss@latest postcss@latest autoprefixer@latest @tailwindcss/forms
npx tailwind init
module.exports = {
plugins: [
require("tailwindcss")("./tailwind.config.js"),
require("postcss-import"),
require("postcss-flexbugs-fixes"),
require("postcss-preset-env")({
autoprefixer: {
flexbox: "no-2009",
},
stage: 3,
}),
],
}
module.exports = {
purge: [
'./app/**/*/*.html.erb',
'./app/helpers/**/*/*.rb',
'./app/javascript/**/*/*.js',
],
darkMode: false, // or 'media' or 'class'
theme: {
extend: {},
},
variants: {
extend: {},
},
plugins: [
require('@tailwindcss/forms')
],
}
mkdir app/javascript/stylesheets
touch app/javascript/stylesheets/application.scss
@import "tailwindcss/base";
@import "tailwindcss/components";
@import "tailwindcss/utilities";
application.js
file to import application.scss:import Rails from "@rails/ujs"
import "@hotwired/turbo-rails"
import * as ActiveStorage from "@rails/activestorage"
import "channels"
// This is the line we're adding
import "stylesheets/application"
Rails.start()
ActiveStorage.start()
import "controllers"
app/views/layouts/application.html.erb
with the below:<!DOCTYPE html>
<html>
<head>
<title>HotwireModals</title>
<meta name="viewport" content="width=device-width,initial-scale=1">
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
<%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
<%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
</head>
<body class="max-w-7xl mx-auto mt-8">
<%= yield %>
</body>
</html>
Comment
resource, which we'll use to build our modal form submission flow.rails g scaffold Comment body:string
rails db:migrate
touch app/views/comments/_comment.html.erb
<!-- app/view/comments/index.html.erb -->
<div class="max-w-3xl mx-auto mt-8">
<div class="flex justify-between items-baseline mb-6">
<h1 class="text-3xl text-gray-900">Comments</h1>
<%= link_to 'New Comment', new_comment_path, class: "text-blue-600" %>
</div>
<div class="flex flex-col items-baseline space-y-6 p-4 shadow-lg rounded">
<% @comments.each do |comment| %>
<%= render "comment", comment: comment %>
<% end %>
</div>
</div>
_comment
partial for each one. Let's fill that in with the below:<!-- app/view/comments/index.html.erb -->
<div class="text-gray-700 border-b border-gray-200 w-full pb-2">
<%= comment.body %>
</div>
yarn add tailwindcss-stimulus-components
import { Modal } from "tailwindcss-stimulus-components"
application.register('modal', Modal)
touch app/javascript/controllers/extended_modal_controller.js
import { Modal } from "tailwindcss-stimulus-components"
export default class ExtendedModal extends Modal {
static targets = ["form"]
connect() {
super.connect()
}
}
ExtendedModal
controller created, we are ready to start building our Hotwire-powered modal interface. touch app/views/comments/_modal.html.erb
<div data-extended-modal-target="container" class="hidden animated fadeIn fixed inset-0 overflow-y-auto flex items-center justify-center" style="z-index: 9999;">
<div class="max-w-lg max-h-screen w-full relative">
<div class="m-1 bg-white rounded shadow">
<div class="px-4 py-5 border-b border-gray-200 sm:px-6">
<h3 class="text-lg leading-6 font-medium text-gray-900">
Comment
</h3>
</div>
<form id="comment_form"></form>
</div>
</div>
</div>
<form id="comment_form">
tag. This empty tag serves as a placeholder for the comments form content that will be populated when the modal opens.<%= turbo_frame_tag 'comment_modal' %>
<div class="max-w-3xl mx-auto mt-8">
<div class="flex justify-between items-baseline mb-6" data-controller="extended-modal" data-extended-modal-prevent-default-action-opening="false">
<%= render 'modal' %>
<h1 class="text-3xl text-gray-900">Comments</h1>
<%= link_to 'New Comment', new_comment_path, class: "text-blue-600", data: { action: "click->extended-modal#open", 'turbo-frame': 'comment_modal' } %>
</div>
<%= turbo_frame_tag "comments", class: "flex flex-col items-baseline space-y-6 p-4 shadow-lg rounded" do %>
<% @comments.each do |comment| %>
<%= render "comment", comment: comment %>
<% end %>
<% end %>
</div>
<turbo_frame>
. We'll populate this frame with the content of the modal when the New Comment link is clicked.data-controller="extended-modal"
and override the default behavior of the modal controller with prevent-default-action-opening="false"
which tells our modal controller to allow the link_to action to run as normal.link_to
which has two data attributes. The first is a standard Stimulus action attribute which fires the open function of our modal controller on click. <turbo_frame>
that we added at the top of the view. Without this data attribute, Turbo would not know where to place the response from comments/new and clicking on the New Comment link would just render an empty modal.<!-- comments/new.html.erb -->
<%= turbo_frame_tag 'comment_modal' do %>
<turbo-stream target="comment_form" action="replace">
<template>
<%= render partial: "form", locals: { comment: @comment } %>
</template>
</turbo-stream>
<% end %>
<%= form_with(model: @comment, id: "comment_form", data: { extended_modal_target: 'form' }) do |form| %>
<div class="px-4 pt-5 pb-4 sm:p-6 sm:pb-4">
<% if @comment.errors.any? %>
<div class="p-4 border border-red-600">
<ul>
<% @comment.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>
<div class="form-group">
<%= form.label :body %>
<%= form.text_field :body %>
</div>
</div>
<div class="rounded-b mt-6 px-4 sm:grid sm:grid-cols-2 sm:gap-3 sm:grid-flow-row-dense">
<%= form.button class: "w-full sm:col-start-2 bg-blue-600 px-4 py-2 mb-4 text-white rounded-sm hover:bg-blue-700" %>
<button data-action="click->extended-modal#close" class="mt-3 w-full sm:mt-0 sm:col-start-1 mb-4 bg-gray-100 hover:bg-gray-200 rounded-sm px-4 py-2">
Cancel
</button>
</div>
<% end %>
form_with
. First we set the id of the form to comment_form. Recall that the turbo-stream in comments/new is looking for an element with that id. # Snipped content
def create
@comment = Comment.new(comment_params)
if @comment.save
render turbo_stream: turbo_stream.append(
'comments',
partial: 'comment',
locals: {
comment: @comment
}
)
else
# TODO: Handle errors
end
end
# Snipped content
app/javascript/controllers/extended_modal_controller.js
handleSuccess({ detail: { success } }) {
if (success) {
super.close()
this.formTarget.reset()
}
}
<div data-extended-modal-target="container"
data-action="turbo:submit-end->extended-modal#handleSuccess"
class="hidden animated fadeIn fixed inset-0 overflow-y-auto flex items-center justify-center"
style="z-index: 9999;">
<!-- Snip inner content -->
</div>
turbo:submit-end
event. When that event fires, our modal controller's handleSuccess function is called, allowing our modal to close when the form is submitted successfully.class Comment < ApplicationRecord
validates_presence_of :body
end
def create
@comment = Comment.new(comment_params)
if @comment.save
render turbo_stream: turbo_stream.append(
'comments',
partial: 'comment',
locals: {
comment: @comment
}
)
else
render turbo_stream: turbo_stream.replace(
'comment_form',
partial: 'form',
locals: {
comment: @comment
}
), status: :unprocessable_entity
end
end
import { Modal } from "tailwindcss-stimulus-components"
export default class ExtendedModal extends Modal {
static targets = ["form", "errors"]
connect() {
super.connect()
}
handleSuccess({ detail: { success } }) {
if (success) {
super.close()
this.clearErrors()
this.formTarget.reset()
}
}
clearErrors() {
if (this.hasErrorsTarget) {
this.errorsTarget.remove()
}
}
}
<!-- app/views/comments/_form.html.erb -->
<% if @comment.errors.any? %>
<div class="p-4 border border-red-600" data-extended-modal-target="errors">
<h2>
Could not save comment
</h2>
<ul>
<% @comment.errors.full_messages.each do |message| %>
<li><%= message %></li>
<% end %>
</ul>
</div>
<% end %>