├── .rspec ├── gemfiles ├── .bundle │ └── config ├── rails_6_0.gemfile ├── rails_6_1.gemfile ├── rails_5_1.gemfile ├── rails_5_2.gemfile ├── rails_master.gemfile └── rails_5_1.gemfile.lock ├── spec ├── support │ ├── test_application │ │ ├── app │ │ │ ├── assets │ │ │ │ ├── images │ │ │ │ │ └── .keep │ │ │ │ ├── stylesheets │ │ │ │ │ └── application.css │ │ │ │ └── config │ │ │ │ │ └── manifest.js │ │ │ ├── components │ │ │ │ ├── button_component.html.erb │ │ │ │ ├── callback_component.html.erb │ │ │ │ ├── test_component.rb │ │ │ │ ├── toy_fields_component.html.erb │ │ │ │ ├── toy_fields_component.rb │ │ │ │ ├── callback_component.rb │ │ │ │ ├── button_component.rb │ │ │ │ ├── timer_component.rb │ │ │ │ ├── dog_form_component.html.erb │ │ │ │ ├── counter_component.html.erb │ │ │ │ ├── counter_component.rb │ │ │ │ └── dog_form_component.rb │ │ │ ├── models │ │ │ │ ├── application_record.rb │ │ │ │ ├── toy.rb │ │ │ │ └── dog.rb │ │ │ ├── cables │ │ │ │ └── application_cable │ │ │ │ │ ├── channel.rb │ │ │ │ │ └── connection.rb │ │ │ ├── controllers │ │ │ │ ├── test_components_controller.rb │ │ │ │ ├── timer_components_controller.rb │ │ │ │ ├── callback_components_controller.rb │ │ │ │ ├── counter_components_controller.rb │ │ │ │ ├── dogs_controller.rb │ │ │ │ └── application_controller.rb │ │ │ ├── views │ │ │ │ └── layouts │ │ │ │ │ └── application.html.erb │ │ │ └── javascript │ │ │ │ └── packs │ │ │ │ └── application.js │ │ ├── config │ │ │ ├── cable.yml │ │ │ ├── webpack │ │ │ │ ├── environment.js │ │ │ │ └── test.js │ │ │ ├── database.yml │ │ │ ├── boot.rb │ │ │ ├── initializers │ │ │ │ ├── initialize_schema.rb │ │ │ │ └── motion.rb │ │ │ ├── environment.rb │ │ │ ├── routes.rb │ │ │ ├── application.rb │ │ │ └── webpacker.yml │ │ ├── bin │ │ │ ├── rails │ │ │ ├── webpack │ │ │ └── yarn │ │ ├── config.ru │ │ ├── package.json │ │ ├── db │ │ │ └── schema.rb │ │ └── babel.config.js │ ├── coverage_report.rb │ ├── action_cable_testing_workaround.rb │ ├── webdriver.rb │ ├── test_application.rb │ ├── system_test_helpers.rb │ └── test_component.rb ├── motion │ ├── component_spec.rb │ ├── action_cable_extentions │ │ ├── log_suppression_spec.rb │ │ └── declarative_streams_spec.rb │ ├── component │ │ ├── callbacks_spec.rb │ │ ├── rendering_spec.rb │ │ ├── motions_spec.rb │ │ └── periodic_timers_spec.rb │ ├── callback_spec.rb │ ├── markup_transformer_spec.rb │ ├── revision_calculator_spec.rb │ ├── event_spec.rb │ ├── element_spec.rb │ ├── configuration_spec.rb │ ├── serializer_spec.rb │ ├── log_helper_spec.rb │ └── component_connection_spec.rb ├── javascript │ ├── serializeEvent.js │ ├── parseBindings.js │ └── AttributeTracker.js ├── generators │ └── motion │ │ ├── install_generator_spec.rb │ │ └── component_generator_spec.rb ├── system │ ├── live_validating_form_demo_spec.rb │ └── core_functionality_spec.rb ├── spec_helper.rb └── motion_spec.rb ├── lib ├── motion │ ├── version.rb │ ├── railtie.rb │ ├── component │ │ ├── callbacks.rb │ │ ├── motions.rb │ │ ├── periodic_timers.rb │ │ ├── rendering.rb │ │ ├── broadcasts.rb │ │ └── lifecycle.rb │ ├── action_cable_extentions.rb │ ├── component.rb │ ├── action_cable_extentions │ │ ├── log_suppression.rb │ │ ├── synchronization.rb │ │ ├── declarative_streams.rb │ │ └── declarative_notifications.rb │ ├── callback.rb │ ├── event.rb │ ├── test_helpers.rb │ ├── revision_calculator.rb │ ├── markup_transformer.rb │ ├── element.rb │ ├── log_helper.rb │ ├── channel.rb │ ├── component_connection.rb │ ├── serializer.rb │ ├── configuration.rb │ └── errors.rb ├── generators │ └── motion │ │ ├── install_generator.rb │ │ ├── component_generator.rb │ │ └── templates │ │ ├── motion.js │ │ └── motion.rb └── motion.rb ├── .standard.yml ├── bin ├── console ├── setup ├── yarn ├── appraisal ├── standardrb ├── rake ├── rspec └── bundle ├── javascript ├── index.js ├── getFallbackConsumer.js ├── dispatchEvent.js ├── documentLifecycle.js ├── parseBindings.js ├── serializeEvent.js ├── reconcile.js ├── Client.js ├── BindingManager.js ├── Component.js └── AttributeTracker.js ├── Gemfile ├── .gitignore ├── Appraisals ├── LICENSE.txt ├── karma.conf.js ├── Rakefile ├── motion.gemspec ├── CHANGELOG.md ├── package.json ├── .travis.yml └── CODE_OF_CONDUCT.md /.rspec: -------------------------------------------------------------------------------- 1 | --color 2 | --require spec_helper 3 | -------------------------------------------------------------------------------- /gemfiles/.bundle/config: -------------------------------------------------------------------------------- 1 | --- 2 | BUNDLE_RETRY: "1" 3 | -------------------------------------------------------------------------------- /spec/support/test_application/app/assets/images/.keep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/test_application/app/assets/stylesheets/application.css: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /spec/support/test_application/config/cable.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: test 3 | -------------------------------------------------------------------------------- /lib/motion/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Motion 4 | VERSION = "0.4.4" 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/button_component.html.erb: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.standard.yml: -------------------------------------------------------------------------------- 1 | parallel: true 2 | format: progress 3 | 4 | ignore: 5 | - '**/node_modules/**/*' 6 | - 'gemfiles/vendor/**/*' -------------------------------------------------------------------------------- /spec/support/test_application/app/assets/config/manifest.js: -------------------------------------------------------------------------------- 1 | //= link_tree ../images 2 | //= link_directory ../stylesheets .css 3 | -------------------------------------------------------------------------------- /spec/support/test_application/config/webpack/environment.js: -------------------------------------------------------------------------------- 1 | const { environment } = require('@rails/webpacker') 2 | 3 | module.exports = environment 4 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require 'bundler/setup' 5 | require 'motion' 6 | 7 | require 'pry' 8 | Pry.start 9 | -------------------------------------------------------------------------------- /javascript/index.js: -------------------------------------------------------------------------------- 1 | import Client from './Client' 2 | 3 | export function createClient (options) { 4 | return new Client(options) 5 | } 6 | 7 | export default createClient 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/models/application_record.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationRecord < ActiveRecord::Base 4 | self.abstract_class = true 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/test_application/app/models/toy.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Toy < ApplicationRecord 4 | belongs_to :dog 5 | 6 | validates :name, presence: true 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_application/config/database.yml: -------------------------------------------------------------------------------- 1 | test: 2 | adapter: sqlite3 3 | pool: <%= ENV.fetch("RAILS_MAX_THREADS") { 5 } %> 4 | timeout: 5000 5 | database: db/test.sqlite3 6 | -------------------------------------------------------------------------------- /spec/support/test_application/app/cables/application_cable/channel.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Channel < ActionCable::Channel::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/test_application/bin/rails: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | APP_PATH = File.expand_path("../config/application", __dir__) 4 | require_relative "../config/boot" 5 | require "rails/commands" 6 | -------------------------------------------------------------------------------- /spec/support/test_application/app/cables/application_cable/connection.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module ApplicationCable 4 | class Connection < ActionCable::Connection::Base 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /spec/support/test_application/config/boot.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ENV["RAILS_ENV"] ||= "test" 4 | ENV["RACK_ENV"] ||= "test" 5 | ENV["NODE_ENV"] ||= "test" 6 | 7 | require "bundler/setup" 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/callback_component.html.erb: -------------------------------------------------------------------------------- 1 |
2 |

The count is <%= count %>!

3 | 4 | <%= render ButtonComponent.new(text: "+", on_click: bind(:increment)) %> 5 |
-------------------------------------------------------------------------------- /spec/support/test_application/config.ru: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This file is used by Rack-based servers to start the application. 4 | 5 | require_relative "config/environment" 6 | 7 | run Rails.application 8 | -------------------------------------------------------------------------------- /spec/support/test_application/config/initializers/initialize_schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Migration.suppress_messages do 4 | ActiveRecord::Tasks::DatabaseTasks.load_schema_current 5 | end 6 | -------------------------------------------------------------------------------- /spec/support/test_application/config/webpack/test.js: -------------------------------------------------------------------------------- 1 | process.env.NODE_ENV = process.env.NODE_ENV || 'development' 2 | 3 | const environment = require('./environment') 4 | 5 | module.exports = environment.toWebpackConfig() 6 | -------------------------------------------------------------------------------- /spec/support/test_application/config/environment.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # Load the Rails application. 4 | require_relative "application" 5 | 6 | # Initialize the Rails application. 7 | Rails.application.initialize! 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/test_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # The TestComponent is used by specs _outside_ of the TestApplication, so it is 4 | # kept in support. 5 | require_relative "../../../test_component" 6 | -------------------------------------------------------------------------------- /javascript/getFallbackConsumer.js: -------------------------------------------------------------------------------- 1 | import { createConsumer } from '@rails/actioncable' 2 | 3 | let fallbackConsumer = null 4 | 5 | export default function getFallbackConsumer () { 6 | return fallbackConsumer || (fallbackConsumer = createConsumer()) 7 | } 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/test_components_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TestComponentsController < ApplicationController 4 | def show 5 | render_component_in_layout(TestComponent.new) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/timer_components_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TimerComponentsController < ApplicationController 4 | def show 5 | render_component_in_layout(TimerComponent.new) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/callback_components_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CallbackComponentsController < ApplicationController 4 | def show 5 | render_component_in_layout(CallbackComponent.new) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/counter_components_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CounterComponentsController < ApplicationController 4 | def show 5 | render_component_in_layout(CounterComponent.new) 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/toy_fields_component.html.erb: -------------------------------------------------------------------------------- 1 |
2 | <%= f.label :name, 'Toy' %> 3 | <%= f.text_field :name, 4 | data: { identifier_for_test_suite: "toy-name[#{f.index}]" } %> 5 | <%= f.object.errors[:name].to_sentence %> 6 |
7 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/toy_fields_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ToyFieldsComponent < ViewComponent::Base 4 | include Motion::Component 5 | 6 | attr_reader :f 7 | 8 | def initialize(f:) 9 | @f = f 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /spec/support/test_application/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "test_application", 3 | "private": true, 4 | "dependencies": { 5 | "@rails/actioncable": "^6.0.0", 6 | "@rails/webpacker": "5.2.1", 7 | "@unabridged/motion": ">= 0.0.0" 8 | }, 9 | "version": "0.1.0" 10 | } 11 | -------------------------------------------------------------------------------- /spec/support/test_application/bin/webpack: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | 3 | require "bundler/setup" 4 | 5 | require "webpacker" 6 | require "webpacker/webpack_runner" 7 | 8 | APP_ROOT = File.expand_path("..", __dir__) 9 | 10 | Dir.chdir(APP_ROOT) do 11 | Webpacker::WebpackRunner.run(ARGV) 12 | end 13 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | # Switch to the project root directory 7 | DIR="$(cd -P -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" 8 | cd "${DIR}/.." 9 | 10 | # Install development dependencies 11 | bin/bundle install 12 | bin/yarn install 13 | -------------------------------------------------------------------------------- /lib/motion/railtie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "motion" 4 | 5 | module Motion 6 | class MyRailtie < Rails::Railtie 7 | generators do 8 | require "generators/motion/install_generator" 9 | require "generators/motion/component_generator" 10 | end 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/coverage_report.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "simplecov" 4 | 5 | SimpleCov.start do 6 | add_filter "/bin/" 7 | add_filter "/spec/" 8 | end 9 | 10 | # TODO: Update this to branch coverge when we upgrade to v0.18 11 | # https://github.com/colszowka/simplecov#minimum-coverage 12 | SimpleCov.minimum_coverage(80) 13 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/callback_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class CallbackComponent < ViewComponent::Base 4 | include Motion::Component 5 | 6 | attr_reader :count 7 | 8 | def initialize(count: 0) 9 | @count = count 10 | end 11 | 12 | def increment 13 | @count += 1 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /javascript/dispatchEvent.js: -------------------------------------------------------------------------------- 1 | export default function dispatchEvent (target, name) { 2 | try { 3 | const event = new CustomEvent(name, { 4 | bubbles: true, 5 | cancelable: false 6 | }) 7 | 8 | target.dispatchEvent(event) 9 | } catch (error) { 10 | console.error('Error while dispatching', name, 'on', target, error) 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /spec/support/test_application/config/routes.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Rails.application.routes.draw do 4 | resource :counter_component, only: :show 5 | resource :timer_component, only: :show 6 | resource :test_component, only: :show 7 | resource :callback_component, only: :show 8 | 9 | resources :dogs, only: [:new, :create] 10 | end 11 | -------------------------------------------------------------------------------- /spec/support/test_application/bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_ROOT = File.expand_path("..", __dir__) 5 | Dir.chdir(APP_ROOT) do 6 | exec "yarnpkg", *ARGV 7 | rescue Errno::ENOENT 8 | warn "Yarn executable was not detected in the system." 9 | warn "Download Yarn at https://yarnpkg.com/en/docs/install" 10 | exit 1 11 | end 12 | -------------------------------------------------------------------------------- /bin/yarn: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | APP_ROOT = File.expand_path('..', __dir__) 5 | Dir.chdir(APP_ROOT) do 6 | begin 7 | exec 'yarnpkg', *ARGV 8 | rescue Errno::ENOENT 9 | warn 'Yarn executable was not detected in the system.' 10 | warn 'Download Yarn at https://yarnpkg.com/en/docs/install' 11 | exit 1 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/button_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ButtonComponent < ViewComponent::Base 4 | include Motion::Component 5 | 6 | attr_reader :text, :on_click 7 | 8 | def initialize(text:, on_click:) 9 | @text = text 10 | @on_click = on_click 11 | end 12 | 13 | map_motion :click 14 | 15 | def click 16 | on_click.call 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/action_cable_testing_workaround.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # https://github.com/palkan/action-cable-testing/issues/76 4 | if Rails::VERSION::MAJOR == 5 5 | require "action_cable/testing" 6 | require "rspec/rails/feature_check" 7 | 8 | RSpec::Rails::FeatureCheck.module_eval do 9 | module_function 10 | 11 | def has_action_cable_testing? 12 | true 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /spec/support/test_application/app/models/dog.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class Dog < ApplicationRecord 4 | has_many :toys, dependent: :destroy 5 | 6 | validates :name, uniqueness: true, presence: true 7 | 8 | after_commit :broadcast_created!, on: :create 9 | 10 | accepts_nested_attributes_for :toys 11 | 12 | private 13 | 14 | def broadcast_created! 15 | ActionCable.server.broadcast("dogs:created", id) 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/motion/component/callbacks.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | require "motion" 6 | 7 | module Motion 8 | module Component 9 | module Callbacks 10 | def bind(method) 11 | Callback.new(self, method) 12 | end 13 | 14 | def stable_instance_identifier_for_callbacks 15 | @_stable_instance_identifier_for_callbacks ||= SecureRandom.uuid 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/dogs_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DogsController < ApplicationController 4 | def new 5 | render_component_in_layout(DogFormComponent.new) 6 | end 7 | 8 | def create 9 | Dog.create!(dog_params) 10 | 11 | redirect_to(new_dog_path) 12 | end 13 | 14 | private 15 | 16 | def dog_params 17 | params.require(:dog).permit(:name, toys_attributes: [:id, :name]) 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/timer_component.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class TimerComponent < ViewComponent::Base 4 | include Motion::Component 5 | 6 | def initialize(seconds: 1) 7 | @seconds = seconds 8 | end 9 | 10 | every 1.second, :tick 11 | 12 | def tick 13 | @seconds -= 1 14 | 15 | stop_periodic_timer(:tick) if @seconds.zero? 16 | end 17 | 18 | def call 19 | content_tag(:div) { @seconds.to_s } 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /spec/support/test_application/config/initializers/motion.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | Motion.configure do |config| 4 | config.revision = "test-revision" 5 | 6 | # The default implimentation is not compatible with `ConnectionStub` which 7 | # does not have an underlying request or env, so we just use the main renderer 8 | # directly for most specs. 9 | config.renderer_for_connection_proc = ->(_connection) do 10 | ApplicationController.renderer 11 | end 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/test_application/app/views/layouts/application.html.erb: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | TestApplication 5 | <%= csrf_meta_tags %> 6 | <% unless Rails::VERSION::MAJOR == 5 && Rails::VERSION::MINOR == 1 %> 7 | <%= csp_meta_tag %> 8 | <% end %> 9 | 10 | <%= stylesheet_link_tag 'application', media: 'all' %> 11 | <%= javascript_pack_tag 'application' %> 12 | 13 | 14 | 15 | <%= yield %> 16 | 17 | 18 | -------------------------------------------------------------------------------- /gemfiles/rails_6_0.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rspec-rails" 9 | gem "standard" 10 | gem "view_component" 11 | gem "simplecov", "< 0.18", require: false 12 | gem "generator_spec" 13 | gem "capybara", ">= 2.15", "< 4.0" 14 | gem "webdrivers", "~> 4.0", require: false 15 | gem "puma" 16 | gem "webpacker" 17 | gem "sqlite3" 18 | gem "appraisal" 19 | gem "rails", "~> 6.0" 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /gemfiles/rails_6_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rspec-rails" 9 | gem "standard" 10 | gem "view_component" 11 | gem "simplecov", "< 0.18", require: false 12 | gem "generator_spec" 13 | gem "capybara", ">= 2.15", "< 4.0" 14 | gem "webdrivers", "~> 4.0", require: false 15 | gem "puma" 16 | gem "webpacker" 17 | gem "sqlite3" 18 | gem "appraisal" 19 | gem "rails", "~> 6.1.0.rc1" 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /spec/support/test_application/app/controllers/application_controller.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class ApplicationController < ActionController::Base 4 | private 5 | 6 | # Rendering the component inline like this causes the application layout to 7 | # be used (when rendering directly, this does not happen for some reason). 8 | def render_component_in_layout(component) 9 | render inline: "<%= render component %>", 10 | locals: {component: component}, 11 | layout: true 12 | end 13 | end 14 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | gemspec 6 | 7 | gem "pry" 8 | gem "rake", "~> 12.0" 9 | gem "rspec", "~> 3.0" 10 | gem "rspec-rails" 11 | gem "standard" 12 | gem "view_component" 13 | 14 | # https://github.com/codeclimate/test-reporter/issues/413 15 | gem "simplecov", "< 0.18", require: false 16 | 17 | gem "generator_spec" 18 | gem "capybara", ">= 2.15", "< 4.0" 19 | gem "webdrivers", "~> 4.0", require: false 20 | gem "puma" 21 | gem "webpacker" 22 | gem "sqlite3" 23 | gem "appraisal" 24 | -------------------------------------------------------------------------------- /spec/motion/component_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Motion::Component do 4 | subject { TestComponent } 5 | 6 | it { is_expected.to include(Motion::Component::Broadcasts) } 7 | it { is_expected.to include(Motion::Component::Callbacks) } 8 | it { is_expected.to include(Motion::Component::Lifecycle) } 9 | it { is_expected.to include(Motion::Component::Motions) } 10 | it { is_expected.to include(Motion::Component::PeriodicTimers) } 11 | it { is_expected.to include(Motion::Component::Rendering) } 12 | end 13 | -------------------------------------------------------------------------------- /spec/support/test_application/db/schema.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ActiveRecord::Schema.define do 4 | create_table "dogs", force: :cascade do |t| 5 | t.string "name", null: false 6 | 7 | t.datetime "created_at", null: false 8 | t.datetime "updated_at", null: false 9 | end 10 | 11 | create_table "toys", force: :cascade do |t| 12 | t.integer "dog_id", null: false 13 | t.string "name", null: false 14 | 15 | t.datetime "created_at", null: false 16 | t.datetime "updated_at", null: false 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/dog_form_component.html.erb: -------------------------------------------------------------------------------- 1 | <%= form_for(dog, data: { motion: 'change->validate' }) do |f| %> 2 |
3 | <%= f.label :name %> 4 | <%= f.text_field :name %> 5 | <%= f.object.errors[:name].to_sentence %> 6 |
7 | 8 |
9 | <%= f.fields_for :toys do |f| %> 10 | <%= render ToyFieldsComponent.new(f: f) %> 11 | <% end %> 12 | 13 | 14 |
15 | 16 | <%= f.submit %> 17 | <% end %> 18 | -------------------------------------------------------------------------------- /gemfiles/rails_5_1.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rspec-rails" 9 | gem "standard" 10 | gem "view_component" 11 | gem "simplecov", "< 0.18", require: false 12 | gem "generator_spec" 13 | gem "capybara", ">= 2.15", "< 4.0" 14 | gem "webdrivers", "~> 4.0", require: false 15 | gem "puma" 16 | gem "webpacker" 17 | gem "sqlite3" 18 | gem "appraisal" 19 | gem "rails", "~> 5.1.7" 20 | gem "action-cable-testing" 21 | 22 | gemspec path: "../" 23 | -------------------------------------------------------------------------------- /gemfiles/rails_5_2.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rspec-rails" 9 | gem "standard" 10 | gem "view_component" 11 | gem "simplecov", "< 0.18", require: false 12 | gem "generator_spec" 13 | gem "capybara", ">= 2.15", "< 4.0" 14 | gem "webdrivers", "~> 4.0", require: false 15 | gem "puma" 16 | gem "webpacker" 17 | gem "sqlite3" 18 | gem "appraisal" 19 | gem "rails", "~> 5.2" 20 | gem "action-cable-testing" 21 | 22 | gemspec path: "../" 23 | -------------------------------------------------------------------------------- /gemfiles/rails_master.gemfile: -------------------------------------------------------------------------------- 1 | # This file was generated by Appraisal 2 | 3 | source "https://rubygems.org" 4 | 5 | gem "pry" 6 | gem "rake", "~> 12.0" 7 | gem "rspec", "~> 3.0" 8 | gem "rspec-rails" 9 | gem "standard" 10 | gem "view_component" 11 | gem "simplecov", "< 0.18", require: false 12 | gem "generator_spec" 13 | gem "capybara", ">= 2.15", "< 4.0" 14 | gem "webdrivers", "~> 4.0", require: false 15 | gem "puma" 16 | gem "webpacker" 17 | gem "sqlite3" 18 | gem "appraisal" 19 | gem "rails", git: "https://github.com/rails/rails.git", ref: "master" 20 | 21 | gemspec path: "../" 22 | -------------------------------------------------------------------------------- /lib/motion/action_cable_extentions.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "motion" 4 | 5 | module Motion 6 | module ActionCableExtentions 7 | autoload :DeclarativeNotifications, 8 | "motion/action_cable_extentions/declarative_notifications" 9 | 10 | autoload :DeclarativeStreams, 11 | "motion/action_cable_extentions/declarative_streams" 12 | 13 | autoload :LogSuppression, 14 | "motion/action_cable_extentions/log_suppression" 15 | 16 | autoload :Synchronization, 17 | "motion/action_cable_extentions/synchronization" 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /spec/support/test_application/app/components/counter_component.html.erb: -------------------------------------------------------------------------------- 1 |
2 |
"> 3 |

The current count is <%= count %>

4 | 5 |
6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 | 14 | <%= render(child) if child %> 15 |
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 `
` or " \ 55 | "`
`." 56 | ) 57 | end 58 | end 59 | 60 | class RenderAborted < ComponentRenderingError 61 | def initialize(component) 62 | super(component, <<~MSG) 63 | Rendering #{component.class} was aborted by a callback. 64 | MSG 65 | end 66 | end 67 | 68 | class InvalidComponentStateError < ComponentError; end 69 | 70 | class UnrepresentableStateError < InvalidComponentStateError 71 | def initialize(component, cause) 72 | super( 73 | component, 74 | "Some state prevented `#{component.class}` from being serialized " \ 75 | "into a string. Motion components must be serializable using " \ 76 | "`Marshal.dump`. Many types of objects are not serializable " \ 77 | "including procs, references to anonymous classes, and more. See the " \ 78 | "documentation for `Marshal.dump` for more information.\n" \ 79 | "\n" \ 80 | "The specific error from `Marshal.dump` was: #{cause}\n" \ 81 | "\n" \ 82 | "Hint: Ensure that any exotic state variables in " \ 83 | "`#{component.class}` are removed or replaced." 84 | ) 85 | end 86 | end 87 | 88 | class SerializedComponentError < Error; end 89 | 90 | class InvalidSerializedStateError < SerializedComponentError 91 | def initialize 92 | super( 93 | "The serialized state of your component is not valid.\n" \ 94 | "\n" \ 95 | "Hint: Ensure that you have not tampered with the contents of data " \ 96 | "attributes added by Motion in the DOM or changed the value of " \ 97 | "`Motion.config.secret`." 98 | ) 99 | end 100 | end 101 | 102 | class UpgradeNotImplementedError < ComponentError 103 | attr_reader :previous_revision, 104 | :current_revision 105 | 106 | def initialize(component, previous_revision, current_revision) 107 | super( 108 | component, 109 | "Cannot upgrade `#{component.class}` from a previous revision of the " \ 110 | "application (#{previous_revision}) to the current revision of the " \ 111 | "application (#{current_revision})\n" \ 112 | "\n" \ 113 | "By default, Motion does not allow components from other revisions " \ 114 | "of the application to be mounted because new code with old state " \ 115 | "can lead to unpredictable and unsafe behavior.\n" \ 116 | "\n" \ 117 | "Hint: If you would like to allow this component to surive " \ 118 | "deployments, consider providing an alternative implimentation for " \ 119 | "`#{component.class}.upgrade_from`." 120 | ) 121 | 122 | @previous_revision = previous_revision 123 | @current_revision = current_revision 124 | end 125 | end 126 | 127 | class AlreadyConfiguredError < Error 128 | def initialize 129 | super( 130 | "Motion is already configured.\n" \ 131 | "\n" \ 132 | "Hint: Move all Motion config to `config/initializers/motion.rb`." 133 | ) 134 | end 135 | end 136 | 137 | class IncompatibleClientError < Error 138 | attr_reader :server_version, :client_version 139 | 140 | def initialize(server_version, client_version) 141 | super( 142 | "The client version (#{client_version}) is newer than the server " \ 143 | "version (#{server_version}). Please upgrade the Motion gem.\n" \ 144 | "\n" \ 145 | "Hint: Run `bundle add motion --version \">= #{client_version}\"`." 146 | ) 147 | 148 | @server_version = server_version 149 | @client_version = client_version 150 | end 151 | end 152 | 153 | class BadSecretError < Error 154 | attr_reader :minimum_bytes 155 | 156 | def initialize(minimum_bytes) 157 | super( 158 | "The secret that you provided is not long enough. It must be at " \ 159 | "least #{minimum_bytes} bytes long." 160 | ) 161 | end 162 | end 163 | 164 | class BadRevisionError < Error 165 | def initialize 166 | super("The revision cannot contain a NULL byte.") 167 | end 168 | end 169 | 170 | class BadRevisionPathsError < Error 171 | def initialize 172 | super( 173 | "Revision paths must be a `Rails::Paths::Root` object or an object " \ 174 | "that responds to `all_paths.flat_map(&:existent)` and returns an " \ 175 | "Array of strings representing full paths." 176 | ) 177 | end 178 | end 179 | end 180 | -------------------------------------------------------------------------------- /gemfiles/rails_5_1.gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: .. 3 | specs: 4 | motion (0.4.4) 5 | lz4-ruby (>= 0.3.3) 6 | nokogiri 7 | rails (>= 5.1) 8 | 9 | GEM 10 | remote: https://rubygems.org/ 11 | specs: 12 | action-cable-testing (0.6.1) 13 | actioncable (>= 5.0) 14 | actioncable (5.1.7) 15 | actionpack (= 5.1.7) 16 | nio4r (~> 2.0) 17 | websocket-driver (~> 0.6.1) 18 | actionmailer (5.1.7) 19 | actionpack (= 5.1.7) 20 | actionview (= 5.1.7) 21 | activejob (= 5.1.7) 22 | mail (~> 2.5, >= 2.5.4) 23 | rails-dom-testing (~> 2.0) 24 | actionpack (5.1.7) 25 | actionview (= 5.1.7) 26 | activesupport (= 5.1.7) 27 | rack (~> 2.0) 28 | rack-test (>= 0.6.3) 29 | rails-dom-testing (~> 2.0) 30 | rails-html-sanitizer (~> 1.0, >= 1.0.2) 31 | actionview (5.1.7) 32 | activesupport (= 5.1.7) 33 | builder (~> 3.1) 34 | erubi (~> 1.4) 35 | rails-dom-testing (~> 2.0) 36 | rails-html-sanitizer (~> 1.0, >= 1.0.3) 37 | activejob (5.1.7) 38 | activesupport (= 5.1.7) 39 | globalid (>= 0.3.6) 40 | activemodel (5.1.7) 41 | activesupport (= 5.1.7) 42 | activerecord (5.1.7) 43 | activemodel (= 5.1.7) 44 | activesupport (= 5.1.7) 45 | arel (~> 8.0) 46 | activesupport (5.1.7) 47 | concurrent-ruby (~> 1.0, >= 1.0.2) 48 | i18n (>= 0.7, < 2) 49 | minitest (~> 5.1) 50 | tzinfo (~> 1.1) 51 | addressable (2.7.0) 52 | public_suffix (>= 2.0.2, < 5.0) 53 | appraisal (2.3.0) 54 | bundler 55 | rake 56 | thor (>= 0.14.0) 57 | arel (8.0.0) 58 | ast (2.4.1) 59 | builder (3.2.4) 60 | capybara (3.33.0) 61 | addressable 62 | mini_mime (>= 0.1.3) 63 | nokogiri (~> 1.8) 64 | rack (>= 1.6.0) 65 | rack-test (>= 0.6.3) 66 | regexp_parser (~> 1.5) 67 | xpath (~> 3.2) 68 | childprocess (3.0.0) 69 | coderay (1.1.3) 70 | concurrent-ruby (1.1.7) 71 | crass (1.0.6) 72 | diff-lcs (1.4.4) 73 | docile (1.3.2) 74 | erubi (1.9.0) 75 | generator_spec (0.9.4) 76 | activesupport (>= 3.0.0) 77 | railties (>= 3.0.0) 78 | globalid (0.4.2) 79 | activesupport (>= 4.2.0) 80 | i18n (1.8.5) 81 | concurrent-ruby (~> 1.0) 82 | json (2.3.1) 83 | loofah (2.7.0) 84 | crass (~> 1.0.2) 85 | nokogiri (>= 1.5.9) 86 | lz4-ruby (0.3.3) 87 | mail (2.7.1) 88 | mini_mime (>= 0.1.1) 89 | method_source (1.0.0) 90 | mini_mime (1.0.2) 91 | mini_portile2 (2.4.0) 92 | minitest (5.14.2) 93 | nio4r (2.5.4) 94 | nokogiri (1.10.10) 95 | mini_portile2 (~> 2.4.0) 96 | parallel (1.19.2) 97 | parser (2.7.1.4) 98 | ast (~> 2.4.1) 99 | pry (0.13.1) 100 | coderay (~> 1.1) 101 | method_source (~> 1.0) 102 | public_suffix (4.0.6) 103 | puma (5.0.0) 104 | nio4r (~> 2.0) 105 | rack (2.2.3) 106 | rack-proxy (0.6.5) 107 | rack 108 | rack-test (1.1.0) 109 | rack (>= 1.0, < 3) 110 | rails (5.1.7) 111 | actioncable (= 5.1.7) 112 | actionmailer (= 5.1.7) 113 | actionpack (= 5.1.7) 114 | actionview (= 5.1.7) 115 | activejob (= 5.1.7) 116 | activemodel (= 5.1.7) 117 | activerecord (= 5.1.7) 118 | activesupport (= 5.1.7) 119 | bundler (>= 1.3.0) 120 | railties (= 5.1.7) 121 | sprockets-rails (>= 2.0.0) 122 | rails-dom-testing (2.0.3) 123 | activesupport (>= 4.2.0) 124 | nokogiri (>= 1.6) 125 | rails-html-sanitizer (1.3.0) 126 | loofah (~> 2.3) 127 | railties (5.1.7) 128 | actionpack (= 5.1.7) 129 | activesupport (= 5.1.7) 130 | method_source 131 | rake (>= 0.8.7) 132 | thor (>= 0.18.1, < 2.0) 133 | rainbow (3.0.0) 134 | rake (12.3.3) 135 | regexp_parser (1.8.0) 136 | rexml (3.2.4) 137 | rspec (3.9.0) 138 | rspec-core (~> 3.9.0) 139 | rspec-expectations (~> 3.9.0) 140 | rspec-mocks (~> 3.9.0) 141 | rspec-core (3.9.2) 142 | rspec-support (~> 3.9.3) 143 | rspec-expectations (3.9.2) 144 | diff-lcs (>= 1.2.0, < 2.0) 145 | rspec-support (~> 3.9.0) 146 | rspec-mocks (3.9.1) 147 | diff-lcs (>= 1.2.0, < 2.0) 148 | rspec-support (~> 3.9.0) 149 | rspec-rails (4.0.1) 150 | actionpack (>= 4.2) 151 | activesupport (>= 4.2) 152 | railties (>= 4.2) 153 | rspec-core (~> 3.9) 154 | rspec-expectations (~> 3.9) 155 | rspec-mocks (~> 3.9) 156 | rspec-support (~> 3.9) 157 | rspec-support (3.9.3) 158 | rubocop (0.91.0) 159 | parallel (~> 1.10) 160 | parser (>= 2.7.1.1) 161 | rainbow (>= 2.2.2, < 4.0) 162 | regexp_parser (>= 1.7) 163 | rexml 164 | rubocop-ast (>= 0.4.0, < 1.0) 165 | ruby-progressbar (~> 1.7) 166 | unicode-display_width (>= 1.4.0, < 2.0) 167 | rubocop-ast (0.4.2) 168 | parser (>= 2.7.1.4) 169 | rubocop-performance (1.8.1) 170 | rubocop (>= 0.87.0) 171 | rubocop-ast (>= 0.4.0) 172 | ruby-progressbar (1.10.1) 173 | rubyzip (2.3.0) 174 | selenium-webdriver (3.142.7) 175 | childprocess (>= 0.5, < 4.0) 176 | rubyzip (>= 1.2.2) 177 | simplecov (0.17.1) 178 | docile (~> 1.1) 179 | json (>= 1.8, < 3) 180 | simplecov-html (~> 0.10.0) 181 | simplecov-html (0.10.2) 182 | sprockets (4.0.2) 183 | concurrent-ruby (~> 1.0) 184 | rack (> 1, < 3) 185 | sprockets-rails (3.2.2) 186 | actionpack (>= 4.0) 187 | activesupport (>= 4.0) 188 | sprockets (>= 3.0.0) 189 | sqlite3 (1.4.2) 190 | standard (0.6.1) 191 | rubocop (~> 0.90) 192 | rubocop-performance (~> 1.8.0) 193 | thor (1.0.1) 194 | thread_safe (0.3.6) 195 | tzinfo (1.2.7) 196 | thread_safe (~> 0.1) 197 | unicode-display_width (1.7.0) 198 | view_component (2.19.1) 199 | activesupport (>= 5.0.0, < 7.0) 200 | webdrivers (4.4.1) 201 | nokogiri (~> 1.6) 202 | rubyzip (>= 1.3.0) 203 | selenium-webdriver (>= 3.0, < 4.0) 204 | webpacker (4.3.0) 205 | activesupport (>= 4.2) 206 | rack-proxy (>= 0.6.1) 207 | railties (>= 4.2) 208 | websocket-driver (0.6.5) 209 | websocket-extensions (>= 0.1.0) 210 | websocket-extensions (0.1.5) 211 | xpath (3.2.0) 212 | nokogiri (~> 1.8) 213 | 214 | PLATFORMS 215 | ruby 216 | 217 | DEPENDENCIES 218 | action-cable-testing 219 | appraisal 220 | capybara (>= 2.15, < 4.0) 221 | generator_spec 222 | motion! 223 | pry 224 | puma 225 | rails (~> 5.1.7) 226 | rake (~> 12.0) 227 | rspec (~> 3.0) 228 | rspec-rails 229 | simplecov (< 0.18) 230 | sqlite3 231 | standard 232 | view_component 233 | webdrivers (~> 4.0) 234 | webpacker 235 | 236 | BUNDLED WITH 237 | 2.1.4 238 | --------------------------------------------------------------------------------