funicular 0.0.1 → 0.2.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 +79 -0
- data/README.md +66 -20
- data/Rakefile +103 -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/architecture.md +118 -0
- data/exe/funicular +32 -0
- data/lib/funicular/assets/funicular.css +23 -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 +143 -0
- data/lib/funicular/configuration.rb +76 -0
- data/lib/funicular/helpers/picoruby_helper.rb +112 -0
- data/lib/funicular/middleware.rb +123 -0
- data/lib/funicular/plugin.rb +147 -0
- data/lib/funicular/railtie.rb +26 -0
- data/lib/funicular/route_parser.rb +137 -0
- data/lib/funicular/schema.rb +167 -0
- data/lib/funicular/ssr/runtime.rb +101 -0
- data/lib/funicular/ssr.rb +51 -0
- data/lib/funicular/testing/node_runner.mjs +293 -0
- data/lib/funicular/testing/node_runner.rb +190 -0
- data/lib/funicular/testing.rb +22 -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 +6423 -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/vendor/picoruby-test-node/VERSION +1 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.js +6885 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm +0 -0
- data/lib/funicular/vendor/picoruby-test-node/picoruby.wasm.map +1 -0
- data/lib/funicular/version.rb +1 -1
- data/lib/funicular.rb +32 -1
- data/lib/generators/funicular/chat/chat_generator.rb +104 -0
- data/lib/generators/funicular/chat/templates/application_cable_channel.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/application_cable_connection.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/create_funicular_chat_messages.rb.tt +10 -0
- data/lib/generators/funicular/chat/templates/funicular_chat.css.tt +141 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_channel.rb.tt +5 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component.rb.tt +135 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_component_picotest.rb.tt +64 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_controller.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_message.rb.tt +13 -0
- data/lib/generators/funicular/chat/templates/funicular_chat_messages_controller.rb.tt +23 -0
- data/lib/generators/funicular/chat/templates/initializer.rb.tt +4 -0
- data/lib/generators/funicular/chat/templates/show.html.erb.tt +6 -0
- data/lib/tasks/funicular.rake +218 -0
- data/minitest/fixtures/funicular_app/components/greeting_component.rb +16 -0
- data/minitest/fixtures/funicular_app/initializer.rb +5 -0
- data/minitest/funicular_test.rb +13 -0
- data/minitest/hydration_test.rb +87 -0
- data/minitest/plugin_test.rb +51 -0
- data/minitest/schema_test.rb +106 -0
- data/minitest/ssr_test.rb +94 -0
- data/minitest/test_helper.rb +7 -0
- data/minitest/validations_test.rb +183 -0
- data/mrbgem.rake +16 -0
- data/mrblib/0_validations.rb +206 -0
- data/mrblib/1_validators.rb +180 -0
- data/mrblib/cable.rb +432 -0
- data/mrblib/component.rb +1050 -0
- data/mrblib/debug.rb +208 -0
- data/mrblib/differ.rb +254 -0
- data/mrblib/environment_inquirer.rb +34 -0
- data/mrblib/error_boundary.rb +125 -0
- data/mrblib/file_upload.rb +192 -0
- data/mrblib/form_builder.rb +300 -0
- data/mrblib/funicular.rb +245 -0
- data/mrblib/html_serializer.rb +121 -0
- data/mrblib/http.rb +183 -0
- data/mrblib/model.rb +196 -0
- data/mrblib/patcher.rb +269 -0
- data/mrblib/router.rb +266 -0
- data/mrblib/store.rb +304 -0
- data/mrblib/store_collection.rb +171 -0
- data/mrblib/store_singleton.rb +79 -0
- data/mrblib/styles.rb +83 -0
- data/mrblib/vdom.rb +273 -0
- data/sig/cable.rbs +66 -0
- data/sig/component.rbs +149 -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 +24 -1
- data/sig/html_serializer.rbs +20 -0
- data/sig/http.rbs +37 -0
- data/sig/model.rbs +28 -0
- data/sig/patcher.rbs +18 -0
- data/sig/router.rbs +44 -0
- data/sig/store.rbs +89 -0
- data/sig/store_collection.rbs +43 -0
- data/sig/store_singleton.rbs +19 -0
- data/sig/styles.rbs +25 -0
- data/sig/validations.rbs +103 -0
- data/sig/vdom.rbs +59 -0
- metadata +154 -8
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# 'rng' is a PicoRuby gem available only in the wasm build. When the runtime
|
|
2
|
+
# is loaded under CRuby for SSR it is absent; FileUpload is client-only and
|
|
3
|
+
# its methods are never called on the server (see Funicular.server?).
|
|
4
|
+
begin
|
|
5
|
+
require 'rng'
|
|
6
|
+
rescue LoadError
|
|
7
|
+
# not available outside wasm environment
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
module Funicular
|
|
11
|
+
module FileUpload
|
|
12
|
+
JS_HELPER_CODE = <<~JAVASCRIPT
|
|
13
|
+
(function() {
|
|
14
|
+
'use strict';
|
|
15
|
+
// Avoid duplicate mounting
|
|
16
|
+
if (window.funicularFormDataUpload) {
|
|
17
|
+
return;
|
|
18
|
+
}
|
|
19
|
+
// FormData upload helper
|
|
20
|
+
window.funicularFormDataUpload = function(url, fieldsObj, fileFieldName, fileRefId) {
|
|
21
|
+
const formData = new FormData();
|
|
22
|
+
for (const [key, value] of Object.entries(fieldsObj)) {
|
|
23
|
+
formData.append(key, String(value));
|
|
24
|
+
}
|
|
25
|
+
if (fileFieldName && fileRefId !== null && fileRefId !== undefined) {
|
|
26
|
+
const file = window.picorubyRefs[fileRefId];
|
|
27
|
+
if (file) {
|
|
28
|
+
formData.append(fileFieldName, file);
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return fetch(url, {
|
|
32
|
+
method: 'PATCH',
|
|
33
|
+
body: formData
|
|
34
|
+
}).then(response => response.json());
|
|
35
|
+
};
|
|
36
|
+
console.log('Funicular helpers mounted');
|
|
37
|
+
})();
|
|
38
|
+
JAVASCRIPT
|
|
39
|
+
|
|
40
|
+
def self.mount
|
|
41
|
+
return if Funicular.server?
|
|
42
|
+
script = JS.document.createElement("script")
|
|
43
|
+
script[:textContent] = JS_HELPER_CODE
|
|
44
|
+
JS.document.body.appendChild(script)
|
|
45
|
+
JS.document.body.removeChild(script)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Select a file from input element and generate preview
|
|
49
|
+
# @param input_id [String] DOM element ID of the file input
|
|
50
|
+
# @param block [Proc] Callback with preview data URL (or nil if no file)
|
|
51
|
+
def self.select_file_with_preview(input_id, &block)
|
|
52
|
+
input = JS.document.getElementById(input_id)
|
|
53
|
+
return block.call(nil, nil) unless input
|
|
54
|
+
|
|
55
|
+
# @type var files: Array[JS::Object]
|
|
56
|
+
files = input[:files]
|
|
57
|
+
if !files || files.length.to_i == 0
|
|
58
|
+
return block.call(nil, nil)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
file = files[0]
|
|
62
|
+
|
|
63
|
+
# Use URL.createObjectURL instead of FileReader for preview
|
|
64
|
+
# This is simpler and doesn't require async callbacks
|
|
65
|
+
if file.nil?
|
|
66
|
+
raise "Selected file is nil"
|
|
67
|
+
else
|
|
68
|
+
url = JS.global[:URL]
|
|
69
|
+
if url.is_a?(JS::Object)
|
|
70
|
+
preview_url = url.createObjectURL(file)
|
|
71
|
+
else
|
|
72
|
+
preview_url = nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# Call the block with file and preview URL
|
|
77
|
+
# @type var preview_url: String
|
|
78
|
+
block.call(file, preview_url) if block
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
# Upload data with FormData (supports file upload)
|
|
82
|
+
# @param url [String] Upload URL
|
|
83
|
+
# @param fields [Hash] Form fields to include
|
|
84
|
+
# @param file_field [String] Name of the file field
|
|
85
|
+
# @param file [JS::Object] File object from input element
|
|
86
|
+
# @param block [Proc] Callback with response data
|
|
87
|
+
def self.upload_with_formdata(url, fields: {}, file_field: nil, file: nil, &block)
|
|
88
|
+
# Store file and callback ID
|
|
89
|
+
@callback_counters ||= [] # steep:ignore UnannotatedEmptyCollection
|
|
90
|
+
callback_id = _ = nil
|
|
91
|
+
while true
|
|
92
|
+
callback_id = RNG.random_int
|
|
93
|
+
break unless @callback_counters.include?(callback_id)
|
|
94
|
+
end
|
|
95
|
+
@callback_counters << callback_id
|
|
96
|
+
|
|
97
|
+
if file
|
|
98
|
+
JS.global[:_tempFileForUpload] = file
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# Build fields object as JSON string
|
|
102
|
+
fields_json = JSON.generate(fields)
|
|
103
|
+
|
|
104
|
+
# Call JavaScript helper
|
|
105
|
+
# Note: The helper returns a Promise that resolves to JSON data (not Response)
|
|
106
|
+
script = JS.document.createElement("script")
|
|
107
|
+
script_code = <<~JS
|
|
108
|
+
(function() {
|
|
109
|
+
var fieldsObj = JSON.parse('#{fields_json.gsub("'", "\\\\'")}');
|
|
110
|
+
var fileRefId = null;
|
|
111
|
+
if (window._tempFileForUpload) {
|
|
112
|
+
fileRefId = window.picorubyRefs.indexOf(window._tempFileForUpload);
|
|
113
|
+
}
|
|
114
|
+
// Call helper and store result as JSON string when Promise resolves
|
|
115
|
+
window.funicularFormDataUpload(
|
|
116
|
+
'#{url}',
|
|
117
|
+
fieldsObj,
|
|
118
|
+
#{file_field ? "'#{file_field}'" : 'null'},
|
|
119
|
+
fileRefId
|
|
120
|
+
).then(function(data) {
|
|
121
|
+
// Store as JSON string for easy Ruby parsing
|
|
122
|
+
window._funicularUploadResult_#{callback_id} = JSON.stringify(data);
|
|
123
|
+
}).catch(function(error) {
|
|
124
|
+
window._funicularUploadResult_#{callback_id} = JSON.stringify({error: error.toString()});
|
|
125
|
+
});
|
|
126
|
+
})();
|
|
127
|
+
JS
|
|
128
|
+
script[:textContent] = script_code
|
|
129
|
+
JS.document.body.appendChild(script)
|
|
130
|
+
JS.document.body.removeChild(script)
|
|
131
|
+
|
|
132
|
+
# Poll for result (simple polling approach)
|
|
133
|
+
counter = 0
|
|
134
|
+
json_str = nil
|
|
135
|
+
while true
|
|
136
|
+
counter += 1
|
|
137
|
+
sleep 0.1
|
|
138
|
+
json_str = JS.global[:"_funicularUploadResult_#{callback_id}"]
|
|
139
|
+
break if json_str && !json_str.to_s.empty?
|
|
140
|
+
if 100 < counter
|
|
141
|
+
puts "[WARN] Timeout waiting for upload result. Return without calling block."
|
|
142
|
+
return
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# Get JSON string from global variable (already retrieved above)
|
|
147
|
+
result = JSON.parse(json_str.to_s)
|
|
148
|
+
|
|
149
|
+
block.call(result) if block
|
|
150
|
+
puts "[DEBUG] Block called"
|
|
151
|
+
ensure
|
|
152
|
+
# Always cleanup global references, even on timeout or error
|
|
153
|
+
if callback_id
|
|
154
|
+
@callback_counters.delete(callback_id) if @callback_counters
|
|
155
|
+
if JS.global[:"_funicularUploadResult_#{callback_id}"]
|
|
156
|
+
JS.global[:"_funicularUploadResult_#{callback_id}"] = nil
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
if JS.global[:_tempFileForUpload]
|
|
160
|
+
JS.global[:_tempFileForUpload] = nil
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
# Helper: Store file in global variable for later use
|
|
165
|
+
def self.store_file(input_id, storage_key = '_selectedFile')
|
|
166
|
+
input = JS.document.getElementById(input_id)
|
|
167
|
+
return nil unless input
|
|
168
|
+
|
|
169
|
+
# @type var files: Array[JS::Object]
|
|
170
|
+
files = input[:files]
|
|
171
|
+
if !files || files.length.to_i == 0
|
|
172
|
+
JS.global[storage_key] = nil
|
|
173
|
+
return nil
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
file = files[0]
|
|
177
|
+
JS.global[storage_key] = file
|
|
178
|
+
file
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
# Helper: Retrieve stored file
|
|
182
|
+
def self.retrieve_file(storage_key = '_selectedFile')
|
|
183
|
+
file = JS.global[storage_key]
|
|
184
|
+
JS.global[storage_key] = nil if file
|
|
185
|
+
if file.is_a?(JS::Object)
|
|
186
|
+
file
|
|
187
|
+
else
|
|
188
|
+
nil
|
|
189
|
+
end
|
|
190
|
+
end
|
|
191
|
+
end
|
|
192
|
+
end
|
|
@@ -0,0 +1,300 @@
|
|
|
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
|
+
# Per-form options win, then the global Funicular.configure_forms config,
|
|
10
|
+
# then the built-in defaults. The defaults are semantic class names whose
|
|
11
|
+
# CSS the gem ships and injects via picoruby_include_tag, so error styling
|
|
12
|
+
# works without depending on the host app's CSS pipeline (e.g. Tailwind,
|
|
13
|
+
# which never scans the gem and so would not generate utility classes
|
|
14
|
+
# emitted from here).
|
|
15
|
+
config = Funicular.form_builder_config || {}
|
|
16
|
+
@error_class = options[:error_class] || config[:error_class] || "funicular-error"
|
|
17
|
+
@field_error_class = options[:field_error_class] || config[:field_error_class] || "funicular-field-error"
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Generic field builder for input elements
|
|
21
|
+
def build_field(field_name, field_type, field_options = {})
|
|
22
|
+
field_key = field_name.to_s
|
|
23
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
24
|
+
|
|
25
|
+
# Get current value from state
|
|
26
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
27
|
+
|
|
28
|
+
# Build input change handler
|
|
29
|
+
on_input = ->(event) do
|
|
30
|
+
new_value = event.target[:value]
|
|
31
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Check for errors
|
|
35
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
36
|
+
# errors may be a single message (legacy) or an array of messages
|
|
37
|
+
# (Funicular::Model::Errors#messages). Show the first.
|
|
38
|
+
error_message = error_message.first if error_message.is_a?(Array)
|
|
39
|
+
has_error = !(error_message.nil? || error_message == "")
|
|
40
|
+
|
|
41
|
+
# Merge CSS classes (add error class if error exists)
|
|
42
|
+
css_class = field_options[:class]
|
|
43
|
+
css_class = css_class.to_s if css_class
|
|
44
|
+
css_class ||= ""
|
|
45
|
+
|
|
46
|
+
if has_error
|
|
47
|
+
if css_class.empty?
|
|
48
|
+
css_class = @field_error_class
|
|
49
|
+
else
|
|
50
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# Build field attributes
|
|
55
|
+
attrs = {
|
|
56
|
+
type: field_type,
|
|
57
|
+
value: value,
|
|
58
|
+
oninput: on_input
|
|
59
|
+
}.merge(field_options.reject { |k, _| k == :class })
|
|
60
|
+
|
|
61
|
+
# Add class attribute if not empty
|
|
62
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
63
|
+
|
|
64
|
+
# Render field + error message
|
|
65
|
+
@component.div do
|
|
66
|
+
@component.input(attrs)
|
|
67
|
+
if has_error
|
|
68
|
+
@component.div(class: @error_class) { error_message }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Public field methods
|
|
74
|
+
def text_field(field_name, options = {})
|
|
75
|
+
build_field(field_name, "text", options)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def password_field(field_name, options = {})
|
|
79
|
+
build_field(field_name, "password", options)
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def email_field(field_name, options = {})
|
|
83
|
+
build_field(field_name, "email", options)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def number_field(field_name, options = {})
|
|
87
|
+
build_field(field_name, "number", options)
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def textarea(field_name, options = {})
|
|
91
|
+
field_key = field_name.to_s
|
|
92
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
93
|
+
|
|
94
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
95
|
+
|
|
96
|
+
on_input = ->(event) do
|
|
97
|
+
new_value = event.target[:value]
|
|
98
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
102
|
+
# errors may be a single message (legacy) or an array of messages
|
|
103
|
+
# (Funicular::Model::Errors#messages). Show the first.
|
|
104
|
+
error_message = error_message.first if error_message.is_a?(Array)
|
|
105
|
+
has_error = !(error_message.nil? || error_message == "")
|
|
106
|
+
|
|
107
|
+
css_class = options[:class]
|
|
108
|
+
css_class = css_class.to_s if css_class
|
|
109
|
+
css_class ||= ""
|
|
110
|
+
|
|
111
|
+
if has_error
|
|
112
|
+
if css_class.empty?
|
|
113
|
+
css_class = @field_error_class
|
|
114
|
+
else
|
|
115
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
attrs = {
|
|
120
|
+
oninput: on_input
|
|
121
|
+
}.merge(options.reject { |k, _| k == :class })
|
|
122
|
+
|
|
123
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
124
|
+
|
|
125
|
+
@component.div do
|
|
126
|
+
@component.textarea(attrs) { value }
|
|
127
|
+
if has_error
|
|
128
|
+
@component.div(class: @error_class) { error_message }
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def checkbox(field_name, options = {})
|
|
134
|
+
field_key = field_name.to_s
|
|
135
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
136
|
+
|
|
137
|
+
value = get_nested_value(@component.state, full_key) || false
|
|
138
|
+
|
|
139
|
+
on_change = ->(event) do
|
|
140
|
+
new_value = event.target[:checked]
|
|
141
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
attrs = {
|
|
145
|
+
type: "checkbox",
|
|
146
|
+
checked: value,
|
|
147
|
+
onchange: on_change
|
|
148
|
+
}.merge(options)
|
|
149
|
+
|
|
150
|
+
@component.input(attrs)
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def select(field_name, choices, options = {})
|
|
154
|
+
field_key = field_name.to_s
|
|
155
|
+
full_key = "#{@model_key}.#{field_key}"
|
|
156
|
+
|
|
157
|
+
value = get_nested_value(@component.state, full_key) || ""
|
|
158
|
+
|
|
159
|
+
on_change = ->(event) do
|
|
160
|
+
new_value = event.target[:value]
|
|
161
|
+
set_nested_value(@model_key, field_key, new_value)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
error_message = @component.state.errors ? @component.state.errors[field_key.to_sym] : nil
|
|
165
|
+
# errors may be a single message (legacy) or an array of messages
|
|
166
|
+
# (Funicular::Model::Errors#messages). Show the first.
|
|
167
|
+
error_message = error_message.first if error_message.is_a?(Array)
|
|
168
|
+
has_error = !(error_message.nil? || error_message == "")
|
|
169
|
+
|
|
170
|
+
css_class = options[:class]
|
|
171
|
+
css_class = css_class.to_s if css_class
|
|
172
|
+
css_class ||= ""
|
|
173
|
+
|
|
174
|
+
if has_error
|
|
175
|
+
if css_class.empty?
|
|
176
|
+
css_class = @field_error_class
|
|
177
|
+
else
|
|
178
|
+
css_class = "#{css_class} #{@field_error_class}"
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
attrs = {
|
|
183
|
+
onchange: on_change
|
|
184
|
+
}.merge(options.reject { |k, _| k == :class })
|
|
185
|
+
|
|
186
|
+
attrs[:class] = css_class unless css_class.empty?
|
|
187
|
+
|
|
188
|
+
@component.div do
|
|
189
|
+
@component.select(attrs) do
|
|
190
|
+
choices.each do |choice|
|
|
191
|
+
option_value, option_text = choice.is_a?(Array) ? choice : [choice, choice]
|
|
192
|
+
selected = value.to_s == option_value.to_s
|
|
193
|
+
@component.option(value: option_value, selected: selected) do
|
|
194
|
+
option_text
|
|
195
|
+
end
|
|
196
|
+
end
|
|
197
|
+
end
|
|
198
|
+
if has_error
|
|
199
|
+
@component.div(class: @error_class) { error_message }
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def file_field(field_name, options = {})
|
|
205
|
+
field_key = field_name.to_s
|
|
206
|
+
|
|
207
|
+
# Check if custom onchange handler is provided
|
|
208
|
+
custom_handler = options.delete(:onchange)
|
|
209
|
+
|
|
210
|
+
# Default handler: store file name in state
|
|
211
|
+
default_handler = ->(event) do
|
|
212
|
+
file = event.target[:files] ? event.target[:files][0] : nil
|
|
213
|
+
file_name = if file.nil?
|
|
214
|
+
nil
|
|
215
|
+
else
|
|
216
|
+
# @type var file: Hash[Symbol, String]
|
|
217
|
+
file[:name]
|
|
218
|
+
end
|
|
219
|
+
set_nested_value(@model_key, field_key, file_name)
|
|
220
|
+
end
|
|
221
|
+
|
|
222
|
+
on_change = custom_handler || default_handler
|
|
223
|
+
|
|
224
|
+
attrs = {
|
|
225
|
+
type: "file",
|
|
226
|
+
onchange: on_change
|
|
227
|
+
}.merge(options)
|
|
228
|
+
|
|
229
|
+
@component.input(attrs)
|
|
230
|
+
end
|
|
231
|
+
|
|
232
|
+
def submit(label = "Submit", options = {})
|
|
233
|
+
attrs = { type: "submit" }.merge(options)
|
|
234
|
+
@component.button(attrs) { label }
|
|
235
|
+
end
|
|
236
|
+
|
|
237
|
+
def label(field_name, text = nil, options = {})
|
|
238
|
+
text ||= field_name.to_s.split('_').map { |word| word.capitalize }.join(' ')
|
|
239
|
+
@component.label(options) { text }
|
|
240
|
+
end
|
|
241
|
+
|
|
242
|
+
private
|
|
243
|
+
|
|
244
|
+
# Get nested value from state (e.g., "user.username")
|
|
245
|
+
def get_nested_value(state, key_path)
|
|
246
|
+
keys = key_path.split('.')
|
|
247
|
+
value = state
|
|
248
|
+
keys.each do |key|
|
|
249
|
+
if value.respond_to?(key.to_sym)
|
|
250
|
+
value = value.send(key.to_sym)
|
|
251
|
+
elsif value.is_a?(Hash)
|
|
252
|
+
value = value[key.to_sym] || value[key]
|
|
253
|
+
else
|
|
254
|
+
value = nil
|
|
255
|
+
end
|
|
256
|
+
break if value.nil?
|
|
257
|
+
end
|
|
258
|
+
value
|
|
259
|
+
end
|
|
260
|
+
|
|
261
|
+
# Set nested value in state via patch
|
|
262
|
+
def set_nested_value(model_key, field_key, new_value)
|
|
263
|
+
# Handle nested keys like "address.city"
|
|
264
|
+
if field_key.include?('.')
|
|
265
|
+
# Complex nested update
|
|
266
|
+
keys = field_key.split('.')
|
|
267
|
+
current_model = @component.state.send(model_key.to_sym)
|
|
268
|
+
updated_model = deep_merge_value(current_model, keys, new_value)
|
|
269
|
+
@component.patch(model_key.to_sym => updated_model)
|
|
270
|
+
else
|
|
271
|
+
# Simple update
|
|
272
|
+
current_model = @component.state.send(model_key.to_sym)
|
|
273
|
+
if current_model.nil?
|
|
274
|
+
@component.patch(model_key.to_sym => { field_key.to_sym => new_value })
|
|
275
|
+
elsif current_model.is_a?(Hash)
|
|
276
|
+
updated_model = current_model.merge(field_key.to_sym => new_value)
|
|
277
|
+
@component.patch(model_key.to_sym => updated_model)
|
|
278
|
+
else
|
|
279
|
+
# Assume it's an object with instance variables
|
|
280
|
+
current_model.instance_variable_set("@#{field_key}", new_value)
|
|
281
|
+
@component.patch(model_key.to_sym => current_model)
|
|
282
|
+
end
|
|
283
|
+
end
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
# Deep merge helper for nested keys
|
|
287
|
+
def deep_merge_value(hash, keys, value)
|
|
288
|
+
return hash unless hash.is_a?(Hash)
|
|
289
|
+
|
|
290
|
+
hash = hash.dup
|
|
291
|
+
if keys.length == 1
|
|
292
|
+
hash[keys[0].to_sym] = value
|
|
293
|
+
else
|
|
294
|
+
key = keys[0].to_sym
|
|
295
|
+
hash[key] = deep_merge_value(hash[key] || {}, (keys[1..-1] || []), value)
|
|
296
|
+
end
|
|
297
|
+
hash
|
|
298
|
+
end
|
|
299
|
+
end
|
|
300
|
+
end
|