Store invalid submission parameters in the Flash
New Dependencies
First, add the turbo
gem and a dev-build
of the
basecamp/turbolinks
release of Turbolinks 6 alpha.
Next, remove @rails/ujs
from the package.json
and remove the
import
and call to Rails.start()
. Most of the value of Rails
Unobtrusive JavaScript will be replaced by Turbolinks, either through
the new turbolinks:submit-start
and turbolinks:submit-end
events or
other existing turbolinks:
-prefixed events.
Finally, add activerecord-session_store
and use it to replace the
cookie store .
Implementation details
Turbolinks 6 will require that every form submission response results in
a redirect or a visit. This is incompatible with the popular Rails
pattern of in-place HTML rendering with a call to render :new
or
render :edit
(from a #create
or #update
action, respectively).
As an alternative to relying on a ActiveModel
or ActiveRecord
instance state, write the parameters submitted to the messages#create
action to the (ActiveRecord::SessionStore-backed) Flash under the
params:
key.
This extension is scoped to the FlashParams
controller concern.
Next, once redirected to the messages#index
, read the parameters from
flash[:params]
, and pass those to Message.restore_submission
. Within
Message.restore_submission
, assign the attributes and call #validate
so that the record is marked as invalid and the error messages are
available for the form_with
call in the messages#index
template.
In cases where a visit to messages#index
is not a result of a invalid
form submission, but is instead a direct visit, skip the call to
#validate
.
This extension is scoped to the Restorable
model concern.
To set a baseline for this example application’s Accessibility (a11y ), ensure that the <input
type="text">
element that captures the value for content
declares an
aria-describedby
attribute that references a <span>
element with a
matching [id]
attribute that renders the error message.
Collapse Gemfile
Expand Gemfile
Gemfile
diff --git a/Gemfile b/Gemfile
index 025dcb7..c63f18f 100644
--- a/Gemfile
+++ b/Gemfile
@@ -14,8 +14,8 @@ gem 'sass-rails', '>= 6'
# Use development version of Webpacker
gem 'webpacker', github: 'rails/webpacker'
-# Turbolinks makes navigating your web application faster. Read more: https://github.com/turbolinks/turbolinks
-gem 'turbolinks', '~> 5'
+# Turbo makes navigating your web application faster. Read more: https://github.com/hotwired/turbo-rails
+gem 'turbo-rails'
# Build JSON APIs with ease. Read more: https://github.com/rails/jbuilder
gem 'jbuilder', '~> 2.7'
# Use Redis adapter to run Action Cable in production
@@ -29,6 +29,8 @@ gem 'jbuilder', '~> 2.7'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', '>= 1.4.4', require: false
+gem 'activerecord-session_store'
+
group :development, :test do
# Call 'byebug' anywhere in the code to stop execution and get a debugger console
gem 'byebug', platforms: [:mri, :mingw, :x64_mingw]
Collapse Gemfile.lock
Expand Gemfile.lock
Gemfile.lock
diff --git a/Gemfile.lock b/Gemfile.lock
index 06f5244..663218b 100644
--- a/Gemfile.lock
+++ b/Gemfile.lock
@@ -106,6 +106,12 @@ GIT
GEM
remote: https://rubygems.org/
specs:
+ activerecord-session_store (1.1.3)
+ actionpack (>= 4.0)
+ activerecord (>= 4.0)
+ multi_json (~> 1.11, >= 1.11.2)
+ rack (>= 1.5.2, < 3)
+ railties (>= 4.0)
addressable (2.7.0)
public_suffix (>= 2.0.2, < 5.0)
bindex (0.8.1)
@@ -148,6 +154,7 @@ GEM
mini_portile2 (2.4.0)
minitest (5.14.2)
msgpack (1.3.3)
+ multi_json (1.15.0)
nio4r (2.5.4)
nokogiri (1.10.10)
mini_portile2 (~> 2.4.0)
@@ -197,10 +204,9 @@ GEM
sprockets (>= 3.0.0)
thor (1.0.1)
tilt (2.0.10)
- turbolinks (5.2.1)
- turbolinks-source (~> 5.2)
- turbolinks-source (5.2.0)
- tzinfo (2.0.4)
+ turbo-rails (0.5.1)
+ rails (>= 6.0.0)
+ tzinfo (2.0.2)
concurrent-ruby (~> 1.0)
webdrivers (4.4.1)
nokogiri (~> 1.6)
@@ -217,6 +223,7 @@ PLATFORMS
ruby
DEPENDENCIES
+ activerecord-session_store
bootsnap (>= 1.4.4)
byebug
capybara (>= 3.26)
@@ -229,7 +236,7 @@ DEPENDENCIES
sass-rails (>= 6)
selenium-webdriver
spring
- turbolinks (~> 5)
+ turbo-rails
tzinfo-data
web-console!
webdrivers
Collapse app/controllers/application_controller.rb
Expand app/controllers/application_controller.rb
app/controllers/application_controller.rb
diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb
index 09705d1..6242faf 100644
--- a/app/controllers/application_controller.rb
+++ b/app/controllers/application_controller.rb
@@ -1,2 +1,3 @@
class ApplicationController < ActionController::Base
+ include FlashParams
end
Collapse app/controllers/concerns/flash_params.rb
Expand app/controllers/concerns/flash_params.rb
app/controllers/concerns/flash_params.rb
diff --git a/app/controllers/concerns/flash_params.rb b/app/controllers/concerns/flash_params.rb
new file mode 100644
index 0000000..9b3507a
--- /dev/null
+++ b/app/controllers/concerns/flash_params.rb
@@ -0,0 +1,14 @@
+module FlashParams
+ extend ActiveSupport::Concern
+
+ included do
+ self._flash_types += [:params]
+
+ def params
+ flash_params = flash[:params].to_h
+ request_params = super.to_unsafe_hash
+
+ ActionController::Parameters.new(request_params.deep_merge(flash_params))
+ end
+ end
+end
Collapse app/controllers/messages_controller.rb
Expand app/controllers/messages_controller.rb
app/controllers/messages_controller.rb
diff --git a/app/controllers/messages_controller.rb b/app/controllers/messages_controller.rb
new file mode 100644
index 0000000..27fd30a
--- /dev/null
+++ b/app/controllers/messages_controller.rb
@@ -0,0 +1,24 @@
+class MessagesController < ApplicationController
+ def create
+ message = Message.new(message_params)
+
+ if message.save
+ redirect_to messages_url
+ else
+ redirect_to messages_url, params: { message: message_params }
+ end
+ end
+
+ def index
+ messages = Message.all
+ message = Message.restore_submission(message_params)
+
+ render locals: { messages: messages, message: message }
+ end
+
+ private
+
+ def message_params
+ params.fetch(:message, {}).permit(:content)
+ end
+end
Collapse app/javascript/packs/application.js
Expand app/javascript/packs/application.js
app/javascript/packs/application.js
diff --git a/app/javascript/packs/application.js b/app/javascript/packs/application.js
index 0b93167..50f0126 100644
--- a/app/javascript/packs/application.js
+++ b/app/javascript/packs/application.js
@@ -3,15 +3,12 @@
// a relevant structure within app/javascript and only use these pack files to reference
// that code so it'll be compiled.
-import Rails from "@rails/ujs"
-import Turbolinks from "turbolinks"
+import "@hotwired/turbo-rails"
import * as ActiveStorage from "@rails/activestorage"
import "trix"
import "@rails/actiontext"
import "channels"
-Rails.start()
-Turbolinks.start()
ActiveStorage.start()
// Uncomment to copy all static images under ../images to the output folder and reference
Collapse app/models/application_record.rb
Expand app/models/application_record.rb
app/models/application_record.rb
diff --git a/app/models/application_record.rb b/app/models/application_record.rb
index 10a4cba..cb07a57 100644
--- a/app/models/application_record.rb
+++ b/app/models/application_record.rb
@@ -1,3 +1,5 @@
class ApplicationRecord < ActiveRecord::Base
+ include Restorable
+
self.abstract_class = true
end
Collapse app/models/concerns/restorable.rb
Expand app/models/concerns/restorable.rb
app/models/concerns/restorable.rb
diff --git a/app/models/concerns/restorable.rb b/app/models/concerns/restorable.rb
new file mode 100644
index 0000000..0ad362d
--- /dev/null
+++ b/app/models/concerns/restorable.rb
@@ -0,0 +1,9 @@
+module Restorable
+ extend ActiveSupport::Concern
+
+ class_methods do
+ def restore_submission(attributes)
+ new(attributes).tap { |model| model.validate unless attributes.empty? }
+ end
+ end
+end
Collapse app/models/message.rb
Expand app/models/message.rb
app/models/message.rb
diff --git a/app/models/message.rb b/app/models/message.rb
new file mode 100644
index 0000000..1ed7734
--- /dev/null
+++ b/app/models/message.rb
@@ -0,0 +1,5 @@
+class Message < ApplicationRecord
+ has_rich_text :content
+
+ validates :content, presence: true
+end
Collapse app/views/layouts/application.html.erb
Expand app/views/layouts/application.html.erb
app/views/layouts/application.html.erb
diff --git a/app/views/layouts/application.html.erb b/app/views/layouts/application.html.erb
index 9953ef5..7beff8f 100644
--- a/app/views/layouts/application.html.erb
+++ b/app/views/layouts/application.html.erb
@@ -6,8 +6,8 @@
<%= csrf_meta_tags %>
<%= csp_meta_tag %>
- <%= stylesheet_pack_tag 'application', media: 'all', 'data-turbolinks-track': 'reload' %>
- <%= javascript_pack_tag 'application', 'data-turbolinks-track': 'reload' %>
+ <%= stylesheet_pack_tag 'application', media: 'all', 'data-turbo-track': 'reload' %>
+ <%= javascript_pack_tag 'application', 'data-turbo-track': 'reload' %>
</head>
<body>
Collapse app/views/messages/index.html.erb
Expand app/views/messages/index.html.erb
app/views/messages/index.html.erb
diff --git a/app/views/messages/index.html.erb b/app/views/messages/index.html.erb
new file mode 100644
index 0000000..4f44a89
--- /dev/null
+++ b/app/views/messages/index.html.erb
@@ -0,0 +1,20 @@
+<ul>
+ <% messages.each do |message| %>
+ <li><%= message.content %></li>
+ <% end %>
+</ul>
+
+<%= form_with model: message do |form| %>
+ <% form.object.errors[:content].tap do |errors| %>
+ <%= form.label :content %>
+ <%= form.rich_text_area :content, aria: {
+ describedby: {message_content_validation_message: errors.any?},
+ invalid: errors.any?
+ } %>
+ <% if errors.any? %>
+ <%= tag.span errors.to_sentence, id: :message_content_validation_message %>
+ <% end %>
+ <% end %>
+
+ <%= form.button %>
+<% end %>
Collapse config/initializers/session_store.rb
Expand config/initializers/session_store.rb
config/initializers/session_store.rb
diff --git a/config/initializers/session_store.rb b/config/initializers/session_store.rb
new file mode 100644
index 0000000..c00dbf3
--- /dev/null
+++ b/config/initializers/session_store.rb
@@ -0,0 +1 @@
+Rails.application.config.session_store :active_record_store, :key => '_constraint_validation_example_session'
Collapse config/routes.rb
Expand config/routes.rb
config/routes.rb
diff --git a/config/routes.rb b/config/routes.rb
index c06383a..37345a4 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -1,3 +1,6 @@
Rails.application.routes.draw do
# For details on the DSL available within this file, see https://guides.rubyonrails.org/routing.html
+ resources :messages, only: [:index, :create]
+
+ root to: redirect("/messages")
end
Collapse db/migrate/20201003194927_add_sessions_table.rb
Expand db/migrate/20201003194927_add_sessions_table.rb
db/migrate/20201003194927_add_sessions_table.rb
diff --git a/db/migrate/20201003194927_add_sessions_table.rb b/db/migrate/20201003194927_add_sessions_table.rb
new file mode 100644
index 0000000..855e0c0
--- /dev/null
+++ b/db/migrate/20201003194927_add_sessions_table.rb
@@ -0,0 +1,12 @@
+class AddSessionsTable < ActiveRecord::Migration[6.1]
+ def change
+ create_table :sessions do |t|
+ t.string :session_id, :null => false
+ t.text :data
+ t.timestamps
+ end
+
+ add_index :sessions, :session_id, :unique => true
+ add_index :sessions, :updated_at
+ end
+end
Collapse db/migrate/20201004015757_create_messages.rb
Expand db/migrate/20201004015757_create_messages.rb
db/migrate/20201004015757_create_messages.rb
diff --git a/db/migrate/20201004015757_create_messages.rb b/db/migrate/20201004015757_create_messages.rb
new file mode 100644
index 0000000..f772f3f
--- /dev/null
+++ b/db/migrate/20201004015757_create_messages.rb
@@ -0,0 +1,7 @@
+class CreateMessages < ActiveRecord::Migration[6.1]
+ def change
+ create_table :messages do |t|
+ t.timestamps
+ end
+ end
+end
Collapse db/schema.rb
Expand db/schema.rb
db/schema.rb
diff --git a/db/schema.rb b/db/schema.rb
index 2daeec1..8fd5890 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema.define(version: 2020_10_01_040643) do
+ActiveRecord::Schema.define(version: 2020_10_04_015757) do
# These are extensions that must be enabled in order to support this database
enable_extension "plpgsql"
@@ -53,6 +53,20 @@ ActiveRecord::Schema.define(version: 2020_10_01_040643) do
t.index ["blob_id", "variation_digest"], name: "index_active_storage_variant_records_uniqueness", unique: true
end
+ create_table "messages", force: :cascade do |t|
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ end
+
+ create_table "sessions", force: :cascade do |t|
+ t.string "session_id", null: false
+ t.text "data"
+ t.datetime "created_at", precision: 6, null: false
+ t.datetime "updated_at", precision: 6, null: false
+ t.index ["session_id"], name: "index_sessions_on_session_id", unique: true
+ t.index ["updated_at"], name: "index_sessions_on_updated_at"
+ end
+
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
end
Collapse package.json
Expand package.json
package.json
diff --git a/package.json b/package.json
index 68db034..e0c6a58 100644
--- a/package.json
+++ b/package.json
@@ -3,14 +3,13 @@
"private": true,
"dependencies": {
"@babel/preset-typescript": "^7.10.4",
+ "@hotwired/turbo-rails": "^7.0.0-beta.1",
"@rails/actioncable": "^6.0.0",
"@rails/actiontext": "^6.0.0",
"@rails/activestorage": "^6.0.0",
- "@rails/ujs": "^6.0.0",
"@rails/webpacker": "5.1.1",
"tailwindcss": "^1.8.10",
"trix": "^1.3.1",
- "turbolinks": "^5.2.0",
"typescript": "^4.0.3"
},
"version": "0.1.0",
Collapse test/controllers/messages_controller_test.rb
Expand test/controllers/messages_controller_test.rb
test/controllers/messages_controller_test.rb
diff --git a/test/controllers/messages_controller_test.rb b/test/controllers/messages_controller_test.rb
new file mode 100644
index 0000000..0c8edf8
--- /dev/null
+++ b/test/controllers/messages_controller_test.rb
@@ -0,0 +1,55 @@
+require "test_helper"
+
+class MessagesControllerTest < ActionDispatch::IntegrationTest
+ test "#create with invalid parameters redirects to the new action" do
+ post messages_path, params: {
+ message: { content: "" }
+ }
+
+ assert_redirected_to messages_url
+ assert_flash_params({ message: { content: "" } })
+ end
+
+ test "#create clears parameters once successful" do
+ post messages_path, params: {
+ message: { content: "" }
+ }
+ follow_redirect!
+ post messages_path, params: {
+ message: { content: "Hello, world" }
+ }
+
+ assert_response(:redirect).then { follow_redirect! }
+ assert_empty flash
+ assert_select "ul" do
+ assert_select "li", text: "Hello, world", count: 1
+ end
+ end
+
+ test "#index through a direct request renders a fresh Message form" do
+ get messages_path
+
+ assert_response :success
+ assert_select "#message_content_validation_message", count: 0
+ assert_select %{trix-editor:not([aria-invalid="true"]):not([aria-describedby])}
+ end
+
+ test "#index when redirected with invalid parameters renders errors" do
+ post messages_path, params: {
+ message: { content: "" }
+ }
+
+ assert_redirected_to(messages_url).then { follow_redirect! }
+ assert_select "#message_content_validation_message", "can't be blank"
+ assert_select %{trix-editor[aria-invalid="true"][aria-describedby~="message_content_validation_message"]}
+ end
+
+ private
+ def assert_flash_params(params)
+ values_from_flash = flash[:params].slice(*params.keys)
+
+ assert_equal \
+ params.with_indifferent_access,
+ values_from_flash.with_indifferent_access
+ end
+end
Collapse test/models/message_test.rb
Expand test/models/message_test.rb
test/models/message_test.rb
diff --git a/test/models/message_test.rb b/test/models/message_test.rb
new file mode 100644
index 0000000..ddfc572
--- /dev/null
+++ b/test/models/message_test.rb
@@ -0,0 +1,12 @@
+require "test_helper"
+
+class MessageTest < ActiveSupport::TestCase
+ test "invalid when content is missing" do
+ message = Message.new
+
+ valid = message.validate
+
+ assert_not valid
+ assert_includes message.errors, :content
+ end
+end
Collapse yarn.lock
Expand yarn.lock
yarn.lock
diff --git a/yarn.lock b/yarn.lock
index ec25bae..b3314be 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -875,11 +875,29 @@
postcss "7.0.32"
purgecss "^2.3.0"
+"@hotwired/turbo-rails@^7.0.0-beta.1":
+ version "7.0.0-beta.1"
+ resolved "https://registry.yarnpkg.com/@hotwired/turbo-rails/-/turbo-rails-7.0.0-beta.1.tgz#90399dd43616efee263b4c8391aa302559c3a17e"
+ integrity sha512-5Y8rq4PUAAk3+yiTFJxOEMQbrfo9cY5E9+nQYUifColm/GdX1IHV6rJvj0+ND/jdniTL6E3eaI5biBkVis57dA==
+ dependencies:
+ "@hotwired/turbo" "^7.0.0-beta.1"
+ "@rails/actioncable" "^6.1.0"
+
+"@hotwired/turbo@^7.0.0-beta.1":
+ version "7.0.0-beta.1"
+ resolved "https://registry.yarnpkg.com/@hotwired/turbo/-/turbo-7.0.0-beta.1.tgz#0111db8a80a7bb308c4cfdd2720112d06bf25c33"
+ integrity sha512-V7hhELbDkYUGaHw/Yw4tu9pDUT1WE4t3A7tRs7Zh0Uwtk2vaJnmykNZ2uq20BnLjofufA1+MLJZ6AXJ3B/Il5A==
+
"@rails/actioncable@^6.0.0":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.0.3.tgz#722b4b639936129307ddbab3a390f6bcacf3e7bc"
integrity sha512-I01hgqxxnOgOtJTGlq0ZsGJYiTEEiSGVEGQn3vimZSqEP1HqzyFNbzGTq14Xdyeow2yGJjygjoFF1pmtE+SQaw==
+"@rails/actioncable@^6.1.0":
+ version "6.1.0"
+ resolved "https://registry.yarnpkg.com/@rails/actioncable/-/actioncable-6.1.0.tgz#f336f25450b1bc43b99bc60557a70b6e6bb1d3d2"
+ integrity sha512-eDgy+vcKN9RIzxmMBfSAe77rTj2cp6kJALiVQyKrW2O9EK2MdostOmP+99At/Dit3ur5+77NVnruxD7y14ZYFA==
+
"@rails/actiontext@^6.0.0":
version "6.0.3"
resolved "https://registry.yarnpkg.com/@rails/actiontext/-/actiontext-6.0.3.tgz#71dacd49df7c16a363e20aa89a53efcad8fcecde"
@@ -894,11 +912,6 @@
dependencies:
spark-md5 "^3.0.0"
-"@rails/ujs@^6.0.0":
- version "6.0.3"
- resolved "https://registry.yarnpkg.com/@rails/ujs/-/ujs-6.0.3.tgz#e68a03278e30daea6a110aac5dfa33c60c53055d"
- integrity sha512-CM9OEvoN9eXkaX7PXEnbsQLULJ97b9rVmwliZbz/iBOERLJ68Rk3ClJe+fQEMKU4CBZfky2lIRnfslOdUs9SLQ==
-
"@rails/webpacker@5.1.1":
version "5.1.1"
resolved "https://registry.yarnpkg.com/@rails/webpacker/-/webpacker-5.1.1.tgz#3c937aa719e46341f037a3f37349ef58085950df"
@@ -7318,11 +7331,6 @@ tunnel-agent@^0.6.0:
dependencies:
safe-buffer "^5.0.1"
-turbolinks@^5.2.0:
- version "5.2.0"
- resolved "https://registry.yarnpkg.com/turbolinks/-/turbolinks-5.2.0.tgz#e6877a55ea5c1cb3bb225f0a4ae303d6d32ff77c"
- integrity sha512-pMiez3tyBo6uRHFNNZoYMmrES/IaGgMhQQM+VFF36keryjb5ms0XkVpmKHkfW/4Vy96qiGW3K9bz0tF5sK9bBw==
-
tweetnacl@^0.14.3, tweetnacl@~0.14.0:
version "0.14.5"
resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64"