16 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | /.bundle/
2 | /.yardoc
3 | /_yardoc/
4 | /coverage/
5 | /doc/
6 | /pkg/
7 | /spec/reports/
8 | /tmp/
9 |
10 | # rspec failure tracking
11 | .rspec_status
12 |
13 | /node_modules
14 | /yarn-error.log
15 | yarn-debug.log*
16 | .yarn-integrity
17 |
18 | /spec/support/test_application/node_modules
19 | /spec/support/test_application/tmp
20 | /spec/support/test_application/log
21 | /spec/support/test_application/public
22 | /spec/support/test_application/db/*.sqlite3
23 | /spec/support/test_application/yarn-error.log
24 | /spec/support/test_application/yarn-debug.log*
25 | /spec/support/test_application/.yarn-integrity
26 |
--------------------------------------------------------------------------------
/Appraisals:
--------------------------------------------------------------------------------
1 | appraise "rails-5-1" do
2 | gem "rails", "~> 5.1.7"
3 |
4 | # Rails 5 does not have built-in support for ActionCable tests.
5 | gem "action-cable-testing"
6 | end
7 |
8 | appraise "rails-5-2" do
9 | gem "rails", "~> 5.2"
10 |
11 | # Rails 5 does not have built-in support for ActionCable tests.
12 | gem "action-cable-testing"
13 | end
14 |
15 | appraise "rails-6-0" do
16 | gem "rails", "~> 6.0"
17 | end
18 |
19 | appraise "rails-6-1" do
20 | gem "rails", "~> 6.1.0.rc1"
21 | end
22 |
23 | appraise "rails-master" do
24 | gem "rails", git: "https://github.com/rails/rails.git", ref: "master"
25 | end
26 |
--------------------------------------------------------------------------------
/spec/support/test_application/app/javascript/packs/application.js:
--------------------------------------------------------------------------------
1 | import { createConsumer } from '@rails/actioncable'
2 | import { createClient } from '@unabridged/motion'
3 |
4 | const consumer = createConsumer()
5 |
6 | createClient({
7 | consumer,
8 | logging: true
9 | })
10 |
11 | // Expose client state in globals for `spec/support/system_test_helpers.rb`:
12 | window.connectCount = 0;
13 | window.renderCount = 0;
14 |
15 | document.addEventListener('motion:connect', () => {
16 | window.connectCount += 1;
17 | })
18 |
19 | document.addEventListener('motion:render', () => {
20 | window.renderCount += 1;
21 | })
--------------------------------------------------------------------------------
/lib/motion/component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/concern"
4 |
5 | require "motion"
6 |
7 | require "motion/component/broadcasts"
8 | require "motion/component/callbacks"
9 | require "motion/component/lifecycle"
10 | require "motion/component/motions"
11 | require "motion/component/periodic_timers"
12 | require "motion/component/rendering"
13 |
14 | module Motion
15 | module Component
16 | extend ActiveSupport::Concern
17 |
18 | include Broadcasts
19 | include Callbacks
20 | include Lifecycle
21 | include Motions
22 | include PeriodicTimers
23 | include Rendering
24 | end
25 | end
26 |
--------------------------------------------------------------------------------
/spec/support/test_application/app/components/counter_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class CounterComponent < ViewComponent::Base
4 | include Motion::Component
5 |
6 | attr_reader :count, :child
7 |
8 | def initialize(count: 0)
9 | @count = count
10 | @child = nil
11 | end
12 |
13 | map_motion :increment
14 |
15 | def increment
16 | @count += 1
17 | end
18 |
19 | map_motion :decrement
20 |
21 | def decrement
22 | @count -= 1
23 | end
24 |
25 | map_motion :build_child
26 |
27 | def build_child
28 | @child = CounterComponent.new(count: count)
29 | end
30 |
31 | map_motion :clear_child
32 |
33 | def clear_child
34 | @child = nil
35 | end
36 | end
37 |
--------------------------------------------------------------------------------
/spec/support/webdriver.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "capybara"
4 | require "webdrivers"
5 | require "webdrivers/chromedriver"
6 |
7 | # See https://docs.travis-ci.com/user/chrome#capybara for details.
8 | Capybara.register_driver :headless_chrome_no_sandbox do |app|
9 | Capybara::Selenium::Driver.new(
10 | app,
11 | browser: :chrome,
12 | options: Selenium::WebDriver::Chrome::Options.new(
13 | args: %w[
14 | no-sandbox
15 | headless
16 | disable-gpu
17 | ]
18 | )
19 | )
20 | end
21 |
22 | Capybara.javascript_driver = :headless_chrome_no_sandbox
23 |
24 | # This is not a good solution, but I do not know a better one.
25 | Capybara.default_max_wait_time = 5
26 |
--------------------------------------------------------------------------------
/javascript/documentLifecycle.js:
--------------------------------------------------------------------------------
1 | export const documentLoaded = new Promise((resolve) => {
2 | if (/^loaded|^i|^c/i.test(document.readyState)) {
3 | resolve()
4 | } else {
5 | once(document, 'DOMContentLoaded', resolve)
6 | }
7 | })
8 |
9 | export const beforeDocumentUnload = new Promise((resolve) => {
10 | window.addEventListener('beforeunload', () => {
11 | once(window, 'beforeunload', ({ defaultPrevented }) => {
12 | if (!defaultPrevented) {
13 | resolve()
14 | }
15 | })
16 | }, true)
17 | })
18 |
19 | function once (target, event, callback) {
20 | target.addEventListener(event, function handler (event) {
21 | target.removeEventListener(event, handler)
22 |
23 | callback(event)
24 | })
25 | }
26 |
--------------------------------------------------------------------------------
/spec/javascript/serializeEvent.js:
--------------------------------------------------------------------------------
1 | import serializeEvent from '../../javascript/serializeEvent.js'
2 |
3 | describe('serializeEvent', () => {
4 | it('extracts event object details', () => {
5 | const evt = new KeyboardEvent('keydown', { key: 'A', keyCode: 65 })
6 | const { details } = serializeEvent(evt)
7 |
8 | expect({}.hasOwnProperty.call(details, 'button')).to.eql(false)
9 | expect(details.key).to.eql('A')
10 | expect(details.keyCode).to.eql(65)
11 | expect({}.hasOwnProperty.call(details, 'x')).to.eql(false)
12 | expect({}.hasOwnProperty.call(details, 'y')).to.eql(false)
13 | expect(details.altKey).to.eql(false)
14 | expect(details.metaKey).to.eql(false)
15 | expect(details.shiftKey).to.eql(false)
16 | })
17 | })
18 |
--------------------------------------------------------------------------------
/lib/motion/action_cable_extentions/log_suppression.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module ActionCableExtentions
7 | # By default ActionCable logs a lot. This module suppresses the debugging
8 | # information on a _per channel_ basis.
9 | module LogSuppression
10 | class Suppressor < SimpleDelegator
11 | def info(*)
12 | end
13 |
14 | def debug(*)
15 | end
16 | end
17 |
18 | private_constant :Suppressor
19 |
20 | def initialize(*)
21 | super
22 |
23 | @_logger = Suppressor.new(logger)
24 | end
25 |
26 | def logger
27 | return super unless defined?(@_logger)
28 |
29 | @_logger
30 | end
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/lib/motion/callback.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class Callback
7 | attr_reader :broadcast
8 |
9 | NAMESPACE = "motion:callback"
10 | private_constant :NAMESPACE
11 |
12 | def self.broadcast_for(component, method)
13 | [
14 | NAMESPACE,
15 | component.stable_instance_identifier_for_callbacks,
16 | method
17 | ].join(":")
18 | end
19 |
20 | def initialize(component, method)
21 | @broadcast = self.class.broadcast_for(component, method)
22 |
23 | component.stream_from(broadcast, method)
24 | end
25 |
26 | def ==(other)
27 | other.is_a?(Callback) &&
28 | other.broadcast == broadcast
29 | end
30 |
31 | def call(message = nil)
32 | ActionCable.server.broadcast(broadcast, message)
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/generators/motion/install_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails/generators/base"
4 |
5 | module Motion
6 | module Generators
7 | class InstallGenerator < Rails::Generators::Base
8 | source_root File.expand_path("templates", __dir__)
9 |
10 | desc "Installs Motion into your application."
11 |
12 | def copy_initializer
13 | template(
14 | "motion.rb",
15 | "config/initializers/motion.rb"
16 | )
17 | end
18 |
19 | def copy_client_initializer
20 | template(
21 | "motion.js",
22 | "app/javascript/motion.js"
23 | )
24 | end
25 |
26 | def add_client_to_application_pack
27 | append_to_file(
28 | "app/javascript/packs/application.js",
29 | 'import "motion"'
30 | )
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/bin/appraisal:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'appraisal' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "pathname"
12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13 | Pathname.new(__FILE__).realpath)
14 |
15 | bundle_binstub = File.expand_path("../bundle", __FILE__)
16 |
17 | if File.file?(bundle_binstub)
18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19 | load(bundle_binstub)
20 | else
21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23 | end
24 | end
25 |
26 | require "rubygems"
27 | require "bundler/setup"
28 |
29 | load Gem.bin_path("appraisal", "appraisal")
30 |
--------------------------------------------------------------------------------
/bin/standardrb:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'standardrb' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require "pathname"
12 | ENV["BUNDLE_GEMFILE"] ||= File.expand_path("../../Gemfile",
13 | Pathname.new(__FILE__).realpath)
14 |
15 | bundle_binstub = File.expand_path("../bundle", __FILE__)
16 |
17 | if File.file?(bundle_binstub)
18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19 | load(bundle_binstub)
20 | else
21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23 | end
24 | end
25 |
26 | require "rubygems"
27 | require "bundler/setup"
28 |
29 | load Gem.bin_path("standard", "standardrb")
30 |
--------------------------------------------------------------------------------
/lib/motion/event.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class Event
7 | def self.from_raw(raw)
8 | new(raw) if raw
9 | end
10 |
11 | attr_reader :raw
12 |
13 | def initialize(raw)
14 | @raw = raw.freeze
15 | end
16 |
17 | def type
18 | raw["type"]
19 | end
20 |
21 | alias_method :name, :type
22 |
23 | def details
24 | raw.fetch("details", {})
25 | end
26 |
27 | def extra_data
28 | raw["extraData"]
29 | end
30 |
31 | def target
32 | return @target if defined?(@target)
33 |
34 | @target = Motion::Element.from_raw(raw["target"])
35 | end
36 |
37 | def element
38 | return @element if defined?(@element)
39 |
40 | @element = Motion::Element.from_raw(raw["element"])
41 | end
42 |
43 | def form_data
44 | element&.form_data
45 | end
46 | end
47 | end
48 |
--------------------------------------------------------------------------------
/lib/motion/action_cable_extentions/synchronization.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module ActionCableExtentions
7 | module Synchronization
8 | def initialize(*)
9 | super
10 |
11 | @_monitor = Monitor.new
12 | end
13 |
14 | # Additional entrypoints added by other modules should wrap any entry
15 | # points that they add with this.
16 | def synchronize_entrypoint!(&block)
17 | @_monitor.synchronize(&block)
18 | end
19 |
20 | # Synchronize all standard ActionCable entry points.
21 | def subscribe_to_channel(*)
22 | synchronize_entrypoint! { super }
23 | end
24 |
25 | def unsubscribe_from_channel(*)
26 | synchronize_entrypoint! { super }
27 | end
28 |
29 | def perform_action(*)
30 | synchronize_entrypoint! { super }
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/lib/motion/test_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module TestHelpers
7 | def assert_motion(component, motion_name)
8 | assert motion?(component, motion_name)
9 | end
10 |
11 | def refute_motion(component, motion_name)
12 | refute motion?(component, motion_name)
13 | end
14 |
15 | def motion?(component, motion_name)
16 | component.motions.include?(motion_name.to_s)
17 | end
18 |
19 | def run_motion(component, motion_name, event = motion_event)
20 | if block_given?
21 | c = component.dup
22 | c.process_motion(motion_name.to_s, event)
23 | yield c
24 | else
25 | component.process_motion(motion_name.to_s, event)
26 | end
27 | end
28 |
29 | def motion_event(attributes = {})
30 | Motion::Event.new(ActiveSupport::JSON.decode(attributes.to_json))
31 | end
32 | end
33 | end
34 |
--------------------------------------------------------------------------------
/bin/rake:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'rake' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require 'pathname'
12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13 | Pathname.new(__FILE__).realpath)
14 |
15 | bundle_binstub = File.expand_path('bundle', __dir__)
16 |
17 | if File.file?(bundle_binstub)
18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19 | load(bundle_binstub)
20 | else
21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23 | end
24 | end
25 |
26 | require 'rubygems'
27 | require 'bundler/setup'
28 |
29 | load Gem.bin_path('rake', 'rake')
30 |
--------------------------------------------------------------------------------
/bin/rspec:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'rspec' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require 'pathname'
12 | ENV['BUNDLE_GEMFILE'] ||= File.expand_path('../../Gemfile',
13 | Pathname.new(__FILE__).realpath)
14 |
15 | bundle_binstub = File.expand_path('bundle', __dir__)
16 |
17 | if File.file?(bundle_binstub)
18 | if File.read(bundle_binstub, 300) =~ /This file was generated by Bundler/
19 | load(bundle_binstub)
20 | else
21 | abort("Your `bin/bundle` was not generated by Bundler, so this binstub cannot run.
22 | Replace `bin/bundle` by running `bundle binstubs bundler --force`, then run this command again.")
23 | end
24 | end
25 |
26 | require 'rubygems'
27 | require 'bundler/setup'
28 |
29 | load Gem.bin_path('rspec-core', 'rspec')
30 |
--------------------------------------------------------------------------------
/spec/generators/motion/install_generator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Generators::InstallGenerator, type: :generator do
4 | before(:each) do
5 | # Ensure an application pack exists
6 | pack_path = File.join(destination_root, "app/javascript/packs")
7 | FileUtils.mkdir_p(pack_path)
8 | FileUtils.touch(File.join(pack_path, "application.js"))
9 |
10 | run_generator
11 | end
12 |
13 | it "is accessible via `motion:install`" do
14 | expect(generator_class.banner).to include("rails generate motion:install")
15 | end
16 |
17 | it "creates the Ruby initializer" do
18 | assert_file "config/initializers/motion.rb"
19 | end
20 |
21 | it "creates the JavaScript initializer" do
22 | assert_file "app/javascript/motion.js"
23 | end
24 |
25 | it "imports the Motion client into the application's bundle" do
26 | assert_file "app/javascript/packs/application.js", /import "motion"/
27 | end
28 | end
29 |
--------------------------------------------------------------------------------
/spec/motion/action_cable_extentions/log_suppression_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TestChannel < ApplicationCable::Channel
4 | include Motion::ActionCableExtentions::LogSuppression
5 | end
6 |
7 | RSpec.describe Motion::ActionCableExtentions::LogSuppression do
8 | describe TestChannel, type: :channel do
9 | before(:each) { subscribe }
10 |
11 | let(:connection_logger) { connection.logger }
12 | let(:channel_logger) { subscription.logger }
13 |
14 | it "silences `info` messages" do
15 | expect(connection_logger).not_to receive(:info)
16 | channel_logger.info("message")
17 | end
18 |
19 | it "silences `debug` messages" do
20 | expect(connection_logger).not_to receive(:debug)
21 | channel_logger.debug("message")
22 | end
23 |
24 | it "still allows `error` messages" do
25 | expect(connection_logger).to receive(:error)
26 | channel_logger.error("message")
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/lib/generators/motion/component_generator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails/generators/named_base"
4 |
5 | module Motion
6 | module Generators
7 | class ComponentGenerator < Rails::Generators::NamedBase
8 | desc "Creates a Motion-enabled component in your application."
9 |
10 | argument :attributes, type: :array, default: [], banner: "attribute"
11 |
12 | def generate_component
13 | generate "component", class_name, *attributes.map(&:name)
14 | end
15 |
16 | def include_motion
17 | inject_into_class component_path, "#{class_name}Component" do
18 | " include Motion::Component\n\n"
19 | end
20 | end
21 |
22 | private
23 |
24 | def component_path
25 | @component_path ||=
26 | File.join("app/components", class_path, "#{file_name}_component.rb")
27 | end
28 |
29 | def file_name
30 | @_file_name ||= super.sub(/_component\z/i, "")
31 | end
32 | end
33 | end
34 | end
35 |
--------------------------------------------------------------------------------
/spec/support/test_application/config/application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "boot"
4 |
5 | require "rails"
6 | require "active_record/railtie"
7 | require "action_cable/engine"
8 | require "action_controller/railtie"
9 | require "action_view/railtie"
10 | require "sprockets/railtie"
11 | require "webpacker"
12 |
13 | require "view_component/engine"
14 | require "motion"
15 |
16 | class TestApplication < Rails::Application
17 | config.root = File.expand_path("..", __dir__)
18 |
19 | config.secret_key_base = "test-secret-key-base"
20 | config.eager_load = true
21 |
22 | # Silence irrelevant deprecation warning in Rails 5.2
23 | if Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 2
24 | config.active_record.sqlite3.represent_boolean_as_integer = true
25 | end
26 |
27 | # Raise exceptions instead of rendering exception templates
28 | config.action_dispatch.show_exceptions = false
29 |
30 | # Enable stdout logger
31 | config.logger = Logger.new($stdout)
32 |
33 | # Keep the logger quiet by default
34 | config.log_level = ENV.fetch("LOG_LEVEL", "ERROR")
35 | end
36 |
--------------------------------------------------------------------------------
/spec/support/test_application/config/webpacker.yml:
--------------------------------------------------------------------------------
1 | test:
2 | source_path: app/javascript
3 | source_entry_path: packs
4 | public_root_path: public
5 | public_output_path: packs
6 | cache_path: tmp/cache/webpacker
7 | check_yarn_integrity: false
8 | webpack_compile_output: true
9 |
10 | # Additional paths webpack should lookup modules
11 | # ['app/assets', 'engine/foo/app/assets']
12 | additional_paths: ["app/assets"]
13 |
14 | # Reload manifest.json on all requests so we reload latest compiled packs
15 | cache_manifest: false
16 |
17 | # Extract and emit a css file
18 | extract_css: false
19 |
20 | static_assets_extensions:
21 | - .jpg
22 | - .jpeg
23 | - .png
24 | - .gif
25 | - .tiff
26 | - .ico
27 | - .svg
28 | - .eot
29 | - .otf
30 | - .ttf
31 | - .woff
32 | - .woff2
33 |
34 | extensions:
35 | - .mjs
36 | - .js
37 | - .sass
38 | - .scss
39 | - .css
40 | - .module.sass
41 | - .module.scss
42 | - .module.css
43 | - .png
44 | - .svg
45 | - .gif
46 | - .jpeg
47 | - .jpg
48 |
49 | compile: true
50 |
--------------------------------------------------------------------------------
/spec/motion/action_cable_extentions/declarative_streams_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class TestChannel < ApplicationCable::Channel
4 | include Motion::ActionCableExtentions::DeclarativeStreams
5 | end
6 |
7 | # TODO: These unit tests are very lacking because of the stubbing done by the
8 | # ActionCable test helpers. There currently does not seem to be any way to setup
9 | # and handle a real broadcast.
10 | RSpec.describe Motion::ActionCableExtentions::DeclarativeStreams do
11 | describe TestChannel, type: :channel do
12 | before(:each) { subscribe }
13 |
14 | describe "#streaming_from" do
15 | subject! { subscription.streaming_from(streams, to: target) }
16 |
17 | let(:streams) { Array.new(rand(1..10)) { SecureRandom.hex } }
18 | let(:target) { :"hand_broadcast_#{SecureRandom.hex}" }
19 |
20 | it "listens to the provided streams" do
21 | expect(subscription.streams).to include(*streams)
22 | end
23 |
24 | it "sets the handler to the provided target" do
25 | expect(subscription.declarative_stream_target).to eq(target)
26 | end
27 | end
28 | end
29 | end
30 |
--------------------------------------------------------------------------------
/spec/motion/component/callbacks_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Component::Callbacks do
4 | subject(:component) { TestComponent.new }
5 |
6 | describe "#bind" do
7 | subject { component.bind(method) }
8 |
9 | let(:method) { :noop }
10 |
11 | it "gives a callback bound to the component and method" do
12 | expect(subject).to eq(Motion::Callback.new(component, method))
13 | end
14 | end
15 |
16 | describe "#stable_instance_identifier_for_callbacks" do
17 | subject { component.stable_instance_identifier_for_callbacks }
18 |
19 | it "is unique to the instance" do
20 | expect(subject).not_to(
21 | eq(TestComponent.new.stable_instance_identifier_for_callbacks)
22 | )
23 | end
24 |
25 | let(:serializer) { Motion.serializer }
26 | let(:serialized) { serializer.serialize(component).last }
27 | let(:deserialized) { serializer.deserialize(serialized) }
28 |
29 | it "is preserved through serialization" do
30 | expect(subject).to(
31 | eq(deserialized.stable_instance_identifier_for_callbacks)
32 | )
33 | end
34 | end
35 | end
36 |
--------------------------------------------------------------------------------
/lib/motion/revision_calculator.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "digest"
4 | require "motion"
5 |
6 | module Motion
7 | class RevisionCalculator
8 | attr_reader :revision_paths
9 |
10 | def initialize(revision_paths:)
11 | @revision_paths = revision_paths
12 | end
13 |
14 | def perform
15 | derive_file_hash
16 | end
17 |
18 | private
19 |
20 | def derive_file_hash
21 | digest = Digest::MD5.new
22 |
23 | files.each do |file|
24 | digest << file # include filename as well as contents
25 | digest << File.read(file)
26 | end
27 |
28 | digest.hexdigest
29 | end
30 |
31 | def existent_paths
32 | @existent_paths ||=
33 | begin
34 | revision_paths.all_paths.flat_map(&:existent)
35 | rescue
36 | raise BadRevisionPathsError
37 | end
38 | end
39 |
40 | def existent_files(path)
41 | Dir["#{path}/**/*", path].reject { |f| File.directory?(f) }.uniq
42 | end
43 |
44 | def files
45 | @files ||= existent_paths.flat_map { |path| existent_files(path) }.sort
46 | end
47 | end
48 | end
49 |
--------------------------------------------------------------------------------
/LICENSE.txt:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020 Unabridged Software LLC
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in
13 | all copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
21 | THE SOFTWARE.
22 |
--------------------------------------------------------------------------------
/spec/support/test_application/app/components/dog_form_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | class DogFormComponent < ViewComponent::Base
4 | include Motion::Component
5 |
6 | attr_reader :dog
7 |
8 | def initialize(dog: Dog.new)
9 | @dog = dog
10 | end
11 |
12 | map_motion :validate
13 |
14 | def validate(event)
15 | clear_unidentifiable_toys
16 |
17 | dog.assign_attributes(dog_params(event.form_data))
18 | dog.validate
19 | end
20 |
21 | map_motion :add_toy
22 |
23 | def add_toy
24 | dog.toys.build
25 | end
26 |
27 | stream_from "dogs:created", :handle_dog_created
28 |
29 | def handle_dog_created(_new_id)
30 | dog.validate
31 | end
32 |
33 | private
34 |
35 | # TODO: This is required because `accepts_nested_attributes_for` doesn't have
36 | # a way to identify unpersisted records which makes assignment non-idempotent.
37 | # It would be ideal to fix the problem there.
38 | def clear_unidentifiable_toys
39 | dog.toys.target&.select!(&:persisted?)
40 | end
41 |
42 | def dog_params(params)
43 | params.require(:dog).permit(:name, toys_attributes: [:id, :name])
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/spec/generators/motion/component_generator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # TODO: This is super awkward, but it is the only way I could get the generator
4 | # to run without having a full Rails app already installed into the destination
5 | # directory.
6 | Motion::Generators::ComponentGenerator.class_eval do
7 | protected
8 |
9 | def generate(generator, *args)
10 | return super unless generator == "component"
11 |
12 | require "rails/generators/component/component_generator"
13 |
14 | Rails::Generators::ComponentGenerator.start(
15 | args,
16 | destination_root: destination_root
17 | )
18 | end
19 | end
20 |
21 | RSpec.describe Motion::Generators::ComponentGenerator, type: :generator do
22 | before(:each) do
23 | run_generator
24 | end
25 |
26 | it "is accessible via `motion:component`" do
27 | expect(generator_class.banner).to include("rails generate motion:component")
28 | end
29 |
30 | context "with only a component name" do
31 | arguments %w[MagicComponent]
32 |
33 | it "creates a component that includes `Motion::Component`" do
34 | assert_file(
35 | "app/components/magic_component.rb",
36 | /include Motion::Component/
37 | )
38 | end
39 | end
40 | end
41 |
--------------------------------------------------------------------------------
/karma.conf.js:
--------------------------------------------------------------------------------
1 | // Karma configuration
2 |
3 | process.env.CHROME_BIN = require('puppeteer').executablePath()
4 |
5 | module.exports = function (config) {
6 | config.set({
7 |
8 | // frameworks to use
9 | frameworks: ['esm', 'mocha', 'chai'],
10 |
11 | // list of files / patterns to load in the browser
12 | files: [
13 | { pattern: 'javascript/parseBindings.js', type: 'module' },
14 | { pattern: 'spec/javascript/**/*.js', type: 'module' }
15 | ],
16 |
17 | plugins: [
18 | require.resolve('@open-wc/karma-esm'),
19 |
20 | // fallback: resolve any karma- plugins
21 | 'karma-*'
22 | ],
23 |
24 | // preprocess matching files before serving them to the browser
25 | preprocessors: {
26 | 'javascript/parseBindings.js': ['coverage']
27 | },
28 |
29 | // test results reporter to use
30 | reporters: ['dots', 'coverage'],
31 |
32 | // enable / disable watching file and executing tests whenever any file changes
33 | autoWatch: false,
34 |
35 | // start these browsers
36 | browsers: ['ChromeHeadless'],
37 |
38 | // Continuous Integration mode
39 | // if true, Karma captures browsers, runs the tests and exits
40 | singleRun: true,
41 |
42 | esm: {
43 | nodeResolve: true,
44 | coverage: true
45 | }
46 | })
47 | }
48 |
--------------------------------------------------------------------------------
/lib/motion/markup_transformer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "nokogiri"
4 | require "active_support/core_ext/object/blank"
5 |
6 | require "motion"
7 |
8 | module Motion
9 | class MarkupTransformer
10 | attr_reader :serializer,
11 | :key_attribute,
12 | :state_attribute
13 |
14 | def initialize(
15 | serializer: Motion.serializer,
16 | key_attribute: Motion.config.key_attribute,
17 | state_attribute: Motion.config.state_attribute
18 | )
19 | @serializer = serializer
20 | @key_attribute = key_attribute
21 | @state_attribute = state_attribute
22 | end
23 |
24 | def add_state_to_html(component, html)
25 | return if html.blank?
26 |
27 | key, state = serializer.serialize(component)
28 |
29 | transform_root(component, html) do |root|
30 | root[key_attribute] = key
31 | root[state_attribute] = state
32 | end
33 | end
34 |
35 | private
36 |
37 | def transform_root(component, html)
38 | fragment = Nokogiri::HTML::DocumentFragment.parse(html)
39 | root, *unexpected_others = fragment.children
40 |
41 | if !root || unexpected_others.any?(&:present?)
42 | raise MultipleRootsError, component
43 | end
44 |
45 | yield root
46 |
47 | fragment.to_html.html_safe
48 | end
49 | end
50 | end
51 |
--------------------------------------------------------------------------------
/lib/motion/element.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class Element
7 | def self.from_raw(raw)
8 | new(raw) if raw
9 | end
10 |
11 | attr_reader :raw
12 |
13 | def initialize(raw)
14 | @raw = raw.freeze
15 | end
16 |
17 | def tag_name
18 | raw["tagName"]
19 | end
20 |
21 | def value
22 | raw["value"]
23 | end
24 |
25 | def attributes
26 | raw.fetch("attributes", {})
27 | end
28 |
29 | def [](key)
30 | key = key.to_s
31 |
32 | attributes[key] || attributes[key.tr("_", "-")]
33 | end
34 |
35 | def id
36 | self[:id]
37 | end
38 |
39 | class DataAttributes
40 | attr_reader :element
41 |
42 | def initialize(element)
43 | @element = element
44 | end
45 |
46 | def [](data)
47 | element["data-#{data}"]
48 | end
49 | end
50 |
51 | private_constant :DataAttributes
52 |
53 | def data
54 | return @data if defined?(@data)
55 |
56 | @data = DataAttributes.new(self)
57 | end
58 |
59 | def form_data
60 | return @form_data if defined?(@form_data)
61 |
62 | @form_data =
63 | ActionController::Parameters.new(
64 | Rack::Utils.parse_nested_query(
65 | raw.fetch("formData", "")
66 | )
67 | )
68 | end
69 | end
70 | end
71 |
--------------------------------------------------------------------------------
/spec/support/test_application.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | # Load the TestApplication environment into this Ruby process
4 | require_relative "test_application/config/environment"
5 |
6 | # Also, load the generators since some specs depend on those.
7 | TestApplication.load_generators
8 |
9 | # Add a helper method to sync the JavaScript in the test app with the outer gem.
10 | class << TestApplication
11 | def link_motion_client!
12 | return if @linked_motion_client
13 |
14 | yarn! "--cwd", "../../..", "link"
15 | yarn! "link", "@unabridged/motion"
16 | yarn! "install"
17 |
18 | clear_webpacker_cache!
19 |
20 | @linked_motion_client = true
21 | end
22 |
23 | def unlink_motion_client!
24 | return unless @linked_motion_client
25 |
26 | yarn! "unlink", "@unabridged/motion"
27 | end
28 |
29 | private
30 |
31 | def clear_webpacker_cache!
32 | webpacker_cache_path = File.expand_path("tmp/cache", Rails.root)
33 | FileUtils.rm_r(webpacker_cache_path) if File.exist?(webpacker_cache_path)
34 | end
35 |
36 | def yarn!(*args)
37 | stdout, stderr, status =
38 | Open3.capture3("bin/yarn", *args, chdir: Rails.root)
39 |
40 | unless status.success?
41 | short_output = [stdout, stderr].delete_if(&:empty?).join("\n\n")
42 | raise "Failed to `yarn #{args.join(" ")}`! Yarn says:\n#{short_output}"
43 | end
44 | end
45 | end
46 |
--------------------------------------------------------------------------------
/Rakefile:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/gem_tasks"
4 |
5 | task default: %i[lint test]
6 |
7 | namespace :test do
8 | task :refresh do
9 | sh "bin/appraisal clean"
10 | sh "bin/appraisal generate"
11 | end
12 |
13 | task :all do
14 | sh "bin/appraisal install"
15 | sh "bin/appraisal bin/rake test"
16 | end
17 | end
18 |
19 | task :test do
20 | sh "bin/rspec"
21 | sh "bin/yarn test"
22 | end
23 |
24 | task :lint do
25 | if ENV["TRAVIS"]
26 | sh "bin/standardrb"
27 | sh "bin/yarn lint"
28 | else
29 | sh "bin/standardrb --fix"
30 | sh "bin/yarn lint --fix"
31 | end
32 | end
33 |
34 | namespace :release do
35 | task :guard_version_match do
36 | require "json"
37 |
38 | package_version =
39 | JSON.parse(File.read("package.json")).fetch("version")
40 |
41 | next if Motion::VERSION == package_version
42 |
43 | raise "The package version and the gem version do not match!"
44 | end
45 |
46 | task :yarn_publish do
47 | sh "bin/yarn publish --new-version '#{Motion::VERSION}' --access public"
48 | end
49 | end
50 |
51 | # Remove Bundler's release task so we can add our own hooks
52 | Rake::Task["release"].clear
53 |
54 | task :release, %i[release] => %i[
55 | lint
56 | test:all
57 | build
58 | release:guard_clean
59 | release:guard_version_match
60 | release:source_control_push
61 | release:yarn_publish
62 | release:rubygem_push
63 | ]
64 |
--------------------------------------------------------------------------------
/motion.gemspec:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require_relative "lib/motion/version"
4 |
5 | Gem::Specification.new do |spec|
6 | spec.name = "motion"
7 | spec.version = Motion::VERSION
8 | spec.authors = ["Alec Larsen", "Drew Ulmer"]
9 | spec.email = ["alec@unabridgedsoftware.com", "drew@unabridgedsoftware.com"]
10 |
11 | spec.summary = "Reactive frontend UI components for Rails in pure Ruby. "
12 | spec.description = <<~TEXT
13 | Motion extends Github's `view_component` to allow you to build reactive,
14 | real-time frontend UI components in your Rails application using pure Ruby.
15 | TEXT
16 |
17 | spec.license = "MIT"
18 | spec.homepage = "https://github.com/unabridged/motion"
19 |
20 | spec.metadata = {
21 | "bug_tracker_uri" => spec.homepage,
22 | "source_code_uri" => spec.homepage
23 | }
24 |
25 | spec.files = Dir["lib/**/*"]
26 | spec.require_paths = ["lib"]
27 |
28 | # The lowest version of Ruby against which Motion is tested. See `.travis.yml`
29 | # for the full matrix.
30 | spec.required_ruby_version = Gem::Requirement.new(">= 2.5.0")
31 |
32 | spec.add_dependency "nokogiri"
33 | spec.add_dependency "rails", ">= 5.1"
34 | spec.add_dependency "lz4-ruby", ">= 0.3.3"
35 |
36 | spec.post_install_message = <<~MSG
37 | Friendly reminder: When updating the motion gem, don't forget to update the
38 | NPM package as well (`bin/yarn add '@unabridged/motion@#{spec.version}'`).
39 | MSG
40 | end
41 |
--------------------------------------------------------------------------------
/CHANGELOG.md:
--------------------------------------------------------------------------------
1 | ## Unreleased
2 |
3 | ## 0.4.4 - 2020-11-19
4 |
5 | * Features
6 | * Add expanded test helpers. ([#60](https://github.com/unabridged/motion/pull/60))
7 |
8 | * Fixes
9 | * Fix serializing components that use Rails' asset helpers (i.e. `image_tag`). ([#67](https://github.com/unabridged/motion/pull/67))
10 |
11 | ## 0.4.3 - 2020-09-22
12 |
13 | * Features
14 | * Add support for form builders in motion state ([#47](https://github.com/unabridged/motion/pull/47))
15 | * Add support for Rails 5.1 ([#57](https://github.com/unabridged/motion/pull/57))
16 |
17 | * Fixes
18 | * Fix memory leak and race condition allowing invocation of motion after disconnecting ([#58](https://github.com/unabridged/motion/pull/58))
19 | * Fix issue copying attributes using destructuring assignment from Event objects ([#57](https://github.com/unabridged/motion/pull/57))
20 |
21 | * Removals
22 | * Drop support for Ruby 2.4 ([#48](https://github.com/unabridged/motion/pull/48))
23 | * Remove mention of removed API Event#current_target
24 |
25 | ## 0.4.2 - 2020-09-02
26 |
27 | * Fixes
28 | * Upgrade of vulnerable dependencies ([#44](https://github.com/unabridged/motion/pull/44))
29 |
30 | ## 0.4.1 - 2020-08-21
31 |
32 | * Features
33 | * Add compression to serialization pipeline ([#38](https://github.com/unabridged/motion/pull/38))
34 |
35 | ## 0.4.0 - 2020-07-16
36 |
37 | * Features
38 | * Add Callbacks API (`bind`) ([#32](https://github.com/unabridged/motion/pull/32))
39 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "@unabridged/motion",
3 | "version": "0.4.4",
4 | "description": "Reactive view components written in Ruby for Rails",
5 | "main": "javascript/index.js",
6 | "files": [
7 | "package.json",
8 | "javascript/**/*"
9 | ],
10 | "directories": {
11 | "lib": "javascript"
12 | },
13 | "dependencies": {
14 | "@rails/actioncable": ">= 6.0",
15 | "morphdom": "^2.6.1"
16 | },
17 | "scripts": {
18 | "test": "karma start",
19 | "lint": "standard javascript/**/*.js spec/javascript/**/*.js",
20 | "postinstall": "echo \"Friendly reminder: When updating the @unabridged/motion NPM package, don't forget to update the Ruby gem as well.\"; exit 0"
21 | },
22 | "repository": {
23 | "type": "git",
24 | "url": "git+https://github.com/unabridged/motion.git"
25 | },
26 | "author": "",
27 | "license": "MIT",
28 | "bugs": {
29 | "url": "https://github.com/unabridged/motion/issues"
30 | },
31 | "homepage": "https://github.com/unabridged/motion#readme",
32 | "devDependencies": {
33 | "@open-wc/karma-esm": "^2.16.16",
34 | "chai": "^4.2.0",
35 | "karma": "^5.2.0",
36 | "karma-chai": "^0.1.0",
37 | "karma-chrome-launcher": "^3.1.0",
38 | "karma-coverage": "^2.0.2",
39 | "karma-mocha": "^2.0.1",
40 | "mocha": "^8.0.1",
41 | "puppeteer": "^5.5.0",
42 | "standard": "^14.3.4"
43 | },
44 | "standard": {
45 | "envs": [
46 | "browser",
47 | "mocha"
48 | ],
49 | "globals": [
50 | "expect"
51 | ]
52 | },
53 | "publishConfig": {
54 | "access": "public"
55 | }
56 | }
57 |
--------------------------------------------------------------------------------
/lib/generators/motion/templates/motion.js:
--------------------------------------------------------------------------------
1 | import { createClient } from '@unabridged/motion'
2 | import consumer from './channels/consumer'
3 |
4 | export default createClient({
5 |
6 | // To avoid creating a second websocket, make sure to reuse the application's
7 | // ActionCable consumer. If you are not otherwise using ActionCable, you can
8 | // remove this line and the corresponding import.
9 | consumer,
10 |
11 | // Motion can log information about the lifecycle of components to the
12 | // browser's console. It is recommended to turn this feature off outside of
13 | // development.
14 | logging: process.env.RAILS_ENV === 'development'
15 |
16 | // This function will be called for every motion, and the return value will be
17 | // made available at `Motion::Event#extra_data`:
18 | //
19 | // getExtraDataForEvent(event) {},
20 |
21 | // By default, the Motion client automatically disconnects all components when
22 | // it detects the browser navigating away to a new page. This is done to
23 | // prevent flashes of new content in components with broadcasts because of
24 | // some action being taken by the controller that the user is navigating to
25 | // (like submitting a form). If you do not want or need this functionally, you
26 | // can turn it off:
27 | //
28 | // shutdownBeforeUnload: false,
29 |
30 | // The data attributes used by Motion can be customized, but these values must
31 | // also be updated in the Ruby initializer:
32 | //
33 | // keyAttribute: 'data-motion-key',
34 | // stateAttribute: 'data-motion-state',
35 | // motionAttribute: 'data-motion',
36 |
37 | })
38 |
--------------------------------------------------------------------------------
/javascript/parseBindings.js:
--------------------------------------------------------------------------------
1 | const identifier = '[^\\s\\(\\)]+'
2 | const binding = `((${identifier})(\\((${identifier})\\))?->)?(${identifier})`
3 | const regExp = new RegExp(binding, 'g')
4 |
5 | const captureIndicies = {
6 | id: 0,
7 | event: 2,
8 | mode: 4,
9 | motion: 5
10 | }
11 |
12 | export const MODE_LISTEN = 'listen'
13 | export const MODE_HANDLE = 'handle'
14 |
15 | const DEFAULT_EVENT = {
16 | _other: 'click',
17 |
18 | FORM: 'submit',
19 | INPUT: ({ type }) => type === 'submit' ? 'click' : 'change',
20 | SELECT: 'change',
21 | TEXTAREA: 'change'
22 | }
23 |
24 | const DEFAULT_MODE = {
25 | _other: MODE_HANDLE,
26 | change: MODE_LISTEN
27 | }
28 |
29 | export default function parseBindings (input, element) {
30 | if (!input) {
31 | return []
32 | }
33 |
34 | return Array.from(input.matchAll(regExp), match => {
35 | const id = match[captureIndicies.id]
36 | const motion = match[captureIndicies.motion]
37 |
38 | const event =
39 | match[captureIndicies.event] ||
40 | defaultEventFor(element)
41 |
42 | const mode =
43 | match[captureIndicies.mode] ||
44 | defaultModeFor(event)
45 |
46 | return {
47 | id,
48 | motion,
49 | event,
50 | mode
51 | }
52 | })
53 | }
54 |
55 | function defaultEventFor (element) {
56 | const event =
57 | DEFAULT_EVENT[element && element.tagName] ||
58 | DEFAULT_EVENT._other
59 |
60 | if (typeof (event) === 'function') {
61 | return event(element)
62 | } else {
63 | return event
64 | }
65 | }
66 |
67 | function defaultModeFor (event) {
68 | return DEFAULT_MODE[event] || DEFAULT_MODE._other
69 | }
70 |
--------------------------------------------------------------------------------
/spec/system/live_validating_form_demo_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe "Live Validating Form Demo", type: :system do
4 | before(:each) do
5 | visit(new_dog_path)
6 | wait_for_connect
7 | end
8 |
9 | it "works like a normal form" do
10 | fill_in "dog_name", with: "Fido"
11 | click_button "Create Dog"
12 |
13 | expect(Dog.find_by(name: "Fido")).to be_present
14 | end
15 |
16 | it "automatically validates after user input" do
17 | Dog.create!(name: "Taken")
18 |
19 | fill_in "dog_name", with: "Taken"
20 | blur
21 | wait_for_render
22 |
23 | expect(page).to have_text("taken")
24 |
25 | fill_in "dog_name", with: "Available"
26 | blur
27 |
28 | expect(page).not_to have_text("taken")
29 | end
30 |
31 | it "automatically validates when a new record is created elsewhere" do
32 | fill_in "dog_name", with: "Tibbles"
33 | blur
34 | wait_for_render
35 |
36 | expect(page).not_to have_text("taken")
37 |
38 | Dog.create!(name: "Tibbles")
39 | wait_for_render
40 |
41 | expect(page).to have_text("taken")
42 | end
43 |
44 | it "works with nested attributes" do
45 | fill_in "dog_name", with: "Fido"
46 |
47 | click_button "Add Toy"
48 | wait_for_render
49 |
50 | find('[data-identifier-for-test-suite="toy-name[0]"]').fill_in(with: "Ball")
51 |
52 | expect(page).not_to have_text("can't be blank")
53 |
54 | click_button "Add Toy"
55 | wait_for_render
56 |
57 | find('[data-identifier-for-test-suite="toy-name[0]"]').fill_in(with: "")
58 | find('[data-identifier-for-test-suite="toy-name[1]"]').fill_in(with: "Ball")
59 | blur
60 | wait_for_render
61 |
62 | expect(page).to have_text("can't be blank")
63 | end
64 | end
65 |
--------------------------------------------------------------------------------
/javascript/serializeEvent.js:
--------------------------------------------------------------------------------
1 | export default function serializeEvent (event, extraData = null, element = null) {
2 | const { type } = event
3 | const details = serializeEventDetails(event)
4 | const target = serializeElement(event.target)
5 |
6 | return {
7 | type,
8 | details,
9 | extraData,
10 | target,
11 | element: element && serializeElement(element)
12 | }
13 | };
14 |
15 | const detailProperties = [
16 | 'button',
17 | 'x',
18 | 'y',
19 | 'key',
20 | 'keyCode',
21 | 'altKey',
22 | 'ctrlKey',
23 | 'metaKey',
24 | 'shiftKey'
25 | ]
26 |
27 | function serializeEventDetails (event) {
28 | const details = {}
29 |
30 | for (const property of detailProperties) {
31 | if (event[property] !== undefined) {
32 | details[property] = event[property]
33 | }
34 | }
35 |
36 | return details
37 | }
38 |
39 | function serializeElement (element) {
40 | if (!element) return {}
41 |
42 | const { tagName, value } = element
43 | const attributes = serializeElementAttributes(element)
44 | const formData = serializeElementFormData(element)
45 |
46 | return {
47 | tagName,
48 | value,
49 | attributes,
50 | formData
51 | }
52 | }
53 |
54 | function serializeElementAttributes (element) {
55 | const attributes = {}
56 |
57 | for (const attributeName of element.getAttributeNames()) {
58 | attributes[attributeName] = element.getAttribute(attributeName)
59 | }
60 |
61 | return attributes
62 | }
63 |
64 | function serializeElementFormData (element) {
65 | const form = element.form || element.closest('form')
66 |
67 | if (!form) {
68 | return null
69 | }
70 |
71 | const formData = new FormData(form)
72 |
73 | return Array
74 | .from(
75 | formData.entries(),
76 | ([key, value]) => (
77 | `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
78 | )
79 | )
80 | .join('&')
81 | }
82 |
--------------------------------------------------------------------------------
/spec/support/system_test_helpers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | module SystemTestHelpers
4 | # https://bloggie.io/@kinopyo/capybara-trigger-blur-event
5 | def blur
6 | find("body").click
7 | end
8 |
9 | # See `spec/support/test_application/app/javascript/packs/application.js`:
10 | JS_CONNECT_COUNT = "window.connectCount"
11 | JS_RENDER_COUNT = "window.renderCount"
12 |
13 | # Blocks until a new component connects (since the last call)
14 | def wait_for_connect
15 | wait_for_action_cable_idle
16 | wait_for_expression_to_increase(JS_CONNECT_COUNT)
17 | end
18 |
19 | # Blocks until a component renders (since the last call)
20 | def wait_for_render
21 | wait_for_action_cable_idle
22 | wait_for_expression_to_increase(JS_RENDER_COUNT)
23 | end
24 |
25 | private
26 |
27 | def wait_for_action_cable_idle
28 | executor = ActionCable.server.worker_pool.executor
29 |
30 | block_until do
31 | executor.send(:synchronize) do
32 | executor.completed_task_count == executor.scheduled_task_count
33 | end
34 | end
35 | end
36 |
37 | def wait_for_expression_to_increase(expression)
38 | last_values = (@_wait_for_expression_to_increase_state ||= Hash.new(0))
39 |
40 | last_value = last_values[expression]
41 | new_value = nil
42 |
43 | block_until { (new_value = page.evaluate_script(expression)) > last_value }
44 |
45 | last_values[expression] = new_value
46 | end
47 |
48 | def block_until(max_wait_time: Capybara.default_max_wait_time)
49 | expiration = max_wait_time && Time.now + max_wait_time
50 |
51 | loop do
52 | break if yield
53 |
54 | raise "timeout: condition not met before expiration" if expiration&.past?
55 |
56 | # Let the scheduler know that we are in a tight loop and waiting for other
57 | # threads/processes to make progress.
58 | Thread.pass
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/spec/motion/callback_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Callback do
4 | subject(:callback) { described_class.new(component, method) }
5 |
6 | let(:component) { TestComponent.new }
7 | let(:method) { :noop }
8 |
9 | let(:identifier) { component.stable_instance_identifier_for_callbacks }
10 |
11 | describe ".broadcast_for" do
12 | subject { described_class.broadcast_for(component, method) }
13 |
14 | it { is_expected.to start_with("motion:callback") }
15 | it { is_expected.to include(identifier.to_s) }
16 | it { is_expected.to include(method.to_s) }
17 | end
18 |
19 | it "causes the component to stream from its broadcast" do
20 | subject
21 | expect(component.broadcasts).to include(callback.broadcast)
22 | end
23 |
24 | describe "#==" do
25 | subject { callback == other }
26 |
27 | context "with another callback for the same component and method" do
28 | let(:other) { described_class.new(component, method) }
29 |
30 | it { is_expected.to be_truthy }
31 | end
32 |
33 | context "with another callback for a different component" do
34 | let(:other) { described_class.new(different_component, method) }
35 | let(:different_component) { TestComponent.new }
36 |
37 | it { is_expected.to be_falsey }
38 | end
39 |
40 | context "with another callback for a different method" do
41 | let(:other) { described_class.new(component, different_method) }
42 | let(:different_method) { :change_state }
43 |
44 | it { is_expected.to be_falsey }
45 | end
46 | end
47 |
48 | describe "#call" do
49 | subject { callback.call(message) }
50 |
51 | let(:message) { double }
52 |
53 | it "broadcasts the provided message to the callback topic" do
54 | expect(ActionCable.server).to(
55 | receive(:broadcast).with(callback.broadcast, message)
56 | )
57 |
58 | subject
59 | end
60 | end
61 | end
62 |
--------------------------------------------------------------------------------
/lib/motion/log_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "rails"
4 | require "active_support/core_ext/string/indent"
5 |
6 | require "motion"
7 |
8 | module Motion
9 | class LogHelper
10 | BACKTRACE_FRAMES = 5
11 | DEFAULT_TAG = "Motion"
12 |
13 | def self.for_channel(channel, logger: channel.connection.logger)
14 | new(logger: logger, tag: DEFAULT_TAG)
15 | end
16 |
17 | def self.for_component(component, logger: nil)
18 | new(logger: logger, tag: "#{component.class}:#{component.object_id}")
19 | end
20 |
21 | attr_reader :logger, :tag
22 |
23 | def initialize(logger: nil, tag: nil)
24 | @logger = logger || Rails.logger
25 | @tag = tag || DEFAULT_TAG
26 | end
27 |
28 | def error(message, error: nil)
29 | error_info = error ? ":\n#{indent(format_exception(error))}" : ""
30 |
31 | logger.error("[#{tag}] #{message}#{error_info}")
32 |
33 | Motion.notify_error(error, message)
34 | end
35 |
36 | def info(message)
37 | logger.info("[#{tag}] #{message}")
38 | end
39 |
40 | def timing(message)
41 | start_time = Time.now
42 | result = yield
43 | end_time = Time.now
44 |
45 | info("#{message} (in #{format_duration(end_time - start_time)})")
46 |
47 | result
48 | end
49 |
50 | def for_component(component)
51 | self.class.for_component(component, logger: logger)
52 | end
53 |
54 | private
55 |
56 | def format_exception(exception)
57 | frames = exception.backtrace.first(BACKTRACE_FRAMES).join("\n")
58 |
59 | "#{exception.class}: #{exception}\n#{indent(frames)}"
60 | end
61 |
62 | def format_duration(duration)
63 | duration_ms = duration * 1000
64 |
65 | if duration_ms < 0.1
66 | "less than 0.1ms"
67 | else
68 | "#{duration_ms.round(1)}ms"
69 | end
70 | end
71 |
72 | def indent(string)
73 | string.indent(1, "\t")
74 | end
75 | end
76 | end
77 |
--------------------------------------------------------------------------------
/javascript/reconcile.js:
--------------------------------------------------------------------------------
1 | import morphdom from 'morphdom'
2 |
3 | export default (rootElement, newState, keyAttribute) => {
4 | if (typeof (newState) !== 'string') {
5 | throw new TypeError('Expected raw HTML for reconcile newState')
6 | }
7 |
8 | // remove root element when component sends an empty state
9 | if (!newState) return rootElement.remove()
10 |
11 | const rootKey = rootElement.getAttribute(keyAttribute)
12 |
13 | if (!rootKey) {
14 | throw new TypeError('Expected key on reconcile rootElement')
15 | }
16 |
17 | const onBeforeElUpdated = (fromElement, toElement) => {
18 | // When we are doing an inner update, propgrate the key and replace.
19 | if (rootElement === fromElement) {
20 | toElement.setAttribute(keyAttribute, rootKey)
21 | return true
22 | }
23 |
24 | // When we are doing an outer update, do not replace if the key is the same.
25 | const toKey = toElement.getAttribute(keyAttribute)
26 | if (toKey && toKey === fromElement.getAttribute(keyAttribute)) {
27 | return false
28 | }
29 |
30 | if (
31 | // For some reason, it it seems like all TEXTAREAs are equal to eachother
32 | // regardless of their content which is super werid because the same thing
33 | // does not seem to be true for INPUTs or SELECTs whose value has changed.
34 | fromElement.tagName !== 'TEXTAREA' &&
35 |
36 | // When two nodes have (deep) DOM equality, don't replace. This is correct
37 | // because we checked above that we are reconsiling against an HTML string
38 | // (which cannot possibly have state outside of the DOM because no handles
39 | // have been allowed to leave this function since parsing).
40 | fromElement.isEqualNode(toElement)
41 | ) {
42 | return false
43 | }
44 |
45 | // Otherwise, take the new version.
46 | return true
47 | }
48 |
49 | return morphdom(
50 | rootElement,
51 | newState,
52 | {
53 | onBeforeElUpdated
54 | }
55 | )
56 | }
57 |
--------------------------------------------------------------------------------
/lib/motion/component/motions.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/concern"
4 | require "active_support/core_ext/hash/except"
5 |
6 | require "motion"
7 |
8 | module Motion
9 | module Component
10 | module Motions
11 | extend ActiveSupport::Concern
12 |
13 | DEFAULT = {}.freeze
14 | private_constant :DEFAULT
15 |
16 | # Analogous to `module_function` (available on both class and instance)
17 | module ModuleFunctions
18 | def map_motion(motion, handler = motion)
19 | self._motion_handlers =
20 | _motion_handlers.merge(motion.to_s => handler.to_sym).freeze
21 | end
22 |
23 | def unmap_motion(motion)
24 | self._motion_handlers =
25 | _motion_handlers.except(motion.to_s).freeze
26 | end
27 |
28 | def motions
29 | _motion_handlers.keys
30 | end
31 | end
32 |
33 | class_methods do
34 | include ModuleFunctions
35 |
36 | attr_writer :_motion_handlers
37 |
38 | def _motion_handlers
39 | return @_motion_handlers if defined?(@_motion_handlers)
40 | return superclass._motion_handlers if superclass.respond_to?(:_motion_handlers)
41 |
42 | DEFAULT
43 | end
44 | end
45 |
46 | include ModuleFunctions
47 |
48 | def process_motion(motion, event = nil)
49 | unless (handler = _motion_handlers[motion])
50 | raise MotionNotMapped.new(self, motion)
51 | end
52 |
53 | _run_action_callbacks(context: handler) do
54 | if method(handler).arity.zero?
55 | send(handler)
56 | else
57 | send(handler, event)
58 | end
59 | end
60 | end
61 |
62 | private
63 |
64 | attr_writer :_motion_handlers
65 |
66 | def _motion_handlers
67 | return @_motion_handlers if defined?(@_motion_handlers)
68 |
69 | self.class._motion_handlers
70 | end
71 | end
72 | end
73 | end
74 |
--------------------------------------------------------------------------------
/javascript/Client.js:
--------------------------------------------------------------------------------
1 | import AttributeTracker from './AttributeTracker'
2 | import BindingManager from './BindingManager'
3 | import Component from './Component'
4 | import { documentLoaded, beforeDocumentUnload } from './documentLifecycle'
5 | import getFallbackConsumer from './getFallbackConsumer'
6 |
7 | export default class Client {
8 | constructor (options = {}) {
9 | Object.assign(this, Client.defaultOptions, options)
10 |
11 | this._componentSelector = `[${this.keyAttribute}][${this.stateAttribute}]`
12 |
13 | this._componentTracker =
14 | new AttributeTracker(this.keyAttribute, (element) => (
15 | element.hasAttribute(this.stateAttribute) // ensure matches selector
16 | ? new Component(this, element) : null
17 | ))
18 |
19 | this._motionTracker =
20 | new AttributeTracker(this.motionAttribute, (element) => (
21 | new BindingManager(this, element)
22 | ))
23 |
24 | documentLoaded.then(() => { // avoid mutations while loading the document
25 | this._componentTracker.attachRoot(this.root)
26 | this._motionTracker.attachRoot(this.root)
27 | })
28 |
29 | if (this.shutdownBeforeUnload) {
30 | beforeDocumentUnload.then(() => this.shutdown())
31 | }
32 | }
33 |
34 | log (...args) {
35 | if (this.logging) {
36 | console.log('[Motion]', ...args)
37 | }
38 | }
39 |
40 | getComponent (element) {
41 | return this._componentTracker.getManager(
42 | element.closest(this._componentSelector)
43 | )
44 | }
45 |
46 | shutdown () {
47 | this._componentTracker.shutdown()
48 | this._motionTracker.shutdown()
49 | }
50 | }
51 |
52 | Client.defaultOptions = {
53 | get consumer () {
54 | return getFallbackConsumer()
55 | },
56 |
57 | getExtraDataForEvent (_event) {
58 | // noop
59 | },
60 |
61 | logging: false,
62 |
63 | root: document,
64 | shutdownBeforeUnload: true,
65 |
66 | keyAttribute: 'data-motion-key',
67 | stateAttribute: 'data-motion-state',
68 | motionAttribute: 'data-motion'
69 | }
70 |
--------------------------------------------------------------------------------
/lib/motion/component/periodic_timers.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/concern"
4 | require "active_support/core_ext/hash/except"
5 |
6 | require "motion"
7 |
8 | module Motion
9 | module Component
10 | module PeriodicTimers
11 | extend ActiveSupport::Concern
12 |
13 | DEFAULT = {}.freeze
14 | private_constant :DEFAULT
15 |
16 | # Analogous to `module_function` (available on both class and instance)
17 | module ModuleFunctions
18 | def every(interval, handler, name: handler)
19 | periodic_timer(name, handler, every: interval)
20 | end
21 |
22 | def periodic_timer(name, handler = name, every:)
23 | self._periodic_timers =
24 | _periodic_timers.merge(name.to_s => [handler.to_sym, every]).freeze
25 | end
26 |
27 | def stop_periodic_timer(name)
28 | self._periodic_timers =
29 | _periodic_timers.except(name.to_s).freeze
30 | end
31 |
32 | def periodic_timers
33 | _periodic_timers.transform_values { |_handler, interval| interval }
34 | end
35 | end
36 |
37 | class_methods do
38 | include ModuleFunctions
39 |
40 | attr_writer :_periodic_timers
41 |
42 | def _periodic_timers
43 | return @_periodic_timers if defined?(@_periodic_timers)
44 | return superclass._periodic_timers if superclass.respond_to?(:_periodic_timers)
45 |
46 | DEFAULT
47 | end
48 | end
49 |
50 | include ModuleFunctions
51 |
52 | def process_periodic_timer(name)
53 | return unless (handler, _interval = _periodic_timers[name])
54 |
55 | _run_action_callbacks(context: handler) do
56 | send(handler)
57 | end
58 | end
59 |
60 | private
61 |
62 | attr_writer :_periodic_timers
63 |
64 | def _periodic_timers
65 | return @_periodic_timers if defined?(@_periodic_timers)
66 |
67 | self.class._periodic_timers
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/lib/motion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion/version"
4 | require "motion/errors"
5 |
6 | module Motion
7 | autoload :ActionCableExtentions, "motion/action_cable_extentions"
8 | autoload :Callback, "motion/callback"
9 | autoload :Channel, "motion/channel"
10 | autoload :Component, "motion/component"
11 | autoload :ComponentConnection, "motion/component_connection"
12 | autoload :Configuration, "motion/configuration"
13 | autoload :Element, "motion/element"
14 | autoload :Event, "motion/event"
15 | autoload :LogHelper, "motion/log_helper"
16 | autoload :MarkupTransformer, "motion/markup_transformer"
17 | autoload :Railtie, "motion/railtie"
18 | autoload :RevisionCalculator, "motion/revision_calculator"
19 | autoload :Serializer, "motion/serializer"
20 | autoload :TestHelpers, "motion/test_helpers"
21 |
22 | class << self
23 | def configure(&block)
24 | raise AlreadyConfiguredError if @config
25 |
26 | @config = Configuration.new(&block)
27 | end
28 |
29 | def config
30 | @config ||= Configuration.default
31 | end
32 |
33 | alias_method :configuration, :config
34 |
35 | def serializer
36 | @serializer ||= Serializer.new
37 | end
38 |
39 | def markup_transformer
40 | @markup_transformer ||= MarkupTransformer.new
41 | end
42 |
43 | def build_renderer_for(websocket_connection)
44 | config.renderer_for_connection_proc.call(websocket_connection)
45 | end
46 |
47 | def notify_error(error, message)
48 | config.error_notification_proc&.call(error, message)
49 | end
50 |
51 | # This method only exists for testing. Changing configuration while Motion
52 | # is in use is not supported. It is only safe to call this method when no
53 | # components are currently mounted.
54 | def reset_internal_state_for_testing!(new_configuration = nil)
55 | @config = new_configuration
56 | @serializer = nil
57 | @markup_transformer = nil
58 | end
59 | end
60 | end
61 |
62 | require "motion/railtie" if defined?(Rails)
63 |
--------------------------------------------------------------------------------
/spec/support/test_application/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = function (api) {
2 | var validEnv = ['development', 'test', 'production']
3 | var currentEnv = api.env()
4 | var isDevelopmentEnv = api.env('development')
5 | var isProductionEnv = api.env('production')
6 | var isTestEnv = api.env('test')
7 |
8 | if (!validEnv.includes(currentEnv)) {
9 | throw new Error(
10 | 'Please specify a valid `NODE_ENV` or ' +
11 | '`BABEL_ENV` environment variables. Valid values are "development", ' +
12 | '"test", and "production". Instead, received: ' +
13 | JSON.stringify(currentEnv) +
14 | '.'
15 | )
16 | }
17 |
18 | return {
19 | presets: [
20 | isTestEnv && [
21 | '@babel/preset-env',
22 | {
23 | targets: {
24 | node: 'current'
25 | }
26 | }
27 | ],
28 | (isProductionEnv || isDevelopmentEnv) && [
29 | '@babel/preset-env',
30 | {
31 | forceAllTransforms: true,
32 | useBuiltIns: 'entry',
33 | corejs: 3,
34 | modules: false,
35 | exclude: ['transform-typeof-symbol']
36 | }
37 | ]
38 | ].filter(Boolean),
39 | plugins: [
40 | 'babel-plugin-macros',
41 | '@babel/plugin-syntax-dynamic-import',
42 | isTestEnv && 'babel-plugin-dynamic-import-node',
43 | '@babel/plugin-transform-destructuring',
44 | [
45 | '@babel/plugin-proposal-class-properties',
46 | {
47 | loose: true
48 | }
49 | ],
50 | [
51 | '@babel/plugin-proposal-object-rest-spread',
52 | {
53 | useBuiltIns: true
54 | }
55 | ],
56 | [
57 | '@babel/plugin-transform-runtime',
58 | {
59 | helpers: false,
60 | regenerator: true,
61 | corejs: false
62 | }
63 | ],
64 | [
65 | '@babel/plugin-transform-regenerator',
66 | {
67 | async: false
68 | }
69 | ]
70 | ].filter(Boolean)
71 | }
72 | }
73 |
--------------------------------------------------------------------------------
/javascript/BindingManager.js:
--------------------------------------------------------------------------------
1 | import parseBindings, { MODE_HANDLE } from './parseBindings'
2 |
3 | export default class BindingManager {
4 | constructor (client, element) {
5 | this.client = client
6 | this.element = element
7 |
8 | this._handlers = new Map()
9 |
10 | this.update()
11 | }
12 |
13 | update () {
14 | const targetBindings = this._parseBindings()
15 |
16 | this._removeExtraHandlers(targetBindings)
17 | this._setupMissingHandlers(targetBindings)
18 | }
19 |
20 | shutdown () {
21 | for (const [eventName, callback] of this._handlers.values()) {
22 | this.element.removeEventListener(eventName, callback)
23 | }
24 |
25 | this._handlers.clear()
26 | }
27 |
28 | _parseBindings () {
29 | const { motionAttribute } = this.client
30 | const bindingsString = this.element.getAttribute(motionAttribute)
31 | const bindings = new Map()
32 |
33 | for (const binding of parseBindings(bindingsString, this.element)) {
34 | bindings.set(binding.id, binding)
35 | }
36 |
37 | return bindings
38 | }
39 |
40 | _buildHandlerForBinding ({ mode, motion }) {
41 | return (event) => {
42 | const component = this._getComponent()
43 |
44 | if (
45 | component &&
46 | component.processMotion(motion, event, this.element) &&
47 | mode === MODE_HANDLE
48 | ) {
49 | event.preventDefault()
50 | }
51 | }
52 | }
53 |
54 | _getComponent () {
55 | return this.client.getComponent(this.element)
56 | }
57 |
58 | _setupMissingHandlers (targetBindings) {
59 | for (const [id, binding] of targetBindings.entries()) {
60 | if (!this._handlers.has(id)) {
61 | const { event } = binding
62 | const handler = this._buildHandlerForBinding(binding)
63 |
64 | this.element.addEventListener(event, handler)
65 | this._handlers.set(id, [event, handler])
66 | }
67 | }
68 | }
69 |
70 | _removeExtraHandlers (targetBindings) {
71 | for (const [id, [event, callback]] of this._handlers.entries()) {
72 | if (!targetBindings.has(id)) {
73 | this.element.removeEventListener(event, callback)
74 | this._handlers.delete(id)
75 | }
76 | }
77 | }
78 | }
79 |
--------------------------------------------------------------------------------
/lib/motion/component/rendering.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module Component
7 | module Rendering
8 | STATE_EXCLUDED_IVARS = %i[
9 | @_action_callback_context
10 | @_awaiting_forced_rerender
11 | @_routes
12 |
13 | @view_context
14 | @lookup_context
15 | @view_renderer
16 | @view_flow
17 | @virtual_path
18 | @variant
19 | @current_template
20 | @output_buffer
21 |
22 | @helpers
23 | @controller
24 | @request
25 | @tag_builder
26 |
27 | @asset_resolver_strategies
28 | @assets_environment
29 | ].freeze
30 |
31 | private_constant :STATE_EXCLUDED_IVARS
32 |
33 | def rerender!
34 | @_awaiting_forced_rerender = true
35 | end
36 |
37 | def awaiting_forced_rerender?
38 | @_awaiting_forced_rerender
39 | end
40 |
41 | # * This can be overwritten.
42 | # * It will _not_ be sent to the client.
43 | # * If it doesn't change every time the component's state changes,
44 | # things may fall out of sync unless you also call `#rerender!`
45 | def render_hash
46 | Motion.serializer.weak_digest(self)
47 | end
48 |
49 | def render_in(view_context)
50 | raise BlockNotAllowedError, self if block_given?
51 |
52 | html =
53 | _run_action_callbacks(context: :render) {
54 | _clear_awaiting_forced_rerender!
55 |
56 | view_context.capture { super }
57 | }
58 |
59 | raise RenderAborted, self if html == false
60 |
61 | Motion.markup_transformer.add_state_to_html(self, html)
62 | end
63 |
64 | private
65 |
66 | def _clear_awaiting_forced_rerender!
67 | @_awaiting_forced_rerender = false
68 | end
69 |
70 | def marshal_dump
71 | (instance_variables - STATE_EXCLUDED_IVARS)
72 | .map { |ivar| [ivar, instance_variable_get(ivar)] }
73 | .to_h
74 | end
75 |
76 | def marshal_load(instance_variables)
77 | instance_variables.each do |ivar, value|
78 | instance_variable_set(ivar, value)
79 | end
80 | end
81 | end
82 | end
83 | end
84 |
--------------------------------------------------------------------------------
/spec/motion/markup_transformer_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::MarkupTransformer do
4 | subject(:markup_transformer) do
5 | described_class.new(
6 | serializer: serializer,
7 | key_attribute: key_attribute,
8 | state_attribute: state_attribute
9 | )
10 | end
11 |
12 | let(:key_attribute) { SecureRandom.hex }
13 | let(:state_attribute) { SecureRandom.hex }
14 |
15 | let(:serializer) { double(Motion::Serializer, serialize: [key, state]) }
16 | let(:key) { SecureRandom.hex }
17 | let(:state) { SecureRandom.hex }
18 | let(:component) { Object.new }
19 |
20 | describe "#add_state_to_html" do
21 | subject { markup_transformer.add_state_to_html(component, html) }
22 |
23 | context "when the markup has a single root element" do
24 | let(:html) { "
content
" }
25 |
26 | it "transforms the markup to include the extra attributes" do
27 | expect(subject).to(
28 | eq(
29 | "
" \
33 | "content" \
34 | "
"
35 | )
36 | )
37 | end
38 | end
39 |
40 | context "when the markup has multiple elements" do
41 | let(:html) { "" }
42 |
43 | it "raises MultipleRootsError" do
44 | expect { subject }.to raise_error(Motion::MultipleRootsError)
45 | end
46 | end
47 |
48 | context "when there is a single root element with trailing whitespace" do
49 | let(:html) { "
content
\n\n" }
50 |
51 | it "preserves the whitespace around the element" do
52 | expect(subject).to(
53 | eq(
54 | "
" \
58 | "content" \
59 | "
\n\n"
60 | )
61 | )
62 | end
63 | end
64 |
65 | context "when the component does not generate any markup" do
66 | let(:html) { "" }
67 |
68 | it { is_expected.to be_nil }
69 | end
70 |
71 | context "when the component generates only whitespace markup" do
72 | let(:html) { "" }
73 |
74 | it { is_expected.to be_nil }
75 | end
76 | end
77 | end
78 |
--------------------------------------------------------------------------------
/lib/motion/action_cable_extentions/declarative_streams.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module ActionCableExtentions
7 | # Provides a `streaming_from(broadcasts, to:)` API that can be used to
8 | # declaratively specify what `broadcasts` the channel is interested in
9 | # receiving and `to` what method they should be routed.
10 | module DeclarativeStreams
11 | include Synchronization
12 |
13 | def initialize(*)
14 | super
15 |
16 | # Streams that we are currently interested in
17 | @_declarative_streams = Set.new
18 |
19 | # The method we are currently routing those streams to
20 | @_declarative_stream_target = nil
21 |
22 | # Streams that we are setup to listen to. Sadly, there is no public API
23 | # to stop streaming so this will only grow.
24 | @_declarative_stream_proxies = Set.new
25 | end
26 |
27 | # Clean up declarative streams when all streams are stopped.
28 | def stop_all_streams
29 | super
30 |
31 | @_declarative_streams.clear
32 | @_declarative_stream_target = nil
33 |
34 | @_declarative_stream_proxies.clear
35 | end
36 |
37 | # Declaratively routes provided broadcasts to the provided method.
38 | def streaming_from(broadcasts, to:)
39 | @_declarative_streams.replace(broadcasts)
40 | @_declarative_stream_target = to
41 |
42 | @_declarative_streams.each(&method(:_ensure_declarative_stream_proxy))
43 | end
44 |
45 | def declarative_stream_target
46 | @_declarative_stream_target
47 | end
48 |
49 | private
50 |
51 | def _ensure_declarative_stream_proxy(broadcast)
52 | return unless @_declarative_stream_proxies.add?(broadcast)
53 |
54 | # TODO: I feel like the fact that we have to specify the coder here is
55 | # a bug in ActionCable. It should be the default for this karg.
56 | stream_from(broadcast, coder: ActiveSupport::JSON) do |message|
57 | synchronize_entrypoint! do
58 | _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
59 | end
60 | end
61 | end
62 |
63 | def _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
64 | return unless @_declarative_stream_target &&
65 | @_declarative_streams.include?(broadcast)
66 |
67 | send(@_declarative_stream_target, broadcast, message)
68 | end
69 | end
70 | end
71 | end
72 |
--------------------------------------------------------------------------------
/spec/motion/revision_calculator_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::RevisionCalculator do
4 | subject(:calculator) do
5 | described_class.new(
6 | revision_paths: revision_paths
7 | )
8 | end
9 |
10 | let(:revision_paths) { Rails.application.config.paths.dup }
11 |
12 | describe "#perform" do
13 | subject(:output) { calculator.perform }
14 | let(:empty_hash) { Digest::MD5.new.hexdigest }
15 |
16 | context "when the revisions path is not a Rails::Paths::Root object" do
17 | let(:revision_paths) { [] }
18 |
19 | it "raises BadRevisionPathsError" do
20 | expect { subject }.to raise_error(Motion::BadRevisionPathsError)
21 | end
22 | end
23 |
24 | context "when there are no paths to hash" do
25 | let(:revision_paths) { Rails::Paths::Root.new(Rails.application.root) }
26 | let(:empty_hash) { Digest::MD5.new.hexdigest }
27 |
28 | it "hashes empty digest" do
29 | expect(subject).to eq(empty_hash)
30 | end
31 | end
32 |
33 | context "when paths do not exist" do
34 | let(:revision_paths) do
35 | paths = Rails::Paths::Root.new(Rails.application.root)
36 | paths.add "foo"
37 | paths
38 | end
39 |
40 | it "ignores empty directory" do
41 | expect(subject).to eq(empty_hash)
42 | end
43 | end
44 |
45 | context "for normal application with files" do
46 | let(:all_dirs) { revision_paths.all_paths.flat_map(&:existent) }
47 | let(:first_dir) { all_dirs.first }
48 | let(:new_file) { "#{first_dir}/foot.txt" }
49 |
50 | it "hashes contents" do
51 | expect(subject).not_to eq(empty_hash)
52 | end
53 |
54 | it "has files to hash" do
55 | assert all_dirs.length.positive?
56 | end
57 | end
58 | context "for additional directories or files" do
59 | let(:new_file) { "#{tempdir}/foot.txt" }
60 | let(:tempdir_and_revision_paths) { revision_paths.tap { |p| p.add tempdir } }
61 | attr_reader :tempdir
62 |
63 | around(:each) do |example|
64 | Dir.mktmpdir do |path|
65 | @tempdir = path
66 |
67 | example.run
68 | end
69 | end
70 |
71 | it "changes contents when file contents change" do
72 | first_result = subject
73 |
74 | File.open(new_file, "w+") { |file| file.write("test") }
75 | second_calc = Motion::RevisionCalculator.new(revision_paths: tempdir_and_revision_paths).perform
76 | expect(second_calc).not_to eq(first_result)
77 | expect(second_calc).not_to eq(empty_hash)
78 | end
79 | end
80 | end
81 | end
82 |
--------------------------------------------------------------------------------
/spec/spec_helper.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "bundler/setup"
4 | require "pry"
5 |
6 | # Accurate coverage reports require reporting to be started early.
7 | require_relative "support/coverage_report"
8 |
9 | # Sadly, we must always load the test application (even though many specs will
10 | # never reference it) because `rspec/rails` uses the constants it defines to do
11 | # intelligent feature detection.
12 | require_relative "support/test_application"
13 |
14 | # This needs to be required by `rspec/rails` to ensure that everything is setup
15 | # properly. It only has an effect in Rails 5.
16 | require_relative "support/action_cable_testing_workaround"
17 |
18 | require "rspec/rails"
19 | require "capybara/rspec"
20 | require "generator_spec"
21 |
22 | require_relative "support/system_test_helpers"
23 | require_relative "support/test_component"
24 | require_relative "support/webdriver"
25 |
26 | RSpec.configure do |config|
27 | # Enable flags like --only-failures and --next-failure
28 | config.example_status_persistence_file_path = ".rspec_status"
29 |
30 | # Disable RSpec exposing methods globally on `Module` and `main`
31 | config.disable_monkey_patching!
32 |
33 | config.expect_with :rspec do |c|
34 | c.syntax = :expect
35 | end
36 |
37 | # Isolate any database effects
38 | config.use_transactional_fixtures = true
39 |
40 | # Isolate the effects of the generator specs to a temporary folder
41 | config.around(:each, type: :generator) do |example|
42 | Dir.mktmpdir do |path|
43 | self.destination_root = path
44 | prepare_destination
45 |
46 | example.run
47 | end
48 | end
49 |
50 | config.before(:each, type: :system) do
51 | # Ensure that the client JavaScript within the app is synced with the gem
52 | TestApplication.link_motion_client!
53 |
54 | # Use headless Chrome for system tests
55 | driven_by :headless_chrome_no_sandbox
56 | end
57 |
58 | # To avoid running every test twice on subsequent runs because of the
59 | # recursive symlink, make sure to unlink the client.
60 | config.after(:suite) do
61 | TestApplication.unlink_motion_client!
62 | end
63 |
64 | # For most specs, we want Motion to be configured in a predictable way, but
65 | # when we are testing the configuration specifically, we need Motion in an
66 | # unconfigured state.
67 | config.around(:each, unconfigured: true) do |example|
68 | testing_configuration = Motion.config
69 |
70 | Motion.reset_internal_state_for_testing!
71 |
72 | example.run
73 |
74 | Motion.reset_internal_state_for_testing!(testing_configuration)
75 | end
76 |
77 | config.include(SystemTestHelpers, type: :system)
78 | end
79 |
--------------------------------------------------------------------------------
/spec/system/core_functionality_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe "Core Functionality", type: :system do
4 | scenario "Triggering a state change with user input causes a render" do
5 | visit(test_component_path)
6 | wait_for_connect
7 |
8 | expect(page).to have_text("The state has been changed 0 times.")
9 |
10 | click_button "change_state"
11 | wait_for_render
12 |
13 | expect(page).to have_text("The state has been changed 1 times.")
14 | end
15 |
16 | scenario "Triggering a state change with broadcasts causes a render" do
17 | visit(test_component_path)
18 | wait_for_connect
19 |
20 | expect(page).to have_text("The state has been changed 0 times.")
21 |
22 | ActionCable.server.broadcast "change_state", "message"
23 | wait_for_render
24 |
25 | expect(page).to have_text("The state has been changed 1 times.")
26 | end
27 |
28 | scenario "Nested state is preserved when an outer component renders" do
29 | visit(counter_component_path)
30 | wait_for_connect
31 |
32 | click_button "+"
33 | click_button "+"
34 |
35 | wait_for_render
36 |
37 | expect(find(".count")).to have_text("2")
38 |
39 | click_button "Build Child"
40 | wait_for_render
41 |
42 | expect(find(".parent .count")).to have_text("2")
43 | expect(find(".child .count")).to have_text("2")
44 |
45 | within ".parent" do
46 | click_button "+"
47 | end
48 |
49 | within ".child" do
50 | click_button "-"
51 | end
52 |
53 | wait_for_render
54 |
55 | expect(find(".parent .count")).to have_text("3")
56 | expect(find(".child .count")).to have_text("1")
57 |
58 | within ".parent" do
59 | click_button "Clear Child"
60 | end
61 |
62 | wait_for_render
63 |
64 | expect(find(".count")).to have_text("3")
65 |
66 | click_button "Build Child"
67 |
68 | wait_for_render
69 |
70 | expect(find(".parent .count")).to have_text("3")
71 | expect(find(".child .count")).to have_text("3")
72 | end
73 |
74 | scenario "Periodic timers run and can be removed dynamically" do
75 | visit(timer_component_path)
76 | wait_for_connect
77 |
78 | expect(page).to have_text("1")
79 | sleep 1
80 | expect(page).to have_text("0")
81 | sleep 1
82 | expect(page).to have_text("0")
83 | end
84 |
85 | scenario "Callbacks can be passed to children and trigger on parents" do
86 | visit(callback_component_path)
87 | wait_for_connect
88 |
89 | expect(page).to have_text("The count is 0")
90 |
91 | click_button "+"
92 | click_button "+"
93 |
94 | wait_for_render
95 |
96 | expect(page).to have_text("The count is 2")
97 | end
98 | end
99 |
--------------------------------------------------------------------------------
/lib/generators/motion/templates/motion.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | Motion.configure do |config|
4 | # Motion needs to be able to uniquely identify the version of the running
5 | # version of your application. By default, the commit hash from git is used,
6 | # but depending on your deployment, this may not be available in production.
7 | #
8 | # Motion automatically calculates your revision by hashing the contents of
9 | # files in `revision_paths` The defaults revision paths are:
10 | # rails paths, bin, and Gemfile.lock.
11 | #
12 | # To change or add to your revision paths, uncomment this line:
13 | #
14 | # config.revision_paths += w(additional_path another_path)
15 | #
16 | # If you prefer to use git or an environmental variable for the revision
17 | # in production, define the revision directly below.
18 | #
19 | # config.revision =
20 | # ENV.fetch("MY_DEPLOYMENT_NUMBER") { `git rev-parse HEAD`.chomp }
21 | #
22 | # Using a value that does not change on every deployment will likely lead to
23 | # confusing errors if components are connected during a deployment.
24 |
25 | # This proc will be invoked by Motion in order to create a renderer for each
26 | # websocket connection. By default, your `ApplicationController` will be used
27 | # and the session/cookies **as they were when the websocket was first open**
28 | # will be available:
29 | #
30 | # config.renderer_for_connection_proc = ->(websocket_connection) do
31 | # ApplicationController.renderer.new(
32 | # websocket_connection.env.slice(
33 | # Rack::HTTP_COOKIE, # Cookies
34 | # Rack::RACK_SESSION, # Session
35 | # 'warden' # Warden (needed for `current_user` in Devise)
36 | # )
37 | # )
38 | # end
39 |
40 | # This proc will be invoked by Motion when an unhandled error occurs. By
41 | # default, an error is logged to the application's default logger but no
42 | # additional action is taken. If you are using an error tracking tool like
43 | # Bugsnag, Sentry, Honeybadger, or Rollbar, you can provide a proc which
44 | # notifies that as well:
45 | #
46 | # config.error_notification_proc = ->(error, message) do
47 | # Bugsnag.notify(error) do |report|
48 | # report.add_tab(:motion, {
49 | # message: message
50 | # })
51 | # end
52 | # end
53 |
54 | # The data attributes used by Motion can be customized, but these values must
55 | # also be updated in the JavaScript client configuration:
56 | #
57 | # config.key_attribute = "data-motion-key"
58 | # config.state_attribute = "data-motion-state"
59 | # config.motion_attribute = "data-motion"
60 | #
61 | end
62 |
--------------------------------------------------------------------------------
/spec/motion/component/rendering_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Component::Rendering do
4 | subject(:component) { TestComponent.new }
5 |
6 | describe "#rerender!" do
7 | subject { component.rerender! }
8 |
9 | it "sets the component to be rerendered" do
10 | expect { subject }.to(
11 | change { component.awaiting_forced_rerender? }
12 | .to(true)
13 | )
14 | end
15 | end
16 |
17 | describe "#render_hash" do
18 | subject { component.render_hash }
19 |
20 | it "changes when the component's state changes" do
21 | expect { component.change_state }.to change { component.render_hash }
22 | end
23 |
24 | it "does not change when the component's state does not change" do
25 | expect { component.noop }.not_to change { component.render_hash }
26 | end
27 | end
28 |
29 | describe ".render_in" do
30 | subject { ApplicationController.render(component) }
31 |
32 | it "transforms the rendered markup" do
33 | expect(subject).to(
34 | include(
35 | Motion.config.key_attribute,
36 | Motion.config.state_attribute
37 | )
38 | )
39 | end
40 |
41 | it "runs the action callbacks with the context of `:render`" do
42 | expect(component).to(
43 | receive(:_run_action_callbacks).with(context: :render)
44 | )
45 |
46 | subject
47 | end
48 |
49 | context "when the component is awaiting a forced re-render" do
50 | before(:each) { component.rerender! }
51 |
52 | it "clears #awaiting_forced_rerender?" do
53 | expect { subject }.to(
54 | change { component.awaiting_forced_rerender? }
55 | .to(false)
56 | )
57 | end
58 | end
59 |
60 | context "when there is a render block" do
61 | subject do
62 | ApplicationController.render(inline: <<~ERB)
63 | <%= render(TestComponent.new) do %>
64 | block content
65 | <% end %>
66 | ERB
67 | end
68 |
69 | it "raises BlockNotAllowedError" do
70 | # ActionView will wrap our error, so we check the message.
71 | expect { subject }.to(
72 | raise_error(/Motion does not support rendering with a block/)
73 | )
74 | end
75 | end
76 |
77 | context "when the action callbacks abort" do
78 | let(:component) do
79 | stub_const("ActionAbortingComponent", Class.new(ViewComponent::Base) {
80 | include Motion::Component
81 |
82 | before_action { throw :abort }
83 | })
84 |
85 | ActionAbortingComponent.new
86 | end
87 |
88 | it "raises RenderAborted" do
89 | # ActionView will wrap our error, so we check the message.
90 | expect { subject }.to raise_error(/aborted by a callback/)
91 | end
92 | end
93 | end
94 | end
95 |
--------------------------------------------------------------------------------
/lib/motion/channel.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "action_cable"
4 |
5 | require "motion"
6 |
7 | module Motion
8 | class Channel < ActionCable::Channel::Base
9 | include ActionCableExtentions::DeclarativeNotifications
10 | include ActionCableExtentions::DeclarativeStreams
11 | include ActionCableExtentions::LogSuppression
12 |
13 | ACTION_METHODS = Set.new(["process_motion"]).freeze
14 | private_constant :ACTION_METHODS
15 |
16 | # Don't use the ActionCable huertistic for deciding what actions can be
17 | # called from JavaScript. Instead, hard-code the list so we can make other
18 | # methods public without worrying about them being called from JavaScript.
19 | def self.action_methods
20 | ACTION_METHODS
21 | end
22 |
23 | attr_reader :component_connection
24 |
25 | def subscribed
26 | state, client_version = params.values_at("state", "version")
27 |
28 | if Gem::Version.new(Motion::VERSION) < Gem::Version.new(client_version)
29 | raise IncompatibleClientError.new(Motion::VERSION, client_version)
30 | end
31 |
32 | @component_connection =
33 | ComponentConnection.from_state(state, log_helper: log_helper)
34 |
35 | synchronize
36 | rescue => error
37 | reject
38 |
39 | handle_error(error, "connecting a component")
40 | end
41 |
42 | def unsubscribed
43 | component_connection&.close
44 |
45 | @component_connection = nil
46 | end
47 |
48 | def process_motion(data)
49 | motion, raw_event = data.values_at("name", "event")
50 |
51 | component_connection.process_motion(motion, Event.from_raw(raw_event))
52 | synchronize
53 | end
54 |
55 | def process_broadcast(broadcast, message)
56 | component_connection.process_broadcast(broadcast, message)
57 | synchronize
58 | end
59 |
60 | def process_periodic_timer(timer)
61 | component_connection.process_periodic_timer(timer)
62 | synchronize
63 | end
64 |
65 | private
66 |
67 | def synchronize
68 | component_connection.if_render_required do |component|
69 | transmit(renderer.render(component))
70 | end
71 |
72 | streaming_from component_connection.broadcasts,
73 | to: :process_broadcast
74 |
75 | periodically_notify component_connection.periodic_timers,
76 | via: :process_periodic_timer
77 | end
78 |
79 | def handle_error(error, context)
80 | log_helper.error("An error occurred while #{context}", error: error)
81 | end
82 |
83 | def log_helper
84 | @log_helper ||= LogHelper.for_channel(self)
85 | end
86 |
87 | # Memoize the renderer on the connection so that it can be shared accross
88 | # all components. `ActionController::Renderer` is already thread-safe and
89 | # designed to be reused.
90 | def renderer
91 | connection.instance_eval do
92 | @_motion_renderer ||= Motion.build_renderer_for(self)
93 | end
94 | end
95 | end
96 | end
97 |
--------------------------------------------------------------------------------
/lib/motion/component_connection.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class ComponentConnection
7 | def self.from_state(
8 | state,
9 | serializer: Motion.serializer,
10 | log_helper: LogHelper.new,
11 | **kargs
12 | )
13 | component = serializer.deserialize(state)
14 |
15 | new(component, log_helper: log_helper.for_component(component), **kargs)
16 | end
17 |
18 | attr_reader :component
19 |
20 | def initialize(component, log_helper: LogHelper.for_component(component))
21 | @component = component
22 | @log_helper = log_helper
23 |
24 | timing("Connected") do
25 | @render_hash = component.render_hash
26 |
27 | component.process_connect
28 | end
29 | end
30 |
31 | def close
32 | timing("Disconnected") do
33 | component.process_disconnect
34 | end
35 |
36 | true
37 | rescue => error
38 | handle_error(error, "disconnecting the component")
39 |
40 | false
41 | end
42 |
43 | def process_motion(motion, event = nil)
44 | timing("Processed #{motion}") do
45 | component.process_motion(motion, event)
46 | end
47 |
48 | true
49 | rescue => error
50 | handle_error(error, "processing #{motion}")
51 |
52 | false
53 | end
54 |
55 | def process_broadcast(broadcast, message)
56 | timing("Processed broadcast to #{broadcast}") do
57 | component.process_broadcast broadcast, message
58 | end
59 |
60 | true
61 | rescue => error
62 | handle_error(error, "processing a broadcast to #{broadcast}")
63 |
64 | false
65 | end
66 |
67 | def process_periodic_timer(timer)
68 | timing("Processed periodic timer #{timer}") do
69 | component.process_periodic_timer timer
70 | end
71 |
72 | true
73 | rescue => error
74 | handle_error(error, "processing periodic timer #{timer}")
75 |
76 | false
77 | end
78 |
79 | def if_render_required(&block)
80 | timing("Rendered") do
81 | next_render_hash = component.render_hash
82 |
83 | return if @render_hash == next_render_hash &&
84 | !component.awaiting_forced_rerender?
85 |
86 | yield(component)
87 |
88 | @render_hash = next_render_hash
89 | end
90 | rescue => error
91 | handle_error(error, "rendering the component")
92 | end
93 |
94 | def broadcasts
95 | component.broadcasts
96 | end
97 |
98 | def periodic_timers
99 | component.periodic_timers
100 | end
101 |
102 | private
103 |
104 | attr_reader :log_helper
105 |
106 | def timing(context, &block)
107 | log_helper.timing(context, &block)
108 | end
109 |
110 | def handle_error(error, context)
111 | log_helper.error("An error occurred while #{context}", error: error)
112 | end
113 | end
114 | end
115 |
--------------------------------------------------------------------------------
/javascript/Component.js:
--------------------------------------------------------------------------------
1 | import dispatchEvent from './dispatchEvent'
2 | import serializeEvent from './serializeEvent'
3 | import reconcile from './reconcile'
4 |
5 | import { version } from '../package.json'
6 |
7 | export default class Component {
8 | constructor (client, element) {
9 | this._isShutdown = false
10 |
11 | this.client = client
12 | this.element = element
13 |
14 | this._beforeConnect()
15 |
16 | const subscription = this.client.consumer.subscriptions.create(
17 | {
18 | channel: 'Motion::Channel',
19 | version,
20 | state: this.element.getAttribute(this.client.stateAttribute)
21 | },
22 | {
23 | connected: () => {
24 | if (this._isShutdown) {
25 | subscription.unsubscribe()
26 | return
27 | }
28 |
29 | this._subscription = subscription
30 | this._connect()
31 | },
32 | rejected: () => this._connectFailed(),
33 | disconnected: () => this._disconnect(),
34 | received: newState => this._render(newState)
35 | }
36 | )
37 | }
38 |
39 | processMotion (name, event = null, element = event && event.currentTarget) {
40 | if (!this._subscription) {
41 | this.client.log('Dropped motion', name, 'on', this.element)
42 | return false
43 | }
44 |
45 | this.client.log('Processing motion', name, 'on', this.element)
46 |
47 | const extraDataForEvent = event && this.client.getExtraDataForEvent(event)
48 |
49 | this._subscription.perform(
50 | 'process_motion',
51 | {
52 | name,
53 | event: event && serializeEvent(event, extraDataForEvent, element)
54 | }
55 | )
56 |
57 | return true
58 | }
59 |
60 | shutdown () {
61 | this._isShutdown = true
62 |
63 | if (this._subscription) {
64 | this._subscription.unsubscribe()
65 | delete this._subscription
66 | }
67 |
68 | this._disconnect()
69 | }
70 |
71 | _beforeConnect () {
72 | this.client.log('Connecting component', this.element)
73 |
74 | dispatchEvent(this.element, 'motion:before-connect')
75 | }
76 |
77 | _connect () {
78 | this.client.log('Component connected', this.element)
79 |
80 | dispatchEvent(this.element, 'motion:connect')
81 | }
82 |
83 | _connectFailed () {
84 | this.client.log('Failed to connect component', this.element)
85 |
86 | dispatchEvent(this.element, 'motion:connect-failed')
87 | }
88 |
89 | _disconnect () {
90 | this.client.log('Component disconnected', this.element)
91 |
92 | dispatchEvent(this.element, 'motion:disconnect')
93 | }
94 |
95 | _render (newState) {
96 | dispatchEvent(this.element, 'motion:before-render')
97 |
98 | reconcile(this.element, newState, this.client.keyAttribute)
99 |
100 | this.client.log('Component rendered', this.element)
101 |
102 | dispatchEvent(this.element, 'motion:render')
103 | }
104 | }
105 |
--------------------------------------------------------------------------------
/spec/motion/event_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Event do
4 | describe ".from_raw" do
5 | subject { described_class.from_raw(input) }
6 |
7 | context "when the input is nil" do
8 | let(:input) { nil }
9 |
10 | it { is_expected.to be_nil }
11 | end
12 |
13 | context "when the input is a hash" do
14 | let(:input) { {"type" => "click"} }
15 |
16 | it { is_expected.to be_a(described_class) }
17 |
18 | it "wraps it" do
19 | expect(subject.raw).to eq(input)
20 | end
21 | end
22 | end
23 |
24 | subject(:event) { described_class.new(raw) }
25 |
26 | context "in a particular case" do
27 | let(:raw) do
28 | {
29 | "type" => "click",
30 | "details" => {
31 | "x" => "1",
32 | "y" => "7"
33 | },
34 | "extraData" => nil,
35 | "target" => {
36 | "tagName" => "INPUT",
37 | "value" => "test",
38 | "attributes" => {
39 | "class" => "form-control",
40 | "data-field" => "name",
41 | "type" => "text",
42 | "name" => "sign_up[name]",
43 | "id" => "sign_up_name"
44 | },
45 | "formData" => "sign_up%5Bname%5D=test"
46 | },
47 | "element" => {
48 | "tagName" => "INPUT",
49 | "value" => "test",
50 | "attributes" => {
51 | "class" => "form-control",
52 | "data-field" => "name",
53 | "type" => "text",
54 | "name" => "sign_up[name]",
55 | "id" => "sign_up_name"
56 | },
57 | "formData" => "sign_up%5Bname%5D=test"
58 | }
59 | }
60 | end
61 |
62 | describe "#type" do
63 | subject { event.type }
64 |
65 | it { is_expected.to eq("click") }
66 | end
67 |
68 | describe "#name" do
69 | subject { event.name }
70 |
71 | it { is_expected.to eq("click") }
72 | end
73 |
74 | describe "#details" do
75 | subject { event.details }
76 |
77 | it { is_expected.to eq("x" => "1", "y" => "7") }
78 | end
79 |
80 | describe "#extra_data" do
81 | subject { event.extra_data }
82 |
83 | it { is_expected.to be_nil }
84 | end
85 |
86 | describe "#target" do
87 | subject { event.target }
88 |
89 | it { is_expected.to be_a(Motion::Element) }
90 |
91 | it "has raw data from the underlying event" do
92 | expect(subject.raw).to eq(raw["target"])
93 | end
94 | end
95 |
96 | describe "#element" do
97 | subject { event.element }
98 |
99 | it { is_expected.to be_a(Motion::Element) }
100 |
101 | it "has raw data from the underlying event" do
102 | expect(subject.raw).to eq(raw["element"])
103 | end
104 | end
105 |
106 | describe "#form_data" do
107 | subject { event.form_data }
108 |
109 | it { is_expected.to eq({"sign_up" => {"name" => "test"}}) }
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/spec/motion/element_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Element do
4 | describe ".from_raw" do
5 | subject { described_class.from_raw(input) }
6 |
7 | context "when the input is nil" do
8 | let(:input) { nil }
9 |
10 | it { is_expected.to be_nil }
11 | end
12 |
13 | context "when the input is a hash" do
14 | let(:input) { {"tagName" => "INPUT"} }
15 |
16 | it { is_expected.to be_a(described_class) }
17 |
18 | it "wraps it" do
19 | expect(subject.raw).to eq(input)
20 | end
21 | end
22 | end
23 |
24 | subject(:element) { described_class.new(raw) }
25 |
26 | context "in a particular case" do
27 | let(:raw) do
28 | {
29 | "tagName" => "INPUT",
30 | "value" => "test",
31 | "attributes" => {
32 | "class" => "form-control",
33 | "data-field" => "name",
34 | "data-magic-field" => "pony",
35 | "type" => "text",
36 | "name" => "sign_up[name]",
37 | "id" => "sign_up_name"
38 | },
39 | "formData" => "sign_up%5Bname%5D=test"
40 | }
41 | end
42 |
43 | describe "#tag_name" do
44 | subject { element.tag_name }
45 |
46 | it { is_expected.to eq("INPUT") }
47 | end
48 |
49 | describe "#value" do
50 | subject { element.value }
51 |
52 | it { is_expected.to eq("test") }
53 | end
54 |
55 | describe "#attributes" do
56 | subject { element.attributes }
57 |
58 | it do
59 | is_expected.to(
60 | eq(
61 | "class" => "form-control",
62 | "data-field" => "name",
63 | "data-magic-field" => "pony",
64 | "type" => "text",
65 | "name" => "sign_up[name]",
66 | "id" => "sign_up_name"
67 | )
68 | )
69 | end
70 | end
71 |
72 | describe "#[]" do
73 | subject { element[key] }
74 |
75 | context "with a string key exactly matching the data" do
76 | let(:key) { "class" }
77 |
78 | it { is_expected.to eq("form-control") }
79 | end
80 |
81 | context "with a symbol key in underscore case" do
82 | let(:key) { :data_field }
83 |
84 | it { is_expected.to eq("name") }
85 | end
86 | end
87 |
88 | describe "#id" do
89 | subject { element.id }
90 |
91 | it { is_expected.to eq("sign_up_name") }
92 | end
93 |
94 | describe "#data" do
95 | subject { element.data[key] }
96 |
97 | context "with a string key exactly matching the data" do
98 | let(:key) { "magic-field" }
99 |
100 | it { is_expected.to eq("pony") }
101 | end
102 |
103 | context "with a symbol key in underscore case" do
104 | let(:key) { :magic_field }
105 |
106 | it { is_expected.to eq("pony") }
107 | end
108 | end
109 |
110 | describe "#form_data" do
111 | subject { element.form_data }
112 |
113 | it { is_expected.to eq({"sign_up" => {"name" => "test"}}) }
114 | end
115 | end
116 | end
117 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | ---
2 | os: linux
3 | dist: xenial
4 | language: ruby
5 |
6 | env:
7 | global:
8 | secure: gbhfFSnxpYU4yviZTCJ7bjKuNpGEI0fnjKp+wROeaBgg8TAh0Ood/RwqcRWPT/OAsuuAtZS3gSrlMJYqzj2DOjP6ZiXRiQqTOcm1Onx6KpejFA9GXAKxC5H/JGikSpizwgSWQI6ZxqdkxB97UY03Gl6wNdIwMgynLVeBHlhv2PijTLAFrpbNtz/VtWm9FDppw3r6nEBKLK9GAThYHbicyvXVYgALLNQA56AJEPF2jGap+f1dERtzrUHl0/TD61uPyAd9GHKadbaff0tRKUl5pKX7vI8AOmiLEBAe0Legfm2uZWZUolEXpAWN0qwBkjPbgUwDVCABQayIPhNkec5ddKDMcUcTum67SdFRUH+4Bk0cnW7QlpTl5TgzXEizteYGPkuFmo+6ZStcrwCEJ1DgJSHKWQAS94X7LO3WZVyPdbH18K9KJgnfWNESwzR93Bor1AVRSSHk86VvHmfLALqR3yCnpVbGWzJZ5KnJASfDTjrFJOCTM72Sg6MFxsjg8enVmU3QxFEAIEdrOXNNsQmGcY25S0pi67JvoqOq26JUgwkCwl0V/pKhWToKUlT8gu+4yctS9CKSkwalljKu0oyeHlTPoyrvESVLhhyWbUrcKK/yRItH1J8b4K488UrTXmOH4HgaHE2ndNhXds1VDHSR1r+OBxyCEu7Mha3H4OHR8jw=
9 |
10 | jobs:
11 | - NODE_VERSION=12
12 |
13 | rvm:
14 | - 2.5.8
15 | - 2.6.6
16 | - 2.7.2
17 |
18 | gemfile:
19 | - gemfiles/rails_5_1.gemfile
20 | - gemfiles/rails_5_2.gemfile
21 | - gemfiles/rails_6_0.gemfile
22 | - gemfiles/rails_6_1.gemfile
23 | - gemfiles/rails_master.gemfile
24 |
25 | addons:
26 | chrome: stable
27 |
28 | cache:
29 | - bundler
30 | - yarn
31 |
32 | before_install:
33 | # Install Node and JS dependencies
34 | - nvm install $NODE_VERSION
35 | - bin/yarn install
36 |
37 | # Install the Code Climate reporter
38 | - curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ./cc-test-reporter
39 | - chmod +x ./cc-test-reporter
40 |
41 | # Install the Chrome Driver for the installed version of Chrome
42 | - CHROME_MAIN_VERSION=`google-chrome-stable --version | sed -E 's/(^Google Chrome |\.[0-9]+ )//g'`
43 | - CHROMEDRIVER_VERSION=`curl -s "https://chromedriver.storage.googleapis.com/LATEST_RELEASE_$CHROME_MAIN_VERSION"`
44 | - curl "https://chromedriver.storage.googleapis.com/${CHROMEDRIVER_VERSION}/chromedriver_linux64.zip" -O
45 | - unzip chromedriver_linux64.zip -d ~/bin
46 |
47 | # Always use the latest versions of rubygems and bundler regardless of the Ruby version
48 | - gem update --system --no-doc
49 | - gem install bundler --no-doc
50 |
51 | before_script:
52 | - ./cc-test-reporter before-build
53 |
54 | after_script:
55 | # TODO: Also report JavaScript test coverage
56 | - ./cc-test-reporter after-build --exit-code $TRAVIS_TEST_RESULT
57 |
58 | notifications:
59 | email: false
60 |
61 | slack:
62 | rooms:
63 | secure: Sf5JqkbZU0IQ4cSUhEA+2uw7w6/HkuA2pUGg3/wEJGLMEeBKzTakpJR9esx8TmHLHg4h2TH5MD7EPhCJFhJ4zzLYmIOOwJpqBgnUKXl7HZ4yIseV0H7DWqmLAMDRVufQW8aGtanUD0UutR3CmQN1sqomJWDWovRS8ollSZ/4seuV5xaoVDABWVqppZVfeqbAOkE44xSXizQ97ORk0+2YbUahLmNgtwQBZtLFHo94USU4BFimm9zV8N3Ze0SDk8p991PI6q5R9n3+mNDapGl+5fjSpUw/wbGJ4fBC7ASu8esnQsAyTJ61T9krcX8XHT2lnxsNamrx9D83NOFuTfTteJv99m5EdzP+9eb3x1l1sk/sQfc9dum76Tf5Toe7aHTUOTDKdHPQazh4Cx293o+4UobieYP0+u99HFsznexX7jRYR0wb+0vzC825szzDS/msfvfAxflxMaFlhP2ZFCQcsKS1Z1m2yMcfNklcaskjiNYylTiwhVcq1L8wTydaHkBO2tcz8LYMxCATFMKtIODkklx75/8P1bVtlK6wT5p8yLnovUzQl1Hvk577y6v12yhYrW5QoBHoKd3qOyCO0pBsvOYCMXgvLlWf3nfD6PByw4Usz1Q2rQnBiGr2NGfuECUUp2OiTLJUUWnw8Loyz0Nq8ZRFMAlKte/QuC3I3LvfaSE=
64 | on_failure: always
65 | on_success: change # when fixed
66 | on_pull_requests: false
67 |
--------------------------------------------------------------------------------
/lib/motion/serializer.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "digest"
4 | require "lz4-ruby"
5 | require "active_support/message_encryptor"
6 |
7 | require "motion"
8 |
9 | module Motion
10 | class Serializer
11 | HASH_PEPPER = "Motion"
12 | private_constant :HASH_PEPPER
13 |
14 | NULL_BYTE = "\0"
15 |
16 | attr_reader :secret, :revision
17 |
18 | def self.minimum_secret_byte_length
19 | ActiveSupport::MessageEncryptor.key_len
20 | end
21 |
22 | def initialize(
23 | secret: Motion.config.secret,
24 | revision: Motion.config.revision
25 | )
26 | unless secret.each_byte.count >= self.class.minimum_secret_byte_length
27 | raise BadSecretError.new(self.class.minimum_secret_byte_length)
28 | end
29 |
30 | raise BadRevisionError if revision.include?(NULL_BYTE)
31 |
32 | @secret = secret
33 | @revision = revision
34 | end
35 |
36 | def weak_digest(component)
37 | dump(component).hash
38 | end
39 |
40 | def serialize(component)
41 | state = deflate(dump(component))
42 | state_with_revision = "#{revision}#{NULL_BYTE}#{state}"
43 |
44 | [
45 | salted_digest(state_with_revision),
46 | encrypt_and_sign(state_with_revision)
47 | ]
48 | end
49 |
50 | def deserialize(serialized_component)
51 | state_with_revision = decrypt_and_verify(serialized_component)
52 | serialized_revision, state = state_with_revision.split(NULL_BYTE, 2)
53 | component = load(inflate(state))
54 |
55 | if revision == serialized_revision
56 | component
57 | else
58 | component.class.upgrade_from(serialized_revision, component)
59 | end
60 | end
61 |
62 | private
63 |
64 | def dump(component)
65 | Marshal.dump(component)
66 | rescue TypeError => e
67 | raise UnrepresentableStateError.new(component, e.message)
68 | end
69 |
70 | def load(state)
71 | Marshal.load(state)
72 | end
73 |
74 | def deflate(dumped_component)
75 | LZ4.compress(dumped_component)
76 | end
77 |
78 | def inflate(deflated_state)
79 | LZ4.uncompress(deflated_state)
80 | end
81 |
82 | def encrypt_and_sign(cleartext)
83 | encryptor.encrypt_and_sign(cleartext)
84 | end
85 |
86 | def decrypt_and_verify(cypertext)
87 | encryptor.decrypt_and_verify(cypertext)
88 | rescue ActiveSupport::MessageEncryptor::InvalidMessage,
89 | ActiveSupport::MessageVerifier::InvalidSignature
90 | raise InvalidSerializedStateError
91 | end
92 |
93 | def salted_digest(input)
94 | Digest::SHA256.base64digest(hash_salt + input)
95 | end
96 |
97 | def encryptor
98 | @encryptor ||= ActiveSupport::MessageEncryptor.new(derive_encryptor_key)
99 | end
100 |
101 | def hash_salt
102 | @hash_salt ||= derive_hash_salt
103 | end
104 |
105 | def derive_encryptor_key
106 | secret.byteslice(0, self.class.minimum_secret_byte_length)
107 | end
108 |
109 | def derive_hash_salt
110 | Digest::SHA256.digest(HASH_PEPPER + secret)
111 | end
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/lib/motion/component/broadcasts.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/concern"
4 | require "active_support/core_ext/object/to_param"
5 | require "active_support/core_ext/hash/except"
6 |
7 | require "motion"
8 |
9 | module Motion
10 | module Component
11 | module Broadcasts
12 | extend ActiveSupport::Concern
13 |
14 | DEFAULT = {}.freeze
15 | private_constant :DEFAULT
16 |
17 | # Analogous to `module_function` (available on both class and instance)
18 | module ModuleFunctions
19 | def stream_from(broadcast, handler)
20 | self._broadcast_handlers =
21 | _broadcast_handlers.merge(broadcast.to_s => handler.to_sym).freeze
22 | end
23 |
24 | def stop_streaming_from(broadcast)
25 | self._broadcast_handlers =
26 | _broadcast_handlers.except(broadcast.to_s).freeze
27 | end
28 |
29 | def stream_for(model, handler)
30 | stream_from(broadcasting_for(model), handler)
31 | end
32 |
33 | def stop_streaming_for(model)
34 | stop_streaming_from(broadcasting_for(model))
35 | end
36 |
37 | def broadcasts
38 | _broadcast_handlers.keys
39 | end
40 | end
41 |
42 | class_methods do
43 | include ModuleFunctions
44 |
45 | def broadcast_to(model, message)
46 | ActionCable.server.broadcast(broadcasting_for(model), message)
47 | end
48 |
49 | def broadcasting_for(model)
50 | serialize_broadcasting([name, model])
51 | end
52 |
53 | attr_writer :_broadcast_handlers
54 |
55 | def _broadcast_handlers
56 | return @_broadcast_handlers if defined?(@_broadcast_handlers)
57 | return superclass._broadcast_handlers if superclass.respond_to?(:_broadcast_handlers)
58 |
59 | DEFAULT
60 | end
61 |
62 | private
63 |
64 | # This definition is copied from ActionCable::Channel::Broadcasting
65 | def serialize_broadcasting(object)
66 | if object.is_a?(Array)
67 | object.map { |m| serialize_broadcasting(m) }.join(":")
68 | elsif object.respond_to?(:to_gid_param)
69 | object.to_gid_param
70 | else
71 | object.to_param
72 | end
73 | end
74 | end
75 |
76 | include ModuleFunctions
77 |
78 | def process_broadcast(broadcast, message)
79 | return unless (handler = _broadcast_handlers[broadcast])
80 |
81 | _run_action_callbacks(context: handler) do
82 | if method(handler).arity.zero?
83 | send(handler)
84 | else
85 | send(handler, message)
86 | end
87 | end
88 | end
89 |
90 | private
91 |
92 | def broadcasting_for(model)
93 | self.class.broadcasting_for(model)
94 | end
95 |
96 | attr_writer :_broadcast_handlers
97 |
98 | def _broadcast_handlers
99 | return @_broadcast_handlers if defined?(@_broadcast_handlers)
100 |
101 | self.class._broadcast_handlers
102 | end
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/spec/motion_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion do
4 | it "has a version number" do
5 | expect(Motion::VERSION).not_to be nil
6 | end
7 |
8 | describe ".configure", unconfigured: true do
9 | subject do
10 | @configure_block_called = false
11 |
12 | described_class.configure do |_config|
13 | @configure_block_called = true
14 | end
15 | end
16 |
17 | context "when Motion has not yet been configured" do
18 | it "creates a configuration using the block" do
19 | expect { subject }.not_to raise_error
20 | expect(@configure_block_called).to be true
21 | end
22 | end
23 |
24 | context "when Motion has already been configured" do
25 | before(:each) { Motion.configure {} }
26 |
27 | it "raises an error and does not call the block" do
28 | expect { subject }.to raise_error(Motion::AlreadyConfiguredError)
29 | expect(@configure_block_called).to be false
30 | end
31 | end
32 | end
33 |
34 | describe ".config", unconfigured: true do
35 | subject { described_class.config }
36 |
37 | context "when Motion has already been configured" do
38 | before(:each) do
39 | Motion.configure do |config|
40 | @configuration_from_configure_call = config
41 | end
42 | end
43 |
44 | it { is_expected.to be_a(Motion::Configuration) }
45 |
46 | it "gives the current configuration" do
47 | expect(subject).to be(@configuration_from_configure_call)
48 | end
49 | end
50 |
51 | context "when Motion has not yet been configured" do
52 | it "automatically uses the default configuration" do
53 | expect(subject).to be(Motion::Configuration.default)
54 | end
55 | end
56 | end
57 |
58 | describe ".serializer" do
59 | subject { described_class.serializer }
60 |
61 | it { is_expected.to be_a(Motion::Serializer) }
62 | end
63 |
64 | describe ".markup_transformer" do
65 | subject { described_class.markup_transformer }
66 |
67 | it { is_expected.to be_a(Motion::MarkupTransformer) }
68 | end
69 |
70 | describe ".build_renderer_for", unconfigured: true do
71 | subject { described_class.build_renderer_for(connection) }
72 |
73 | let(:connection) { double }
74 | let(:renderer) { double }
75 |
76 | before(:each) do
77 | Motion.configure do |config|
78 | config.renderer_for_connection_proc = ->(input) do
79 | expect(input).to be(connection)
80 | renderer
81 | end
82 | end
83 | end
84 |
85 | it { is_expected.to be(renderer) }
86 | end
87 |
88 | describe ".notify_error", unconfigured: true do
89 | subject { described_class.notify_error(error, message) }
90 |
91 | let(:error) { double }
92 | let(:message) { double }
93 |
94 | it "forwards the error and message to the `error_notification_proc`" do
95 | Motion.configure do |config|
96 | config.error_notification_proc = ->(input_error, input_message) do
97 | expect(input_error).to be(error)
98 | expect(input_message).to be(message)
99 | end
100 | end
101 |
102 | subject
103 | end
104 | end
105 | end
106 |
--------------------------------------------------------------------------------
/lib/motion/configuration.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class Configuration
7 | class << self
8 | attr_reader :options
9 |
10 | def default
11 | @default ||= new
12 | end
13 |
14 | private
15 |
16 | attr_writer :options
17 |
18 | def option(option, &default)
19 | define_option_reader(option, &default)
20 | define_option_writer(option)
21 |
22 | self.options = [*options, option].freeze
23 | end
24 |
25 | def define_option_reader(option, &default)
26 | define_method(option) do
27 | if instance_variable_defined?(:"@#{option}")
28 | instance_variable_get(:"@#{option}")
29 | else
30 | instance_variable_set(:"@#{option}", instance_exec(&default))
31 | end
32 | end
33 | end
34 |
35 | def define_option_writer(option)
36 | define_method(:"#{option}=") do |value|
37 | raise AlreadyConfiguredError if @finalized
38 |
39 | instance_variable_set(:"@#{option}", value)
40 | end
41 | end
42 | end
43 |
44 | def initialize
45 | yield self if block_given?
46 |
47 | # Ensure a value is selected for all options
48 | self.class.options.each(&method(:public_send))
49 |
50 | # Prevent further changes
51 | @finalized = true
52 | end
53 |
54 | # //////////////////////////////////////////////////////////////////////////
55 |
56 | option :secret do
57 | require "rails"
58 |
59 | Rails.application.key_generator.generate_key("motion:secret")
60 | end
61 |
62 | option :revision_paths do
63 | require "rails"
64 |
65 | Rails.application.config.paths.dup.tap do |paths|
66 | paths.add "bin", glob: "*"
67 | paths.add "Gemfile.lock"
68 | end
69 | end
70 |
71 | option :revision do
72 | RevisionCalculator.new(revision_paths: revision_paths).perform
73 | end
74 |
75 | # TODO: Is this always the correct key?
76 | WARDEN_ENV = "warden"
77 | private_constant :WARDEN_ENV
78 |
79 | option :renderer_for_connection_proc do
80 | ->(websocket_connection) do
81 | require "rack"
82 | require "action_controller"
83 |
84 | # Make a special effort to use the host application's base controller
85 | # in case the CSRF protection has been customized, but don't couple to
86 | # a particular constant from the outer application.
87 | controller =
88 | if defined?(ApplicationController)
89 | ApplicationController
90 | else
91 | ActionController::Base
92 | end
93 |
94 | controller.renderer.new(
95 | websocket_connection.env.slice(
96 | Rack::HTTP_COOKIE,
97 | Rack::RACK_SESSION,
98 | WARDEN_ENV
99 | )
100 | )
101 | end
102 | end
103 |
104 | option(:error_notification_proc) { nil }
105 |
106 | option(:key_attribute) { "data-motion-key" }
107 | option(:state_attribute) { "data-motion-state" }
108 |
109 | # This is included for completeness. It is not currently used internally by
110 | # Motion, but it might be required for building view helpers in the future.
111 | option(:motion_attribute) { "data-motion" }
112 | end
113 | end
114 |
--------------------------------------------------------------------------------
/bin/bundle:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env ruby
2 | # frozen_string_literal: true
3 |
4 | #
5 | # This file was generated by Bundler.
6 | #
7 | # The application 'bundle' is installed as part of a gem, and
8 | # this file is here to facilitate running it.
9 | #
10 |
11 | require 'rubygems'
12 |
13 | m = Module.new do
14 | module_function
15 |
16 | def invoked_as_script?
17 | File.expand_path($PROGRAM_NAME) == File.expand_path(__FILE__)
18 | end
19 |
20 | def env_var_version
21 | ENV['BUNDLER_VERSION']
22 | end
23 |
24 | def cli_arg_version
25 | return unless invoked_as_script? # don't want to hijack other binstubs
26 | return unless 'update'.start_with?(ARGV.first || ' ') # must be running `bundle update`
27 |
28 | bundler_version = nil
29 | update_index = nil
30 | ARGV.each_with_index do |a, i|
31 | bundler_version = a if update_index && update_index.succ == i && a =~ Gem::Version::ANCHORED_VERSION_PATTERN
32 | next unless a =~ /\A--bundler(?:[= ](#{Gem::Version::VERSION_PATTERN}))?\z/
33 |
34 | bundler_version = Regexp.last_match(1)
35 | update_index = i
36 | end
37 | bundler_version
38 | end
39 |
40 | def gemfile
41 | gemfile = ENV['BUNDLE_GEMFILE']
42 | return gemfile if gemfile && !gemfile.empty?
43 |
44 | File.expand_path('../Gemfile', __dir__)
45 | end
46 |
47 | def lockfile
48 | lockfile =
49 | case File.basename(gemfile)
50 | when 'gems.rb' then gemfile.sub(/\.rb$/, gemfile)
51 | else "#{gemfile}.lock"
52 | end
53 | File.expand_path(lockfile)
54 | end
55 |
56 | def lockfile_version
57 | return unless File.file?(lockfile)
58 |
59 | lockfile_contents = File.read(lockfile)
60 | return unless lockfile_contents =~ /\n\nBUNDLED WITH\n\s{2,}(#{Gem::Version::VERSION_PATTERN})\n/
61 |
62 | Regexp.last_match(1)
63 | end
64 |
65 | def bundler_version
66 | @bundler_version ||=
67 | env_var_version || cli_arg_version ||
68 | lockfile_version
69 | end
70 |
71 | def bundler_requirement
72 | return "#{Gem::Requirement.default}.a" unless bundler_version
73 |
74 | bundler_gem_version = Gem::Version.new(bundler_version)
75 |
76 | requirement = bundler_gem_version.approximate_recommendation
77 |
78 | return requirement unless Gem::Version.new(Gem::VERSION) < Gem::Version.new('2.7.0')
79 |
80 | requirement += '.a' if bundler_gem_version.prerelease?
81 |
82 | requirement
83 | end
84 |
85 | def load_bundler!
86 | ENV['BUNDLE_GEMFILE'] ||= gemfile
87 |
88 | activate_bundler
89 | end
90 |
91 | def activate_bundler
92 | gem_error = activation_error_handling do
93 | gem 'bundler', bundler_requirement
94 | end
95 | return if gem_error.nil?
96 |
97 | require_error = activation_error_handling do
98 | require 'bundler/version'
99 | end
100 | if require_error.nil? && Gem::Requirement.new(bundler_requirement).satisfied_by?(Gem::Version.new(Bundler::VERSION))
101 | return
102 | end
103 |
104 | warn "Activating bundler (#{bundler_requirement}) failed:\n#{gem_error.message}\n\nTo install the version of bundler this project requires, run `gem install bundler -v '#{bundler_requirement}'`"
105 | exit 42
106 | end
107 |
108 | def activation_error_handling
109 | yield
110 | nil
111 | rescue StandardError, LoadError => e
112 | e
113 | end
114 | end
115 |
116 | m.load_bundler!
117 |
118 | load Gem.bin_path('bundler', 'bundle') if m.invoked_as_script?
119 |
--------------------------------------------------------------------------------
/CODE_OF_CONDUCT.md:
--------------------------------------------------------------------------------
1 | # Contributor Covenant Code of Conduct
2 |
3 | ## Our Pledge
4 |
5 | In the interest of fostering an open and welcoming environment, we as
6 | contributors and maintainers pledge to making participation in our project and
7 | our community a harassment-free experience for everyone, regardless of age, body
8 | size, disability, ethnicity, sex characteristics, gender identity and expression,
9 | level of experience, education, socio-economic status, nationality, personal
10 | appearance, race, religion, or sexual identity and orientation.
11 |
12 | ## Our Standards
13 |
14 | Examples of behavior that contributes to creating a positive environment
15 | include:
16 |
17 | * Using welcoming and inclusive language
18 | * Being respectful of differing viewpoints and experiences
19 | * Gracefully accepting constructive criticism
20 | * Focusing on what is best for the community
21 | * Showing empathy towards other community members
22 |
23 | Examples of unacceptable behavior by participants include:
24 |
25 | * The use of sexualized language or imagery and unwelcome sexual attention or
26 | advances
27 | * Trolling, insulting/derogatory comments, and personal or political attacks
28 | * Public or private harassment
29 | * Publishing others' private information, such as a physical or electronic
30 | address, without explicit permission
31 | * Other conduct which could reasonably be considered inappropriate in a
32 | professional setting
33 |
34 | ## Our Responsibilities
35 |
36 | Project maintainers are responsible for clarifying the standards of acceptable
37 | behavior and are expected to take appropriate and fair corrective action in
38 | response to any instances of unacceptable behavior.
39 |
40 | Project maintainers have the right and responsibility to remove, edit, or
41 | reject comments, commits, code, wiki edits, issues, and other contributions
42 | that are not aligned to this Code of Conduct, or to ban temporarily or
43 | permanently any contributor for other behaviors that they deem inappropriate,
44 | threatening, offensive, or harmful.
45 |
46 | ## Scope
47 |
48 | This Code of Conduct applies both within project spaces and in public spaces
49 | when an individual is representing the project or its community. Examples of
50 | representing a project or community include using an official project e-mail
51 | address, posting via an official social media account, or acting as an appointed
52 | representative at an online or offline event. Representation of a project may be
53 | further defined and clarified by project maintainers.
54 |
55 | ## Enforcement
56 |
57 | Instances of abusive, harassing, or otherwise unacceptable behavior may be
58 | reported by contacting the project team at contact@unabridgedsoftware.com. All
59 | complaints will be reviewed and investigated and will result in a response that
60 | is deemed necessary and appropriate to the circumstances. The project team is
61 | obligated to maintain confidentiality with regard to the reporter of an incident.
62 | Further details of specific enforcement policies may be posted separately.
63 |
64 | Project maintainers who do not follow or enforce the Code of Conduct in good
65 | faith may face temporary or permanent repercussions as determined by other
66 | members of the project's leadership.
67 |
68 | ## Attribution
69 |
70 | This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
71 | available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html
72 |
73 | [homepage]: https://www.contributor-covenant.org
74 |
75 | For answers to common questions about this code of conduct, see
76 | https://www.contributor-covenant.org/faq
77 |
--------------------------------------------------------------------------------
/spec/javascript/parseBindings.js:
--------------------------------------------------------------------------------
1 | import parseBindings from '../../javascript/parseBindings.js'
2 |
3 | describe('parseBindings', () => {
4 | context('with an empty string', () => {
5 | const input = ''
6 |
7 | it('gives an empty array', () => {
8 | expect(parseBindings(input)).to.eql([])
9 | })
10 | })
11 |
12 | context('with `null`', () => {
13 | const input = null
14 |
15 | it('gives an empty array', () => {
16 | expect(parseBindings(input)).to.eql([])
17 | })
18 | })
19 |
20 | context('with a valid binding string', () => {
21 | const input = 'sing song:finished->dance click(listen)->backflip'
22 |
23 | it('gives the correct parsing', () => {
24 | expect(parseBindings(input)).to.eql([
25 | {
26 | id: 'sing',
27 | event: 'click',
28 | mode: 'handle',
29 | motion: 'sing'
30 | },
31 | {
32 | id: 'song:finished->dance',
33 | event: 'song:finished',
34 | mode: 'handle',
35 | motion: 'dance'
36 | },
37 | {
38 | id: 'click(listen)->backflip',
39 | event: 'click',
40 | mode: 'listen',
41 | motion: 'backflip'
42 | }
43 | ])
44 | })
45 |
46 | context('on a form', () => {
47 | const element = document.createElement('FORM')
48 |
49 | it('gives the correct parsing', () => {
50 | expect(parseBindings(input, element)).to.eql([
51 | {
52 | id: 'sing',
53 | event: 'submit',
54 | mode: 'handle',
55 | motion: 'sing'
56 | },
57 | {
58 | id: 'song:finished->dance',
59 | event: 'song:finished',
60 | mode: 'handle',
61 | motion: 'dance'
62 | },
63 | {
64 | id: 'click(listen)->backflip',
65 | event: 'click',
66 | mode: 'listen',
67 | motion: 'backflip'
68 | }
69 | ])
70 | })
71 | })
72 |
73 | context('on an input', () => {
74 | const element = document.createElement('INPUT')
75 |
76 | it('gives the correct parsing', () => {
77 | expect(parseBindings(input, element)).to.eql([
78 | {
79 | id: 'sing',
80 | event: 'change',
81 | mode: 'listen',
82 | motion: 'sing'
83 | },
84 | {
85 | id: 'song:finished->dance',
86 | event: 'song:finished',
87 | mode: 'handle',
88 | motion: 'dance'
89 | },
90 | {
91 | id: 'click(listen)->backflip',
92 | event: 'click',
93 | mode: 'listen',
94 | motion: 'backflip'
95 | }
96 | ])
97 | })
98 | })
99 |
100 | context('on an input[type=submit]', () => {
101 | const element = document.createElement('INPUT')
102 | element.setAttribute('type', 'submit')
103 |
104 | it('gives the correct parsing', () => {
105 | expect(parseBindings(input, element)).to.eql([
106 | {
107 | id: 'sing',
108 | event: 'click',
109 | mode: 'handle',
110 | motion: 'sing'
111 | },
112 | {
113 | id: 'song:finished->dance',
114 | event: 'song:finished',
115 | mode: 'handle',
116 | motion: 'dance'
117 | },
118 | {
119 | id: 'click(listen)->backflip',
120 | event: 'click',
121 | mode: 'listen',
122 | motion: 'backflip'
123 | }
124 | ])
125 | })
126 | })
127 | })
128 | })
129 |
--------------------------------------------------------------------------------
/lib/motion/component/lifecycle.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "active_support/callbacks"
4 | require "active_support/concern"
5 | require "active_support/deprecation"
6 |
7 | require "motion"
8 |
9 | module Motion
10 | module Component
11 | module Lifecycle
12 | extend ActiveSupport::Concern
13 |
14 | include ActiveSupport::Callbacks
15 |
16 | included do
17 | define_callbacks :action, :connect, :disconnect
18 |
19 | # The built-in triggers defined on the target class will override ours.
20 | remove_method(:_run_action_callbacks)
21 | end
22 |
23 | class_methods do
24 | def upgrade_from(previous_revision, instance)
25 | raise UpgradeNotImplementedError.new(
26 | instance,
27 | previous_revision,
28 | Motion.config.revision
29 | )
30 | end
31 |
32 | def before_action(*methods, **options, &block)
33 | set_action_callback(:before, *methods, **options, &block)
34 | end
35 |
36 | def around_action(*methods, **options, &block)
37 | set_action_callback(:around, *methods, **options, &block)
38 | end
39 |
40 | def after_action(*methods, **options, &block)
41 | set_action_callback(:after, *methods, **options, &block)
42 | end
43 |
44 | def after_connect(*methods, **options, &block)
45 | set_callback(:connect, :after, *methods, **options, &block)
46 | end
47 |
48 | def after_disconnect(*methods, **options, &block)
49 | set_callback(:disconnect, :after, *methods, **options, &block)
50 | end
51 |
52 | private
53 |
54 | def set_action_callback(kind, *methods, **options, &block)
55 | filters = Array(options.delete(:if))
56 |
57 | if (only = Array(options.delete(:only))).any?
58 | filters << action_callback_context_filter(only)
59 | end
60 |
61 | if (except = Array(options.delete(:except))).any?
62 | filters << action_callback_context_filter(except, invert: true)
63 | end
64 |
65 | set_callback(:action, kind, *methods, if: filters, **options, &block)
66 | end
67 |
68 | def action_callback_context_filter(contexts, invert: false)
69 | proc { contexts.include?(@_action_callback_context) ^ invert }
70 | end
71 | end
72 |
73 | def process_connect
74 | _run_connect_callbacks
75 |
76 | # TODO: Remove at next minor release
77 | if respond_to?(:connected)
78 | ActiveSupport::Deprecation.warn(
79 | "The `connected` lifecycle method is being replaced by the " \
80 | "`after_connect` callback and will no longer be automatically " \
81 | "invoked in the next **minor release** of Motion."
82 | )
83 |
84 | send(:connected)
85 | end
86 | end
87 |
88 | def process_disconnect
89 | _run_disconnect_callbacks
90 |
91 | # TODO: Remove at next minor release
92 | if respond_to?(:disconnected)
93 | ActiveSupport::Deprecation.warn(
94 | "The `disconnected` lifecycle method is being replaced by the " \
95 | "`after_disconnect` callback and will no longer be automatically " \
96 | "invoked in the next **minor release** of Motion."
97 | )
98 |
99 | send(:disconnected)
100 | end
101 | end
102 |
103 | def _run_action_callbacks(context:, &block)
104 | @_action_callback_context = context
105 |
106 | run_callbacks(:action, &block)
107 | ensure
108 | @_action_callback_context = nil
109 | end
110 | end
111 | end
112 | end
113 |
--------------------------------------------------------------------------------
/lib/motion/action_cable_extentions/declarative_notifications.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | module ActionCableExtentions
7 | # Provides a `periodically_notify(broadcasts, to:)` API that can be used to
8 | # declaratively specify when a handler should be called.
9 | module DeclarativeNotifications
10 | include Synchronization
11 |
12 | def initialize(*)
13 | super
14 |
15 | # The current set of declarative notifications
16 | @_declarative_notifications = {}
17 |
18 | # The active timers for the declarative notifications
19 | @_declarative_notifications_timers = {}
20 |
21 | # The method we are routing declarative notifications to
22 | @_declarative_notifications_target = nil
23 | end
24 |
25 | def declarative_notifications
26 | @_declarative_notifications
27 | end
28 |
29 | def periodically_notify(notifications, via:)
30 | (@_declarative_notifications.to_a - notifications.to_a)
31 | .each do |notification, _interval|
32 | _shutdown_declarative_notifcation_timer(notification)
33 | end
34 |
35 | (notifications.to_a - @_declarative_notifications.to_a)
36 | .each do |notification, interval|
37 | _setup_declarative_notifcation_timer(notification, interval)
38 | end
39 |
40 | @_declarative_notifications = notifications
41 | @_declarative_notifications_target = via
42 | end
43 |
44 | private
45 |
46 | def stop_periodic_timers
47 | super
48 |
49 | @_declarative_notifications.clear
50 | @_declarative_notifications_timers.clear
51 | @_declarative_notifications_target = nil
52 | end
53 |
54 | # The only public interface in ActionCable for defining periodic timers is
55 | # exposed at the class level. Looking at the source though, it is easy to
56 | # see that new timers can be setup with `start_periodic_timer`. To ensure
57 | # that we do not leak any timers, it is important to store these instances
58 | # in `active_periodic_timers` so that ActionCable cleans them up for us
59 | # when the channel shuts down. Also, periodic timers are not supported by
60 | # the testing adapter, so we have to skip all of this in unit tests (it
61 | # _will_ be covered in systems tests though).
62 | #
63 | # See `ActionCable::Channel::PeriodicTimers` for details.
64 | def _setup_declarative_notifcation_timer(notification, interval)
65 | return if _stubbed_connection? ||
66 | @_declarative_notifications_timers.include?(notification)
67 |
68 | callback = proc do
69 | synchronize_entrypoint! do
70 | _handle_declarative_notifcation(notification)
71 | end
72 | end
73 |
74 | timer = start_periodic_timer(callback, every: interval)
75 |
76 | @_declarative_notifications_timers[notification] = timer
77 | active_periodic_timers << timer
78 | end
79 |
80 | def _stubbed_connection?
81 | defined?(ActionCable::Channel::ConnectionStub) &&
82 | connection.is_a?(ActionCable::Channel::ConnectionStub)
83 | end
84 |
85 | def _shutdown_declarative_notifcation_timer(notification, *)
86 | timer = @_declarative_notifications_timers.delete(notification)
87 | return unless timer
88 |
89 | timer.shutdown
90 | active_periodic_timers.delete(timer)
91 | end
92 |
93 | def _handle_declarative_notifcation(notification)
94 | return unless @_declarative_notifications_target &&
95 | @_declarative_notifications.include?(notification)
96 |
97 | send(@_declarative_notifications_target, notification)
98 | end
99 | end
100 | end
101 | end
102 |
--------------------------------------------------------------------------------
/spec/motion/configuration_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Configuration do
4 | describe "the default configuration" do
5 | subject(:default) { described_class.new }
6 |
7 | describe "#secret" do
8 | subject { default.secret }
9 |
10 | it "derives some entropy from the application secret" do
11 | expect(subject).to(
12 | eq(Rails.application.key_generator.generate_key("motion:secret"))
13 | )
14 | end
15 | end
16 |
17 | describe "#revision" do
18 | let(:revision_hash) { "revision-hash" }
19 |
20 | before(:each) do
21 | expect_any_instance_of(Motion::RevisionCalculator).to(
22 | receive(:perform).and_return(revision_hash)
23 | )
24 | end
25 |
26 | subject { default.revision }
27 |
28 | it { is_expected.to eq(revision_hash) }
29 | end
30 |
31 | describe "#revision_paths" do
32 | subject { default.revision_paths }
33 | let(:rails_path_keys) { Rails.application.config.paths.keys }
34 | let(:additional_paths) { %w[bin Gemfile.lock] }
35 | let(:revision_path_keys) { subject.keys }
36 |
37 | it { is_expected.to be_a_kind_of(Rails::Paths::Root) }
38 | it { expect(revision_path_keys).to include(*rails_path_keys) }
39 | it { expect(revision_path_keys).to include(*additional_paths) }
40 | end
41 |
42 | describe "#renderer_for_connection_proc" do
43 | subject { default.renderer_for_connection_proc.call(connection) }
44 |
45 | let(:connection) { double(ApplicationCable::Connection, env: env) }
46 |
47 | let(:env) do
48 | {
49 | Rack::RACK_SESSION => session,
50 | Rack::HTTP_COOKIE => cookie
51 | }
52 | end
53 |
54 | let(:cookie) { cookies.map { |key, value| "#{key}=#{value}" }.join("&") }
55 |
56 | let(:session) { {"foo" => "bar"} }
57 | let(:cookies) { {"bar" => "baz"} }
58 |
59 | it "builds a renderer from the ApplicationController" do
60 | expect(subject.controller).to eq(ApplicationController)
61 | end
62 |
63 | it "builds a render which has access to the session" do
64 | expect(subject.render(inline: "<%= session['foo'] %>")).to(
65 | eq(session["foo"])
66 | )
67 | end
68 |
69 | it "builds a render which has access to the cookies" do
70 | expect(subject.render(inline: "<%= cookies['bar'] %>")).to(
71 | eq(cookies["bar"])
72 | )
73 | end
74 |
75 | context "when no ApplicationController is defined" do
76 | before(:each) { hide_const("ApplicationController") }
77 |
78 | it "builds a renderer from ActionController::Base" do
79 | expect(subject.controller).to eq(ActionController::Base)
80 | end
81 | end
82 | end
83 |
84 | describe "#key_attribute" do
85 | subject { default.key_attribute }
86 |
87 | it { is_expected.to eq("data-motion-key") }
88 | end
89 |
90 | describe "#state_attribute" do
91 | subject { default.state_attribute }
92 |
93 | it { is_expected.to eq("data-motion-state") }
94 | end
95 |
96 | describe "#motion_attribute" do
97 | subject { default.motion_attribute }
98 |
99 | it { is_expected.to eq("data-motion") }
100 | end
101 | end
102 |
103 | it "allows options to be set within the initialization block" do
104 | config =
105 | described_class.new { |c|
106 | c.revision = "value"
107 | }
108 |
109 | expect(config.revision).to eq("value")
110 | end
111 |
112 | it "does not allow options to be set after initialization" do
113 | config =
114 | described_class.new { |c|
115 | c.revision = "value"
116 | }
117 |
118 | expect { config.revision = "new value" }.to(
119 | raise_error(Motion::AlreadyConfiguredError)
120 | )
121 | end
122 | end
123 |
--------------------------------------------------------------------------------
/javascript/AttributeTracker.js:
--------------------------------------------------------------------------------
1 | export default class AttributeTracker {
2 | constructor (attribute, createManager) {
3 | this.attribute = attribute
4 | this.createManager = createManager
5 |
6 | this._managers = new Map()
7 | this._attributeSelector = `[${attribute}]`
8 |
9 | this._mutationObserver = new MutationObserver(mutations => {
10 | for (const mutation of mutations) {
11 | this._processMutation(mutation)
12 | }
13 | })
14 | }
15 |
16 | attachRoot (element) {
17 | this._forEachMatchingUnder(element, (match) => this._detect(match))
18 |
19 | this._mutationObserver.observe(element, {
20 | attributes: true,
21 | attributeFilter: [
22 | this.attribute
23 | ],
24 | childList: true,
25 | subtree: true
26 | })
27 | }
28 |
29 | shutdown () {
30 | this._mutationObserver.disconnect()
31 |
32 | for (const manager of this._managers.values()) {
33 | if (manager) {
34 | this._errorBoundry(() => manager.shutdown())
35 | }
36 | }
37 |
38 | this._managers.clear()
39 | }
40 |
41 | getManager (element) {
42 | return this._managers.get(element)
43 | }
44 |
45 | _detect (element) {
46 | let manager = null
47 |
48 | this._errorBoundry(() => {
49 | if (this._managers.has(element)) {
50 | throw new Error('Double detect in AttributeTracker')
51 | }
52 |
53 | manager = this.createManager(element)
54 | })
55 |
56 | if (manager) {
57 | this._managers.set(element, manager)
58 | }
59 | }
60 |
61 | _update (element) {
62 | const manager = this._managers.get(element)
63 |
64 | if (manager && manager.update) {
65 | this._errorBoundry(() => manager.update())
66 | } else {
67 | this._remove(element)
68 | this._detect(element)
69 | }
70 | }
71 |
72 | _remove (element) {
73 | const manager = this._managers.get(element)
74 |
75 | if (manager && manager.shutdown) {
76 | this._errorBoundry(() => manager.shutdown())
77 | }
78 |
79 | this._managers.delete(element)
80 | }
81 |
82 | _processMutation (mutation) {
83 | if (mutation.type === 'childList') {
84 | this._processChildListMutation(mutation)
85 | } else if (mutation.type === 'attributes') {
86 | this._processAttributesMutation(mutation)
87 | }
88 | }
89 |
90 | _processChildListMutation ({ removedNodes, addedNodes }) {
91 | this._forEachMatchingIn(removedNodes, (match) => this._remove(match))
92 | this._forEachMatchingIn(addedNodes, (match) => this._detect(match))
93 | }
94 |
95 | _processAttributesMutation ({ target }) {
96 | if (this._managers.has(target)) {
97 | this._processAttributeUpdateToTracked(target)
98 | } else {
99 | this._processAttributeUpdateToUntracked(target)
100 | }
101 | }
102 |
103 | _processAttributeUpdateToTracked (element) {
104 | if (element.hasAttribute(this.attribute)) {
105 | this._update(element)
106 | } else {
107 | this._remove(element)
108 | }
109 | }
110 |
111 | _processAttributeUpdateToUntracked (element) {
112 | if (element.hasAttribute(this.attribute)) {
113 | this._detect(element)
114 | }
115 | }
116 |
117 | _forEachMatchingIn (nodes, callback) {
118 | for (const node of nodes) {
119 | this._forEachMatchingUnder(node, callback)
120 | }
121 | }
122 |
123 | _forEachMatchingUnder (node, callback) {
124 | if (node.hasAttribute && node.hasAttribute(this.attribute)) {
125 | callback(node)
126 | }
127 |
128 | if (node.querySelectorAll) {
129 | for (const match of node.querySelectorAll(this._attributeSelector)) {
130 | callback(match)
131 | }
132 | }
133 | }
134 |
135 | _errorBoundry (callback) {
136 | try {
137 | callback()
138 | } catch (error) {
139 | console.error('[Motion] An internal error occurred:', error)
140 | }
141 | }
142 | }
143 |
--------------------------------------------------------------------------------
/spec/support/test_component.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "view_component"
4 | require "motion"
5 |
6 | class TestComponent < ViewComponent::Base
7 | include Motion::Component
8 |
9 | # used by tests that want to know the initial motions
10 | STATIC_MOTIONS = %w[
11 | noop
12 | noop_with_arg
13 | noop_without_arg
14 | change_state
15 | force_rerender
16 | setup_dynamic_motion
17 | setup_dynamic_stream
18 | setup_dynamic_timer
19 | raise_error
20 | ].freeze
21 |
22 | # used by tests that want to know the initial broadcasts
23 | STATIC_BROADCASTS = %w[
24 | noop
25 | noop_with_arg
26 | noop_without_arg
27 | change_state
28 | force_rerender
29 | setup_dynamic_motion
30 | setup_dynamic_stream
31 | setup_dynamic_timer
32 | raise_error
33 | ].freeze
34 |
35 | # used by tests that want to know the initial timers
36 | STATIC_TIMERS = %w[
37 | noop
38 | change_state
39 | force_rerender
40 | setup_dynamic_motion
41 | setup_dynamic_stream
42 | setup_dynamic_timer
43 | raise_error
44 | ].freeze
45 |
46 | attr_reader :count
47 |
48 | def initialize(connected: :noop, disconnected: :noop, count: 0)
49 | @connected = connected
50 | @disconnected = disconnected
51 |
52 | @count = count
53 | end
54 |
55 | def call
56 | content_tag(:div) do
57 | safe_join(
58 | [
59 | content_tag(:div) { "The state has been changed #{@count} times." },
60 | *STATIC_MOTIONS.map do |motion|
61 | content_tag(:div) do
62 | content_tag(:button, motion, data: {motion: motion})
63 | end
64 | end
65 | ]
66 | )
67 | end
68 | end
69 |
70 | after_connect { public_send(@connected) }
71 | after_disconnect { public_send(@disconnected) }
72 |
73 | stream_from "noop", :noop
74 | map_motion :noop
75 | every 10_000.years, :noop
76 |
77 | def noop(*)
78 | end
79 |
80 | stream_from "noop_with_arg", :noop_with_arg
81 | map_motion :noop_with_arg
82 |
83 | def noop_with_arg(_event_or_message)
84 | end
85 |
86 | stream_from "noop_without_arg", :noop_without_arg
87 | map_motion :noop_without_arg
88 |
89 | def noop_without_arg
90 | end
91 |
92 | stream_from "change_state", :change_state
93 | map_motion :change_state
94 | every 10_000.years, :change_state
95 |
96 | def change_state(*)
97 | @count += 1
98 | end
99 |
100 | stream_from "force_rerender", :force_rerender
101 | map_motion :force_rerender
102 | every 10_000.years, :force_rerender
103 |
104 | def force_rerender(*)
105 | rerender!
106 | end
107 |
108 | stream_from "setup_dynamic_motion", :setup_dynamic_motion
109 | map_motion :setup_dynamic_motion
110 | every 10_000.years, :setup_dynamic_motion
111 |
112 | # used for tests that want to detect this dynamic motion being setup
113 | DYNAMIC_MOTION = "dynamic_motion"
114 |
115 | def setup_dynamic_motion(*)
116 | map_motion DYNAMIC_MOTION, :noop
117 | end
118 |
119 | stream_from "setup_dynamic_stream", :setup_dynamic_stream
120 | map_motion :setup_dynamic_stream
121 | every 10_000.years, :setup_dynamic_stream
122 |
123 | # used for tests that want to detect this dynamic broadcast being setup
124 | DYNAMIC_BROADCAST = "dynamic_broadcast"
125 |
126 | def setup_dynamic_stream(*)
127 | stream_from DYNAMIC_BROADCAST, :noop
128 | end
129 |
130 | stream_from "setup_dynamic_timer", :setup_dynamic_timer
131 | map_motion :setup_dynamic_timer
132 | every 10_000.years, :setup_dynamic_timer
133 |
134 | # used for tests that want to detect this dynamic timer being setup
135 | DYNAMIC_TIMER = "dynamic_timer"
136 |
137 | def setup_dynamic_timer(*)
138 | periodic_timer DYNAMIC_TIMER, :noop, every: 10_000.years
139 | end
140 |
141 | stream_from "raise_error", :raise_error
142 | map_motion :raise_error
143 | every 10_000.years, :raise_error
144 |
145 | def raise_error(*)
146 | raise "Error from TestComponent"
147 | end
148 | end
149 |
--------------------------------------------------------------------------------
/spec/motion/component/motions_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Component::Motions do
4 | describe described_class::ClassMethods do
5 | subject(:component_class) do # We need a fresh class for every spec
6 | stub_const("TemporaryComponent", Class.new(ViewComponent::Base) {
7 | include Motion::Component
8 |
9 | def noop
10 | end
11 | })
12 | end
13 |
14 | let(:component) { component_class.new }
15 |
16 | describe "#map_motion" do
17 | subject! { component_class.map_motion(motion, :noop) }
18 |
19 | let(:motion) { SecureRandom.hex }
20 |
21 | it "causes instances of the component to have that motion" do
22 | expect(component.motions).to include(motion)
23 | end
24 | end
25 |
26 | describe "#unmap_motion" do
27 | subject { component_class.unmap_motion(motion) }
28 |
29 | context "for a mapped motion" do
30 | before(:each) { component_class.map_motion(motion, :noop) }
31 |
32 | let(:motion) { SecureRandom.hex }
33 |
34 | it "causes instances of the component not to have that motion" do
35 | subject
36 | expect(component.motions).not_to include(motion)
37 | end
38 | end
39 | end
40 | end
41 |
42 | subject(:component) { TestComponent.new }
43 |
44 | describe "#motions" do
45 | subject { component.motions }
46 |
47 | it { is_expected.to contain_exactly(*TestComponent::STATIC_MOTIONS) }
48 |
49 | context "when a dynamic motion is added" do
50 | before(:each) { component.setup_dynamic_motion }
51 |
52 | it { is_expected.to include(TestComponent::DYNAMIC_MOTION) }
53 | end
54 | end
55 |
56 | describe "#process_motion" do
57 | subject { component.process_motion(motion, event) }
58 |
59 | let(:event) { Motion::Event.new({}) }
60 |
61 | context "for a motion that takes an event" do
62 | let(:motion) { "noop_with_arg" }
63 |
64 | it "calls the handler with the event" do
65 | expect(component).to receive(:noop_with_arg).with(event)
66 | subject
67 | end
68 |
69 | it "runs the action callbacks with the context of the handler" do
70 | expect(component).to(
71 | receive(:_run_action_callbacks).with(context: :noop_with_arg)
72 | )
73 |
74 | subject
75 | end
76 | end
77 |
78 | context "for a motion that does not take an event" do
79 | let(:motion) { "noop_without_arg" }
80 |
81 | it "calls the handler without the event" do
82 | # Sadly, the way rspec's mocking works, this will change the arity:
83 | # expect(component).to receive(:noop_without_event).with(no_args)
84 | #
85 | # Instead we roll out own watcher that we know will take 0 args:
86 | called = false
87 |
88 | component.define_singleton_method(:noop_without_arg) do
89 | called = true
90 | super()
91 | end
92 |
93 | subject
94 |
95 | expect(called).to be(true)
96 | end
97 |
98 | it "runs the action callbacks with the context of the handler" do
99 | expect(component).to(
100 | receive(:_run_action_callbacks).with(context: :noop_without_arg)
101 | )
102 |
103 | subject
104 | end
105 | end
106 |
107 | context "for a motion which is not mapped" do
108 | let(:motion) { "invalid_#{SecureRandom.hex}" }
109 |
110 | it "raises MotionNotMapped and does not run the action callbacks" do
111 | expect(component).not_to receive(:_run_action_callbacks)
112 |
113 | expect { subject }.to raise_error(Motion::MotionNotMapped)
114 | end
115 | end
116 | end
117 |
118 | describe "#map_motion" do
119 | subject! { component.map_motion(motion, :noop) }
120 |
121 | let(:motion) { SecureRandom.hex }
122 |
123 | it "sets up the motion" do
124 | expect(component.motions).to include(motion)
125 | end
126 | end
127 |
128 | describe "#unmap_motion" do
129 | subject! { component.unmap_motion(motion) }
130 |
131 | context "for a mapped motion" do
132 | let(:motion) { TestComponent::STATIC_MOTIONS.sample }
133 |
134 | it "removes the motion" do
135 | expect(component.motions).not_to include(motion)
136 | end
137 | end
138 | end
139 | end
140 |
--------------------------------------------------------------------------------
/spec/motion/serializer_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Serializer do
4 | subject(:serializer) do
5 | described_class.new(
6 | secret: secret,
7 | revision: revision
8 | )
9 | end
10 |
11 | let(:secret) { SecureRandom.random_bytes(64) }
12 | let(:revision) { "revision-string" }
13 |
14 | context "when the secret is too short" do
15 | let(:secret) { SecureRandom.random_bytes(1) }
16 |
17 | it "raises BadSecretError" do
18 | expect { subject }.to raise_error(Motion::BadSecretError)
19 | end
20 | end
21 |
22 | context "when the revision contains a NULL byte" do
23 | let(:revision) { "hello\0world" }
24 |
25 | it "raises BadRevisionError" do
26 | expect { subject }.to raise_error(Motion::BadRevisionError)
27 | end
28 | end
29 |
30 | describe "#weak_digest" do
31 | subject(:weak_digest) { serializer.weak_digest(object) }
32 |
33 | context "when the object can be serialized" do
34 | let(:object) { [data] }
35 | let(:data) { SecureRandom.hex }
36 |
37 | let(:other_object_with_same_state) { [data] }
38 |
39 | let(:other_object_with_different_state) { [other_data] }
40 | let(:other_data) { SecureRandom.hex }
41 |
42 | it "gives the same result for an object with the same state" do
43 | expect(subject).to(
44 | eq(serializer.weak_digest(other_object_with_same_state))
45 | )
46 | end
47 |
48 | it "gives a different result for an object with different state" do
49 | expect(subject).not_to(
50 | eq(serializer.weak_digest(other_object_with_different_state))
51 | )
52 | end
53 | end
54 |
55 | context "when the object cannot be serialized" do
56 | let(:object) { Class.new.new }
57 |
58 | it "raises Motion::UnrepresentableStateError" do
59 | expect { subject }.to raise_error(Motion::UnrepresentableStateError)
60 | end
61 | end
62 | end
63 |
64 | describe "#serialize" do
65 | subject(:output) { serializer.serialize(object) }
66 |
67 | let(:key) { output[0] }
68 | let(:state) { output[1] }
69 |
70 | context "when the object can be serialized" do
71 | let(:object) { [secret_data] }
72 | let(:secret_data) { SecureRandom.hex }
73 |
74 | it "does not give a key which reveals any internal information" do
75 | expect(key).not_to include(secret_data)
76 | end
77 |
78 | it "does not give state which reveals any internal information" do
79 | expect(state).not_to include(secret_data)
80 | end
81 | end
82 |
83 | context "when the object cannot be serialized" do
84 | let(:object) { Class.new.new }
85 |
86 | it "raises Motion::UnrepresentableStateError" do
87 | expect { subject }.to raise_error(Motion::UnrepresentableStateError)
88 | end
89 | end
90 | end
91 |
92 | describe "#deserialize" do
93 | subject { serializer.deserialize(state) }
94 |
95 | context "with invalid state" do
96 | let(:state) { SecureRandom.hex }
97 |
98 | it "raises InvalidSerializedStateError" do
99 | expect { subject }.to raise_error(Motion::InvalidSerializedStateError)
100 | end
101 | end
102 |
103 | context "with valid state" do
104 | let(:state) do
105 | _key, state = serializer.serialize(object)
106 | state
107 | end
108 |
109 | let(:object) { [SecureRandom.hex] }
110 |
111 | it "deserializes the object" do
112 | expect(subject).to eq(object)
113 | end
114 | end
115 |
116 | context "with state that needs to be upgraded" do
117 | let(:state) do
118 | _key, state = serializer_for_previous_revision.serialize(object)
119 | state
120 | end
121 |
122 | let(:object) { Object.new }
123 | let(:upgraded_object) { Object.new }
124 |
125 | let(:serializer_for_previous_revision) do
126 | described_class.new(secret: secret, revision: previous_revision)
127 | end
128 |
129 | let(:previous_revision) { "a-revision-before-#{revision}" }
130 |
131 | it "tries to upgrade the component" do
132 | expect(object.class).to(
133 | receive(:upgrade_from)
134 | .with(previous_revision, object.class)
135 | .and_return(upgraded_object)
136 | )
137 |
138 | expect(subject).to be(upgraded_object)
139 | end
140 | end
141 | end
142 | end
143 |
--------------------------------------------------------------------------------
/spec/motion/component/periodic_timers_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::Component::PeriodicTimers do
4 | describe described_class::ClassMethods do
5 | subject(:component_class) do # We need a fresh class for every spec
6 | stub_const("TemporaryComponent", Class.new(ViewComponent::Base) {
7 | include Motion::Component
8 |
9 | def noop
10 | end
11 | })
12 | end
13 |
14 | let(:component) { component_class.new }
15 |
16 | describe "#every" do
17 | subject! { component_class.every(interval, handler) }
18 |
19 | let(:interval) { rand(1..10) }
20 | let(:handler) { :noop }
21 |
22 | it "registers the handler to be invoked at the interval" do
23 | expect(component.periodic_timers[handler.to_s]).to eq(interval)
24 | end
25 | end
26 |
27 | describe "#periodic_timer" do
28 | subject! do
29 | component_class.periodic_timer(name, handler, every: interval)
30 | end
31 |
32 | let(:name) { SecureRandom.hex }
33 | let(:interval) { rand(1..10) }
34 | let(:handler) { :noop }
35 |
36 | it "sets up a new periodic timer" do
37 | expect(component.periodic_timers[name]).to eq(interval)
38 | end
39 | end
40 |
41 | describe "#stop_periodic_timer" do
42 | subject { component_class.stop_periodic_timer(name) }
43 |
44 | context "with a timer that has already been setup" do
45 | before(:each) { component_class.periodic_timer(name, :noop, every: 1) }
46 |
47 | let(:name) { SecureRandom.hex }
48 |
49 | it "removes the periodic timer" do
50 | subject
51 | expect(component.periodic_timers).not_to include(name)
52 | end
53 | end
54 | end
55 |
56 | describe "#periodic_timers" do
57 | subject { component_class.periodic_timers }
58 |
59 | it "gives the default periodic timers for the instance" do
60 | expect(subject).to eq(component.periodic_timers)
61 | end
62 | end
63 | end
64 |
65 | subject(:component) { TestComponent.new }
66 |
67 | describe "#process_periodic_timer" do
68 | subject { component.process_periodic_timer(name) }
69 |
70 | context "with a timer that is registered" do
71 | let(:name) { "noop" }
72 |
73 | it "invokes the corresponding handler" do
74 | expect(component).to receive(:noop)
75 | subject
76 | end
77 |
78 | it "runs the action callbacks with the context of the handler" do
79 | expect(component).to(
80 | receive(:_run_action_callbacks).with(context: :noop)
81 | )
82 |
83 | subject
84 | end
85 | end
86 |
87 | context "with a timer that is not registered" do
88 | let(:name) { SecureRandom.hex }
89 |
90 | it "does not invoke the corresponding handler" do
91 | expect(component).not_to receive(name)
92 | subject
93 | end
94 |
95 | it "does not run the action callbacks" do
96 | expect(component).not_to receive(:_run_action_callbacks)
97 | subject
98 | end
99 | end
100 | end
101 |
102 | describe "#every" do
103 | subject! { component.every(interval, handler) }
104 |
105 | let(:interval) { rand(1..10) }
106 | let(:handler) { :noop }
107 |
108 | it "registers the handler to be invoked at the interval" do
109 | expect(component.periodic_timers[handler.to_s]).to eq(interval)
110 | end
111 | end
112 |
113 | describe "#periodic_timer" do
114 | subject! { component.periodic_timer(name, handler, every: interval) }
115 |
116 | let(:name) { SecureRandom.hex }
117 | let(:interval) { rand(1..10) }
118 | let(:handler) { :noop }
119 |
120 | it "sets up a new periodic timer" do
121 | expect(component.periodic_timers[name]).to eq(interval)
122 | end
123 | end
124 |
125 | describe "#stop_periodic_timer" do
126 | subject! { component.stop_periodic_timer(name) }
127 |
128 | context "with a timer that is registered" do
129 | let(:name) { "noop" }
130 |
131 | it "removes the periodic timer" do
132 | expect(component.periodic_timers).not_to include(name)
133 | end
134 | end
135 | end
136 |
137 | describe "#periodic_timers" do
138 | subject { component.periodic_timers }
139 |
140 | it "gives the periodic timers for the component" do
141 | expect(subject.keys).to contain_exactly(*TestComponent::STATIC_TIMERS)
142 | end
143 | end
144 | end
145 |
--------------------------------------------------------------------------------
/spec/motion/log_helper_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::LogHelper do
4 | subject(:log_helper) { described_class.new(logger: logger, tag: tag) }
5 |
6 | let(:logger) { Logger.new(File::NULL) }
7 | let(:tag) { SecureRandom.hex }
8 |
9 | describe ".for_channel" do
10 | subject { described_class.for_channel(channel) }
11 |
12 | let(:channel) do
13 | double(
14 | Motion::Channel,
15 | connection: double(
16 | ApplicationCable::Connection,
17 | logger: logger
18 | )
19 | )
20 | end
21 |
22 | it "creates a log helper for the connection's logger" do
23 | expect(subject.logger).to be(logger)
24 | end
25 | end
26 |
27 | describe ".for_component" do
28 | subject { described_class.for_component(component, logger: logger) }
29 |
30 | let(:component) { TestComponent.new }
31 |
32 | it "creates a log helper tagged for the component" do
33 | expect(subject.tag).to include("TestComponent")
34 | end
35 | end
36 |
37 | describe "#error" do
38 | subject { log_helper.error(message, error: error) }
39 |
40 | let(:message) { SecureRandom.hex }
41 |
42 | context "without an error" do
43 | let(:error) { nil }
44 |
45 | it "logs the message" do
46 | expect(logger).to receive(:error).with(/#{Regexp.quote(message)}/)
47 | subject
48 | end
49 |
50 | it "includes the log tag" do
51 | expect(logger).to receive(:error).with(/#{Regexp.quote(tag)}/)
52 | subject
53 | end
54 |
55 | it "notifies about the error" do
56 | expect(Motion).to receive(:notify_error).with(error, message)
57 | subject
58 | end
59 | end
60 |
61 | context "with an error" do
62 | let(:error) do
63 | # raise the error so that it has a backtrace
64 |
65 | raise
66 | rescue => error
67 | error
68 | end
69 |
70 | it "logs the message" do
71 | expect(logger).to receive(:error).with(/#{Regexp.quote(message)}/)
72 | subject
73 | end
74 |
75 | it "includes the log tag" do
76 | expect(logger).to receive(:error).with(/#{Regexp.quote(tag)}/)
77 | subject
78 | end
79 |
80 | it "includes the error message" do
81 | expect(logger).to receive(:error).with(/#{Regexp.quote(error.message)}/)
82 | subject
83 | end
84 |
85 | it "inclues the backtrace" do
86 | expect(logger).to(
87 | receive(:error).with(/#{Regexp.quote(error.backtrace.first)}/)
88 | )
89 |
90 | subject
91 | end
92 |
93 | it "notifies about the error" do
94 | expect(Motion).to receive(:notify_error).with(error, message)
95 | subject
96 | end
97 | end
98 | end
99 |
100 | describe "#info" do
101 | subject { log_helper.info(message) }
102 |
103 | let(:message) { SecureRandom.hex }
104 |
105 | it "logs the message" do
106 | expect(logger).to receive(:info).with(/#{Regexp.quote(message)}/)
107 | subject
108 | end
109 |
110 | it "includes the log tag" do
111 | expect(logger).to receive(:info).with(/#{Regexp.quote(tag)}/)
112 | subject
113 | end
114 | end
115 |
116 | describe "#timing" do
117 | subject { log_helper.timing(message, &block) }
118 |
119 | let(:message) { SecureRandom.hex }
120 |
121 | context "with a very fast block action" do
122 | let(:block) { proc {} }
123 |
124 | it "logs the message" do
125 | expect(logger).to receive(:info).with(/#{Regexp.quote(message)}/)
126 | subject
127 | end
128 |
129 | it "logs the timing" do
130 | expect(logger).to receive(:info).with(/in less than 0.1ms/)
131 | subject
132 | end
133 | end
134 |
135 | context "with a block that takes some time" do
136 | let(:block) { proc { sleep(duration_ms / 1000.0) } }
137 | let(:duration_ms) { rand(10..50) }
138 |
139 | it "logs the message" do
140 | expect(logger).to receive(:info).with(/#{Regexp.quote(message)}/)
141 | subject
142 | end
143 |
144 | it "logs the timing" do
145 | expect(logger).to(
146 | receive(:info).with(/in (\d\d\.\d)?ms/)
147 | )
148 |
149 | subject
150 | end
151 | end
152 | end
153 |
154 | describe "#for_component" do
155 | subject { log_helper.for_component(component) }
156 |
157 | let(:component) { TestComponent.new }
158 |
159 | it "gives a new instance tagged for the component with the same logger" do
160 | expect(subject.logger).to eq(logger)
161 | expect(subject.tag).not_to eq(tag)
162 | end
163 | end
164 | end
165 |
--------------------------------------------------------------------------------
/spec/javascript/AttributeTracker.js:
--------------------------------------------------------------------------------
1 | import AttributeTracker from '../../javascript/AttributeTracker.js'
2 |
3 | describe('AttributeTracker', () => {
4 | let originalBody
5 |
6 | beforeEach(() => {
7 | originalBody = document.body
8 | document.body = document.createElement('body')
9 | })
10 |
11 | afterEach(() => {
12 | document.body = originalBody
13 | })
14 |
15 | const attribute = 'data-test-attribute'
16 |
17 | it('creates a manager for elements with the attribute', () => {
18 | let testManager = null
19 |
20 | const tracker = new AttributeTracker(attribute, (element) => {
21 | return (testManager = new TestManager(element))
22 | })
23 |
24 | const element = document.createElement('div')
25 | element.setAttribute(attribute, 'value')
26 | document.body.appendChild(element)
27 |
28 | tracker.attachRoot(document)
29 |
30 | expect(testManager.element).to.eq(element)
31 |
32 | tracker.shutdown()
33 | })
34 |
35 | it('creates a manager for elements that are added with the attr', (done) => {
36 | let testManager = null
37 |
38 | const tracker = new AttributeTracker(attribute, (element) => {
39 | return (testManager = new TestManager(element))
40 | })
41 |
42 | tracker.attachRoot(document)
43 |
44 | const element = document.createElement('div')
45 | element.setAttribute(attribute, 'value')
46 | document.body.appendChild(element)
47 |
48 | setTimeout(() => {
49 | expect(testManager.element).to.eq(element)
50 | tracker.shutdown()
51 |
52 | done()
53 | }, 0)
54 | })
55 |
56 | it('creates a manager for elements that have the attribute added', (done) => {
57 | let testManager = null
58 |
59 | const tracker = new AttributeTracker(attribute, (element) => {
60 | return (testManager = new TestManager(element))
61 | })
62 |
63 | const element = document.createElement('div')
64 | document.body.appendChild(element)
65 |
66 | tracker.attachRoot(document)
67 | element.setAttribute(attribute, 'value')
68 |
69 | setTimeout(() => {
70 | expect(testManager.element).to.eq(element)
71 | tracker.shutdown()
72 |
73 | done()
74 | }, 0)
75 | })
76 |
77 | it('calls `update` on the manager when the attribute is changed', (done) => {
78 | let testManager = null
79 |
80 | const tracker = new AttributeTracker(attribute, (element) => {
81 | return (testManager = new TestManager(element))
82 | })
83 |
84 | const element = document.createElement('div')
85 | element.setAttribute(attribute, 'value')
86 | document.body.appendChild(element)
87 |
88 | tracker.attachRoot(document)
89 |
90 | element.setAttribute(attribute, 'changed')
91 |
92 | setTimeout(() => {
93 | expect(testManager.updateCalled).to.eq(true)
94 | tracker.shutdown()
95 |
96 | done()
97 | }, 0)
98 | })
99 |
100 | it('calls `shutdown` on the manager when the attr is removed', (done) => {
101 | let testManager = null
102 |
103 | const tracker = new AttributeTracker(attribute, (element) => {
104 | return (testManager = new TestManager(element))
105 | })
106 |
107 | const element = document.createElement('div')
108 | element.setAttribute(attribute, 'value')
109 | document.body.appendChild(element)
110 |
111 | tracker.attachRoot(document)
112 | element.removeAttribute(attribute)
113 |
114 | setTimeout(() => {
115 | expect(testManager.shutdownCalled).to.eq(true)
116 | tracker.shutdown()
117 |
118 | done()
119 | }, 0)
120 | })
121 |
122 | it('calls `shutdown` on managers when the tracker is shutdown', () => {
123 | let testManager = null
124 |
125 | const tracker = new AttributeTracker(attribute, (element) => {
126 | return (testManager = new TestManager(element))
127 | })
128 |
129 | const element = document.createElement('div')
130 | element.setAttribute(attribute, 'value')
131 | document.body.appendChild(element)
132 |
133 | tracker.attachRoot(document)
134 | tracker.shutdown()
135 |
136 | expect(testManager.shutdownCalled).to.eq(true)
137 | })
138 |
139 | describe('#getManager', () => {
140 | it('gives the manager for an element', () => {
141 | let testManager = null
142 |
143 | const tracker = new AttributeTracker(attribute, (element) => {
144 | return (testManager = new TestManager(element))
145 | })
146 |
147 | const element = document.createElement('div')
148 | element.setAttribute(attribute, 'value')
149 | document.body.appendChild(element)
150 |
151 | tracker.attachRoot(document)
152 |
153 | expect(tracker.getManager(element)).to.eq(testManager)
154 |
155 | tracker.shutdown()
156 | })
157 | })
158 | })
159 |
160 | class TestManager {
161 | constructor (element) {
162 | this.element = element
163 | this.updateCalled = false
164 | this.shutdownCalled = false
165 | }
166 |
167 | update () {
168 | this.updateCalled = true
169 | }
170 |
171 | shutdown () {
172 | this.shutdownCalled = true
173 | }
174 | }
175 |
--------------------------------------------------------------------------------
/spec/motion/component_connection_spec.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | RSpec.describe Motion::ComponentConnection do
4 | subject(:component_connection) { described_class.new(component) }
5 |
6 | let(:component) { TestComponent.new }
7 |
8 | describe ".from_state" do
9 | subject { described_class.from_state(state) }
10 |
11 | let(:state) do
12 | _key, state = Motion.serializer.serialize(component)
13 |
14 | state
15 | end
16 |
17 | it "wraps the component for the state" do
18 | key_a, _state = Motion.serializer.serialize(component)
19 | key_b, _state = Motion.serializer.serialize(subject.component)
20 |
21 | expect(key_a).to eq(key_b)
22 | end
23 | end
24 |
25 | it "processes the connection on the underlying component" do
26 | expect_any_instance_of(TestComponent).to receive(:process_connect)
27 |
28 | subject
29 | end
30 |
31 | it "logs the timing for connecting the component" do
32 | expect(Rails.logger).to receive(:info).with(/Connected/)
33 |
34 | subject
35 | end
36 |
37 | context "when the component's #connected callback raises" do
38 | let(:component) { TestComponent.new(connected: :raise_error) }
39 |
40 | it "cannot be constructed" do
41 | expect { subject }.to raise_error(/Error from TestComponent/)
42 | end
43 | end
44 |
45 | describe "#close" do
46 | subject { component_connection.close }
47 |
48 | before(:each) { component_connection }
49 |
50 | it "processes the disconnection on the underlying component" do
51 | expect_any_instance_of(TestComponent).to receive(:process_disconnect)
52 |
53 | subject
54 | end
55 |
56 | it "logs the timing for disconnecting the component" do
57 | expect(Rails.logger).to receive(:info).with(/Disconnected/)
58 |
59 | subject
60 | end
61 |
62 | context "when an error occurs in the #disconnected callback" do
63 | let(:component) { TestComponent.new(disconnected: :raise_error) }
64 |
65 | it "logs the error and returns false" do
66 | expect(Rails.logger).to receive(:error).with(/Error from TestComponent/)
67 |
68 | expect(subject).to be(false)
69 | end
70 | end
71 | end
72 |
73 | describe "#process_periodic_timer" do
74 | subject { component_connection.process_periodic_timer(timer) }
75 |
76 | before(:each) { component_connection }
77 |
78 | let(:timer) { SecureRandom.hex }
79 |
80 | it "processes the timer callback on the underlying component" do
81 | expect_any_instance_of(TestComponent).to(
82 | receive(:process_periodic_timer).with(timer)
83 | )
84 |
85 | subject
86 | end
87 |
88 | it "logs the timing for processing the timer" do
89 | expect(Rails.logger).to receive(:info).with(/timer/)
90 |
91 | subject
92 | end
93 |
94 | context "when an error occurs while processing the timer" do
95 | let(:timer) { "raise_error" }
96 |
97 | it "logs the error and returns false" do
98 | expect(Rails.logger).to receive(:error).with(/Error from TestComponent/)
99 |
100 | expect(subject).to be(false)
101 | end
102 | end
103 | end
104 |
105 | describe "#if_render_required" do
106 | subject(:yielded?) do
107 | yielded = false
108 |
109 | component_connection.if_render_required do
110 | yielded = true
111 | end
112 |
113 | yielded
114 | end
115 |
116 | before(:each) { component_connection }
117 |
118 | context "initially" do
119 | it "does not yield and logs no timing information" do
120 | expect(Rails.logger).not_to receive(:info)
121 | expect(yielded?).to be(false)
122 | end
123 | end
124 |
125 | context "whan a component does not need to re-render" do
126 | before(:each) { component_connection.process_motion("noop") }
127 |
128 | it "does not yield and logs no timing information" do
129 | expect(Rails.logger).not_to receive(:info)
130 | expect(yielded?).to be(false)
131 | end
132 | end
133 |
134 | context "when component undergoes a state change" do
135 | before(:each) { component_connection.process_motion("change_state") }
136 |
137 | it "yields and logs the timing information" do
138 | expect(Rails.logger).to receive(:info).with(/Rendered/)
139 |
140 | expect(yielded?).to be(true)
141 | end
142 | end
143 |
144 | context "when a component forces a rerender" do
145 | before(:each) { component_connection.process_motion("force_rerender") }
146 |
147 | it "yields and logs the timing information" do
148 | expect(Rails.logger).to receive(:info).with(/Rendered/)
149 | expect(yielded?).to be(true)
150 | end
151 | end
152 |
153 | context "with a component that errors while rendering" do
154 | before(:each) { component.rerender! }
155 |
156 | subject do
157 | component_connection.if_render_required do
158 | raise "error from rendering"
159 | end
160 | end
161 |
162 | it "logs the error" do
163 | expect(Rails.logger).to receive(:error).with(/error from rendering/)
164 | subject
165 | end
166 | end
167 | end
168 |
169 | describe "#broadcasts" do
170 | subject { component_connection.broadcasts }
171 |
172 | it "gives the broadcasts of the underlying component" do
173 | expect(subject).to eq(component.broadcasts)
174 | end
175 | end
176 |
177 | describe "#periodic_timers" do
178 | subject { component_connection.periodic_timers }
179 |
180 | it "gives the periodic timers of the underlying component" do
181 | expect(subject).to eq(component.periodic_timers)
182 | end
183 | end
184 | end
185 |
--------------------------------------------------------------------------------
/lib/motion/errors.rb:
--------------------------------------------------------------------------------
1 | # frozen_string_literal: true
2 |
3 | require "motion"
4 |
5 | module Motion
6 | class Error < StandardError; end
7 |
8 | class ComponentError < Error
9 | attr_reader :component
10 |
11 | def initialize(component, message = nil)
12 | super(message)
13 |
14 | @component = component
15 | end
16 | end
17 |
18 | class ComponentRenderingError < ComponentError; end
19 |
20 | class MotionNotMapped < ComponentError
21 | attr_reader :motion
22 |
23 | def initialize(component, motion)
24 | super(
25 | component,
26 | "No component motion handler mapped for motion `#{motion}` in " \
27 | "component `#{component.class}`.\n" \
28 | "\n" \
29 | "Hint: Consider adding `map_motion :#{motion}` to `#{component.class}`."
30 | )
31 |
32 | @motion = motion
33 | end
34 | end
35 |
36 | class BlockNotAllowedError < ComponentRenderingError
37 | def initialize(component)
38 | super(
39 | component,
40 | "Motion does not support rendering with a block.\n" \
41 | "\n" \
42 | "Hint: Try wrapping a plain component with a motion component."
43 | )
44 | end
45 | end
46 |
47 | class MultipleRootsError < ComponentRenderingError
48 | def initialize(component)
49 | super(
50 | component,
51 | "The template for #{component.class} can only have one root " \
52 | "element.\n" \
53 | "\n" \
54 | "Hint: Wrap all elements in a single element, such as `