tramway 2.3.1.4 → 3.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.
Files changed (36) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +47 -15
  3. data/app/components/tailwind_component.rb +7 -1
  4. data/app/components/tramway/chats/message_component.rb +1 -1
  5. data/app/components/tramway/comments/show_path_component.html.haml +4 -0
  6. data/app/components/tramway/comments/show_path_component.rb +11 -0
  7. data/app/components/tramway/form/builder.rb +21 -2
  8. data/app/components/tramway/form/checkbox_component.html.haml +1 -1
  9. data/app/components/tramway/form/date_field_component.html.haml +3 -3
  10. data/app/components/tramway/form/datetime_field_component.html.haml +1 -1
  11. data/app/components/tramway/form/file_field_component.html.haml +1 -1
  12. data/app/components/tramway/form/multiselect_component.html.haml +1 -1
  13. data/app/components/tramway/form/number_field_component.html.haml +1 -1
  14. data/app/components/tramway/form/select_component.html.haml +1 -1
  15. data/app/components/tramway/form/text_area_component.html.haml +1 -1
  16. data/app/components/tramway/form/text_field_component.html.haml +1 -1
  17. data/app/components/tramway/form/time_field_component.html.haml +6 -0
  18. data/app/components/tramway/form/time_field_component.rb +9 -0
  19. data/app/controllers/tramway/entities_controller.rb +43 -8
  20. data/app/views/kaminari/_paginator.html.haml +1 -2
  21. data/app/views/tramway/entities/_form.html.haml +5 -1
  22. data/app/views/tramway/entities/_list.html.haml +16 -11
  23. data/app/views/tramway/entities/show.html.haml +38 -18
  24. data/config/locales/en.yml +1 -0
  25. data/config/routes.rb +2 -2
  26. data/config/tailwind.config.js +2 -0
  27. data/docs/AGENTS.md +70 -3
  28. data/lib/tramway/config.rb +0 -1
  29. data/lib/tramway/configs/entities/page.rb +1 -0
  30. data/lib/tramway/engine.rb +9 -1
  31. data/lib/tramway/helpers/views_helper.rb +3 -1
  32. data/lib/tramway/searchable.rb +43 -0
  33. data/lib/tramway/utils/field.rb +2 -2
  34. data/lib/tramway/version.rb +1 -1
  35. data/lib/tramway/warnings.rb +17 -0
  36. metadata +7 -1
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 20f2a11f56484a9763bbb712c8bbf2c09558d9ad098b886849ee87429e379a19
4
- data.tar.gz: b5b9a13a2e084cd82fd09c9d4451c8cb2d59a4dd08198968eb8165d34a7dcfc0
3
+ metadata.gz: 6dc5de667eeeb4db78705dc65c49743449c5c14ef4bb2fe45159fd8a5abd209b
4
+ data.tar.gz: fdb10f35b0ed3e6b20fc048044418a5b78a846bc4c4383ff6090981a884df325
5
5
  SHA512:
6
- metadata.gz: c0a2569b1767b5e8c7e65b88f03124c67e845ce2db8999574255e1353135770f06dfe932d3b4d3601ad9bd3bfd0573dc4f217b7e211c502a67414a0e757b3239
7
- data.tar.gz: d6d354cef03905530fc1f959a5768e2682008b14a10a9b34d03a0065c7f07ed343eb3dac7e06c2a5d3a1136f3c149e8f81a62c360ace8c8a4bf7c5615b46b184
6
+ metadata.gz: 562e93acbe9f809cd7b7bbbedb2b4a860be4763b9aa2eb709b6b4cce904804303db6d7c652d2b591468c4d3fc72ac44df19d7251a1829f94a9a54ef92c494eb6
7
+ data.tar.gz: ec1b87e02459067629e2432cd938ef80d639398a06861700b0ee0cc6f854cb83760fffc85dd4488e8bc37303249cc71580dc0d186a3ec380a5bc083296584b64
data/README.md CHANGED
@@ -194,6 +194,30 @@ end
194
194
  In this example, the `Campaign` entity will display only records returned by the `active` scope on its index page, while all
195
195
  other pages continue to show every record unless another scope is specified.
196
196
 
197
+ **search**
198
+
199
+ Search is disabled by default on index pages. Enable it per-entity by setting `search: true` on the `:index` page entry:
200
+
201
+ *config/initializers/tramway.rb*
202
+ ```ruby
203
+ Tramway.configure do |config|
204
+ config.entities = [
205
+ {
206
+ name: :campaign,
207
+ pages: [
208
+ {
209
+ action: :index,
210
+ search: true
211
+ }
212
+ ]
213
+ }
214
+ ]
215
+ end
216
+ ```
217
+
218
+ When search is enabled, Tramway uses `Model.search(query)` if defined. If not, it falls back to `Model.tramway_search(query)` and logs a warning.
219
+ The fallback is generic and not tailored to your data structure, so it is not intended for long-term use and may be slow or not scalable.
220
+
197
221
  **show page**
198
222
 
199
223
  To render a show page for an entity, declare a `:show` action inside the `pages` array in
@@ -267,13 +291,13 @@ end
267
291
 
268
292
  **fields method**
269
293
 
270
- Use `form_fields` in your form class to customize which form helpers get rendered and which options are passed to them.
294
+ Use `fields` in your form class to customize which form helpers get rendered and which options are passed to them.
271
295
  Each field must map to a form helper method name. When you need to pass options, use a hash where `:type` is the helper
272
296
  method name and the remaining keys are passed as named arguments.
273
297
 
274
298
  ```ruby
275
299
  class UserForm < Tramway::BaseForm
276
- properties :email, :about_me, :user_type
300
+ properties :email, :about_me, :user_type, :score
277
301
 
278
302
  fields email: :email,
279
303
  name: :text,
@@ -284,7 +308,15 @@ class UserForm < Tramway::BaseForm
284
308
  user_type: {
285
309
  type: :select,
286
310
  collection: ['regular', 'user']
311
+ },
312
+ score: {
313
+ type: :number,
314
+ value: -> (object) { Score.find_by(user_id: object.id).value }
287
315
  }
316
+
317
+ def score=(value)
318
+ Score.find_by(user_id: object.id).update(value:)
319
+ end
288
320
  end
289
321
  ```
290
322
 
@@ -1091,14 +1123,27 @@ within the form will use the same size value.
1091
1123
  <% end %>
1092
1124
  ```
1093
1125
 
1126
+ Use `horizontal: true` to render a horizontal form layout.
1127
+
1128
+ ```erb
1129
+ <%= tramway_form_for @user, horizontal: true do |f| %>
1130
+ <%= f.text_field :text %>
1131
+ <%= f.submit 'Create User' %>
1132
+ <% end %>
1133
+ ```
1134
+
1094
1135
  Available form helpers:
1095
1136
  * text_field
1096
1137
  * email_field
1138
+ * number_field
1139
+ * text_area
1097
1140
  * password_field
1098
1141
  * file_field
1142
+ * check_box
1099
1143
  * select
1100
1144
  * date_field
1101
1145
  * datetime_field
1146
+ * time_field
1102
1147
  * multiselect ([Stimulus-based](https://github.com/Purple-Magic/tramway#stimulus-based-inputs))
1103
1148
  * submit
1104
1149
 
@@ -1177,19 +1222,6 @@ Tramway uses [Tailwind](https://tailwindcss.com/) by default. It has tailwind-st
1177
1222
 
1178
1223
  #### How to use
1179
1224
 
1180
- *Gemfile*
1181
- ```ruby
1182
- gem 'tramway'
1183
- gem 'kaminari'
1184
- ```
1185
-
1186
- *config/initializers/tramway.rb*
1187
- ```ruby
1188
- Tramway.configure do |config|
1189
- config.pagination = { enabled: true } # enabled is false by default
1190
- end
1191
- ```
1192
-
1193
1225
  *app/views/users/index.html.erb*
1194
1226
  ```erb
1195
1227
  <%= paginate @users %> <%# it will render tailwind-styled pagination buttons by default %>
@@ -58,7 +58,7 @@ class TailwindComponent < Tramway::BaseComponent
58
58
 
59
59
  def file_button_base_classes
60
60
  theme_classes(
61
- classic: 'inline-block text-blue-100 font-semibold rounded-xl cursor-pointer mt-4 bg-blue-900 ' \
61
+ classic: 'inline-block text-white font-semibold rounded-xl cursor-pointer mt-4 bg-blue-600 ' \
62
62
  'hover:bg-blue-800 shadow-md'
63
63
  )
64
64
  end
@@ -83,6 +83,12 @@ class TailwindComponent < Tramway::BaseComponent
83
83
  )
84
84
  end
85
85
 
86
+ def default_container_classes
87
+ return if options[:horizontal]
88
+
89
+ 'mb-4'
90
+ end
91
+
86
92
  def size_class(key)
87
93
  size_classes = SIZE_CLASSES.fetch(size) { SIZE_CLASSES[:medium] }
88
94
  size_classes.fetch(key) { SIZE_CLASSES[:medium].fetch(key) }
@@ -30,7 +30,7 @@ module Tramway
30
30
 
31
31
  def array_2d?(array)
32
32
  array.is_a?(Array) && array.all? do |inner|
33
- inner.is_a?(Array) && inner.none? { |e| e.is_a?(Array) }
33
+ inner.is_a?(Array) && inner.none?(Array)
34
34
  end
35
35
  end
36
36
 
@@ -0,0 +1,4 @@
1
+ - unless Rails.env.production?
2
+ / ==== COMMENTS FROM TRAMWAY ====
3
+ / You should set `show_path` method in #{decorator_class} to make the row below clickable and redirect to the associated record's show page.
4
+ / ==== COMMENTS FROM TRAMWAY ====
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Comments
5
+ # HTML comment that shows in case `show_path` is needed but it's empty
6
+ # It does not show for Rails.env.production
7
+ class ShowPathComponent < Tramway::BaseComponent
8
+ option :decorator_class
9
+ end
10
+ end
11
+ end
@@ -11,9 +11,14 @@ module Tramway
11
11
  include Tramway::ColorsMethods
12
12
 
13
13
  def initialize(object_name, object, template, options)
14
+ @horizontal = options[:horizontal] || false
15
+
16
+ options.merge!(class: [options[:class], 'flex flex-row items-center gap-2'].compact.join(' ')) if @horizontal
17
+
14
18
  super
15
19
 
16
20
  @form_size = options[:size] || options['size'] || :medium
21
+ @form_object_class = options[:form_object_class]
17
22
  end
18
23
 
19
24
  def common_field(component_name, input_method, attribute, **options, &)
@@ -62,6 +67,10 @@ module Tramway
62
67
  common_field(:datetime_field, :datetime_field, attribute, **, &)
63
68
  end
64
69
 
70
+ def time_field(attribute, **, &)
71
+ common_field(:time_field, :time_field, attribute, **, &)
72
+ end
73
+
65
74
  def file_field(attribute, **options, &)
66
75
  sanitized_options = sanitize_options(options)
67
76
  input = super(attribute, **sanitized_options.merge(class: :hidden))
@@ -118,12 +127,22 @@ module Tramway
118
127
  unbound_method.bind(self)
119
128
  end
120
129
 
130
+ def form_object
131
+ @form_object_class&.new object
132
+ end
133
+
121
134
  def get_value(attribute, options)
122
- options[:value] || object.presence&.public_send(attribute)
135
+ options[:value] || form_object&.public_send(attribute).presence || object.presence&.public_send(attribute)
123
136
  end
124
137
 
125
138
  def default_options(attribute, options)
126
- { attribute:, label: label_build(attribute, options), for: for_id(attribute), options:, size: form_size }
139
+ {
140
+ attribute:,
141
+ label: label_build(attribute, options),
142
+ for: for_id(attribute),
143
+ options: options.merge(horizontal: @horizontal),
144
+ size: form_size
145
+ }
127
146
  end
128
147
 
129
148
  def label_build(attribute, options)
@@ -1,4 +1,4 @@
1
- .flex.items-start.gap-2.mb-4
1
+ .flex.items-start.gap-2{ class: default_container_classes }
2
2
  - classes = "#{size_class(:checkbox_input)} #{checkbox_base_classes}"
3
3
  = @input.call @attribute, **@options.merge(class: classes)
4
4
  - if @label
@@ -1,6 +1,6 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
5
- - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
- = @input.call @attribute, **@options.merge(class: classes), value: @value
5
+ - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
+ = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  - classes = "#{size_class(:file_button)} #{file_button_base_classes}"
4
4
 
@@ -1,4 +1,4 @@
1
- .mb-4.relative
1
+ .relative{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -1,4 +1,4 @@
1
- .mb-4
1
+ %div{ class: default_container_classes }
2
2
  - if @label
3
3
  = component('tramway/form/label', for: @for) do
4
4
  = @label
@@ -0,0 +1,6 @@
1
+ %div{ class: default_container_classes }
2
+ - if @label
3
+ = component('tramway/form/label', for: @for) do
4
+ = @label
5
+ - classes = "#{size_class(:text_input)} #{text_input_base_classes}"
6
+ = @input.call @attribute, **@options.merge(class: classes), value: @value
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Form
5
+ # Tailwind-styled time field
6
+ class TimeFieldComponent < TailwindComponent
7
+ end
8
+ end
9
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tramway/warnings'
4
+
3
5
  module Tramway
4
6
  # Main controller for entities pages
5
7
  class EntitiesController < Tramway.config.application_controller.constantize
@@ -15,8 +17,10 @@ module Tramway
15
17
  model_class.public_send(index_scope)
16
18
  else
17
19
  model_class.order(id: :desc)
18
- end.page(params[:page]) => entities
20
+ end => entities
19
21
 
22
+ entities = search(entities)
23
+ entities = entities.page(params[:page])
20
24
  @entities = entities
21
25
 
22
26
  @namespace = entity.namespace
@@ -84,18 +88,49 @@ module Tramway
84
88
  end
85
89
 
86
90
  def set_associations
87
- @associations = @record.show_associations.map do |association|
88
- next unless @record.public_send(association).any?
89
-
90
- records = Kaminari.paginate_array(@record.public_send(association.name)).page(params[:page])
91
+ @associations = @record.show_associations.map do |(association, options)|
92
+ options ||= {}
93
+ association_type = @record.object.class.reflect_on_association(association).macro
91
94
 
92
95
  {
93
96
  name: association,
94
- decorator: records.first.class,
95
- records:,
96
- model_class: records.first.object.class
97
+ association_type:,
98
+ **send("#{association_type}_associations", association, options)
97
99
  }
98
100
  end.compact
99
101
  end
102
+
103
+ # rubocop:disable Naming/PredicatePrefix
104
+ def has_many_associations(association, options)
105
+ records = Kaminari.paginate_array(@record.public_send(association.name)).page(params[:page])
106
+
107
+ {
108
+ decorator: records.first&.class,
109
+ records:,
110
+ model_class: records.first&.object&.class,
111
+ new_record_path: options[:new_record_path]
112
+ }
113
+ end
114
+ # rubocop:enable Naming/PredicatePrefix
115
+
116
+ def belongs_to_associations(association, _options)
117
+ record = @record.public_send(association.name)
118
+
119
+ { decorator: record.class, record:, model_class: record.class }
120
+ end
121
+
122
+ def search(entities)
123
+ query = params[:query]
124
+
125
+ if entity.page(:index).search && query.present?
126
+ return entities.search(query) if entities.respond_to?(:search)
127
+
128
+ Tramway::Warnings.search_fallback model_class
129
+
130
+ return entities.tramway_search(query)
131
+ end
132
+
133
+ entities
134
+ end
100
135
  end
101
136
  end
@@ -1,5 +1,5 @@
1
1
  = paginator.render do
2
- %nav.pagination.flex.items-center.justify-center.space-x-1
2
+ %nav.pagination.flex.items-center.justify-center.gap-x-1
3
3
  = first_page_tag unless current_page.first?
4
4
  = prev_page_tag unless current_page.first?
5
5
  - each_page do |page|
@@ -9,4 +9,3 @@
9
9
  = gap_tag
10
10
  = next_page_tag unless current_page.last?
11
11
  = last_page_tag unless current_page.last?
12
-
@@ -10,8 +10,12 @@
10
10
  - @record.errors.full_messages.each do |message|
11
11
  %li= message
12
12
 
13
+ - prepolulated_values = params[@entity.name.to_param]&.compact || {}
14
+
13
15
  - @record.class.fields.each do |(attribute, field_type)|
14
- = f.tramway_field field_type, attribute
16
+ = f.tramway_field field_type, attribute,
17
+ value: prepolulated_values&.dig(attribute),
18
+ label: @model_class.human_attribute_name(attribute)
15
19
 
16
20
  .flex.flex-row.space-x-2
17
21
  = f.submit t('tramway.actions.save')
@@ -10,15 +10,21 @@
10
10
  = tramway_title do
11
11
  = content_for :title
12
12
 
13
- - if Tramway.config.pagination[:enabled]
14
- = paginate @entities, custom_path_method:
15
- = component 'tramway/actions_buttons_container' do
16
- - if @entity.page(:create).present?
17
- = tramway_button text: t('tramway.actions.new'),
18
- path: Tramway::Engine.routes.url_helpers.public_send(@entity.routes.new),
19
- type: :will
13
+ = component 'tramway/actions_buttons_container' do
14
+ - if @entity.page(:create).present?
15
+ = tramway_button text: t('tramway.actions.new'),
16
+ path: Tramway::Engine.routes.url_helpers.public_send(@entity.routes.new),
17
+ type: :will
20
18
 
21
- = decorator.constantize.index_header_content.call(@entities) if decorator.constantize.index_header_content.present?
19
+ = decorator.constantize.index_header_content.call(@entities) if decorator.constantize.index_header_content.present?
20
+
21
+ .flex.flex-row.justify-end.mt-2.gap-4
22
+ - if @entity.page(:index).search
23
+ = form_with url: public_send(custom_path_method), method: :get, local: true, builder: Tramway::Form::Builder, horizontal: true do |f|
24
+ = f.text_field :query, label: false, value: params[:query], placeholder: t('tramway.actions.search')
25
+ = f.submit t('tramway.actions.search')
26
+
27
+ = paginate @entities, custom_path_method:
22
28
 
23
29
  - if index_attributes.empty?
24
30
  %p.text-center.mt-10
@@ -30,6 +36,5 @@
30
36
  - @entities.each do |item|
31
37
  = component 'tramway/entity', entity: @entity, item: item
32
38
 
33
- - if Tramway.config.pagination[:enabled]
34
- .flex.mt-4
35
- = paginate @entities, custom_path_method:
39
+ .flex.mt-4
40
+ = paginate @entities, custom_path_method:
@@ -20,11 +20,20 @@
20
20
  = @record.show_header_content
21
21
 
22
22
  - if @record.show_attributes.empty?
23
- %p.text-center.mt-10
23
+ %p.text-center.mt-10.text-white
24
24
  You should fill object-level method `show_attributes` inside your
25
25
  = @record.class.name
26
26
 
27
27
  = tramway_table class: 'mt-4' do
28
+ - @associations.select { _1[:association_type] == :belongs_to }.each do |association|
29
+ - if association[:record].show_path.blank?
30
+ = component 'tramway/comments/show_path', decorator_class: association[:record].class.name
31
+ = tramway_row href: association[:record].show_path do
32
+ = tramway_cell do
33
+ = @model_class.human_attribute_name(association[:name])
34
+ = tramway_cell do
35
+ = association[:record].title
36
+
28
37
  - @record.show_attributes.each do |attribute|
29
38
  = tramway_row do
30
39
  = tramway_cell do
@@ -32,26 +41,37 @@
32
41
  = tramway_cell do
33
42
  = @record.public_send(attribute)
34
43
 
35
- - @associations.each do |association|
36
- .flex.flex-row.justify-between.mt-8
44
+ - @associations.select { _1[:association_type] == :has_many }.each do |association|
45
+ .flex.flex-row.justify-between.mt-8.text-white
37
46
  %h3.text-2xl.font-bold
38
47
  = @model_class.human_attribute_name(association[:name])
39
48
 
40
- %div
41
- = paginate association[:records],
42
- custom_path_method: @entity.index_helper_method,
43
- custom_path_arguments: [@record.id]
49
+ - if association[:records].any?
50
+ %div
51
+ = paginate association[:records],
52
+ custom_path_method: @entity.index_helper_method,
53
+ custom_path_arguments: [@record.id]
44
54
 
45
- = tramway_table class: 'mt-4' do
46
- = tramway_header headers: association[:decorator].table_headers(model_class: association[:model_class])
55
+ = tramway_button text: t('tramway.actions.new'),
56
+ path: association[:new_record_path],
57
+ type: :will
58
+
59
+ - if association[:records].any?
60
+ = tramway_table class: 'mt-4' do
61
+ = tramway_header headers: association[:decorator].table_headers(model_class: association[:model_class])
47
62
 
48
- - association[:records].each do |record|
49
- = tramway_row do
50
- - association[:decorator].index_attributes.each do |column|
51
- = tramway_cell do
52
- = record.public_send(column)
63
+ - association[:records].each do |record|
64
+ - if record.show_path.blank?
65
+ = component 'tramway/comments/show_path', decorator_class: association[:decorator].name
66
+ = tramway_row href: record.show_path do
67
+ - if association[:decorator].index_attributes.empty?
68
+ .text-white
69
+ You should set `index_attributes` method in #{association[:decorator].name} to display the table of associated records.
70
+ - association[:decorator].index_attributes.each do |column|
71
+ = tramway_cell do
72
+ = record.public_send(column)
53
73
 
54
- .mt-4
55
- = paginate association[:records],
56
- custom_path_method: @entity.index_helper_method,
57
- custom_path_arguments: [@record.id]
74
+ .mt-4
75
+ = paginate association[:records],
76
+ custom_path_method: @entity.index_helper_method,
77
+ custom_path_arguments: [@record.id]
@@ -11,6 +11,7 @@ en:
11
11
  destroy: "Destroy"
12
12
  save: "Save"
13
13
  cancel: "Cancel"
14
+ search: "Search"
14
15
  notices:
15
16
  created: "The record is created"
16
17
  updated: "The record is updated"
data/config/routes.rb CHANGED
@@ -14,7 +14,7 @@ Tramway::Engine.routes.draw do
14
14
  resource_name = segments.pop
15
15
 
16
16
  define_resource = proc do
17
- actions = entity.pages.reduce([]) do |acc, page|
17
+ entity.pages.reduce([]) do |acc, page|
18
18
  case page.action
19
19
  when 'index'
20
20
  acc << :index
@@ -29,7 +29,7 @@ Tramway::Engine.routes.draw do
29
29
  else
30
30
  acc
31
31
  end
32
- end
32
+ end => actions
33
33
 
34
34
  resources resource_name.pluralize.to_sym,
35
35
  only: actions.map(&:to_sym),
@@ -195,6 +195,7 @@ module.exports = {
195
195
 
196
196
  // === Pagination styles ===
197
197
  'hover:bg-gray-300',
198
+ 'gap-x-1',
198
199
 
199
200
  // === Dark mode pagination styles ===
200
201
  'bg-gray-900',
@@ -331,6 +332,7 @@ module.exports = {
331
332
  'border-red-600',
332
333
  'bg-red-100',
333
334
  'text-red-800',
335
+ 'space-x-2',
334
336
 
335
337
  // === Multiselect dropdown positioning ===
336
338
  'absolute',
data/docs/AGENTS.md CHANGED
@@ -105,6 +105,28 @@ end
105
105
 
106
106
  If admin panel requested to be implemented from scratch, do the same with `namespace: :admin`
107
107
 
108
+ ### Rule 1.1
109
+ Search is disabled by default on index pages. Enable it with `search: true` on the `:index` page definition:
110
+
111
+ ```ruby
112
+ Tramway.configure do |config|
113
+ config.entities = [
114
+ {
115
+ name: :participant,
116
+ pages: [
117
+ {
118
+ action: :index,
119
+ search: true
120
+ }
121
+ ]
122
+ }
123
+ ]
124
+ end
125
+ ```
126
+
127
+ If `Model.search` exists, Tramway uses it. Otherwise it falls back to `Model.tramway_search` and logs a warning.
128
+ The fallback is generic, not tailored to the data structure, and should not be used long-term because it may be slow or not scalable.
129
+
108
130
  ### Rule 2
109
131
  Normalize input with `normalizes` (from Tramway) for attributes like email, phone, etc. Don't use `normalizes` in model unless it requested explicitly.
110
132
 
@@ -133,6 +155,21 @@ Use Tramway Button for buttons. Always add a color of the button via `color:` or
133
155
  ### Rule 7
134
156
  Use `tramway_form_for` instead `form_with`, `form_for`
135
157
 
158
+ Available `tramway_form_for` helpers:
159
+ - `text_field`
160
+ - `email_field`
161
+ - `number_field`
162
+ - `text_area`
163
+ - `password_field`
164
+ - `file_field`
165
+ - `check_box`
166
+ - `select`
167
+ - `date_field`
168
+ - `datetime_field`
169
+ - `time_field`
170
+ - `multiselect`
171
+ - `submit`
172
+
136
173
  ### Rule 8
137
174
  Inherit all components from Tramway::BaseComponent
138
175
 
@@ -150,21 +187,32 @@ it raises `ArgumentError`. `chat_id` must match the stream id used in `tramway_c
150
187
  ### Rule 9
151
188
  If page `create` or `update` is configured for an entity, use Tramway Form pattern for forms. Visible fields are configured via `form_fields` method.
152
189
 
153
- Use form_fields in your form class to customize which form helpers get rendered and which options are passed to them. Each field must map to a form helper method name. When you need to pass options, use a hash where :type is the helper method name and the remaining keys are passed as named arguments.
190
+ Use `fields` in your form class to customize which form helpers get rendered and which options are passed to them. Each field must map to a form helper method name. When you need to pass options, use a hash where :type is the helper method name and the remaining keys are passed as named arguments.
154
191
 
155
192
  Example:
156
193
 
157
- *app/forms/user_form.rb*:
158
194
  ```ruby
159
195
  class UserForm < Tramway::BaseForm
160
- properties :email, :about_me
196
+ properties :email, :about_me, :user_type, :score
161
197
 
162
198
  fields email: :email,
163
199
  name: :text,
164
200
  about_me: {
165
201
  type: :text_area,
166
202
  rows: 5
203
+ },
204
+ user_type: {
205
+ type: :select,
206
+ collection: ['regular', 'user']
207
+ },
208
+ score: {
209
+ type: :number,
210
+ value: -> (object) { Score.find_by(user_id: object.id).value }
167
211
  }
212
+
213
+ def score=(value)
214
+ Score.find_by(user_id: object.id).update(value:)
215
+ end
168
216
  end
169
217
  ```
170
218
 
@@ -537,6 +585,25 @@ class ParticipantsController < ApplicationController
537
585
  end
538
586
  ```
539
587
 
588
+ `tramway_form_for` example
589
+
590
+ ```ruby
591
+ = tramway_form_for @user do |f|
592
+ = f.email_field :email
593
+ = f.password_field :password
594
+ = f.select :role, [["Admin", "admin"], ["Manager", "manager"]], include_blank: "Select role"
595
+ = f.submit 'Save'
596
+ ```
597
+
598
+ `tramway_form_for` supports `horizontal: true` for horizontal form layout.
599
+
600
+ ```ruby
601
+ = tramway_form_for @user, horizontal: true do |f|
602
+ = f.email_field :email
603
+ = f.password_field :password
604
+ = f.submit 'Save'
605
+ ```
606
+
540
607
 
541
608
  ---
542
609
 
@@ -11,7 +11,6 @@ module Tramway
11
11
  include Singleton
12
12
 
13
13
  attr_config(
14
- pagination: { enabled: false },
15
14
  entities: [],
16
15
  application_controller: 'ActionController::Base',
17
16
  theme: :classic
@@ -8,6 +8,7 @@ module Tramway
8
8
  class Page < Dry::Struct
9
9
  attribute :action, Types::Coercible::String
10
10
  attribute? :scope, Types::Coercible::String
11
+ attribute? :search, Types::Bool
11
12
  end
12
13
  end
13
14
  end
@@ -13,7 +13,7 @@ module Tramway
13
13
  load_form_helper
14
14
  load_routes_helper
15
15
  load_chats_broadcast
16
- configure_pagination if Tramway.config.pagination[:enabled]
16
+ load_searchable
17
17
  end
18
18
 
19
19
  initializer 'tramway.assets.precompile' do |app|
@@ -78,6 +78,14 @@ module Tramway
78
78
  end
79
79
  end
80
80
 
81
+ def load_searchable
82
+ ActiveSupport.on_load(:active_record) do |loaded_class|
83
+ require 'tramway/searchable'
84
+
85
+ loaded_class.include Tramway::Searchable
86
+ end
87
+ end
88
+
81
89
  def configure_pagination
82
90
  ActiveSupport.on_load(:action_controller) do
83
91
  # Detecting tramway views path
@@ -9,10 +9,12 @@ module Tramway
9
9
  FORM_SIZES = %i[small medium large].freeze
10
10
 
11
11
  def tramway_form_for(object, *, size: :medium, **options, &)
12
+ form_object_class = object.is_a?(Tramway::BaseForm) ? object.class : nil
13
+
12
14
  form_for(
13
15
  object,
14
16
  *,
15
- **options.merge(builder: Tramway::Form::Builder, size: normalize_form_size(size)),
17
+ **options.merge(builder: Tramway::Form::Builder, size: normalize_form_size(size), form_object_class:),
16
18
  &
17
19
  )
18
20
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ # Searchable module provides a class method `tramway_search` for ActiveRecord models to perform full-text search
5
+ # across all string and text columns using PostgreSQL's full-text search capabilities.
6
+ module Searchable
7
+ extend ActiveSupport::Concern
8
+
9
+ class_methods do
10
+ def tramway_search(query)
11
+ tokens = search_tokens(query)
12
+ columns_to_search = searchable_column_names
13
+
14
+ return all if tokens.empty? || columns_to_search.empty?
15
+
16
+ where(
17
+ Arel.sql("to_tsvector('simple', #{tsvector_expression(columns_to_search)}) @@ to_tsquery('simple', ?)"),
18
+ tsquery_expression(tokens)
19
+ )
20
+ end
21
+
22
+ private
23
+
24
+ def search_tokens(query)
25
+ query.to_s.scan(/[[:alnum:]]+/)
26
+ end
27
+
28
+ def searchable_column_names
29
+ columns.select { |column| column.type.in?(%i[string text]) }.map(&:name)
30
+ end
31
+
32
+ def tsvector_expression(column_names)
33
+ column_names.map do |column_name|
34
+ "coalesce(#{connection.quote_column_name(column_name)}, '')"
35
+ end.join(" || ' ' || ")
36
+ end
37
+
38
+ def tsquery_expression(tokens)
39
+ tokens.map { |token| "#{token}:*" }.join(' & ')
40
+ end
41
+ end
42
+ end
43
+ end
@@ -7,7 +7,7 @@ module Tramway
7
7
  def tramway_field(field_data, attribute, **options, &)
8
8
  input_type = field_type(field_data)
9
9
  input_name = field_name input_type
10
- input_options = field_options(field_data).merge(options)
10
+ input_options = field_options(field_data).merge(options.compact)
11
11
 
12
12
  case input_type.to_sym
13
13
  when :select, :multiselect
@@ -42,7 +42,7 @@ module Tramway
42
42
 
43
43
  def field_options(field_data)
44
44
  if field_data.is_a?(Hash)
45
- value = field_data[:value]&.call
45
+ value = field_data[:value]&.call(object)
46
46
 
47
47
  field_data.merge(value:).except(:type)
48
48
  else
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Tramway
4
- VERSION = '2.3.1.4'
4
+ VERSION = '3.0'
5
5
  end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ # Warnings module provides methods to log warnings for different scenarios
5
+ module Warnings
6
+ module_function
7
+
8
+ def search_fallback(model_class)
9
+ Rails.logger.warn(
10
+ "Tramway search: `#{model_class}.search` is not defined. " \
11
+ "Falling back to `#{model_class}.tramway_search`. " \
12
+ 'This is a generic fallback and not tailored to your data structure, ' \
13
+ 'so it is not intended for long-term use and may be slow or not scalable.'
14
+ )
15
+ end
16
+ end
17
+ end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: tramway
3
3
  version: !ruby/object:Gem::Version
4
- version: 2.3.1.4
4
+ version: '3.0'
5
5
  platform: ruby
6
6
  authors:
7
7
  - kalashnikovisme
@@ -161,6 +161,8 @@ files:
161
161
  - app/components/tramway/chats/messages/table_component.html.haml
162
162
  - app/components/tramway/chats/messages/table_component.rb
163
163
  - app/components/tramway/colors_methods.rb
164
+ - app/components/tramway/comments/show_path_component.html.haml
165
+ - app/components/tramway/comments/show_path_component.rb
164
166
  - app/components/tramway/containers/main_component.html.haml
165
167
  - app/components/tramway/containers/main_component.rb
166
168
  - app/components/tramway/containers/narrow_component.html.haml
@@ -200,6 +202,8 @@ files:
200
202
  - app/components/tramway/form/text_area_component.rb
201
203
  - app/components/tramway/form/text_field_component.html.haml
202
204
  - app/components/tramway/form/text_field_component.rb
205
+ - app/components/tramway/form/time_field_component.html.haml
206
+ - app/components/tramway/form/time_field_component.rb
203
207
  - app/components/tramway/native_text_component.html.haml
204
208
  - app/components/tramway/native_text_component.rb
205
209
  - app/components/tramway/nav/item/button_component.html.haml
@@ -286,10 +290,12 @@ files:
286
290
  - lib/tramway/helpers/routes_helper.rb
287
291
  - lib/tramway/helpers/views_helper.rb
288
292
  - lib/tramway/navbar.rb
293
+ - lib/tramway/searchable.rb
289
294
  - lib/tramway/utils/field.rb
290
295
  - lib/tramway/utils/render.rb
291
296
  - lib/tramway/version.rb
292
297
  - lib/tramway/views/form_builder.rb
298
+ - lib/tramway/warnings.rb
293
299
  - lib/types.rb
294
300
  homepage: https://github.com/purple-magic/tramway
295
301
  licenses: