reactive-actions 0.1.0.pre.alpha.1 → 0.1.0.pre.alpha.3
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.
- checksums.yaml +4 -4
- data/README.md +1156 -324
- data/app/assets/javascripts/reactive_actions.js +271 -13
- data/app/controllers/reactive_actions/reactive_actions_controller.rb +5 -1
- data/lib/generators/reactive_actions/install/install_generator.rb +201 -35
- data/lib/generators/reactive_actions/install/templates/README +96 -20
- data/lib/generators/reactive_actions/install/templates/initializer.rb +59 -0
- data/lib/reactive_actions/concerns/rate_limiter.rb +174 -0
- data/lib/reactive_actions/concerns/security_checks.rb +108 -0
- data/lib/reactive_actions/configuration.rb +23 -3
- data/lib/reactive_actions/controller/rate_limiter.rb +187 -0
- data/lib/reactive_actions/errors.rb +17 -0
- data/lib/reactive_actions/rate_limiter.rb +165 -0
- data/lib/reactive_actions/reactive_action.rb +5 -0
- data/lib/reactive_actions/version.rb +1 -1
- data/lib/reactive_actions.rb +5 -1
- metadata +6 -2
@@ -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
|
-
|
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 ||
|
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 =
|
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
|
-
//
|
92
|
-
|
352
|
+
// ES Module export
|
353
|
+
export default ReactiveActionsClient;
|
93
354
|
|
94
|
-
//
|
355
|
+
// Global export
|
95
356
|
if (typeof window !== 'undefined') {
|
96
|
-
window.
|
97
|
-
}
|
98
|
-
|
99
|
-
// ES Module export
|
100
|
-
export default ReactiveActions;
|
357
|
+
window.ReactiveActionsClient = ReactiveActionsClient;
|
358
|
+
}
|
@@ -4,6 +4,9 @@ module ReactiveActions
|
|
4
4
|
# Main controller for handling reactive action requests
|
5
5
|
# Processes action execution and manages error handling for the ReactiveActions engine
|
6
6
|
class ReactiveActionsController < ApplicationController
|
7
|
+
# Include rate limiting functionality
|
8
|
+
include ReactiveActions::Controller::RateLimiter
|
9
|
+
|
7
10
|
# Allow this action to handle any HTTP method
|
8
11
|
def execute
|
9
12
|
ReactiveActions.logger.info "ReactiveActionsController#execute[#{request.method}]: #{params.inspect}"
|
@@ -118,7 +121,8 @@ module ReactiveActions
|
|
118
121
|
'MissingParameterError' => [:bad_request, 'MISSING_PARAMETER'],
|
119
122
|
'InvalidParametersError' => [:bad_request, 'INVALID_PARAMETERS'],
|
120
123
|
'UnauthorizedError' => [:forbidden, 'UNAUTHORIZED'],
|
121
|
-
'ActionExecutionError' => [:unprocessable_entity, 'EXECUTION_ERROR']
|
124
|
+
'ActionExecutionError' => [:unprocessable_entity, 'EXECUTION_ERROR'],
|
125
|
+
'RateLimitExceededError' => [:too_many_requests, 'RATE_LIMIT_EXCEEDED']
|
122
126
|
}
|
123
127
|
|
124
128
|
error_type = exception.class.name.demodulize
|
@@ -20,9 +20,27 @@ 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
|
|
34
|
+
# Rate limiting options
|
35
|
+
class_option :enable_rate_limiting, type: :boolean, default: false,
|
36
|
+
desc: 'Enable rate limiting features'
|
37
|
+
class_option :enable_global_rate_limiting, type: :boolean, default: false,
|
38
|
+
desc: 'Enable global controller-level rate limiting'
|
39
|
+
class_option :global_rate_limit, type: :numeric, default: 600,
|
40
|
+
desc: 'Global rate limit (requests per window)'
|
41
|
+
class_option :global_rate_limit_window, type: :string, default: '1.minute',
|
42
|
+
desc: 'Global rate limit window (e.g., "1.minute", "5.minutes")'
|
43
|
+
|
26
44
|
def welcome_message
|
27
45
|
return if options[:quiet]
|
28
46
|
|
@@ -31,24 +49,25 @@ module ReactiveActions
|
|
31
49
|
say ''
|
32
50
|
end
|
33
51
|
|
52
|
+
def gather_configuration
|
53
|
+
# Gather all configuration before creating files
|
54
|
+
setup_rate_limiting_configuration_for_initializer
|
55
|
+
end
|
56
|
+
|
34
57
|
def create_initializer
|
35
|
-
|
36
|
-
|
37
|
-
|
38
|
-
|
39
|
-
say '✗ Skipped initializer creation', :yellow
|
40
|
-
end
|
58
|
+
return unless options[:quiet] || yes?('Create ReactiveActions initializer? (recommended)', :green)
|
59
|
+
|
60
|
+
template 'initializer.rb', 'config/initializers/reactive_actions.rb'
|
61
|
+
say '✓ Created initializer', :green unless options[:quiet]
|
41
62
|
end
|
42
63
|
|
43
64
|
def create_actions_directory
|
44
|
-
|
45
|
-
empty_directory 'app/reactive_actions'
|
46
|
-
say '✓ Created actions directory', :green unless options[:quiet]
|
65
|
+
return unless options[:quiet] || yes?('Create app/reactive_actions directory?', :green)
|
47
66
|
|
48
|
-
|
49
|
-
|
50
|
-
|
51
|
-
|
67
|
+
empty_directory 'app/reactive_actions'
|
68
|
+
say '✓ Created actions directory', :green unless options[:quiet]
|
69
|
+
|
70
|
+
ask_for_example_action unless options[:skip_example]
|
52
71
|
end
|
53
72
|
|
54
73
|
def configure_routes
|
@@ -64,13 +83,31 @@ module ReactiveActions
|
|
64
83
|
def configure_javascript
|
65
84
|
return if options[:skip_javascript]
|
66
85
|
return unless javascript_needed?
|
86
|
+
return unless options[:quiet] || yes?('Add ReactiveActions JavaScript client?', :green)
|
67
87
|
|
68
|
-
|
69
|
-
|
70
|
-
|
71
|
-
|
72
|
-
|
73
|
-
|
88
|
+
add_javascript_to_importmap
|
89
|
+
add_javascript_initialization
|
90
|
+
say '✓ Added JavaScript client with initialization', :green unless options[:quiet]
|
91
|
+
end
|
92
|
+
|
93
|
+
def javascript_configuration_options
|
94
|
+
return if options[:skip_javascript] || options[:quiet]
|
95
|
+
return unless javascript_needed?
|
96
|
+
|
97
|
+
configure_javascript_options
|
98
|
+
end
|
99
|
+
|
100
|
+
def rate_limiting_configuration
|
101
|
+
# Show rate limiting configuration summary if it was configured
|
102
|
+
return unless @rate_limiting_config && @rate_limiting_config[:rate_limiting_enabled] && !options[:quiet]
|
103
|
+
|
104
|
+
say '', :green
|
105
|
+
say '✓ Rate limiting configured:', :green
|
106
|
+
say " - Rate limiting: #{@rate_limiting_config[:rate_limiting_enabled] ? 'ENABLED' : 'DISABLED'}", :blue
|
107
|
+
say " - Global rate limiting: #{@rate_limiting_config[:global_rate_limiting_enabled] ? 'ENABLED' : 'DISABLED'}", :blue
|
108
|
+
return unless @rate_limiting_config[:global_rate_limiting_enabled]
|
109
|
+
|
110
|
+
say " - Global limit: #{@rate_limiting_config[:global_rate_limit]} requests per #{@rate_limiting_config[:global_rate_limit_window]}", :blue
|
74
111
|
end
|
75
112
|
|
76
113
|
def configuration_options
|
@@ -94,6 +131,62 @@ module ReactiveActions
|
|
94
131
|
|
95
132
|
private
|
96
133
|
|
134
|
+
def setup_rate_limiting_configuration_for_initializer
|
135
|
+
if rate_limiting_options_provided?
|
136
|
+
configure_rate_limiting_from_options_quietly
|
137
|
+
elsif !options[:quiet] && ask_about_rate_limiting?
|
138
|
+
configure_rate_limiting_interactively
|
139
|
+
else
|
140
|
+
@rate_limiting_config = {}
|
141
|
+
end
|
142
|
+
end
|
143
|
+
|
144
|
+
def rate_limiting_options_provided?
|
145
|
+
options[:enable_rate_limiting] || options[:enable_global_rate_limiting]
|
146
|
+
end
|
147
|
+
|
148
|
+
def ask_about_rate_limiting?
|
149
|
+
yes?('Configure rate limiting? (optional but recommended for production)', :green)
|
150
|
+
end
|
151
|
+
|
152
|
+
def configure_rate_limiting_from_options_quietly
|
153
|
+
@rate_limiting_config = {
|
154
|
+
rate_limiting_enabled: options[:enable_rate_limiting] || options[:enable_global_rate_limiting],
|
155
|
+
global_rate_limiting_enabled: options[:enable_global_rate_limiting]
|
156
|
+
}
|
157
|
+
|
158
|
+
return unless options[:enable_global_rate_limiting]
|
159
|
+
|
160
|
+
@rate_limiting_config[:global_rate_limit] = options[:global_rate_limit]
|
161
|
+
@rate_limiting_config[:global_rate_limit_window] = options[:global_rate_limit_window]
|
162
|
+
end
|
163
|
+
|
164
|
+
def configure_rate_limiting_interactively
|
165
|
+
@rate_limiting_config = {}
|
166
|
+
|
167
|
+
enable_rate_limiting = yes?('Enable rate limiting features?', :green)
|
168
|
+
@rate_limiting_config[:rate_limiting_enabled] = enable_rate_limiting
|
169
|
+
return unless enable_rate_limiting
|
170
|
+
|
171
|
+
enable_global = yes?('Enable global controller-level rate limiting? (recommended)', :green)
|
172
|
+
@rate_limiting_config[:global_rate_limiting_enabled] = enable_global
|
173
|
+
|
174
|
+
return unless enable_global
|
175
|
+
|
176
|
+
limit = ask('Global rate limit (requests per window):', default: 600)
|
177
|
+
@rate_limiting_config[:global_rate_limit] = limit.to_i
|
178
|
+
|
179
|
+
window = ask('Global rate limit window:',
|
180
|
+
limited_to: %w[30.seconds 1.minute 5.minutes 15.minutes 1.hour],
|
181
|
+
default: '1.minute')
|
182
|
+
@rate_limiting_config[:global_rate_limit_window] = window
|
183
|
+
|
184
|
+
return unless yes?('Configure custom rate limit key generator? (advanced)', :green)
|
185
|
+
|
186
|
+
say 'You can customize how rate limit keys are generated in the initializer.'
|
187
|
+
@rate_limiting_config[:custom_key_generator] = true
|
188
|
+
end
|
189
|
+
|
97
190
|
def should_skip_example_action?
|
98
191
|
return true if options[:skip_example]
|
99
192
|
return true if !options[:quiet] && !yes?('Generate example action file?', :green)
|
@@ -164,12 +257,9 @@ module ReactiveActions
|
|
164
257
|
IMPORTMAP
|
165
258
|
|
166
259
|
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
260
|
end
|
171
261
|
|
172
|
-
def
|
262
|
+
def add_javascript_initialization
|
173
263
|
app_js_paths = %w[
|
174
264
|
app/javascript/application.js
|
175
265
|
app/assets/javascripts/application.js
|
@@ -179,29 +269,100 @@ module ReactiveActions
|
|
179
269
|
app_js_path = app_js_paths.find { |path| File.exist?(path) }
|
180
270
|
|
181
271
|
if app_js_path
|
182
|
-
# Check if import already exists
|
183
272
|
app_js_content = File.read(app_js_path)
|
184
|
-
return if app_js_content.include?('
|
273
|
+
return if app_js_content.include?('ReactiveActions') || app_js_content.include?('reactive_actions')
|
185
274
|
|
186
|
-
|
275
|
+
js_config = generate_javascript_config
|
276
|
+
js_code = build_javascript_code(js_config)
|
187
277
|
|
188
|
-
|
189
|
-
|
190
|
-
|
278
|
+
append_to_file app_js_path, js_code
|
279
|
+
say "✓ Added ReactiveActions initialization to #{app_js_path}", :green unless options[:quiet]
|
280
|
+
else
|
281
|
+
say '⚠ Could not find application.js file. Please manually add ReactiveActions initialization.', :yellow
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def generate_javascript_config
|
286
|
+
config = {}
|
287
|
+
mount_path = determine_mount_path || options[:mount_path]
|
288
|
+
config[:baseUrl] = "#{mount_path}/execute" if mount_path != '/reactive_actions'
|
289
|
+
|
290
|
+
config[:enableAutoBinding] = javascript_option_value(:enable_dom_binding)
|
291
|
+
config[:enableMutationObserver] = javascript_option_value(:enable_mutation_observer)
|
292
|
+
config[:defaultHttpMethod] = javascript_option_value(:default_http_method, 'POST')
|
293
|
+
|
294
|
+
# Remove default values to keep config clean
|
295
|
+
config.delete(:enableAutoBinding) if config[:enableAutoBinding] == true
|
296
|
+
config.delete(:enableMutationObserver) if config[:enableMutationObserver] == true
|
297
|
+
config.delete(:defaultHttpMethod) if config[:defaultHttpMethod] == 'POST'
|
298
|
+
|
299
|
+
config.empty? ? '{}' : JSON.pretty_generate(config)
|
300
|
+
end
|
301
|
+
|
302
|
+
def build_javascript_code(js_config)
|
303
|
+
<<~JAVASCRIPT
|
304
|
+
|
305
|
+
// Import and initialize ReactiveActions
|
306
|
+
import ReactiveActionsClient from "reactive_actions"
|
307
|
+
|
308
|
+
// Create and configure ReactiveActions instance
|
309
|
+
const reactiveActions = new ReactiveActionsClient(#{js_config});
|
191
310
|
|
192
|
-
|
193
|
-
|
311
|
+
#{generate_initialization_code}
|
312
|
+
|
313
|
+
// Make ReactiveActions globally available
|
314
|
+
window.ReactiveActions = reactiveActions;
|
315
|
+
JAVASCRIPT
|
316
|
+
end
|
317
|
+
|
318
|
+
def generate_initialization_code
|
319
|
+
if javascript_option_value(:auto_initialize)
|
320
|
+
<<~JAVASCRIPT.strip
|
321
|
+
// Initialize on DOM content loaded and Turbo events
|
322
|
+
document.addEventListener('DOMContentLoaded', () => reactiveActions.initialize());
|
323
|
+
document.addEventListener('turbo:load', () => reactiveActions.initialize());
|
324
|
+
document.addEventListener('turbo:frame-load', () => reactiveActions.initialize());
|
325
|
+
JAVASCRIPT
|
194
326
|
else
|
195
|
-
|
327
|
+
'// Manual initialization - call reactiveActions.initialize() when ready'
|
328
|
+
end
|
329
|
+
end
|
330
|
+
|
331
|
+
def javascript_option_value(option_key, default_value = nil)
|
332
|
+
return options[option_key] if options.key?(option_key)
|
333
|
+
return default_value unless default_value.nil?
|
334
|
+
|
335
|
+
case option_key
|
336
|
+
when :enable_dom_binding, :enable_mutation_observer, :auto_initialize
|
337
|
+
true
|
338
|
+
when :default_http_method
|
339
|
+
'POST'
|
340
|
+
end
|
341
|
+
end
|
342
|
+
|
343
|
+
def configure_javascript_options
|
344
|
+
return unless yes?('Configure JavaScript client options?', :green)
|
345
|
+
|
346
|
+
@javascript_options ||= {}
|
347
|
+
@javascript_options[:auto_initialize] = yes?('Auto-initialize ReactiveActions on page load? (recommended)', :green)
|
348
|
+
|
349
|
+
enable_dom = yes?('Enable automatic DOM binding? (recommended)', :green)
|
350
|
+
@javascript_options[:enable_dom_binding] = enable_dom
|
351
|
+
|
352
|
+
if enable_dom
|
353
|
+
enable_observer = yes?('Enable mutation observer for dynamic content? (recommended)', :green)
|
354
|
+
@javascript_options[:enable_mutation_observer] = enable_observer
|
196
355
|
end
|
356
|
+
|
357
|
+
http_methods = %w[POST GET PUT PATCH DELETE]
|
358
|
+
default_method = ask('Default HTTP method:', limited_to: http_methods, default: 'POST')
|
359
|
+
@javascript_options[:default_http_method] = default_method unless default_method == 'POST'
|
197
360
|
end
|
198
361
|
|
199
362
|
def add_to_sprockets_manifest
|
200
363
|
return unless File.exist?('app/assets/config/manifest.js')
|
201
364
|
|
202
|
-
append_to_file 'app/assets/config/manifest.js'
|
203
|
-
"\n//= link reactive_actions.js\n"
|
204
|
-
end
|
365
|
+
append_to_file 'app/assets/config/manifest.js', "\n//= link reactive_actions.js\n"
|
205
366
|
end
|
206
367
|
|
207
368
|
def configure_delegated_methods
|
@@ -256,6 +417,11 @@ module ReactiveActions
|
|
256
417
|
template 'example_action.rb', "app/reactive_actions/#{sanitized_name}.rb"
|
257
418
|
say "✓ Created #{sanitized_name}.rb", :green unless options[:quiet]
|
258
419
|
end
|
420
|
+
|
421
|
+
# Template method to access rate limiting config in templates
|
422
|
+
def rate_limiting_config
|
423
|
+
@rate_limiting_config || {}
|
424
|
+
end
|
259
425
|
end
|
260
426
|
end
|
261
427
|
end
|