funicular 0.0.1 → 0.1.0
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/CHANGELOG.md +56 -1
- data/README.md +58 -20
- data/Rakefile +74 -2
- data/demo/keymap_editor.html +582 -0
- data/demo/test_cable.html +179 -0
- data/demo/test_chartjs.html +235 -0
- data/demo/test_component.html +201 -0
- data/demo/test_diff_patch.html +146 -0
- data/demo/test_error_boundary.html +284 -0
- data/demo/test_router.html +257 -0
- data/demo/test_vdom.html +100 -0
- data/demo/tic-tac-toe.html +201 -0
- data/docs/README.md +419 -0
- data/docs/advanced-features.md +632 -0
- data/docs/architecture.md +409 -0
- data/docs/components-and-state.md +539 -0
- data/docs/data-fetching.md +528 -0
- data/docs/forms.md +446 -0
- data/docs/rails-integration.md +426 -0
- data/docs/realtime.md +543 -0
- data/docs/routing-and-navigation.md +427 -0
- data/docs/styling.md +285 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.rb +21 -0
- data/lib/funicular/assets/funicular_debug.css +73 -0
- data/lib/funicular/assets/funicular_debug.js +183 -0
- data/lib/funicular/commands/routes.rb +69 -0
- data/lib/funicular/compiler.rb +135 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +50 -0
- data/lib/funicular/middleware.rb +98 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/vendor/picorbc/VERSION +1 -0
- data/lib/funicular/vendor/picorbc/picorbc.js +5283 -0
- data/lib/funicular/vendor/picorbc/picorbc.wasm +0 -0
- data/lib/funicular/vendor/picoruby/VERSION +1 -0
- data/lib/funicular/vendor/picoruby/debug/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.js +6404 -0
- data/lib/funicular/vendor/picoruby/debug/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby/dist/init.iife.js +130 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.js +2 -0
- data/lib/funicular/vendor/picoruby/dist/picoruby.wasm +0 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +29 -1
- data/lib/tasks/funicular.rake +135 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/test_helper.rb +7 -0
- data/mrbgem.rake +15 -0
- data/mrblib/cable.rb +417 -0
- data/mrblib/component.rb +911 -0
- data/mrblib/debug.rb +205 -0
- data/mrblib/differ.rb +244 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +184 -0
- data/mrblib/form_builder.rb +284 -0
- data/mrblib/funicular.rb +156 -0
- data/mrblib/http.rb +89 -0
- data/mrblib/model.rb +146 -0
- data/mrblib/patcher.rb +203 -0
- data/mrblib/router.rb +229 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +65 -0
- data/sig/component.rbs +141 -0
- data/sig/debug.rbs +28 -0
- data/sig/differ.rbs +18 -0
- data/sig/environment_iquirer.rbs +10 -0
- data/sig/error_boundary.rbs +14 -0
- data/sig/file_upload.rbs +18 -0
- data/sig/form_builder.rbs +29 -0
- data/sig/funicular.rbs +11 -1
- data/sig/http.rbs +22 -0
- data/sig/model.rbs +23 -0
- data/sig/patcher.rbs +15 -0
- data/sig/router.rbs +43 -0
- data/sig/styles.rbs +25 -0
- data/sig/vdom.rbs +59 -0
- metadata +119 -8
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
require 'rng'
|
|
2
|
+
|
|
3
|
+
module Funicular
|
|
4
|
+
module FileUpload
|
|
5
|
+
JS_HELPER_CODE = <<~JAVASCRIPT
|
|
6
|
+
(function() {
|
|
7
|
+
'use strict';
|
|
8
|
+
// Avoid duplicate mounting
|
|
9
|
+
if (window.funicularFormDataUpload) {
|
|
10
|
+
return;
|
|
11
|
+
}
|
|
12
|
+
// FormData upload helper
|
|
13
|
+
window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
|
|
14
|
+
const formData = new FormData();
|
|
15
|
+
for (const [key, value] of Object.entries(fieldsObj)) {
|
|
16
|
+
formData.append(key, String(value));
|
|
17
|
+
}
|
|
18
|
+
if (fileFieldName && fileRefId !== null && fileRefId !== undefined) {
|
|
19
|
+
const file = window.picorubyRefs[fileRefId];
|
|
20
|
+
if (file) {
|
|
21
|
+
formData.append(fileFieldName, file);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return fetch(url, {
|
|
25
|
+
method: 'PATCH',
|
|
26
|
+
body: formData
|
|
27
|
+
}).then(response => response.json());
|
|
28
|
+
};
|
|
29
|
+
console.log('Funicular helpers mounted');
|
|
30
|
+
})();
|
|
31
|
+
JAVASCRIPT
|
|
32
|
+
|
|
33
|
+
def self.mount
|
|
34
|
+
script = JS.document.createElement("script")
|
|
35
|
+
script[:textContent] = JS_HELPER_CODE
|
|
36
|
+
JS.document.body.appendChild(script)
|
|
37
|
+
JS.document.body.removeChild(script)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Select a file from input element and generate preview
|
|
41
|
+
# @param input_id [String] DOM element ID of the file input
|
|
42
|
+
# @param block [Proc] Callback with preview data URL (or nil if no file)
|
|
43
|
+
def self.select_file_with_preview(input_id, &block)
|
|
44
|
+
input = JS.document.getElementById(input_id)
|
|
45
|
+
return block.call(nil, nil) unless input
|
|
46
|
+
|
|
47
|
+
# @type var files: Array[JS::Object]
|
|
48
|
+
files = input[:files]
|
|
49
|
+
if !files || files.length.to_i == 0
|
|
50
|
+
return block.call(nil, nil)
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
file = files[0]
|
|
54
|
+
|
|
55
|
+
# Use URL.createObjectURL instead of FileReader for preview
|
|
56
|
+
# This is simpler and doesn't require async callbacks
|
|
57
|
+
if file.nil?
|
|
58
|
+
raise "Selected file is nil"
|
|
59
|
+
else
|
|
60
|
+
url = JS.global[:URL]
|
|
61
|
+
if url.is_a?(JS::Object)
|
|
62
|
+
preview_url = url.createObjectURL(file)
|
|
63
|
+
else
|
|
64
|
+
preview_url = nil
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Call the block with file and preview URL
|
|
69
|
+
# @type var preview_url: String
|
|
70
|
+
block.call(file, preview_url) if block
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Upload data with FormData (supports file upload)
|
|
74
|
+
# @param url [String] Upload URL
|
|
75
|
+
# @param fields [Hash] Form fields to include
|
|
76
|
+
# @param file_field [String] Name of the file field
|
|
77
|
+
# @param file [JS::Object] File object from input element
|
|
78
|
+
# @param block [Proc] Callback with response data
|
|
79
|
+
def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
|
|
80
|
+
# Store file and callback ID
|
|
81
|
+
@callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
|
|
82
|
+
callback_id = _ = nil
|
|
83
|
+
while true
|
|
84
|
+
callback_id = RNG.random_int
|
|
85
|
+
break unless @callback_counters.include?(callback_id)
|
|
86
|
+
end
|
|
87
|
+
@callback_counters << callback_id
|
|
88
|
+
|
|
89
|
+
if file
|
|
90
|
+
JS.global[:_tempFileForUpload] = file
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
# Build fields object as JSON string
|
|
94
|
+
fields_json = JSON.generate(fields)
|
|
95
|
+
|
|
96
|
+
# Call JavaScript helper
|
|
97
|
+
# Note: The helper returns a Promise that resolves to JSON data (not Response)
|
|
98
|
+
script = JS.document.createElement("script")
|
|
99
|
+
script_code = <<~JS
|
|
100
|
+
(function() {
|
|
101
|
+
var fieldsObj = JSON.parse('#{fields_json.gsub("'", "\\\\'")}');
|
|
102
|
+
var fileRefId = null;
|
|
103
|
+
if (window._tempFileForUpload) {
|
|
104
|
+
fileRefId = window.picorubyRefs.indexOf(window._tempFileForUpload);
|
|
105
|
+
}
|
|
106
|
+
// Call helper and store result as JSON string when Promise resolves
|
|
107
|
+
window.funicularFormDataUpload(
|
|
108
|
+
'#{url}',
|
|
109
|
+
fieldsObj,
|
|
110
|
+
#{file_field ? "'#{file_field}'" : 'null'},
|
|
111
|
+
fileRefId
|
|
112
|
+
).then(function(data) {
|
|
113
|
+
// Store as JSON string for easy Ruby parsing
|
|
114
|
+
window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
|
|
115
|
+
}).catch(function(error) {
|
|
116
|
+
window._funicularUploadResult_#{callback_id} = JSON.stringify({error: error.toString()});
|
|
117
|
+
});
|
|
118
|
+
})();
|
|
119
|
+
JS
|
|
120
|
+
script[:textContent] = script_code
|
|
121
|
+
JS.document.body.appendChild(script)
|
|
122
|
+
JS.document.body.removeChild(script)
|
|
123
|
+
|
|
124
|
+
# Poll for result (simple polling approach)
|
|
125
|
+
counter = 0
|
|
126
|
+
json_str = nil
|
|
127
|
+
while true
|
|
128
|
+
counter += 1
|
|
129
|
+
sleep 0.1
|
|
130
|
+
json_str = JS.global[:"_funicularUploadResult_#{callback_id}"]
|
|
131
|
+
break if json_str && !json_str.to_s.empty?
|
|
132
|
+
if 100 < counter
|
|
133
|
+
puts "[WARN] Timeout waiting for upload result. Return without calling block."
|
|
134
|
+
return
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
|
|
138
|
+
# Get JSON string from global variable (already retrieved above)
|
|
139
|
+
result = JSON.parse(json_str.to_s)
|
|
140
|
+
|
|
141
|
+
block.call(result) if block
|
|
142
|
+
puts "[DEBUG] Block called"
|
|
143
|
+
ensure
|
|
144
|
+
# Always cleanup global references, even on timeout or error
|
|
145
|
+
if callback_id
|
|
146
|
+
@callback_counters.delete(callback_id) if @callback_counters
|
|
147
|
+
if JS.global[:"_funicularUploadResult_#{callback_id}"]
|
|
148
|
+
JS.global[:"_funicularUploadResult_#{callback_id}"] = nil
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
if JS.global[:_tempFileForUpload]
|
|
152
|
+
JS.global[:_tempFileForUpload] = nil
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
|
|
156
|
+
# Helper: Store file in global variable for later use
|
|
157
|
+
def self.store_file(input_id, storage_key = '_selectedFile')
|
|
158
|
+
input = JS.document.getElementById(input_id)
|
|
159
|
+
return nil unless input
|
|
160
|
+
|
|
161
|
+
# @type var files: Array[JS::Object]
|
|
162
|
+
files = input[:files]
|
|
163
|
+
if !files || files.length.to_i == 0
|
|
164
|
+
JS.global[storage_key] = nil
|
|
165
|
+
return nil
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
file = files[0]
|
|
169
|
+
JS.global[storage_key] = file
|
|
170
|
+
file
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Helper: Retrieve stored file
|
|
174
|
+
def self.retrieve_file(storage_key = '_selectedFile')
|
|
175
|
+
file = JS.global[storage_key]
|
|
176
|
+
JS.global[storage_key] = nil if file
|
|
177
|
+
if file.is_a?(JS::Object)
|
|
178
|
+
file
|
|
179
|
+
else
|
|
180
|
+
nil
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
class FormBuilder
|
|
3
|
+
attr_reader :component, :model_key, :options
|
|
4
|
+
|
|
5
|
+
def initialize(component, model_key, options = {})
|
|
6
|
+
@component = component
|
|
7
|
+
@model_key = model_key
|
|
8
|
+
@options = options
|
|
9
|
+
@error_class = options[:error_class] || "text-red-600 text-sm mt-1"
|
|
10
|
+
@field_error_class = options[:field_error_class] || "border-red-500"
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
# Generic field builder for input elements
|
|
14
|
+
def build_field(field_name, field_type, field_options = {})
|
|
15
|
+
field_key = field_name.to_s
|
|
16
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
17
|
+
|
|
18
|
+
# Get current value from state
|
|
19
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
20
|
+
|
|
21
|
+
# Build input change handler
|
|
22
|
+
on_input = ->(event) do
|
|
23
|
+
new_value = event.target[:value]
|
|
24
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Check for errors
|
|
28
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
29
|
+
has_error = !error_message.nil?
|
|
30
|
+
|
|
31
|
+
# Merge CSS classes (add error class if error exists)
|
|
32
|
+
css_class = field_options[:class]
|
|
33
|
+
css_class = css_class.to_s if css_class
|
|
34
|
+
css_class ||= ""
|
|
35
|
+
|
|
36
|
+
if has_error
|
|
37
|
+
if css_class.empty?
|
|
38
|
+
css_class = @field_error_class
|
|
39
|
+
else
|
|
40
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Build field attributes
|
|
45
|
+
attrs = {
|
|
46
|
+
type: field_type,
|
|
47
|
+
value: value,
|
|
48
|
+
oninput: on_input
|
|
49
|
+
}.merge(field_options.reject { |k, _| k == :class })
|
|
50
|
+
|
|
51
|
+
# Add class attribute if not empty
|
|
52
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
53
|
+
|
|
54
|
+
# Render field + error message
|
|
55
|
+
@component.div do
|
|
56
|
+
@component.input(attrs)
|
|
57
|
+
if has_error
|
|
58
|
+
@component.div(class: @error_class) { error_message }
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Public field methods
|
|
64
|
+
def text_field(field_name, options = {})
|
|
65
|
+
build_field(field_name, "text", options)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def password_field(field_name, options = {})
|
|
69
|
+
build_field(field_name, "password", options)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def email_field(field_name, options = {})
|
|
73
|
+
build_field(field_name, "email", options)
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def number_field(field_name, options = {})
|
|
77
|
+
build_field(field_name, "number", options)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def textarea(field_name, options = {})
|
|
81
|
+
field_key = field_name.to_s
|
|
82
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
83
|
+
|
|
84
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
85
|
+
|
|
86
|
+
on_input = ->(event) do
|
|
87
|
+
new_value = event.target[:value]
|
|
88
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
92
|
+
has_error = !error_message.nil?
|
|
93
|
+
|
|
94
|
+
css_class = options[:class]
|
|
95
|
+
css_class = css_class.to_s if css_class
|
|
96
|
+
css_class ||= ""
|
|
97
|
+
|
|
98
|
+
if has_error
|
|
99
|
+
if css_class.empty?
|
|
100
|
+
css_class = @field_error_class
|
|
101
|
+
else
|
|
102
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
attrs = {
|
|
107
|
+
oninput: on_input
|
|
108
|
+
}.merge(options.reject { |k, _| k == :class })
|
|
109
|
+
|
|
110
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
111
|
+
|
|
112
|
+
@component.div do
|
|
113
|
+
@component.textarea(attrs) { value }
|
|
114
|
+
if has_error
|
|
115
|
+
@component.div(class: @error_class) { error_message }
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def checkbox(field_name, options = {})
|
|
121
|
+
field_key = field_name.to_s
|
|
122
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
123
|
+
|
|
124
|
+
value = get_nested_value(@component.state, full_key) || false
|
|
125
|
+
|
|
126
|
+
on_change = ->(event) do
|
|
127
|
+
new_value = event.target[:checked]
|
|
128
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
attrs = {
|
|
132
|
+
type: "checkbox",
|
|
133
|
+
checked: value,
|
|
134
|
+
onchange: on_change
|
|
135
|
+
}.merge(options)
|
|
136
|
+
|
|
137
|
+
@component.input(attrs)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def select(field_name, choices, options = {})
|
|
141
|
+
field_key = field_name.to_s
|
|
142
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
143
|
+
|
|
144
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
145
|
+
|
|
146
|
+
on_change = ->(event) do
|
|
147
|
+
new_value = event.target[:value]
|
|
148
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
152
|
+
has_error = !error_message.nil?
|
|
153
|
+
|
|
154
|
+
css_class = options[:class]
|
|
155
|
+
css_class = css_class.to_s if css_class
|
|
156
|
+
css_class ||= ""
|
|
157
|
+
|
|
158
|
+
if has_error
|
|
159
|
+
if css_class.empty?
|
|
160
|
+
css_class = @field_error_class
|
|
161
|
+
else
|
|
162
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
attrs = {
|
|
167
|
+
onchange: on_change
|
|
168
|
+
}.merge(options.reject { |k, _| k == :class })
|
|
169
|
+
|
|
170
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
171
|
+
|
|
172
|
+
@component.div do
|
|
173
|
+
@component.select(attrs) do
|
|
174
|
+
choices.each do |choice|
|
|
175
|
+
option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
|
|
176
|
+
selected = value.to_s == option_value.to_s
|
|
177
|
+
@component.option(value: option_value, selected: selected) do
|
|
178
|
+
option_text
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
if has_error
|
|
183
|
+
@component.div(class: @error_class) { error_message }
|
|
184
|
+
end
|
|
185
|
+
end
|
|
186
|
+
end
|
|
187
|
+
|
|
188
|
+
def file_field(field_name, options = {})
|
|
189
|
+
field_key = field_name.to_s
|
|
190
|
+
|
|
191
|
+
# Check if custom onchange handler is provided
|
|
192
|
+
custom_handler = options.delete(:onchange)
|
|
193
|
+
|
|
194
|
+
# Default handler: store file name in state
|
|
195
|
+
default_handler = ->(event) do
|
|
196
|
+
file = event.target[:files] ? event.target[:files][0] : nil
|
|
197
|
+
file_name = if file.nil?
|
|
198
|
+
nil
|
|
199
|
+
else
|
|
200
|
+
# @type var file: Hash[Symbol, String]
|
|
201
|
+
file[:name]
|
|
202
|
+
end
|
|
203
|
+
set_nested_value(@model_key, field_key, file_name)
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
on_change = custom_handler || default_handler
|
|
207
|
+
|
|
208
|
+
attrs = {
|
|
209
|
+
type: "file",
|
|
210
|
+
onchange: on_change
|
|
211
|
+
}.merge(options)
|
|
212
|
+
|
|
213
|
+
@component.input(attrs)
|
|
214
|
+
end
|
|
215
|
+
|
|
216
|
+
def submit(label = "Submit", options = {})
|
|
217
|
+
attrs = { type: "submit" }.merge(options)
|
|
218
|
+
@component.button(attrs) { label }
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
def label(field_name, text = nil, options = {})
|
|
222
|
+
text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
|
|
223
|
+
@component.label(options) { text }
|
|
224
|
+
end
|
|
225
|
+
|
|
226
|
+
private
|
|
227
|
+
|
|
228
|
+
# Get nested value from state (e.g., "user.username")
|
|
229
|
+
def get_nested_value(state, key_path)
|
|
230
|
+
keys = key_path.split('.')
|
|
231
|
+
value = state
|
|
232
|
+
keys.each do |key|
|
|
233
|
+
if value.respond_to?(key.to_sym)
|
|
234
|
+
value = value.send(key.to_sym)
|
|
235
|
+
elsif value.is_a?(Hash)
|
|
236
|
+
value = value[key.to_sym] || value[key]
|
|
237
|
+
else
|
|
238
|
+
value = nil
|
|
239
|
+
end
|
|
240
|
+
break if value.nil?
|
|
241
|
+
end
|
|
242
|
+
value
|
|
243
|
+
end
|
|
244
|
+
|
|
245
|
+
# Set nested value in state via patch
|
|
246
|
+
def set_nested_value(model_key, field_key, new_value)
|
|
247
|
+
# Handle nested keys like "address.city"
|
|
248
|
+
if field_key.include?('.')
|
|
249
|
+
# Complex nested update
|
|
250
|
+
keys = field_key.split('.')
|
|
251
|
+
current_model = @component.state.send(model_key.to_sym)
|
|
252
|
+
updated_model = deep_merge_value(current_model, keys, new_value)
|
|
253
|
+
@component.patch(model_key.to_sym => updated_model)
|
|
254
|
+
else
|
|
255
|
+
# Simple update
|
|
256
|
+
current_model = @component.state.send(model_key.to_sym)
|
|
257
|
+
if current_model.nil?
|
|
258
|
+
@component.patch(model_key.to_sym => { field_key.to_sym => new_value })
|
|
259
|
+
elsif current_model.is_a?(Hash)
|
|
260
|
+
updated_model = current_model.merge(field_key.to_sym => new_value)
|
|
261
|
+
@component.patch(model_key.to_sym => updated_model)
|
|
262
|
+
else
|
|
263
|
+
# Assume it's an object with instance variables
|
|
264
|
+
current_model.instance_variable_set("@#{field_key}", new_value)
|
|
265
|
+
@component.patch(model_key.to_sym => current_model)
|
|
266
|
+
end
|
|
267
|
+
end
|
|
268
|
+
end
|
|
269
|
+
|
|
270
|
+
# Deep merge helper for nested keys
|
|
271
|
+
def deep_merge_value(hash, keys, value)
|
|
272
|
+
return hash unless hash.is_a?(Hash)
|
|
273
|
+
|
|
274
|
+
hash = hash.dup
|
|
275
|
+
if keys.length == 1
|
|
276
|
+
hash[keys[0].to_sym] = value
|
|
277
|
+
else
|
|
278
|
+
key = keys[0].to_sym
|
|
279
|
+
hash[key] = deep_merge_value(hash[key] || {}, (keys[1..-1] || []), value)
|
|
280
|
+
end
|
|
281
|
+
hash
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
data/mrblib/funicular.rb
ADDED
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
# The 'js' gem (picoruby-wasm) provides JavaScript interop and is only
|
|
2
|
+
# available in wasm builds. During test builds, picoruby-wasm is excluded
|
|
3
|
+
# from dependencies (see mrbgem.rake), so `require 'js'` raises LoadError.
|
|
4
|
+
#
|
|
5
|
+
# Additionally, gem init order is not guaranteed to be stable. A dummy
|
|
6
|
+
# `require` in picoruby-mruby/mrblib/require.rb exists to suppress
|
|
7
|
+
# LoadError during picogem_init, but if picoruby-require initializes
|
|
8
|
+
# before this gem, the real `require` (which raises LoadError) will
|
|
9
|
+
# already be active. Rescuing LoadError here makes the code robust
|
|
10
|
+
# regardless of init order.
|
|
11
|
+
begin
|
|
12
|
+
require 'js'
|
|
13
|
+
rescue LoadError
|
|
14
|
+
# not available outside wasm environment
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
module Funicular
|
|
18
|
+
VERSION = '0.1.0'
|
|
19
|
+
|
|
20
|
+
def self.version
|
|
21
|
+
VERSION
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.env
|
|
25
|
+
@env ||= EnvironmentInquirer.new(ENV['FUNICULAR_ENV'] || ENV['RAILS_ENV'] || 'development')
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.env=(environment)
|
|
29
|
+
case environment
|
|
30
|
+
when EnvironmentInquirer
|
|
31
|
+
@env = environment
|
|
32
|
+
when nil
|
|
33
|
+
@env = nil
|
|
34
|
+
else
|
|
35
|
+
@env = EnvironmentInquirer.new(environment)
|
|
36
|
+
end
|
|
37
|
+
# @type ivar @env: EnvironmentInquirer?
|
|
38
|
+
@env
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
@router = nil
|
|
42
|
+
|
|
43
|
+
def self.router
|
|
44
|
+
@router
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Load schemas for models
|
|
48
|
+
# Usage:
|
|
49
|
+
# Funicular.load_schemas({ User => "user", Session => "session" }) do
|
|
50
|
+
# Funicular.start(container: 'app') { |router| ... }
|
|
51
|
+
# end
|
|
52
|
+
def self.load_schemas(models, &block)
|
|
53
|
+
schemas_loaded = 0
|
|
54
|
+
total_schemas = models.size
|
|
55
|
+
|
|
56
|
+
check_completion = -> {
|
|
57
|
+
if schemas_loaded >= total_schemas
|
|
58
|
+
puts "[Funicular] All schemas loaded (#{schemas_loaded}/#{total_schemas})"
|
|
59
|
+
block.call if block
|
|
60
|
+
end
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
models.each do |model_class, schema_name|
|
|
64
|
+
HTTP.get("/api/schema/#{schema_name}") do |response|
|
|
65
|
+
if response.error?
|
|
66
|
+
puts "[Schema] Failed to load #{schema_name} schema: #{response.error_message}"
|
|
67
|
+
else
|
|
68
|
+
model_class.load_schema(response.data)
|
|
69
|
+
puts "[Schema] #{schema_name} model initialized"
|
|
70
|
+
schemas_loaded += 1
|
|
71
|
+
check_completion.call
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# Start Funicular application
|
|
78
|
+
# Usage:
|
|
79
|
+
# Funicular.start(MyComponent, container: 'app')
|
|
80
|
+
# Funicular.start(MyComponent, container: 'app', props: { name: 'John' })
|
|
81
|
+
def self.start(component_class = nil, container: 'app', props: {}, &block)
|
|
82
|
+
# Export debug configuration to JavaScript
|
|
83
|
+
export_debug_config
|
|
84
|
+
|
|
85
|
+
# Initialize debug module in development mode
|
|
86
|
+
Funicular::Debug.expose_to_global if Funicular.env.development?
|
|
87
|
+
|
|
88
|
+
container_element = if container.is_a?(String)
|
|
89
|
+
JS.document.getElementById(container)
|
|
90
|
+
else
|
|
91
|
+
container
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
unless container_element
|
|
95
|
+
raise "Container element not found: #{container}"
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# If block is given, use router mode
|
|
99
|
+
if block
|
|
100
|
+
router = Router.new(container_element)
|
|
101
|
+
@router = router
|
|
102
|
+
block.call(router)
|
|
103
|
+
router.start
|
|
104
|
+
return router
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# Otherwise, mount single component (backward compatible)
|
|
108
|
+
if component_class
|
|
109
|
+
instance = component_class.new(props)
|
|
110
|
+
instance.mount(container_element)
|
|
111
|
+
return instance
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
raise "Either component_class or block must be provided"
|
|
115
|
+
rescue => e
|
|
116
|
+
puts "Exception in Funicular.start: #{e.message}"
|
|
117
|
+
puts e.backtrace
|
|
118
|
+
raise e
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
# Form builder configuration
|
|
122
|
+
class << self
|
|
123
|
+
attr_accessor :form_builder_config
|
|
124
|
+
|
|
125
|
+
def configure_forms
|
|
126
|
+
@form_builder_config ||= {
|
|
127
|
+
error_class: "text-red-600 text-sm mt-1",
|
|
128
|
+
field_error_class: "border-red-500"
|
|
129
|
+
}
|
|
130
|
+
yield @form_builder_config if block_given?
|
|
131
|
+
end
|
|
132
|
+
end
|
|
133
|
+
|
|
134
|
+
# Initialize default form configuration
|
|
135
|
+
configure_forms
|
|
136
|
+
|
|
137
|
+
# Debug highlighter configuration
|
|
138
|
+
class << self
|
|
139
|
+
attr_accessor :debug_color
|
|
140
|
+
|
|
141
|
+
def configure_debug
|
|
142
|
+
@debug_color = 'green'
|
|
143
|
+
yield self if block_given?
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# Initialize default debug configuration
|
|
148
|
+
configure_debug
|
|
149
|
+
|
|
150
|
+
# Export debug_color to JavaScript global variable
|
|
151
|
+
def self.export_debug_config
|
|
152
|
+
if JS.global[:window]
|
|
153
|
+
JS.global[:window][:FUNICULAR_DEBUG_COLOR] = @debug_color # steep:ignore
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
end
|
data/mrblib/http.rb
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
module Funicular
|
|
2
|
+
module HTTP
|
|
3
|
+
class Response
|
|
4
|
+
attr_reader :data, :status, :ok
|
|
5
|
+
|
|
6
|
+
def initialize(status, data)
|
|
7
|
+
@status = status
|
|
8
|
+
@ok = @status >= 200 && @status < 300
|
|
9
|
+
@data = data
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def error?
|
|
13
|
+
return true unless @ok
|
|
14
|
+
return false unless @data.is_a?(Hash)
|
|
15
|
+
@data["error"] || @data["errors"]
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def error_message
|
|
19
|
+
return nil unless @data.is_a?(Hash)
|
|
20
|
+
@data["error"] || (@data["errors"].is_a?(Array) ? @data["errors"].join(", ") : @data["errors"])
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def self.get(url, &block)
|
|
25
|
+
request("GET", url, nil, &block)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def self.post(url, body = nil, &block)
|
|
29
|
+
request("POST", url, body, &block)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def self.patch(url, body = nil, &block)
|
|
33
|
+
request("PATCH", url, body, &block)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def self.delete(url, &block)
|
|
37
|
+
request("DELETE", url, nil, &block)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def self.put(url, body = nil, &block)
|
|
41
|
+
request("PUT", url, body, &block)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# Get CSRF token from meta tag
|
|
45
|
+
# Note: Don't cache the token - Rails may rotate it after each request
|
|
46
|
+
def self.csrf_token
|
|
47
|
+
meta = JS.document.querySelector('meta[name="csrf-token"]')
|
|
48
|
+
if meta
|
|
49
|
+
# Use getAttribute method (direct method call on JS::Object)
|
|
50
|
+
token_obj = meta.getAttribute('content')
|
|
51
|
+
# Convert JS::Object to Ruby string
|
|
52
|
+
token_obj ? token_obj.to_s : nil
|
|
53
|
+
else
|
|
54
|
+
nil
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def self.request(method, url, body, &block)
|
|
61
|
+
# @type var options: Hash[Symbol, String | Hash[String, String]]
|
|
62
|
+
options = { method: method, credentials: "include" }
|
|
63
|
+
|
|
64
|
+
headers = {} #: Hash[String, String]
|
|
65
|
+
|
|
66
|
+
if body
|
|
67
|
+
headers["Content-Type"] = "application/json"
|
|
68
|
+
options[:body] = JSON.generate(body)
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# Add CSRF token for non-GET requests
|
|
72
|
+
if method != "GET"
|
|
73
|
+
token = csrf_token
|
|
74
|
+
headers["X-CSRF-Token"] = token if token
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
options[:headers] = headers unless headers.empty?
|
|
78
|
+
|
|
79
|
+
JS.global.fetch(url, options) do |response|
|
|
80
|
+
status = response.status.to_i
|
|
81
|
+
json_text = response.to_binary
|
|
82
|
+
data = JSON.parse(json_text)
|
|
83
|
+
# @type var status: Integer
|
|
84
|
+
http_response = Response.new(status, data)
|
|
85
|
+
block.call(http_response) if block
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
end
|