Skip to content

Commit aa553f0

Browse files
Copilotscarroll32
andauthored
Add turbo_search_form_for helper for Turbo/Hotwire integration (#1611)
* Initial plan * Add turbo_search_form_for helper for turbo stream support Co-authored-by: scarroll32 <[email protected]> * Add documentation for turbo_search_form_for helper Co-authored-by: scarroll32 <[email protected]> * Changes before error encountered Co-authored-by: scarroll32 <[email protected]> * Fix logic error in turbo_search_form_for turbo_frame handling Co-authored-by: scarroll32 <[email protected]> * Fix test interference by adding proper Ransack configuration cleanup Co-authored-by: scarroll32 <[email protected]> * Refactor turbo_search_form_for into smaller, testable methods Co-authored-by: scarroll32 <[email protected]> * Fix CI failure by removing unreliable caller_locations usage Co-authored-by: scarroll32 <[email protected]> * Fix data attribute format for Rails form helpers compatibility Co-authored-by: scarroll32 <[email protected]> * Fix specs --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: scarroll32 <[email protected]> Co-authored-by: Sean <[email protected]>
1 parent 2d2c0dd commit aa553f0

File tree

3 files changed

+236
-22
lines changed

3 files changed

+236
-22
lines changed

docs/docs/getting-started/simple-mode.md

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,39 @@ The `search_form_for` answer format can be set like this:
106106
<%= search_form_for(@q, format: :json) do |f| %>
107107
```
108108

109+
### Turbo-enabled search form
110+
111+
For applications using Turbo (Hotwire), Ransack provides `turbo_search_form_for` helper which submits forms via turbo streams instead of traditional HTML GET requests. This is particularly useful when integrating with paginated results or other turbo-enabled components:
112+
113+
```erb
114+
<%= turbo_search_form_for @q do |f| %>
115+
<%= f.label :name_cont %>
116+
<%= f.search_field :name_cont %>
117+
<%= f.submit %>
118+
<% end %>
119+
```
120+
121+
The turbo form helper supports the same options as `search_form_for`, plus additional turbo-specific options:
122+
123+
```erb
124+
# Target a specific turbo frame
125+
<%= turbo_search_form_for @q, turbo_frame: 'search_results' do |f| %>
126+
# form fields
127+
<% end %>
128+
129+
# Use a custom HTTP method (defaults to POST)
130+
<%= turbo_search_form_for @q, method: :patch do |f| %>
131+
# form fields
132+
<% end %>
133+
134+
# Set turbo action behavior
135+
<%= turbo_search_form_for @q, turbo_action: 'replace' do |f| %>
136+
# form fields
137+
<% end %>
138+
```
139+
140+
This eliminates the need for complex controller logic to handle different request formats when combining search with pagination.
141+
109142
### Search link helper
110143

111144
Ransack's `sort_link` helper creates table headers that are sortable links

lib/ransack/helpers/form_helper.rb

Lines changed: 62 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -7,30 +7,28 @@ module FormHelper
77
# <%= search_form_for(@q) do |f| %>
88
#
99
def search_form_for(record, options = {}, &proc)
10-
if record.is_a? Ransack::Search
11-
search = record
12-
options[:url] ||= polymorphic_path(
13-
search.klass, format: options.delete(:format)
14-
)
15-
elsif record.is_a?(Array) &&
16-
(search = record.detect { |o| o.is_a?(Ransack::Search) })
17-
options[:url] ||= polymorphic_path(
18-
options_for(record), format: options.delete(:format)
19-
)
20-
else
21-
raise ArgumentError,
22-
'No Ransack::Search object was provided to search_form_for!'
23-
end
10+
search = extract_search_and_set_url(record, options, 'search_form_for')
2411
options[:html] ||= {}
25-
html_options = {
26-
class: html_option_for(options[:class], search),
27-
id: html_option_for(options[:id], search),
28-
method: :get
29-
}
30-
options[:as] ||= Ransack.options[:search_key]
31-
options[:html].reverse_merge!(html_options)
32-
options[:builder] ||= FormBuilder
12+
html_options = build_html_options(search, options, :get)
13+
finalize_form_options(options, html_options)
14+
form_for(record, options, &proc)
15+
end
3316

17+
# +turbo_search_form_for+
18+
#
19+
# <%= turbo_search_form_for(@q) do |f| %>
20+
#
21+
# This is a turbo-enabled version of search_form_for that submits via turbo streams
22+
# instead of traditional HTML GET requests. Useful for seamless integration with
23+
# paginated results and other turbo-enabled components.
24+
#
25+
def turbo_search_form_for(record, options = {}, &proc)
26+
search = extract_search_and_set_url(record, options, 'turbo_search_form_for')
27+
options[:html] ||= {}
28+
turbo_options = build_turbo_options(options)
29+
method = options.delete(:method) || :post
30+
html_options = build_html_options(search, options, method).merge(turbo_options)
31+
finalize_form_options(options, html_options)
3432
form_for(record, options, &proc)
3533
end
3634

@@ -68,6 +66,48 @@ def sort_url(search_object, attribute, *args)
6866

6967
private
7068

69+
def extract_search_and_set_url(record, options, method_name)
70+
if record.is_a? Ransack::Search
71+
search = record
72+
options[:url] ||= polymorphic_path(
73+
search.klass, format: options.delete(:format)
74+
)
75+
search
76+
elsif record.is_a?(Array) &&
77+
(search = record.detect { |o| o.is_a?(Ransack::Search) })
78+
options[:url] ||= polymorphic_path(
79+
options_for(record), format: options.delete(:format)
80+
)
81+
search
82+
else
83+
raise ArgumentError,
84+
"No Ransack::Search object was provided to #{method_name}!"
85+
end
86+
end
87+
88+
def build_turbo_options(options)
89+
data_options = {}
90+
if options[:turbo_frame]
91+
data_options[:turbo_frame] = options.delete(:turbo_frame)
92+
end
93+
data_options[:turbo_action] = options.delete(:turbo_action) || 'advance'
94+
{ data: data_options }
95+
end
96+
97+
def build_html_options(search, options, method)
98+
{
99+
class: html_option_for(options[:class], search),
100+
id: html_option_for(options[:id], search),
101+
method: method
102+
}
103+
end
104+
105+
def finalize_form_options(options, html_options)
106+
options[:as] ||= Ransack.options[:search_key]
107+
options[:html].reverse_merge!(html_options)
108+
options[:builder] ||= FormBuilder
109+
end
110+
71111
def options_for(record)
72112
record.map { |r| parse_record(r) }
73113
end

spec/ransack/helpers/form_helper_spec.rb

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -849,12 +849,153 @@ module Helpers
849849
before do
850850
Ransack.configure { |c| c.search_key = :example }
851851
end
852+
after do
853+
Ransack.configure { |c| c.search_key = :q }
854+
end
852855
subject {
853856
@controller.view_context
854857
.search_form_for(Person.ransack) { |f| f.text_field :name_eq }
855858
}
856859
it { should match /example_name_eq/ }
857860
end
861+
862+
describe '#turbo_search_form_for with default options' do
863+
subject {
864+
@controller.view_context
865+
.turbo_search_form_for(Person.ransack) {}
866+
}
867+
it { should match /action="\/people"/ }
868+
it { should match /method="post"/ }
869+
it { should match /data-turbo-action="advance"/ }
870+
end
871+
872+
describe '#turbo_search_form_for with custom method' do
873+
subject {
874+
@controller.view_context
875+
.turbo_search_form_for(Person.ransack, method: :patch) {}
876+
}
877+
it { should match /method="post"/ }
878+
it { should match /name="_method" value="patch"/ }
879+
it { should match /data-turbo-action="advance"/ }
880+
end
881+
882+
describe '#turbo_search_form_for with turbo_frame' do
883+
subject {
884+
@controller.view_context
885+
.turbo_search_form_for(Person.ransack, turbo_frame: 'search_results') {}
886+
}
887+
it { should match /data-turbo-frame="search_results"/ }
888+
end
889+
890+
describe '#turbo_search_form_for with custom turbo_action' do
891+
subject {
892+
@controller.view_context
893+
.turbo_search_form_for(Person.ransack, turbo_action: 'replace') {}
894+
}
895+
it { should match /data-turbo-action="replace"/ }
896+
end
897+
898+
describe '#turbo_search_form_for with format' do
899+
subject {
900+
@controller.view_context
901+
.turbo_search_form_for(Person.ransack, format: :json) {}
902+
}
903+
it { should match /action="\/people.json"/ }
904+
end
905+
906+
describe '#turbo_search_form_for with array of routes' do
907+
subject {
908+
@controller.view_context
909+
.turbo_search_form_for([:admin, Comment.ransack]) {}
910+
}
911+
it { should match /action="\/admin\/comments"/ }
912+
end
913+
914+
describe '#turbo_search_form_for with custom search key' do
915+
before do
916+
Ransack.configure { |c| c.search_key = :example }
917+
end
918+
after do
919+
Ransack.configure { |c| c.search_key = :q }
920+
end
921+
subject {
922+
@controller.view_context
923+
.turbo_search_form_for(Person.ransack) { |f| f.text_field :name_eq }
924+
}
925+
it { should match /example_name_eq/ }
926+
end
927+
928+
describe '#turbo_search_form_for without Ransack::Search object' do
929+
it 'raises ArgumentError' do
930+
expect {
931+
@controller.view_context.turbo_search_form_for("not a search object") {}
932+
}.to raise_error(ArgumentError, 'No Ransack::Search object was provided to turbo_search_form_for!')
933+
end
934+
end
935+
936+
describe 'private helper methods' do
937+
let(:helper) { @controller.view_context }
938+
let(:search) { Person.ransack }
939+
940+
describe '#build_turbo_options' do
941+
it 'builds turbo options with frame' do
942+
options = { turbo_frame: 'results', turbo_action: 'replace' }
943+
result = helper.send(:build_turbo_options, options)
944+
expect(result).to eq({
945+
data: {
946+
turbo_frame: 'results',
947+
turbo_action: 'replace'
948+
}
949+
})
950+
expect(options).to be_empty
951+
end
952+
953+
it 'builds turbo options without frame' do
954+
options = { turbo_action: 'advance' }
955+
result = helper.send(:build_turbo_options, options)
956+
expect(result).to eq({ data: { turbo_action: 'advance' } })
957+
end
958+
959+
it 'uses default turbo action' do
960+
options = {}
961+
result = helper.send(:build_turbo_options, options)
962+
expect(result).to eq({ data: { turbo_action: 'advance' } })
963+
end
964+
end
965+
966+
describe '#build_html_options' do
967+
it 'builds HTML options with correct method' do
968+
options = { class: 'custom' }
969+
result = helper.send(:build_html_options, search, options, :post)
970+
expect(result[:method]).to eq(:post)
971+
expect(result[:class]).to include('custom')
972+
end
973+
end
974+
975+
describe '#extract_search_and_set_url' do
976+
it 'extracts search from Ransack::Search object' do
977+
options = {}
978+
result = helper.send(:extract_search_and_set_url, search, options, 'search_form_for')
979+
expect(result).to eq(search)
980+
expect(options[:url]).to match(/people/)
981+
end
982+
983+
it 'extracts search from array with Search object' do
984+
options = {}
985+
comment_search = Comment.ransack
986+
result = helper.send(:extract_search_and_set_url, [:admin, comment_search], options, 'search_form_for')
987+
expect(result).to eq(comment_search)
988+
expect(options[:url]).to match(/admin/)
989+
end
990+
991+
it 'raises error for invalid record with correct method name' do
992+
options = {}
993+
expect {
994+
helper.send(:extract_search_and_set_url, "invalid", options, 'turbo_search_form_for')
995+
}.to raise_error(ArgumentError, 'No Ransack::Search object was provided to turbo_search_form_for!')
996+
end
997+
end
998+
end
858999
end
8591000
end
8601001
end

0 commit comments

Comments
 (0)