optimism 0.2.10

Sign up to get free protection for your applications and to get access to all the features.
data/bin/console ADDED
@@ -0,0 +1,15 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "bundler/setup"
4
+ require "optimism"
5
+
6
+ # You can add fixtures and/or initialization code here to make experimenting
7
+ # with your gem easier. You can also use a different console, if you like.
8
+
9
+ # (If you use this, don't forget to add pry to your Gemfile!)
10
+ # require "pry"
11
+ # Pry.start
12
+
13
+ require "pry"
14
+
15
+ Pry.start
data/bin/loc ADDED
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env bash
2
+
3
+ cloc --exclude-dir=node_modules,test --include-ext=rb,js .
data/bin/setup ADDED
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ IFS=$'\n\t'
4
+ set -vx
5
+
6
+ bundle install
7
+
8
+ # Do any other automated setup that you need to do here
data/bin/standardize ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bash
2
+
3
+ bundle exec standardrb --fix
4
+ cd ./javascript && yarn run prettier-standard --lint javascript/*.js test/*.js
data/lib/optimism.rb ADDED
@@ -0,0 +1,108 @@
1
+ require "cable_ready"
2
+ require "optimism/version"
3
+ require "optimism/railtie" if defined?(Rails)
4
+
5
+ module Optimism
6
+ include CableReady::Broadcaster
7
+ class << self
8
+ mattr_accessor :channel, :form_class, :error_class, :disable_submit, :suffix, :emit_events, :add_css, :inject_inline, :container_selector, :error_selector, :form_selector, :submit_selector
9
+ self.channel = "OptimismChannel"
10
+ self.form_class = "invalid"
11
+ self.error_class = "error"
12
+ self.disable_submit = false
13
+ self.suffix = ""
14
+ self.emit_events = false
15
+ self.add_css = true
16
+ self.inject_inline = true
17
+ self.container_selector = "#RESOURCE_ATTRIBUTE_container"
18
+ self.error_selector = "#RESOURCE_ATTRIBUTE_error"
19
+ self.form_selector = "#RESOURCE_form"
20
+ self.submit_selector = "#RESOURCE_submit"
21
+ end
22
+
23
+ def self.configure(&block)
24
+ yield self
25
+ end
26
+
27
+ def broadcast_errors(model, attributes)
28
+ return unless model&.errors&.messages
29
+ resource = model.class.to_s.downcase
30
+ form_selector, submit_selector = Optimism.form_selector.sub("RESOURCE", resource), Optimism.submit_selector.sub("RESOURCE", resource)
31
+ attributes = case attributes
32
+ when ActionController::Parameters, Hash, ActiveSupport::HashWithIndifferentAccess
33
+ attributes.to_h.keys
34
+ when String, Symbol
35
+ [attributes.to_s]
36
+ when Array
37
+ attributes.flatten.map &:to_s
38
+ else
39
+ raise Exception.new "attributes must be a Hash (Parameters, Indifferent or standard), Array, Symbol or String"
40
+ end
41
+ process_resource(model, attributes, [resource])
42
+ if model.errors.any?
43
+ cable_ready[Optimism.channel].dispatch_event(name: "optimism:form:invalid", detail: {resource: resource}) if Optimism.emit_events
44
+ cable_ready[Optimism.channel].add_css_class(selector: form_selector, name: Optimism.form_class) if Optimism.form_class.present?
45
+ cable_ready[Optimism.channel].set_attribute(selector: submit_selector, name: "disabled") if Optimism.disable_submit
46
+ else
47
+ cable_ready[Optimism.channel].dispatch_event(name: "optimism:form:valid", detail: {resource: resource}) if Optimism.emit_events
48
+ cable_ready[Optimism.channel].remove_css_class(selector: form_selector, name: Optimism.form_class) if Optimism.form_class.present?
49
+ cable_ready[Optimism.channel].remove_attribute(selector: submit_selector, name: "disabled") if Optimism.disable_submit
50
+ end
51
+ cable_ready.broadcast
52
+ head :ok
53
+ end
54
+
55
+ def process_resource(model, attributes, ancestry)
56
+ attributes.each do |attribute|
57
+ if attribute.ends_with?("_attributes")
58
+ resource = attribute[0..-12]
59
+ nested_models = model.send(resource.to_sym)
60
+ nested_models.each_with_index do |nested, index|
61
+ process_resource(nested, attributes[attribute][index.to_s], ancestry + [resource, index]) if attributes[attribute].key?(index.to_s)
62
+ end
63
+ else
64
+ process_attribute(model, attribute, ancestry.dup)
65
+ end
66
+ end
67
+ end
68
+
69
+ def process_attribute(model, attribute, ancestry)
70
+ resource = ancestry.shift
71
+ resource += "_#{ancestry.shift}_attributes_#{ancestry.shift}" until ancestry.empty?
72
+ container_selector, error_selector = Optimism.container_selector.sub("RESOURCE", resource).sub("ATTRIBUTE", attribute), Optimism.error_selector.sub("RESOURCE", resource).sub("ATTRIBUTE", attribute)
73
+ if model.errors.messages.map(&:first).include?(attribute.to_sym)
74
+ message = "#{attribute.humanize} #{model.errors.messages[attribute.to_sym].first}#{Optimism.suffix}"
75
+ cable_ready[Optimism.channel].dispatch_event(name: "optimism:attribute:invalid", detail: {resource: resource, attribute: attribute, text: message}) if Optimism.emit_events
76
+ cable_ready[Optimism.channel].add_css_class(selector: container_selector, name: Optimism.error_class) if Optimism.add_css
77
+ cable_ready[Optimism.channel].text_content(selector: error_selector, text: message) if Optimism.inject_inline
78
+ else
79
+ cable_ready[Optimism.channel].dispatch_event(name: "optimism:attribute:valid", detail: {resource: resource, attribute: attribute}) if Optimism.emit_events
80
+ cable_ready[Optimism.channel].remove_css_class(selector: container_selector, name: Optimism.error_class) if Optimism.add_css
81
+ cable_ready[Optimism.channel].text_content(selector: error_selector, text: "") if Optimism.inject_inline
82
+ end
83
+ end
84
+ end
85
+
86
+ module ActionView::Helpers
87
+ class FormBuilder
88
+ def container_for(attribute, **options, &block)
89
+ @template.tag.div @template.capture(&block), options.merge!(id: container_id_for(attribute)) if block_given?
90
+ end
91
+
92
+ def container_id_for(attribute)
93
+ Optimism.container_selector.sub("RESOURCE", object_name.delete("]").tr("[", "_")).sub("ATTRIBUTE", attribute.to_s)[1..-1]
94
+ end
95
+
96
+ def error_for(attribute, **options)
97
+ @template.tag.span options.merge! id: error_id_for(attribute)
98
+ end
99
+
100
+ def error_id_for(attribute)
101
+ Optimism.error_selector.sub("RESOURCE", object_name.delete("]").tr("[", "_")).sub("ATTRIBUTE", attribute.to_s)[1..-1]
102
+ end
103
+ end
104
+ end
105
+
106
+ class ActionController::Base
107
+ include Optimism
108
+ end
@@ -0,0 +1,11 @@
1
+ require "pathname"
2
+
3
+ module Optimism
4
+ class Railtie < Rails::Railtie
5
+ railtie_name :optimism
6
+
7
+ rake_tasks do
8
+ load Pathname.new(__dir__).join("rake.rb")
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,18 @@
1
+ require "fileutils"
2
+
3
+ desc "Inject some optimism into this application"
4
+ task :"optimism:install" do
5
+ CHANNELS = [
6
+ {app_path: "app/javascript/channels/optimism_channel.js", template_path: "../templates/optimism_channel.js" },
7
+ {app_path: "app/channels/optimism_channel.rb", template_path: "../templates/optimism_channel.rb" },
8
+ ]
9
+
10
+ CHANNELS.each do |channel|
11
+ if File.exist?("./#{channel[:app_path]}")
12
+ $stderr.puts "=> [ skipping ] #{channel[:app_path]} already exists"
13
+ else
14
+ FileUtils.cp(File.expand_path(channel[:template_path], __dir__), "./#{channel[:app_path]}")
15
+ $stderr.puts "=> #{channel[:app_path]} created"
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,3 @@
1
+ module Optimism
2
+ VERSION = "0.2.10"
3
+ end
@@ -0,0 +1,11 @@
1
+ import consumer from './consumer'
2
+ import CableReady from 'cable_ready'
3
+
4
+ consumer.subscriptions.create('OptimismChannel', {
5
+ received (data) {
6
+ if (data.cableReady)
7
+ CableReady.perform(data.operations, {
8
+ emitMissingElementWarnings: false
9
+ })
10
+ }
11
+ })
@@ -0,0 +1,5 @@
1
+ class OptimismChannel < ApplicationCable::Channel
2
+ def subscribed
3
+ stream_from "OptimismChannel"
4
+ end
5
+ end
data/optimism.gemspec ADDED
@@ -0,0 +1,31 @@
1
+ lib = File.expand_path("lib", __dir__)
2
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
3
+ require "optimism/version"
4
+
5
+ Gem::Specification.new do |spec|
6
+ spec.name = "optimism"
7
+ spec.version = Optimism::VERSION
8
+ spec.authors = ["leastbad"]
9
+ spec.email = ["hello@leastbad.com"]
10
+
11
+ spec.summary = "Drop-in Rails form validations"
12
+ spec.description = "Realtime remote form input validations delivered via websockets"
13
+ spec.homepage = "https://github.com/leastbad/optimism"
14
+ spec.license = "MIT"
15
+
16
+ spec.files = Dir.chdir(File.expand_path("..", __FILE__)) do
17
+ `git ls-files -z`.split("\x0").reject { |f| f.match(%r{^(test|spec|features)/}) }
18
+ end
19
+ spec.bindir = "exe"
20
+ spec.executables = spec.files.grep(%r{^exe/}) { |f| File.basename(f) }
21
+ spec.require_paths = ["lib"]
22
+
23
+ spec.add_development_dependency "bundler", "~> 2.0"
24
+ spec.add_development_dependency "rake", "~> 13.0"
25
+ spec.add_development_dependency "pry"
26
+ spec.add_development_dependency "pry-nav"
27
+ spec.add_development_dependency "standardrb"
28
+ spec.add_dependency "rack"
29
+ spec.add_dependency "rails", ">= 5.2"
30
+ spec.add_dependency "cable_ready", "~> 4.0.9"
31
+ end
data/quick-start.md ADDED
@@ -0,0 +1,123 @@
1
+ # Quick Start
2
+
3
+ Let's start with the simplest scenario possible: you have a form with multiple input elements, and when the user clicks on the Submit button you want to display any validation error messages beside the elements that have issues. When the user resolves these issues and clicks the Submit button, the form is processed normal and the page navigates to whatever comes next.
4
+
5
+ ## Model
6
+
7
+ Validations are covered in-depth by the [official documentation](https://guides.rubyonrails.org/active_record_validations.html#validation-helpers). Optimism doesn't require anything special.
8
+
9
+ {% hint style="info" %}
10
+ Optimism is designed for ActiveRecord models that have validations defined, although it should work with any Ruby class that implements [Active Model](https://guides.rubyonrails.org/active_model_basics.html) and has an `errors` accessor.
11
+ {% endhint %}
12
+
13
+ ## View
14
+
15
+ Here's sample form partial for a Post model. It has two attributes - **name** and **body** and was generated with a Rails scaffold command.
16
+
17
+ {% code title="app/views/posts/\_form.html.erb BEFORE Optimism" %}
18
+ ```rust
19
+ <%= form_with(model: post, local: true) do |form| %>
20
+ <% if post.errors.any? %>
21
+ <div id="error_explanation">
22
+ <h2><%= pluralize(post.errors.count, "error") %> prohibited this post from being saved:</h2>
23
+
24
+ <ul>
25
+ <% post.errors.full_messages.each do |message| %>
26
+ <li><%= message %></li>
27
+ <% end %>
28
+ </ul>
29
+ </div>
30
+ <% end %>
31
+
32
+ <div class="field">
33
+ <%= form.label :name %>
34
+ <%= form.text_field :name %>
35
+ </div>
36
+
37
+ <div class="field">
38
+ <%= form.label :body %>
39
+ <%= form.text_area :body %>
40
+ </div>
41
+
42
+ <div class="actions">
43
+ <%= form.submit %>
44
+ </div>
45
+ <% end %>
46
+ ```
47
+ {% endcode %}
48
+
49
+ And here is that same form partial, configured to work with Optimism:
50
+
51
+ {% code title="app/views/posts/\_form.html.erb AFTER Optimism" %}
52
+ ```rust
53
+ <%= form_with(model: post) do |form| %>
54
+ <div class="field">
55
+ <%= form.label :name %>
56
+ <%= form.text_field :name %>
57
+ <%= form.error_for :name %>
58
+ </div>
59
+
60
+ <div class="field">
61
+ <%= form.label :body %>
62
+ <%= form.text_area :body %>
63
+ <%= form.error_for :body %>
64
+ </div>
65
+
66
+ <div class="actions">
67
+ <%= form.submit %>
68
+ </div>
69
+ <% end %>
70
+ ```
71
+ {% endcode %}
72
+
73
+ Eagle-eyed readers will see that setting up a bare-bones Optimism integration requires removing two things and adding one thing to each attribute:
74
+
75
+ 1. Remove `local: true` from the `form_with` on the first line
76
+ 2. Remove the error messages block from lines 2-12 entirely
77
+ 3. Add an `error_for` helper for each attribute
78
+
79
+ The `error_for` helper creates an empty `span` tag with an id such as _posts\_body\_error_, and this is where the error messages for the body attribute will appear.
80
+
81
+ {% hint style="success" %}
82
+ Even though `form_with` is remote-by-default, many developers were confused and frustrated by the lack of opinionated validation handling out of the box for remote forms. Since scaffolds are for new users to get comfortable, remote forms are disabled. This is the primary reason that Optimism was created: we want our tasty remote forms without any heartburn.
83
+ {% endhint %}
84
+
85
+ ## Controller
86
+
87
+ The last step is to slightly modify the **create** and **update** actions in our PostsController. The other actions have been removed for brevity:
88
+
89
+ {% code title="app/controllers/posts\_controller.rb" %}
90
+ ```rust
91
+ def create
92
+ @post = Post.new(post_params)
93
+ respond_to do |format|
94
+ if @post.save
95
+ format.html { redirect_to @post, notice: 'Post was successfully created.' }
96
+ format.json { render :show, status: :created, location: @post }
97
+ else
98
+ format.html { broadcast_errors @post, post_params }
99
+ format.json { render json: @post.errors, status: :unprocessable_entity }
100
+ end
101
+ end
102
+ end
103
+
104
+ def update
105
+ respond_to do |format|
106
+ if @post.update(post_params)
107
+ format.html { redirect_to @post, notice: 'Post was successfully updated.' }
108
+ format.json { render :show, status: :ok, location: @post }
109
+ else
110
+ format.html { broadcast_errors @post, post_params }
111
+ format.json { render json: @post.errors, status: :unprocessable_entity }
112
+ end
113
+ end
114
+ end
115
+ ```
116
+ {% endcode %}
117
+
118
+ The only meaningful change required \(as seen on lines 8 and 20 in this example\) is to replace `render :new` and `render :edit` with a call to `broadcast_errors` which has two mandatory parameters: the model instance and the list of attributes to validate. Usually this is the whitelisted params hash, but you can pass a subset as small as one attribute to be validated.
119
+
120
+ That's all there is to it. You now have live - if _unstyled_ - form validations being delivered over websockets.
121
+
122
+ ![](.gitbook/assets/high_five.svg)
123
+
data/reference.md ADDED
@@ -0,0 +1,165 @@
1
+ # Reference
2
+
3
+ ## ActionController Mixins
4
+
5
+ ### **broadcast\_errors**\(model, attributes\)
6
+
7
+ **model**: an instance of an Active Record model, or a class inheriting from Active Model
8
+ **attributes**: one or many attributes in the form of a ActionController::Parameters, Hash, HashWithIndifferentAccess, Symbol, String or Array \(of Strings or Symbols\)
9
+
10
+ Call this method in a resource controller's `create` or `update` actions when a model validation fails. A list of instructions for the client browser will be prepared and dispatched via a persistent websocket connection.
11
+
12
+ The two most common use cases are form-based \(Parameters\) or in-line editing of a single attribute \(Symbol or String\).
13
+
14
+ #### Example
15
+
16
+ ```ruby
17
+ if @post.update(post_params)
18
+ # Eat. Pray. Love.
19
+ else
20
+ broadcast_errors @post, post_params
21
+ end
22
+ ```
23
+
24
+
25
+
26
+ ## Form Builder Helpers
27
+
28
+ ### container\_for\(attribute, \*\*options, &block\)
29
+
30
+ **attribute**: Symbol identifying the the model attribute to use
31
+ **options**: an implicit Hash allowing you to pass in class names, data attributes and other HTML attributes
32
+ **block**: all ERB content passed to this helper inside the do..end will be rendered as content
33
+
34
+ Call this helper to create a `div` that will wrap your input element along with any labels and error messages. It will have an id attribute that will allow Optimism to route any validation errors to the correct place. When a validation failure occurs, this `div` will have an _error_ class added to it, allowing a style cascade to change the visual appearance of the input element and the error message.
35
+
36
+ #### Example
37
+
38
+ ```rust
39
+ <%= form.container_for :name, class: "field" do %>
40
+ <%= form.text_field :name %>
41
+ <% end %>
42
+ ```
43
+
44
+
45
+
46
+ ### container\_id\_for\(attribute\)
47
+
48
+ **attribute**: Symbol identifying the the model attribute to use
49
+
50
+ Returns the id required for a container to wrap your input elements and receive CSS updates. Use it if you are forced to create your own container markup from scratch; generally it is easiest to make use of **container\_for** if possible.
51
+
52
+ #### Example
53
+
54
+ ```rust
55
+ <blockquote id="<%= form.container_id_for :name %>"></blockquote>
56
+ ```
57
+
58
+
59
+
60
+ ### error\_for\(attribute, \*\*options\)
61
+
62
+ **attribute**: Symbol identifying the the model attribute to use
63
+ **options**: an implicit Hash allowing you to pass in class names, data attributes and other HTML attributes
64
+
65
+ Call this helper to create a `span` that you place adjacent to your your input elements. It will have an id attribute that will allow Optimism to route any validation errors to the correct place. When a validation failure occurs, this `span` will have the error message injected into it. It is typically hidden unless there is a message present.
66
+
67
+ #### Example
68
+
69
+ ```rust
70
+ <%= form.error_for :name, class: "d-none text-danger small form-group" %>
71
+ ```
72
+
73
+
74
+
75
+ ### error\_id\_for\(attribute\)
76
+
77
+ **attribute**: Symbol identifying the the model attribute to use
78
+
79
+ Returns the id required for a container to receive validation error text. Use it if you are forced to create your own error message markup from scratch; generally it is easiest to make use of **error\_for** if possible.
80
+
81
+ ```rust
82
+ <blockquote id="<%= form.error_id_for :name %>"></blockquote>
83
+ ```
84
+
85
+
86
+
87
+ ![](.gitbook/assets/master_plan.svg)
88
+
89
+ ## Initializer
90
+
91
+ Optimism is configurable via an optional initializer file. As with all initializers, changes only take effect after your Rails server has been restarted. Here is a sample initializer file that contains all of the default values for the configuration of the library. All changes apply globally to all instances of Optimism.
92
+
93
+ {% code title="config/initializers/optimism.rb" %}
94
+ ```ruby
95
+ Optimism.configure do |config|
96
+ config.channel = "OptimismChannel"
97
+ config.form_class = "invalid"
98
+ config.error_class = "error"
99
+ config.disable_submit = false
100
+ config.suffix = ""
101
+ config.emit_events = false
102
+ config.add_css = true
103
+ config.inject_inline = true
104
+ config.container_selector = "#RESOURCE_ATTRIBUTE_container"
105
+ config.error_selector = "#RESOURCE_ATTRIBUTE_error"
106
+ config.form_selector = "#RESOURCE_form"
107
+ config.submit_selector = "#RESOURCE_submit"
108
+ end
109
+ ```
110
+ {% endcode %}
111
+
112
+ **channel**: The ActionCable channel created by the `rake optimism:install` setup task. In most cases, you don't need to change this value. In fact, the only good reason to change this value is if you already have an OptimismChannel. If this describes you, congratulations on your optimism.
113
+
114
+ **form\_class**: The CSS class that will be applied to the form if the id has been properly set eg. `posts_form` \(following the simple pattern **resources\_form**\). If form\_class is set to false or nil, no CSS class will be applied.
115
+
116
+ **error\_class**: The CSS class the will be applied to a container when a validation fails. Use this class to cascade to the input elements and change their appearance.
117
+
118
+ **disable\_submit**: If set to true and your Submit button is named properly eg. `posts_submit` \(following the simple pattern **resources\_submit**\), your Submit button will be disabled if there are validation errors. It will also be re-enabled if the validation errors are corrected. Only use this if you are working with in-line validations or else your users will lose the ability to Submit your form more than once.
119
+
120
+ **suffix**: Likely the most important setting of all, this string will be appended to all validation error messages. While most validation errors on the web today do not have a trailing period, this is a matter of developer preference and when you're working on Rails, we care about your trails.
121
+
122
+ **emit\_events**: Optimism is so flexible that you can opt to have it fire DOM events in addition to \(or instead of\) text content and CSS updates. Scroll down for more information on the events that will be sent.
123
+
124
+ **add\_css**: Flag to control whether containers will receive CSS updates when a form is invalid.
125
+
126
+ **inject\_inline**: Flag to control whether validation error messages will be displayed inside _error\_for_ spans.
127
+
128
+ **container\_selector**: This is the pattern from which container id CSS selectors will be constructed. You probably shouldn't change this.
129
+
130
+ **error\_selector**: This is the pattern from which error\_for span id CSS selectors will be constructed. You probably shouldn't change this.
131
+
132
+ **form\_selector**: This is the pattern from which form id CSS selectors will be constructed. You probably shouldn't change this.
133
+
134
+ **submit\_selector**: This is the pattern from which Submit button id CSS selectors will be constructed. You probably shouldn't change this.
135
+
136
+
137
+
138
+ ## Events
139
+
140
+ If you set the `emit_events` property to true in your initializer, Optimism will emit DOM events in response to validation errors. This can happen in addition to or instead of CSS and text updates. This is a great alternative for complicated integrations where you have legacy components which need to be notified of error conditions on the backend.
141
+
142
+ In practical terms, DOM events give you tooling options and creative flexibility that are difficult to achieve with textual error messages and CSS error classes. It's simply a fact that no library can anticipate every design pattern or UI innovation. Today you might connect DOM events to a [toast notification library](https://www.jqueryscript.net/blog/Best-Toast-Notification-jQuery-Plugins.html#vanilla), but tomorrow there could be a mass proliferation of embedded ocular computers with sub-vocalization control interfaces, and we aren't going to tell you how those devices should punish users for bad input.
143
+
144
+ ### Form-level events
145
+
146
+ Event: **optimism:form:invalid**
147
+ Detail: resource
148
+
149
+ Event: **optimism:form:valid**
150
+ Detail: resource
151
+
152
+ One of these events will fire, depending on whether the model is valid. Resource is the pluralized class name of the Active Record model, eg. `posts`
153
+
154
+ ### Attribute-level events
155
+
156
+ Event: **optimism:attribute:invalid**
157
+ Detail: resource, attribute, text
158
+
159
+ Event: **optimism:attribute:valid**
160
+ Detail: resource, attribute
161
+
162
+ One of these events will fire **for each attribute**, depending on whether that attribute is valid. Resource is the pluralized class name of the Active Record model, eg. `posts`. Attribute is hopefully self-explanatory. Text is the text content of the validation error message.
163
+
164
+ ![](.gitbook/assets/alien_science.svg)
165
+