turbo_turbo 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 +7 -0
- data/.claude/settings.local.json +14 -0
- data/.rspec +3 -0
- data/.rubocop.yml +96 -0
- data/CHANGELOG.md +50 -0
- data/LICENSE +21 -0
- data/README.md +498 -0
- data/Rakefile +12 -0
- data/app/assets/stylesheets/turbo_turbo/alerts.css +7 -0
- data/app/assets/stylesheets/turbo_turbo/base.css +4 -0
- data/app/assets/stylesheets/turbo_turbo/button.css +24 -0
- data/app/assets/stylesheets/turbo_turbo/modal.css +81 -0
- data/app/components/turbo_turbo/alerts/alert_component.html.erb +36 -0
- data/app/components/turbo_turbo/alerts/alert_component.rb +12 -0
- data/app/components/turbo_turbo/alerts/error_component.html.erb +36 -0
- data/app/components/turbo_turbo/alerts/error_component.rb +12 -0
- data/app/components/turbo_turbo/alerts/info_component.html.erb +36 -0
- data/app/components/turbo_turbo/alerts/info_component.rb +12 -0
- data/app/components/turbo_turbo/alerts/success_component.html.erb +47 -0
- data/app/components/turbo_turbo/alerts/success_component.rb +12 -0
- data/app/components/turbo_turbo/alerts/warning_component.html.erb +36 -0
- data/app/components/turbo_turbo/alerts/warning_component.rb +12 -0
- data/app/components/turbo_turbo/modal_component.html.erb +20 -0
- data/app/components/turbo_turbo/modal_component.rb +9 -0
- data/app/components/turbo_turbo/modal_footer_component.html.erb +6 -0
- data/app/components/turbo_turbo/modal_footer_component.rb +10 -0
- data/config/locales/en.yml +15 -0
- data/config/routes/turbo_turbo_routes.rb +20 -0
- data/lib/generators/turbo_turbo/install_generator.rb +351 -0
- data/lib/generators/turbo_turbo/layout_generator.rb +221 -0
- data/lib/generators/turbo_turbo/templates/config/routes/turbo_turbo_routes.rb +20 -0
- data/lib/generators/turbo_turbo/templates/turbo_turbo/_error_message.html.erb +15 -0
- data/lib/generators/turbo_turbo/templates/turbo_turbo/_flashes.html.erb +8 -0
- data/lib/generators/turbo_turbo/templates/turbo_turbo/_modal_background.html.erb +2 -0
- data/lib/generators/turbo_turbo/templates/turbo_turbo/flash_controller.js +28 -0
- data/lib/generators/turbo_turbo/templates/turbo_turbo/modal_controller.js +114 -0
- data/lib/generators/turbo_turbo/views_generator.rb +57 -0
- data/lib/turbo_turbo/controller_helpers.rb +157 -0
- data/lib/turbo_turbo/engine.rb +19 -0
- data/lib/turbo_turbo/form_helper.rb +135 -0
- data/lib/turbo_turbo/parameter_sanitizer.rb +69 -0
- data/lib/turbo_turbo/standard_actions.rb +96 -0
- data/lib/turbo_turbo/test_helpers.rb +64 -0
- data/lib/turbo_turbo/version.rb +5 -0
- data/lib/turbo_turbo.rb +15 -0
- data/sig/turbo_turbo.rbs +4 -0
- data/turbo_turbo.gemspec +42 -0
- metadata +136 -0
@@ -0,0 +1,114 @@
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
2
|
+
|
3
|
+
// Connects to data-controller="turbo-turbo--modal"
|
4
|
+
export default class extends Controller {
|
5
|
+
static targets = [ "background", "modalBackdrop", "title", "subtitle", "body", "modal", "footer", "form", "submitButton", "cancelButton" ]
|
6
|
+
|
7
|
+
connect() {
|
8
|
+
this.modalTarget.addEventListener('click', this.stopClickPropagation);
|
9
|
+
}
|
10
|
+
|
11
|
+
appendSubmitAndCancelButtons() {
|
12
|
+
if (this.hasSubmitButtonTarget) {
|
13
|
+
return;
|
14
|
+
}
|
15
|
+
this.footerTarget.appendChild(this.generateSubmitButton(this.dataset?.submitText || "Submit"));
|
16
|
+
this.footerTarget.prepend(this.generateCancelButton(this.dataset?.cancelText || "Cancel"));
|
17
|
+
}
|
18
|
+
|
19
|
+
submitForm() {
|
20
|
+
this.formTarget.requestSubmit();
|
21
|
+
}
|
22
|
+
|
23
|
+
disconnect() {
|
24
|
+
this.modalTarget.removeEventListener('click', this.stopClickPropagation);
|
25
|
+
}
|
26
|
+
|
27
|
+
stopClickPropagation(event) {
|
28
|
+
event.stopPropagation();
|
29
|
+
}
|
30
|
+
|
31
|
+
closeOnSuccess(){
|
32
|
+
if (event.detail.success)
|
33
|
+
this.closeModal()
|
34
|
+
}
|
35
|
+
|
36
|
+
async setRemoteSource() {
|
37
|
+
event.preventDefault()
|
38
|
+
const url = event.currentTarget.dataset.url;
|
39
|
+
const title = event.currentTarget.dataset.title
|
40
|
+
const subtitle = event.currentTarget.dataset.subtitle || ""
|
41
|
+
const response = await fetch(url, {
|
42
|
+
headers: { "Accept": "text/vnd.turbo-stream.html" }
|
43
|
+
});
|
44
|
+
|
45
|
+
const text = await response.text();
|
46
|
+
const parser = new DOMParser();
|
47
|
+
const doc = parser.parseFromString(text, "text/html");
|
48
|
+
const stream = doc.querySelector("turbo-stream");
|
49
|
+
if (title) {
|
50
|
+
this.titleTarget.innerText = title;
|
51
|
+
} else {
|
52
|
+
document.getElementById("modal_title_bar_turbo_turbo").classList.add("hidden")
|
53
|
+
}
|
54
|
+
this.subtitleTarget.innerText = subtitle;
|
55
|
+
this.bodyTarget.innerHTML = stream.querySelector("template").innerHTML;
|
56
|
+
if (this.hasFormTarget) {
|
57
|
+
this.appendSubmitAndCancelButtons();
|
58
|
+
}
|
59
|
+
}
|
60
|
+
|
61
|
+
open() {
|
62
|
+
this.backgroundTarget.classList.add("show");
|
63
|
+
document.getElementById("modal_title_bar_turbo_turbo").classList.remove("hidden")
|
64
|
+
this.backgroundTarget.addEventListener('transitionend', () => {
|
65
|
+
this.modalBackdropTarget.classList.add("show");
|
66
|
+
}, { once: true });
|
67
|
+
document.body.classList.add("overflow-hidden")
|
68
|
+
}
|
69
|
+
|
70
|
+
close() {
|
71
|
+
if (!event.target.closest('.Modal-turbo-turbo')) {
|
72
|
+
this.closeModal()
|
73
|
+
}
|
74
|
+
}
|
75
|
+
|
76
|
+
closeModal(){
|
77
|
+
event.preventDefault()
|
78
|
+
document.body.classList.remove("overflow-hidden")
|
79
|
+
this.modalBackdropTarget.classList.remove("show");
|
80
|
+
this.modalBackdropTarget.addEventListener('transitionend', () => {
|
81
|
+
this.backgroundTarget.classList.remove("show");
|
82
|
+
this.titleTarget.innerText = "";
|
83
|
+
this.subtitleTarget.innerText = "";
|
84
|
+
this.bodyTarget.innerHTML = "";
|
85
|
+
if (this.hasSubmitButtonTarget) {
|
86
|
+
this.submitButtonTarget.remove();
|
87
|
+
}
|
88
|
+
if (this.hasCancelButtonTarget) {
|
89
|
+
this.cancelButtonTarget.remove();
|
90
|
+
}
|
91
|
+
}, { once: true });
|
92
|
+
}
|
93
|
+
|
94
|
+
generateCancelButton(cancelText){
|
95
|
+
const button = document.createElement("button");
|
96
|
+
button.classList.add("font-semibold", "leading-6", "text-gray-900");
|
97
|
+
button.setAttribute("data-action", "turbo-turbo--modal#closeModal");
|
98
|
+
button.setAttribute("data-turbo-turbo--modal-target", "cancelButton");
|
99
|
+
button.textContent = cancelText
|
100
|
+
|
101
|
+
return button
|
102
|
+
}
|
103
|
+
|
104
|
+
generateSubmitButton(submitText){
|
105
|
+
const button = document.createElement("button");
|
106
|
+
button.classList.add("rounded-md", "bg-primary-600", "px-3", "py-2", "text-sm", "font-semibold", "text-white", "shadow-sm", "hover:bg-primary-500", "focus-visible:outline", "focus-visible:outline-2", "focus-visible:outline-offset-2", "focus-visible:outline-primary-600");
|
107
|
+
button.setAttribute("data-action", "turbo-turbo--modal#submitForm");
|
108
|
+
button.setAttribute("data-turbo-turbo--modal-target", "submitButton");
|
109
|
+
button.textContent = submitText
|
110
|
+
|
111
|
+
return button
|
112
|
+
}
|
113
|
+
}
|
114
|
+
|
@@ -0,0 +1,57 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/generators/base"
|
4
|
+
|
5
|
+
module TurboTurbo
|
6
|
+
module Generators
|
7
|
+
class ViewsGenerator < Rails::Generators::Base
|
8
|
+
desc "Copy TurboTurbo views for customization"
|
9
|
+
source_root File.expand_path("../../..", __dir__)
|
10
|
+
|
11
|
+
def copy_view_components
|
12
|
+
empty_directory "app/components/turbo_turbo"
|
13
|
+
empty_directory "app/components/turbo_turbo/alerts"
|
14
|
+
copy_file "app/components/turbo_turbo/alerts/alert_component.rb",
|
15
|
+
"app/components/turbo_turbo/alerts/alert_component.rb"
|
16
|
+
copy_file "app/components/turbo_turbo/alerts/success_component.rb",
|
17
|
+
"app/components/turbo_turbo/alerts/success_component.rb"
|
18
|
+
copy_file "app/components/turbo_turbo/alerts/error_component.rb",
|
19
|
+
"app/components/turbo_turbo/alerts/error_component.rb"
|
20
|
+
copy_file "app/components/turbo_turbo/alerts/info_component.rb",
|
21
|
+
"app/components/turbo_turbo/alerts/info_component.rb"
|
22
|
+
copy_file "app/components/turbo_turbo/alerts/warning_component.rb",
|
23
|
+
"app/components/turbo_turbo/alerts/warning_component.rb"
|
24
|
+
copy_file "app/components/turbo_turbo/modal_component.rb", "app/components/turbo_turbo/modal_component.rb"
|
25
|
+
copy_file "app/components/turbo_turbo/modal_footer_component.rb",
|
26
|
+
"app/components/turbo_turbo/modal_footer_component.rb"
|
27
|
+
end
|
28
|
+
|
29
|
+
def copy_view_templates
|
30
|
+
copy_file "app/components/turbo_turbo/alerts/alert_component.html.erb",
|
31
|
+
"app/components/turbo_turbo/alerts/alert_component.html.erb"
|
32
|
+
copy_file "app/components/turbo_turbo/alerts/success_component.html.erb",
|
33
|
+
"app/components/turbo_turbo/alerts/success_component.html.erb"
|
34
|
+
copy_file "app/components/turbo_turbo/alerts/error_component.html.erb",
|
35
|
+
"app/components/turbo_turbo/alerts/error_component.html.erb"
|
36
|
+
copy_file "app/components/turbo_turbo/alerts/info_component.html.erb",
|
37
|
+
"app/components/turbo_turbo/alerts/info_component.html.erb"
|
38
|
+
copy_file "app/components/turbo_turbo/alerts/warning_component.html.erb",
|
39
|
+
"app/components/turbo_turbo/alerts/warning_component.html.erb"
|
40
|
+
copy_file "app/components/turbo_turbo/modal_component.html.erb",
|
41
|
+
"app/components/turbo_turbo/modal_component.html.erb"
|
42
|
+
copy_file "app/components/turbo_turbo/modal_footer_component.html.erb",
|
43
|
+
"app/components/turbo_turbo/modal_footer_component.html.erb"
|
44
|
+
end
|
45
|
+
|
46
|
+
def display_instructions
|
47
|
+
say "\n\nTurboTurbo views have been copied for customization!", :green
|
48
|
+
say "\nThe following files have been copied to your application:"
|
49
|
+
say "• Alert ViewComponents (turbo_turbo/alerts/)"
|
50
|
+
say "• Modal ViewComponents (turbo_turbo/)"
|
51
|
+
say "• All component templates (.html.erb)"
|
52
|
+
say "\nYou can now customize these components as needed."
|
53
|
+
say "Note: Components in your app/ directory will override the gem versions."
|
54
|
+
end
|
55
|
+
end
|
56
|
+
end
|
57
|
+
end
|
@@ -0,0 +1,157 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboTurbo
|
4
|
+
module ControllerHelpers
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
include StandardActions
|
7
|
+
include FormHelper
|
8
|
+
|
9
|
+
included do
|
10
|
+
# Configuration for turbo response action groups
|
11
|
+
mattr_accessor :turbo_response_actions, default: {
|
12
|
+
prepend: %w[create],
|
13
|
+
replace: %w[update email_modal],
|
14
|
+
remove: %w[destroy archive restore toggle_resolved]
|
15
|
+
}
|
16
|
+
|
17
|
+
mattr_accessor :error_response_actions, default: {
|
18
|
+
validation_errors: %w[create update upload],
|
19
|
+
flash_errors: %w[destroy archive restore]
|
20
|
+
}
|
21
|
+
end
|
22
|
+
|
23
|
+
# ========================================
|
24
|
+
# Main Response Methods
|
25
|
+
# ========================================
|
26
|
+
|
27
|
+
def process_turbo_response(object, event, flash_naming_attribute: nil)
|
28
|
+
if event
|
29
|
+
turbo_success_response(object, flash_naming_attribute)
|
30
|
+
else
|
31
|
+
turbo_error_response(object, flash_naming_attribute)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
|
35
|
+
def turbo_success_response(object, flash_naming_attribute)
|
36
|
+
render turbo_stream: [
|
37
|
+
turbo_behavior_for_success(object),
|
38
|
+
replace_turbo_flashes(flash_reference(object, flash_naming_attribute))
|
39
|
+
].compact_blank
|
40
|
+
end
|
41
|
+
|
42
|
+
def turbo_error_response(object, flash_naming_attribute = nil)
|
43
|
+
if validation_error_action?
|
44
|
+
render turbo_stream: turbo_stream.update(
|
45
|
+
:error_message_turbo_turbo,
|
46
|
+
partial: "turbo_turbo/error_message",
|
47
|
+
locals: { message: validation_error_messages(object) }
|
48
|
+
), status: :unprocessable_entity
|
49
|
+
elsif flash_error_action?
|
50
|
+
render turbo_stream: turbo_stream.replace(
|
51
|
+
:flashes_turbo_turbo,
|
52
|
+
partial: "turbo_turbo/flashes",
|
53
|
+
locals: { flash: turbo_flash_error_message(flash_reference(object, flash_naming_attribute)) }
|
54
|
+
)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
def render_turbo_modal(locals = default_locals, partial = default_partial)
|
59
|
+
render turbo_stream: turbo_stream.update(:modal_body, partial:, locals:)
|
60
|
+
end
|
61
|
+
|
62
|
+
# ========================================
|
63
|
+
# Turbo Stream Behavior Methods
|
64
|
+
# ========================================
|
65
|
+
|
66
|
+
def turbo_behavior_for_success(object, partial: default_partial)
|
67
|
+
if prepend_action?
|
68
|
+
turbo_stream.prepend(:search_results, partial:, locals: { "#{default_object_key}": object })
|
69
|
+
elsif replace_action?
|
70
|
+
turbo_stream.replace(object, partial:, locals: { "#{default_object_key}": object })
|
71
|
+
elsif remove_action?
|
72
|
+
turbo_stream.remove(object)
|
73
|
+
end
|
74
|
+
end
|
75
|
+
|
76
|
+
# ========================================
|
77
|
+
# Flash Message Methods (I18n-ready)
|
78
|
+
# ========================================
|
79
|
+
|
80
|
+
def replace_turbo_flashes(flash_reference)
|
81
|
+
turbo_stream.replace(:flashes_turbo_turbo, partial: "turbo_turbo/flashes",
|
82
|
+
locals: { flash: turbo_flash_success_message(flash_reference) })
|
83
|
+
end
|
84
|
+
|
85
|
+
def turbo_flash_error_message(flash_reference)
|
86
|
+
action_verb = action_name == "destroy" ? "delete" : action_name
|
87
|
+
|
88
|
+
{ error: I18n.t("turbo_turbo.flash.error.default",
|
89
|
+
action: action_verb,
|
90
|
+
resource: flash_reference,
|
91
|
+
default: "We encountered an error trying to %<action>s %<resource>s.") }
|
92
|
+
end
|
93
|
+
|
94
|
+
def turbo_flash_success_message(flash_reference)
|
95
|
+
message = I18n.t("turbo_turbo.flash.success.#{action_name}",
|
96
|
+
resource: flash_reference,
|
97
|
+
action: action_name,
|
98
|
+
default: I18n.t("turbo_turbo.flash.success.default",
|
99
|
+
resource: flash_reference,
|
100
|
+
action: action_name,
|
101
|
+
default: "#{flash_reference} #{action_name}d!"))
|
102
|
+
|
103
|
+
{ success: message }
|
104
|
+
end
|
105
|
+
|
106
|
+
def validation_error_messages(object)
|
107
|
+
bulleted_errors = object.errors.full_messages.map { |e| "<li class='text-red-700'>#{e}</li>" }.join
|
108
|
+
"<ul>#{bulleted_errors}</ul>"
|
109
|
+
end
|
110
|
+
|
111
|
+
# ========================================
|
112
|
+
# Action Group Helper Methods
|
113
|
+
# ========================================
|
114
|
+
|
115
|
+
def prepend_action?
|
116
|
+
turbo_response_actions[:prepend].include?(action_name)
|
117
|
+
end
|
118
|
+
|
119
|
+
def replace_action?
|
120
|
+
turbo_response_actions[:replace].include?(action_name)
|
121
|
+
end
|
122
|
+
|
123
|
+
def remove_action?
|
124
|
+
turbo_response_actions[:remove].include?(action_name)
|
125
|
+
end
|
126
|
+
|
127
|
+
def validation_error_action?
|
128
|
+
error_response_actions[:validation_errors].include?(action_name)
|
129
|
+
end
|
130
|
+
|
131
|
+
def flash_error_action?
|
132
|
+
error_response_actions[:flash_errors].include?(action_name)
|
133
|
+
end
|
134
|
+
|
135
|
+
# ========================================
|
136
|
+
# Utility/Helper Methods
|
137
|
+
# ========================================
|
138
|
+
|
139
|
+
def flash_reference(object, flash_naming_attribute)
|
140
|
+
flash_naming_attribute ? object.send(flash_naming_attribute).titleize : object.class.name.titleize
|
141
|
+
end
|
142
|
+
|
143
|
+
def default_partial
|
144
|
+
action = %w[create update].include?(action_name) ? controller_name.singularize : action_name
|
145
|
+
"#{controller_path}/#{action}"
|
146
|
+
end
|
147
|
+
|
148
|
+
def default_locals
|
149
|
+
key = default_object_key
|
150
|
+
{ "#{key}": send(key) }
|
151
|
+
end
|
152
|
+
|
153
|
+
def default_object_key
|
154
|
+
controller_name.singularize.to_sym
|
155
|
+
end
|
156
|
+
end
|
157
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "rails/engine"
|
4
|
+
|
5
|
+
module TurboTurbo
|
6
|
+
class Engine < ::Rails::Engine
|
7
|
+
isolate_namespace TurboTurbo
|
8
|
+
|
9
|
+
initializer "turbo_turbo.setup" do |_app|
|
10
|
+
ActiveSupport.on_load(:action_controller_base) do
|
11
|
+
include TurboTurbo::ControllerHelpers
|
12
|
+
end
|
13
|
+
|
14
|
+
ActiveSupport.on_load(:action_view) do
|
15
|
+
include TurboTurbo::FormHelper
|
16
|
+
end
|
17
|
+
end
|
18
|
+
end
|
19
|
+
end
|
@@ -0,0 +1,135 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboTurbo
|
4
|
+
# FormHelper provides a standardized way to create turbo-enabled modal forms
|
5
|
+
# that integrate seamlessly with the TurboTurbo modal system.
|
6
|
+
#
|
7
|
+
# == Basic Usage
|
8
|
+
#
|
9
|
+
# Instead of writing:
|
10
|
+
# = simple_form_for service_provider, url: [:admin, service_provider],
|
11
|
+
# data: {
|
12
|
+
# turbo_turbo__modal_target: "form",
|
13
|
+
# action: "turbo:submit-end->turbo-turbo--modal#closeOnSuccess"
|
14
|
+
# } do |form|
|
15
|
+
# .ModalContent-turbo-turbo
|
16
|
+
# #error_message_turbo_turbo
|
17
|
+
# [user-specific fields here]
|
18
|
+
#
|
19
|
+
# You can now write:
|
20
|
+
# <%= turbo_form_for(service_provider, url: [:admin, service_provider]) do |form| %>
|
21
|
+
# <!-- just the form fields -->
|
22
|
+
# <% end %>
|
23
|
+
#
|
24
|
+
# == Form Builder Support
|
25
|
+
#
|
26
|
+
# The helper supports multiple form builders:
|
27
|
+
# - SimpleForm (default): turbo_form_for(object, builder: :simple_form)
|
28
|
+
# - Rails form_with: turbo_form_for(object, builder: :form_with)
|
29
|
+
# - Rails form_for: turbo_form_for(object, builder: :form_for)
|
30
|
+
#
|
31
|
+
# == Customization Options
|
32
|
+
#
|
33
|
+
# turbo_form_for(object,
|
34
|
+
# url: custom_path,
|
35
|
+
# builder: :form_with, # Form builder to use
|
36
|
+
# data: { custom_attr: "value" } # Additional data attributes
|
37
|
+
# )
|
38
|
+
#
|
39
|
+
module FormHelper
|
40
|
+
extend ActiveSupport::Concern
|
41
|
+
|
42
|
+
# Main method for creating turbo-enabled modal forms
|
43
|
+
#
|
44
|
+
# @param object [ActiveRecord::Base] The object for the form
|
45
|
+
# @param options [Hash] Form options including url, method, etc.
|
46
|
+
# @option options [Symbol] :builder (:simple_form) Form builder to use (:simple_form, :form_with, :form_for)
|
47
|
+
# @option options [String] :url The form submission URL
|
48
|
+
# @option options [Symbol] :method HTTP method for the form
|
49
|
+
# @option options [Hash] :data Additional data attributes to merge
|
50
|
+
# @option options [Hash] :html Additional HTML attributes for the form
|
51
|
+
# @yield [form] Form builder object
|
52
|
+
def turbo_form_for(object, options = {}, &)
|
53
|
+
# Extract and set defaults
|
54
|
+
builder_type = options.delete(:builder) || :simple_form
|
55
|
+
|
56
|
+
# Build default data attributes for turbo modal integration
|
57
|
+
default_data = {
|
58
|
+
turbo_turbo__modal_target: "form"
|
59
|
+
}
|
60
|
+
|
61
|
+
# Add auto-close action if enabled
|
62
|
+
default_data[:action] = "turbo:submit-end->turbo-turbo--modal#closeOnSuccess"
|
63
|
+
|
64
|
+
# Merge user-provided data attributes
|
65
|
+
user_data = options.delete(:data) || {}
|
66
|
+
merged_data = default_data.merge(user_data)
|
67
|
+
|
68
|
+
# Set up form options
|
69
|
+
form_options = options.merge(data: merged_data)
|
70
|
+
|
71
|
+
# Ensure URL is set if not provided
|
72
|
+
unless form_options[:url]
|
73
|
+
if object.respond_to?(:persisted?) && object.persisted?
|
74
|
+
form_options[:url] = object
|
75
|
+
else
|
76
|
+
# Try to infer URL from object class
|
77
|
+
object.class.model_name
|
78
|
+
form_options[:url] = [:admin, object] if defined?(controller) && controller.class.name.include?("Admin")
|
79
|
+
end
|
80
|
+
end
|
81
|
+
|
82
|
+
# Generate the form based on builder type
|
83
|
+
form_html = case builder_type
|
84
|
+
when :simple_form
|
85
|
+
build_simple_form(object, form_options, &)
|
86
|
+
when :form_with
|
87
|
+
build_form_with(object, form_options, &)
|
88
|
+
when :form_for
|
89
|
+
build_form_for(object, form_options, &)
|
90
|
+
else
|
91
|
+
raise ArgumentError,
|
92
|
+
"Unknown form builder: #{builder_type}. Supported builders: :simple_form, :form_with, :form_for"
|
93
|
+
end
|
94
|
+
|
95
|
+
# Wrap the form in modal content structure
|
96
|
+
content_tag(:div, class: "ModalContent-turbo-turbo") do
|
97
|
+
concat content_tag(:div, "", id: "error_message_turbo_turbo")
|
98
|
+
concat form_html
|
99
|
+
end
|
100
|
+
end
|
101
|
+
|
102
|
+
# Convenience method for SimpleForm (most common case)
|
103
|
+
def turbo_simple_form_for(object, options = {}, &)
|
104
|
+
turbo_form_for(object, options.merge(builder: :simple_form), &)
|
105
|
+
end
|
106
|
+
|
107
|
+
# Convenience method for Rails form_with
|
108
|
+
def turbo_form_with(model:, **options, &)
|
109
|
+
turbo_form_for(model, options.merge(builder: :form_with), &)
|
110
|
+
end
|
111
|
+
|
112
|
+
private
|
113
|
+
|
114
|
+
def build_simple_form(object, options, &)
|
115
|
+
unless defined?(SimpleForm) && respond_to?(:simple_form_for)
|
116
|
+
raise "SimpleForm is not available. Install the simple_form gem or use a different builder."
|
117
|
+
end
|
118
|
+
|
119
|
+
simple_form_for(object, options, &)
|
120
|
+
end
|
121
|
+
|
122
|
+
def build_form_with(object, options, &)
|
123
|
+
# Convert object-based options to form_with format
|
124
|
+
form_with_options = options.dup
|
125
|
+
form_with_options[:model] = object
|
126
|
+
form_with_options.delete(:url) if form_with_options[:url] == object
|
127
|
+
|
128
|
+
form_with(**form_with_options, &)
|
129
|
+
end
|
130
|
+
|
131
|
+
def build_form_for(object, options, &)
|
132
|
+
form_for(object, options, &)
|
133
|
+
end
|
134
|
+
end
|
135
|
+
end
|
@@ -0,0 +1,69 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboTurbo
|
4
|
+
module ParameterSanitizer
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
# Sanitizes parameters based on the model schema
|
8
|
+
# Converts boolean fields and trims string inputs
|
9
|
+
#
|
10
|
+
# Usage:
|
11
|
+
# params.require(:user).permit(:name, :active).sanitize_for_model(:user)
|
12
|
+
#
|
13
|
+
# @param model_key [Symbol] The key to deduce the model from (e.g., :user for User model)
|
14
|
+
# @return [ActionController::Parameters] The sanitized parameters
|
15
|
+
def sanitize_for_model(model_key)
|
16
|
+
model = model_from_key(model_key)
|
17
|
+
convert_boolean_params(model)
|
18
|
+
trim_input_strings
|
19
|
+
self
|
20
|
+
end
|
21
|
+
|
22
|
+
private
|
23
|
+
|
24
|
+
# Converts string boolean values to actual booleans based on model schema
|
25
|
+
def convert_boolean_params(model)
|
26
|
+
boolean_columns = columns_for(model, [:boolean])
|
27
|
+
each { |k, v| self[k] = convert_to_boolean(v) if boolean_columns.include?(k.to_s) }
|
28
|
+
end
|
29
|
+
|
30
|
+
# Trims whitespace from strings and cleans arrays
|
31
|
+
def trim_input_strings
|
32
|
+
each do |k, v|
|
33
|
+
if v.is_a?(Array)
|
34
|
+
self[k] = v.map { |item| item.is_a?(String) ? item.strip : item }.select(&:present?)
|
35
|
+
elsif v.is_a?(String)
|
36
|
+
self[k] = v.strip
|
37
|
+
end
|
38
|
+
end
|
39
|
+
end
|
40
|
+
|
41
|
+
# Gets columns of specified types from the model
|
42
|
+
def columns_for(model, types)
|
43
|
+
model.columns_hash.select { |_, column| types.include?(column.type) }.keys
|
44
|
+
end
|
45
|
+
|
46
|
+
# Deduces the model class from a symbol key
|
47
|
+
def model_from_key(key)
|
48
|
+
model_name = key.to_s.classify.safe_constantize
|
49
|
+
raise ArgumentError, "Model could not be deduced from symbol (#{key})" unless model_name
|
50
|
+
|
51
|
+
model_name
|
52
|
+
end
|
53
|
+
|
54
|
+
# Converts string boolean values to actual booleans
|
55
|
+
def convert_to_boolean(field)
|
56
|
+
case field
|
57
|
+
when "false", "0"
|
58
|
+
false
|
59
|
+
when "true", "1"
|
60
|
+
true
|
61
|
+
else
|
62
|
+
field
|
63
|
+
end
|
64
|
+
end
|
65
|
+
end
|
66
|
+
end
|
67
|
+
|
68
|
+
# Extend ActionController::Parameters with the sanitization methods
|
69
|
+
ActionController::Parameters.include(TurboTurbo::ParameterSanitizer)
|
@@ -0,0 +1,96 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module TurboTurbo
|
4
|
+
module StandardActions
|
5
|
+
extend ActiveSupport::Concern
|
6
|
+
|
7
|
+
class_methods do
|
8
|
+
# DSL method to automatically generate standard CRUD actions
|
9
|
+
#
|
10
|
+
# Usage:
|
11
|
+
# class MessagesController < ApplicationController
|
12
|
+
# turbo_actions :create, :update, :destroy
|
13
|
+
#
|
14
|
+
# private
|
15
|
+
#
|
16
|
+
# def message_params
|
17
|
+
# params.require(:message).permit(:content, :title)
|
18
|
+
# end
|
19
|
+
# end
|
20
|
+
def turbo_actions(*actions)
|
21
|
+
actions.each do |action|
|
22
|
+
unless %i[
|
23
|
+
create update destroy show new edit
|
24
|
+
].include?(action)
|
25
|
+
raise ArgumentError,
|
26
|
+
"Unknown action: #{action}. Supported actions: :create, :update, :destroy, :show, :new, :edit"
|
27
|
+
end
|
28
|
+
|
29
|
+
send(:"define_turbo_#{action}_action")
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
private
|
34
|
+
|
35
|
+
def define_turbo_create_action
|
36
|
+
define_method :create do
|
37
|
+
model_instance = model_class.new(create_params)
|
38
|
+
process_turbo_response(model_instance, model_instance.save)
|
39
|
+
end
|
40
|
+
end
|
41
|
+
|
42
|
+
def define_turbo_update_action
|
43
|
+
define_method :update do
|
44
|
+
process_turbo_response(model_instance, model_instance.update(send(model_params_method)))
|
45
|
+
end
|
46
|
+
end
|
47
|
+
|
48
|
+
def define_turbo_destroy_action
|
49
|
+
define_method :destroy do
|
50
|
+
process_turbo_response(model_instance, model_instance.destroy)
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
%i[show new edit].each do |action|
|
55
|
+
define_method :"define_turbo_#{action}_action" do
|
56
|
+
define_method action do
|
57
|
+
render_turbo_modal({ model_name: send("model_instance_for_#{action}") })
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
61
|
+
end
|
62
|
+
|
63
|
+
# Instance methods - can be overridden in controllers
|
64
|
+
def create_params
|
65
|
+
send(model_params_method)
|
66
|
+
end
|
67
|
+
|
68
|
+
def model_params_method
|
69
|
+
"#{model_name}_params"
|
70
|
+
end
|
71
|
+
|
72
|
+
def model_instance_for_show
|
73
|
+
model_instance
|
74
|
+
end
|
75
|
+
|
76
|
+
def model_instance_for_edit
|
77
|
+
model_instance
|
78
|
+
end
|
79
|
+
|
80
|
+
def model_instance_for_new
|
81
|
+
model_class.new
|
82
|
+
end
|
83
|
+
|
84
|
+
def model_instance
|
85
|
+
model_class.find(params[:id])
|
86
|
+
end
|
87
|
+
|
88
|
+
def model_class
|
89
|
+
self.class.controller_name.classify.constantize
|
90
|
+
end
|
91
|
+
|
92
|
+
def model_name
|
93
|
+
self.class.controller_name.singularize
|
94
|
+
end
|
95
|
+
end
|
96
|
+
end
|