reactive-actions 0.1.0.pre.alpha.1 → 0.1.0.pre.alpha.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,267 @@
1
1
  // ReactiveActions JavaScript Client for Rails 8
2
- // ES Module compatible version
2
+ // ES Module compatible version with manual initialization
3
3
 
4
4
  class ReactiveActionsClient {
5
- constructor() {
6
- this.baseUrl = '/reactive_actions/execute';
5
+ constructor(options = {}) {
6
+ // Default configuration
7
+ this.config = {
8
+ baseUrl: '/reactive_actions/execute',
9
+ enableAutoBinding: true,
10
+ enableMutationObserver: true,
11
+ defaultHttpMethod: 'POST',
12
+ ...options
13
+ };
14
+
15
+ this.boundElements = new WeakSet();
16
+ this.initialized = false;
17
+ }
18
+
19
+ // Update configuration
20
+ configure(options = {}) {
21
+ this.config = { ...this.config, ...options };
22
+
23
+ // If baseUrl changed, update it
24
+ if (options.baseUrl) {
25
+ this.baseUrl = options.baseUrl;
26
+ }
27
+
28
+ return this;
29
+ }
30
+
31
+ // Get current configuration
32
+ getConfig() {
33
+ return { ...this.config };
34
+ }
35
+
36
+ // Initialize DOM bindings when called - must be called manually
37
+ initialize(options = {}) {
38
+ // Update configuration if options provided
39
+ if (Object.keys(options).length > 0) {
40
+ this.configure(options);
41
+ }
42
+
43
+ // Skip if already initialized
44
+ if (this.initialized) return this;
45
+
46
+ // Only bind if auto-binding is enabled
47
+ if (this.config.enableAutoBinding) {
48
+ this.bindExistingElements();
49
+
50
+ if (this.config.enableMutationObserver) {
51
+ this.setupMutationObserver();
52
+ }
53
+ }
54
+
55
+ this.initialized = true;
56
+ return this;
57
+ }
58
+
59
+ // Force re-initialization (useful after configuration changes)
60
+ reinitialize() {
61
+ this.initialized = false;
62
+ this.boundElements = new WeakSet();
63
+ this.initialize();
64
+ }
65
+
66
+ // Bind all existing elements with reactive-action attributes
67
+ bindExistingElements() {
68
+ const elements = document.querySelectorAll('[reactive-action]');
69
+ elements.forEach(element => this.bindElement(element));
70
+ }
71
+
72
+ // Set up mutation observer to handle dynamically added elements
73
+ setupMutationObserver() {
74
+ const observer = new MutationObserver(mutations => {
75
+ mutations.forEach(mutation => {
76
+ mutation.addedNodes.forEach(node => {
77
+ if (node.nodeType === Node.ELEMENT_NODE) {
78
+ // Check if the node itself has reactive-action
79
+ if (node.hasAttribute && node.hasAttribute('reactive-action')) {
80
+ this.bindElement(node);
81
+ }
82
+ // Check for child elements with reactive-action
83
+ const children = node.querySelectorAll ? node.querySelectorAll('[reactive-action]') : [];
84
+ children.forEach(child => this.bindElement(child));
85
+ }
86
+ });
87
+ });
88
+ });
89
+
90
+ observer.observe(document.body, {
91
+ childList: true,
92
+ subtree: true
93
+ });
94
+ }
95
+
96
+ // Bind a single element to reactive actions
97
+ bindElement(element) {
98
+ // Skip if already bound
99
+ if (this.boundElements.has(element)) return;
100
+
101
+ const actionValue = element.getAttribute('reactive-action');
102
+ if (!actionValue) return;
103
+
104
+ // Parse actions - support multiple actions separated by spaces
105
+ const actions = this.parseActions(actionValue);
106
+
107
+ actions.forEach(({ event, httpMethod, actionName }) => {
108
+ const listener = this.createActionListener(element, actionName, httpMethod);
109
+
110
+ // Handle special cases for form submission
111
+ if (event === 'submit' && element.tagName.toLowerCase() === 'form') {
112
+ element.addEventListener('submit', (e) => {
113
+ e.preventDefault();
114
+ listener(e);
115
+ });
116
+ } else {
117
+ element.addEventListener(event, listener);
118
+ }
119
+ });
120
+
121
+ // Mark as bound
122
+ this.boundElements.add(element);
123
+ }
124
+
125
+ // Parse action string(s) - supports "click->action_name", "click->post#action_name", or multiple actions
126
+ parseActions(actionValue) {
127
+ const actions = [];
128
+ const actionPairs = actionValue.trim().split(/\s+/);
129
+
130
+ actionPairs.forEach(pair => {
131
+ // Match patterns: "event->action", "event->method#action"
132
+ const match = pair.match(/^(\w+)->((?:(\w+)#)?(.+))$/);
133
+ if (match) {
134
+ const [, event, , httpMethod, actionName] = match;
135
+ actions.push({
136
+ event,
137
+ httpMethod: httpMethod ? httpMethod.toUpperCase() : this.config.defaultHttpMethod,
138
+ actionName
139
+ });
140
+ }
141
+ });
142
+
143
+ return actions;
144
+ }
145
+
146
+ // Create event listener for an action
147
+ createActionListener(element, actionName, httpMethod = null) {
148
+ return async (event) => {
149
+ try {
150
+ // Add loading state
151
+ this.setLoadingState(element, true);
152
+
153
+ // Extract data attributes
154
+ const actionParams = this.extractDataAttributes(element);
155
+
156
+ // Add form data if it's a form element
157
+ if (element.tagName.toLowerCase() === 'form') {
158
+ const formData = new FormData(element);
159
+ for (const [key, value] of formData.entries()) {
160
+ actionParams[key] = value;
161
+ }
162
+ }
163
+
164
+ // Add input value for input elements on change events
165
+ if (['input', 'select', 'textarea'].includes(element.tagName.toLowerCase()) &&
166
+ ['change', 'input'].includes(event.type)) {
167
+ actionParams.value = element.value;
168
+ }
169
+
170
+ // Execute the action using the existing method with specified HTTP method
171
+ const method = httpMethod || this.config.defaultHttpMethod;
172
+ const response = await this.execute(actionName, actionParams, { method });
173
+
174
+ // Handle response
175
+ this.handleActionResponse(element, response, event);
176
+
177
+ } catch (error) {
178
+ this.handleActionError(element, error, event);
179
+ } finally {
180
+ this.setLoadingState(element, false);
181
+ }
182
+ };
183
+ }
184
+
185
+ // Extract data attributes from element
186
+ extractDataAttributes(element) {
187
+ const data = {};
188
+ const attributes = element.attributes;
189
+
190
+ for (const attr of attributes) {
191
+ if (attr.name.startsWith('reactive-action-') &&
192
+ !['reactive-action', 'reactive-action-success', 'reactive-action-error'].includes(attr.name)) {
193
+ const key = attr.name
194
+ .replace('reactive-action-', '')
195
+ .replace(/-/g, '_'); // Convert kebab-case to snake_case
196
+ data[key] = attr.value;
197
+ }
198
+ }
199
+
200
+ return data;
201
+ }
202
+
203
+ // Set loading state on element
204
+ setLoadingState(element, loading) {
205
+ if (loading) {
206
+ element.classList.add('reactive-loading');
207
+ if (element.tagName.toLowerCase() === 'button' || element.tagName.toLowerCase() === 'input') {
208
+ element.disabled = true;
209
+ }
210
+ // Store original text if it's a button or link
211
+ if (['button', 'a'].includes(element.tagName.toLowerCase())) {
212
+ element.dataset.originalText = element.textContent;
213
+ element.textContent = element.dataset.loadingText || 'Loading...';
214
+ }
215
+ } else {
216
+ element.classList.remove('reactive-loading');
217
+ if (element.tagName.toLowerCase() === 'button' || element.tagName.toLowerCase() === 'input') {
218
+ element.disabled = false;
219
+ }
220
+ // Restore original text
221
+ if (element.dataset.originalText) {
222
+ element.textContent = element.dataset.originalText;
223
+ delete element.dataset.originalText;
224
+ }
225
+ }
226
+ }
227
+
228
+ // Handle successful action response
229
+ handleActionResponse(element, response, event) {
230
+ // Dispatch custom event
231
+ const customEvent = new CustomEvent('reactive-action:success', {
232
+ detail: { response, element, originalEvent: event },
233
+ bubbles: true
234
+ });
235
+ element.dispatchEvent(customEvent);
236
+
237
+ // Handle success callback if specified
238
+ const successCallback = element.getAttribute('reactive-action-success');
239
+ if (successCallback && typeof window[successCallback] === 'function') {
240
+ window[successCallback](response, element, event);
241
+ }
242
+
243
+ // Log successful responses that aren't ok
244
+ if (!response.ok) {
245
+ console.warn('ReactiveActions response not ok:', response);
246
+ }
247
+ }
248
+
249
+ // Handle action errors
250
+ handleActionError(element, error, event) {
251
+ console.error('ReactiveActions error:', error);
252
+
253
+ // Dispatch custom event
254
+ const customEvent = new CustomEvent('reactive-action:error', {
255
+ detail: { error, element, originalEvent: event },
256
+ bubbles: true
257
+ });
258
+ element.dispatchEvent(customEvent);
259
+
260
+ // Handle error callback if specified
261
+ const errorCallback = element.getAttribute('reactive-action-error');
262
+ if (errorCallback && typeof window[errorCallback] === 'function') {
263
+ window[errorCallback](error, element, event);
264
+ }
7
265
  }
8
266
 
9
267
  // Get CSRF token from meta tag
@@ -14,9 +272,12 @@ class ReactiveActionsClient {
14
272
 
15
273
  // Execute an action with parameters
16
274
  async execute(actionName, actionParams = {}, options = {}) {
17
- const method = options.method || 'POST';
275
+ const method = options.method || this.config.defaultHttpMethod;
18
276
  const contentType = options.contentType || 'application/json';
19
277
 
278
+ // Use configured baseUrl
279
+ const baseUrl = this.config.baseUrl;
280
+
20
281
  // Build request options
21
282
  const requestOptions = {
22
283
  method: method,
@@ -34,7 +295,7 @@ class ReactiveActionsClient {
34
295
  }
35
296
 
36
297
  // Build URL or prepare body based on HTTP method
37
- let url = this.baseUrl;
298
+ let url = baseUrl;
38
299
  if (['GET', 'HEAD'].includes(method.toUpperCase())) {
39
300
  // For GET requests, append parameters to URL
40
301
  const params = new URLSearchParams({
@@ -88,13 +349,10 @@ class ReactiveActionsClient {
88
349
  }
89
350
  }
90
351
 
91
- // Create and export the instance
92
- const ReactiveActions = new ReactiveActionsClient();
352
+ // ES Module export
353
+ export default ReactiveActionsClient;
93
354
 
94
- // For backward compatibility, also expose as global
355
+ // Global export
95
356
  if (typeof window !== 'undefined') {
96
- window.ReactiveActions = ReactiveActions;
97
- }
98
-
99
- // ES Module export
100
- export default ReactiveActions;
357
+ window.ReactiveActionsClient = ReactiveActionsClient;
358
+ }
@@ -20,6 +20,14 @@ module ReactiveActions
20
20
  desc: 'Skip generating example action'
21
21
  class_option :mount_path, type: :string, default: '/reactive_actions',
22
22
  desc: 'Custom mount path for ReactiveActions'
23
+ class_option :auto_initialize, type: :boolean, default: true,
24
+ desc: 'Auto-initialize ReactiveActions client on page load'
25
+ class_option :enable_dom_binding, type: :boolean, default: true,
26
+ desc: 'Enable automatic DOM binding'
27
+ class_option :enable_mutation_observer, type: :boolean, default: true,
28
+ desc: 'Enable mutation observer for dynamic content'
29
+ class_option :default_http_method, type: :string, default: 'POST',
30
+ desc: 'Default HTTP method for actions'
23
31
  class_option :quiet, type: :boolean, default: false,
24
32
  desc: 'Run with minimal output'
25
33
 
@@ -67,12 +75,20 @@ module ReactiveActions
67
75
 
68
76
  if options[:quiet] || yes?('Add ReactiveActions JavaScript client?', :green)
69
77
  add_javascript_to_importmap
70
- say '✓ Added JavaScript client to importmap', :green unless options[:quiet]
78
+ add_javascript_initialization
79
+ say '✓ Added JavaScript client with initialization', :green unless options[:quiet]
71
80
  else
72
81
  say '✗ Skipped JavaScript configuration', :yellow
73
82
  end
74
83
  end
75
84
 
85
+ def javascript_configuration_options
86
+ return if options[:skip_javascript] || options[:quiet]
87
+ return unless javascript_needed?
88
+
89
+ configure_javascript_options
90
+ end
91
+
76
92
  def configuration_options
77
93
  return if options[:quiet]
78
94
  return unless yes?('Configure advanced options?', :green)
@@ -164,12 +180,9 @@ module ReactiveActions
164
180
  IMPORTMAP
165
181
 
166
182
  append_to_file 'config/importmap.rb', importmap_content
167
-
168
- # Add the import to application.js to ensure it executes
169
- add_import_to_application_js
170
183
  end
171
184
 
172
- def add_import_to_application_js
185
+ def add_javascript_initialization
173
186
  app_js_paths = %w[
174
187
  app/javascript/application.js
175
188
  app/assets/javascripts/application.js
@@ -179,21 +192,104 @@ module ReactiveActions
179
192
  app_js_path = app_js_paths.find { |path| File.exist?(path) }
180
193
 
181
194
  if app_js_path
182
- # Check if import already exists
195
+ # Check if ReactiveActions is already configured
183
196
  app_js_content = File.read(app_js_path)
184
- return if app_js_content.include?('import "reactive_actions"')
197
+ return if app_js_content.include?('ReactiveActions') || app_js_content.include?('reactive_actions')
198
+
199
+ # Generate JavaScript initialization code
200
+ js_config = generate_javascript_config
201
+
202
+ js_code = <<~JAVASCRIPT
203
+
204
+ // Import and initialize ReactiveActions
205
+ import ReactiveActionsClient from "reactive_actions"
185
206
 
186
- import_line = <<~JAVASCRIPT
207
+ // Create and configure ReactiveActions instance
208
+ const reactiveActions = new ReactiveActionsClient(#{js_config});
187
209
 
188
- // Import ReactiveActions to make it globally available
189
- import "reactive_actions"
210
+ #{generate_initialization_code}
211
+
212
+ // Make ReactiveActions globally available
213
+ window.ReactiveActions = reactiveActions;
190
214
  JAVASCRIPT
191
215
 
192
- append_to_file app_js_path, import_line
193
- say "✓ Added ReactiveActions import to #{app_js_path}", :green unless options[:quiet]
216
+ append_to_file app_js_path, js_code
217
+ say "✓ Added ReactiveActions initialization to #{app_js_path}", :green unless options[:quiet]
218
+ else
219
+ say '⚠ Could not find application.js file. Please manually add ReactiveActions initialization.', :yellow
220
+ end
221
+ end
222
+
223
+ def generate_javascript_config
224
+ config = {}
225
+
226
+ # Add mount path if different from default
227
+ mount_path = determine_mount_path || options[:mount_path]
228
+ config[:baseUrl] = "#{mount_path}/execute" if mount_path != '/reactive_actions'
229
+
230
+ # Add configuration options
231
+ config[:enableAutoBinding] = javascript_option_value(:enable_dom_binding)
232
+ config[:enableMutationObserver] = javascript_option_value(:enable_mutation_observer)
233
+ config[:defaultHttpMethod] = javascript_option_value(:default_http_method, 'POST')
234
+
235
+ # Remove default values to keep config clean
236
+ config.delete(:enableAutoBinding) if config[:enableAutoBinding] == true
237
+ config.delete(:enableMutationObserver) if config[:enableMutationObserver] == true
238
+ config.delete(:defaultHttpMethod) if config[:defaultHttpMethod] == 'POST'
239
+
240
+ config.empty? ? '{}' : JSON.pretty_generate(config)
241
+ end
242
+
243
+ def generate_initialization_code
244
+ if javascript_option_value(:auto_initialize)
245
+ <<~JAVASCRIPT.strip
246
+ // Initialize on DOM content loaded and Turbo events
247
+ document.addEventListener('DOMContentLoaded', () => reactiveActions.initialize());
248
+ document.addEventListener('turbo:load', () => reactiveActions.initialize());
249
+ document.addEventListener('turbo:frame-load', () => reactiveActions.initialize());
250
+ JAVASCRIPT
194
251
  else
195
- say '⚠ Could not find application.js file. Please manually add: import "reactive_actions"', :yellow
252
+ <<~JAVASCRIPT.strip
253
+ // Manual initialization - call reactiveActions.initialize() when ready
254
+ // Example: reactiveActions.initialize();
255
+ JAVASCRIPT
256
+ end
257
+ end
258
+
259
+ def javascript_option_value(option_key, default_value = nil)
260
+ return options[option_key] if options.key?(option_key)
261
+ return default_value unless default_value.nil?
262
+
263
+ case option_key
264
+ when :enable_dom_binding, :enable_mutation_observer, :auto_initialize
265
+ true
266
+ when :default_http_method
267
+ 'POST'
268
+ end
269
+ end
270
+
271
+ def configure_javascript_options
272
+ return unless yes?('Configure JavaScript client options?', :green)
273
+
274
+ # Auto-initialize option
275
+ auto_init = yes?('Auto-initialize ReactiveActions on page load? (recommended)', :green)
276
+ @javascript_options ||= {}
277
+ @javascript_options[:auto_initialize] = auto_init
278
+
279
+ # DOM binding option
280
+ enable_dom = yes?('Enable automatic DOM binding? (recommended)', :green)
281
+ @javascript_options[:enable_dom_binding] = enable_dom
282
+
283
+ # Mutation observer option
284
+ if enable_dom
285
+ enable_observer = yes?('Enable mutation observer for dynamic content? (recommended)', :green)
286
+ @javascript_options[:enable_mutation_observer] = enable_observer
196
287
  end
288
+
289
+ # Default HTTP method
290
+ http_methods = %w[POST GET PUT PATCH DELETE]
291
+ default_method = ask('Default HTTP method:', limited_to: http_methods, default: 'POST')
292
+ @javascript_options[:default_http_method] = default_method unless default_method == 'POST'
197
293
  end
198
294
 
199
295
  def add_to_sprockets_manifest
@@ -2,31 +2,107 @@
2
2
 
3
3
  ReactiveActions has been installed successfully!
4
4
 
5
- Routes have been added to your config/routes.rb file:
5
+ SETUP COMPLETED:
6
+ ================
7
+
8
+ ✓ Routes added to config/routes.rb:
6
9
  mount ReactiveActions::Engine, at: '/reactive_actions'
7
10
 
8
- You can now access the reactive actions functionality at:
9
- http://localhost:3000/reactive_actions/execute
11
+ JavaScript client configured with automatic initialization
12
+ - Added to config/importmap.rb
13
+ - Initialized in app/javascript/application.js
14
+ - Available globally as window.ReactiveActions
10
15
 
11
- A sample action has been created at:
16
+ Sample action created:
12
17
  app/reactive_actions/example_action.rb
13
18
 
14
- You can try it out with:
15
-
16
- # Using HTTP
17
- curl -X POST http://localhost:3000/reactive_actions/execute \
18
- -H "Content-Type: application/json" \
19
- -d '{"action_name": "example", "action_params": {"name": "YourName"}}'
20
-
21
- # Using JavaScript client
22
- ReactiveActions.execute('example', { name: 'YourName' })
23
- .then(response => console.log(response))
24
-
25
- To create your own actions, add more files to the app/reactive_actions directory.
26
- Each action should inherit from ReactiveActions::ReactiveAction and define
27
- at least the `action` and `response` methods.
28
-
29
- For more information, see the documentation at:
19
+ QUICK START:
20
+ ============
21
+
22
+ 1. Test the API endpoint:
23
+ curl -X POST http://localhost:3000/reactive_actions/execute \
24
+ -H "Content-Type: application/json" \
25
+ -d '{"action_name": "example", "action_params": {"name": "YourName"}}'
26
+
27
+ 2. Use JavaScript client:
28
+ ReactiveActions.execute('example', { name: 'YourName' })
29
+ .then(response => console.log(response))
30
+
31
+ 3. Use DOM binding (no JavaScript required):
32
+ <button reactive-action="click->example"
33
+ reactive-action-name="YourName">
34
+ Test Action
35
+ </button>
36
+
37
+ JAVASCRIPT CLIENT:
38
+ ==================
39
+
40
+ The ReactiveActions client is automatically initialized and available as:
41
+ - window.ReactiveActions (global)
42
+ - Supports all HTTP methods: get, post, put, patch, delete
43
+ - Automatic CSRF token handling
44
+ - DOM binding with mutation observer
45
+
46
+ Examples:
47
+ // Execute actions
48
+ ReactiveActions.execute('action_name', { param: 'value' })
49
+ ReactiveActions.post('create_user', { name: 'John' })
50
+ ReactiveActions.get('fetch_data', { id: 123 })
51
+
52
+ // Configuration (if needed)
53
+ ReactiveActions.configure({
54
+ baseUrl: '/custom/path/execute',
55
+ defaultHttpMethod: 'PUT'
56
+ }).reinitialize()
57
+
58
+ DOM BINDING:
59
+ ============
60
+
61
+ Add reactive-action attributes to any element:
62
+
63
+ <!-- Basic actions -->
64
+ <button reactive-action="click->update_user">Update</button>
65
+ <input reactive-action="change->search" type="text">
66
+ <form reactive-action="submit->create_post">...</form>
67
+
68
+ <!-- With HTTP methods -->
69
+ <button reactive-action="click->put#update_user">Update (PUT)</button>
70
+ <button reactive-action="click->delete#remove_user">Delete</button>
71
+
72
+ <!-- Pass data via attributes -->
73
+ <button reactive-action="click->update_user"
74
+ reactive-action-user-id="123"
75
+ reactive-action-name="John">
76
+ Update User
77
+ </button>
78
+
79
+ CREATING ACTIONS:
80
+ =================
81
+
82
+ Add files to app/reactive_actions/:
83
+
84
+ # app/reactive_actions/update_user_action.rb
85
+ class UpdateUserAction < ReactiveActions::ReactiveAction
86
+ def action
87
+ user = User.find(action_params[:user_id])
88
+ user.update(name: action_params[:name])
89
+ @result = { success: true, user: user.as_json }
90
+ end
91
+
92
+ def response
93
+ render json: @result
94
+ end
95
+ end
96
+
97
+ DOCUMENTATION & SUPPORT:
98
+ ========================
99
+
100
+ For complete documentation and examples:
30
101
  https://github.com/IstvanMs/reactive-actions
31
102
 
103
+ For troubleshooting and configuration options:
104
+ Check config/initializers/reactive_actions.rb
105
+
106
+ Happy coding with ReactiveActions! 🚀
107
+
32
108
  ===============================================================================
@@ -11,3 +11,17 @@ end
11
11
 
12
12
  # Set the logger for ReactiveActions
13
13
  ReactiveActions.logger = Rails.logger
14
+
15
+ # JavaScript Client Configuration
16
+ # ================================
17
+ # The JavaScript client is automatically initialized in application.js
18
+ # You can reconfigure it at runtime if needed:
19
+ #
20
+ # ReactiveActions.configure({
21
+ # baseUrl: '/custom/path/execute',
22
+ # enableAutoBinding: true,
23
+ # enableMutationObserver: true,
24
+ # defaultHttpMethod: 'POST'
25
+ # }).reinitialize();
26
+ #
27
+ # Available globally as window.ReactiveActions
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module ReactiveActions
4
- VERSION = '0.1.0-alpha.1'
4
+ VERSION = '0.1.0-alpha.2'
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: reactive-actions
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0.pre.alpha.1
4
+ version: 0.1.0.pre.alpha.2
5
5
  platform: ruby
6
6
  authors:
7
7
  - Istvan Meszaros
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-05-30 00:00:00.000000000 Z
11
+ date: 2025-06-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: rails