motion 0.1.1 → 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2c9776bff100e3017a158810f93d3c374d97e6df42b8d9ffee43bf96da624b5b
4
- data.tar.gz: a0764632d832cd96b58b3ffa5c28e9598d09136b0dbe6eadc66ab074bc18d5bd
3
+ metadata.gz: 7645381b6b2749a4a9ba90a15c93916636e1b9edfd7b3a6a1bf7d47429fedb3f
4
+ data.tar.gz: 46c929b8aa2d1de8c714eb8b8db1db8524aec892576a13ad1582b7fa858565f7
5
5
  SHA512:
6
- metadata.gz: b5a0955da6087872bd9ec9940b8b3a674bf6fd2782ccad6b72460255703ff810e06e3d62ea99484a6c95d6fb48883953bbdec1cb3224af2513006ba60d5f3c54
7
- data.tar.gz: a0360a0b1a0422b38f5bd4f9a46ba203ed9f1d3a1a6adf6073c89ff77152c0ae5cf212d097e6ad8b864f2bf220772511887db3c1692d7c4b7be3d3f54be01ec5
6
+ metadata.gz: b8c99a480f51e9d818f5d22de3efbf8923155943d1815188e19332c54b8f24b909bc371abc7282d3a9caac754cf1b0aee4bb3193dad75b7f1d86a9112ed13983
7
+ data.tar.gz: a12e9213ba0ae38cbc5d839ae1408a5aab064be055896300f19a2296ebcd74b49922b96636bc52bd7b7aef62be9868ea47437544938f20facaad45534744bc4e
@@ -0,0 +1,34 @@
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
@@ -16,10 +16,17 @@ module Motion
16
16
  )
17
17
  end
18
18
 
19
- def copy_stimlus_controller
19
+ def copy_client_initializer
20
20
  template(
21
- "motion_controller.js",
22
- "app/javascript/controllers/motion_controller.js"
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"'
23
30
  )
24
31
  end
25
32
  end
@@ -0,0 +1,37 @@
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
+ });
@@ -1,22 +1,61 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- # TODO: Explain all the options.
4
3
  Motion.configure do |config|
5
- # config.secret = Rails.application.key_generator.generate_key "motion:secret"
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.
6
24
 
7
- # config.revision = `git rev-parse HEAD`.chomp
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
8
39
 
9
- # config.renderer_for_connection_proc = ->(websocket_connection) do
10
- # ApplicationController.renderer.new(
11
- # websocket_connection.env.slice(
12
- # Rack::HTTP_COOKIE,
13
- # Rack::RACK_SESSION,
14
- # )
15
- # )
16
- # end
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
17
53
 
18
- # config.stimulus_controller_identifier = "motion"
19
- # config.key_attribute = "data-motion-key"
20
- # config.state_attribute = "data-motion-state"
21
- # config.motion_attribute = "data-motion"
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
+ #
22
61
  end
@@ -14,6 +14,7 @@ module Motion
14
14
  autoload :LogHelper, "motion/log_helper"
15
15
  autoload :MarkupTransformer, "motion/markup_transformer"
16
16
  autoload :Railtie, "motion/railtie"
17
+ autoload :RevisionCalculator, "motion/revision_calculator"
17
18
  autoload :Serializer, "motion/serializer"
18
19
  autoload :TestHelpers, "motion/test_helpers"
19
20
 
@@ -41,6 +42,10 @@ module Motion
41
42
  config.renderer_for_connection_proc.call(websocket_connection)
42
43
  end
43
44
 
45
+ def self.notify_error(error, message)
46
+ config.error_notification_proc&.call(error, message)
47
+ end
48
+
44
49
  # This method only exists for testing. Changing configuration while Motion is
45
50
  # in use is not supported. It is only safe to call this method when no
46
51
  # components are currently mounted.
@@ -4,10 +4,16 @@ require "motion"
4
4
 
5
5
  module Motion
6
6
  module ActionCableExtentions
7
+ autoload :DeclarativeNotifications,
8
+ "motion/action_cable_extentions/declarative_notifications"
9
+
7
10
  autoload :DeclarativeStreams,
8
11
  "motion/action_cable_extentions/declarative_streams"
9
12
 
10
13
  autoload :LogSuppression,
11
14
  "motion/action_cable_extentions/log_suppression"
15
+
16
+ autoload :Synchronization,
17
+ "motion/action_cable_extentions/synchronization"
12
18
  end
13
19
  end
@@ -0,0 +1,101 @@
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
@@ -6,19 +6,13 @@ module Motion
6
6
  module ActionCableExtentions
7
7
  # Provides a `streaming_from(broadcasts, to:)` API that can be used to
8
8
  # declaratively specify what `broadcasts` the channel is interested in
9
- # receiving and `to` what method they should be routed. Additionally,
10
- # this module extends the "at most one executor at a time" property that
11
- # naturally comes with actions to the streams that it sets up as well.
9
+ # receiving and `to` what method they should be routed.
12
10
  module DeclarativeStreams
11
+ include Synchronization
12
+
13
13
  def initialize(*)
14
14
  super
15
15
 
16
- # Allowing actions to be bound to streams (as this module provides)
17
- # introduces the possibiliy of multiple threads accessing user code at
18
- # the same time. Protect user code with a Monitor so we only have to
19
- # worry about that here.
20
- @_declarative_stream_monitor = Monitor.new
21
-
22
16
  # Streams that we are currently interested in
23
17
  @_declarative_streams = Set.new
24
18
 
@@ -30,19 +24,6 @@ module Motion
30
24
  @_declarative_stream_proxies = Set.new
31
25
  end
32
26
 
33
- # Synchronize all ActionCable entry points (after initialization).
34
- def subscribe_to_channel(*)
35
- @_declarative_stream_monitor.synchronize { super }
36
- end
37
-
38
- def unsubscribe_from_channel(*)
39
- @_declarative_stream_monitor.synchronize { super }
40
- end
41
-
42
- def perform_action(*)
43
- @_declarative_stream_monitor.synchronize { super }
44
- end
45
-
46
27
  # Clean up declarative streams when all streams are stopped.
47
28
  def stop_all_streams
48
29
  super
@@ -70,34 +51,20 @@ module Motion
70
51
  def _ensure_declarative_stream_proxy(broadcast)
71
52
  return unless @_declarative_stream_proxies.add?(broadcast)
72
53
 
73
- # TODO: Something about this doesn't deal with the coder correctly.
74
- stream_from(broadcast) do |message|
75
- _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
76
- rescue Exception => exception # rubocop:disable Lint/RescueException
77
- # It is very, very important that we do not allow an exception to
78
- # escape here as the internals of ActionCable will stop processing
79
- # the broadcast.
80
-
81
- _handle_exception_in_declarative_stream(broadcast, exception)
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
82
60
  end
83
61
  end
84
62
 
85
63
  def _handle_incoming_broadcast_to_declarative_stream(broadcast, message)
86
- @_declarative_stream_monitor.synchronize do
87
- return unless @_declarative_stream_target &&
88
- @_declarative_streams.include?(broadcast)
89
-
90
- send(@_declarative_stream_target, broadcast, message)
91
- end
92
- end
64
+ return unless @_declarative_stream_target &&
65
+ @_declarative_streams.include?(broadcast)
93
66
 
94
- def _handle_exception_in_declarative_stream(broadcast, exception)
95
- logger.error(
96
- "There was an exception while handling a broadcast to #{broadcast}" \
97
- "on #{self.class}:\n" \
98
- " #{exception.class}: #{exception.message}\n" \
99
- "#{exception.backtrace.map { |line| " #{line}" }.join("\n")}"
100
- )
67
+ send(@_declarative_stream_target, broadcast, message)
101
68
  end
102
69
  end
103
70
  end
@@ -0,0 +1,34 @@
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
@@ -6,6 +6,7 @@ require "motion"
6
6
 
7
7
  module Motion
8
8
  class Channel < ActionCable::Channel::Base
9
+ include ActionCableExtentions::DeclarativeNotifications
9
10
  include ActionCableExtentions::DeclarativeStreams
10
11
  include ActionCableExtentions::LogSuppression
11
12
 
@@ -24,9 +25,7 @@ module Motion
24
25
  def subscribed
25
26
  state, client_version = params.values_at("state", "version")
26
27
 
27
- # TODO: This is too restrictive. Introduce a protocol version and support
28
- # older versions of the client that have a compatible protocol.
29
- unless Motion::VERSION == client_version
28
+ if Gem::Version.new(Motion::VERSION) < Gem::Version.new(client_version)
30
29
  raise IncompatibleClientError.new(Motion::VERSION, client_version)
31
30
  end
32
31
 
@@ -58,10 +57,19 @@ module Motion
58
57
  synchronize
59
58
  end
60
59
 
60
+ def process_periodic_timer(timer)
61
+ component_connection.process_periodic_timer(timer)
62
+ synchronize
63
+ end
64
+
61
65
  private
62
66
 
63
67
  def synchronize
64
- streaming_from(component_connection.broadcasts, to: :process_broadcast)
68
+ streaming_from component_connection.broadcasts,
69
+ to: :process_broadcast
70
+
71
+ periodically_notify component_connection.periodic_timers,
72
+ via: :process_periodic_timer
65
73
 
66
74
  component_connection.if_render_required do |component|
67
75
  transmit(renderer.render(component))