tramway 3.1.2.8 → 4.0.1.1

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 (41) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +134 -18
  3. data/app/assets/builds/tailwind.css +2 -0
  4. data/app/assets/javascripts/tramway/tramway.js +202 -1
  5. data/app/assets/stylesheets/font-awesome.css +2337 -0
  6. data/app/assets/tailwind/application.css +29 -0
  7. data/app/components/tramway/chat_component.html.haml +1 -1
  8. data/app/components/tramway/containers/main_component.rb +1 -3
  9. data/app/components/tramway/containers/narrow_component.rb +5 -4
  10. data/app/components/tramway/form/builder.rb +1 -2
  11. data/app/components/tramway/grid/card_component.html.haml +2 -0
  12. data/app/components/tramway/grid/card_component.rb +37 -0
  13. data/app/components/tramway/grid_component.html.haml +18 -0
  14. data/app/components/tramway/grid_component.rb +76 -0
  15. data/app/components/tramway/native_text_component.rb +1 -2
  16. data/app/components/tramway/nav/item_component.rb +4 -2
  17. data/app/components/tramway/navbar_component.html.haml +131 -102
  18. data/app/components/tramway/navbar_component.rb +84 -2
  19. data/app/components/tramway/table/cell_component.rb +9 -1
  20. data/app/components/tramway/table/header_component.rb +23 -2
  21. data/app/components/tramway/table/row_component.rb +27 -10
  22. data/app/components/tramway/table_component.rb +28 -1
  23. data/app/controllers/tramway/entities_controller.rb +13 -6
  24. data/app/views/tramway/entities/_list.html.haml +2 -1
  25. data/app/views/tramway/layouts/application.html.haml +8 -6
  26. data/config/routes.rb +1 -2
  27. data/config/tailwind.config.js +57 -0
  28. data/lib/generators/tramway/install/font_awesome_helpers.rb +39 -0
  29. data/lib/generators/tramway/install/install_generator.rb +133 -66
  30. data/lib/generators/tramway/install/layout_helpers.rb +95 -0
  31. data/lib/tramway/configs/entities/page.rb +1 -0
  32. data/lib/tramway/engine.rb +12 -0
  33. data/lib/tramway/errors.rb +45 -0
  34. data/lib/tramway/forms/validations.rb +1 -2
  35. data/lib/tramway/helpers/navbar_helper.rb +3 -1
  36. data/lib/tramway/helpers/table_helper.rb +27 -0
  37. data/lib/tramway/helpers/views_helper.rb +26 -2
  38. data/lib/tramway/pg_searchable.rb +62 -0
  39. data/lib/tramway/searchable.rb +8 -26
  40. data/lib/tramway/version.rb +1 -1
  41. metadata +44 -4
@@ -2,12 +2,47 @@
2
2
 
3
3
  require 'rails/generators'
4
4
  require 'fileutils'
5
+ require_relative 'font_awesome_helpers'
6
+ require_relative 'layout_helpers'
5
7
 
6
8
  module Tramway
7
9
  module Generators
8
10
  # nodoc
9
- # rubocop:disable Metrics/ModuleLength
11
+ # rubocop:disable-next Metrics/ModuleLength
10
12
  module InstallGeneratorHelpers
13
+ TAILWIND_SCROLLBAR_UTILITY = <<~CSS.chomp
14
+ @layer utilities {
15
+ .tramway-scrollbar {
16
+ scrollbar-width: thin;
17
+ scrollbar-color: rgba(82, 82, 91, 0.9) rgba(9, 9, 11, 0.92);
18
+ }
19
+
20
+ .tramway-scrollbar::-webkit-scrollbar {
21
+ width: 0.5rem;
22
+ }
23
+
24
+ .tramway-scrollbar::-webkit-scrollbar-track {
25
+ background: rgba(9, 9, 11, 0.92);
26
+ }
27
+
28
+ .tramway-scrollbar::-webkit-scrollbar-thumb {
29
+ border-radius: 9999px;
30
+ background: rgba(82, 82, 91, 0.9);
31
+ }
32
+
33
+ .tramway-scrollbar::-webkit-scrollbar-thumb:hover {
34
+ background: rgba(113, 113, 122, 0.95);
35
+ }
36
+
37
+ .tramway-scrollbar::-webkit-scrollbar-corner {
38
+ background: transparent;
39
+ }
40
+ }
41
+ CSS
42
+
43
+ TAILWIND_APPLICATION_STYLESHEET_CONTENT =
44
+ "@import \"tailwindcss\";\n\n#{TAILWIND_SCROLLBAR_UTILITY}\n".freeze
45
+
11
46
  private
12
47
 
13
48
  def gem_dependencies
@@ -16,7 +51,8 @@ module Tramway
16
51
  { name: 'kaminari', declaration: 'gem "kaminari"' },
17
52
  { name: 'view_component', declaration: 'gem "view_component"' },
18
53
  { name: 'dry-initializer', declaration: "gem 'dry-initializer'" },
19
- { name: 'dry-monads', declaration: "gem 'dry-monads'" }
54
+ { name: 'dry-monads', declaration: "gem 'dry-monads'" },
55
+ { name: 'pg_search', declaration: "gem 'pg_search'" }
20
56
  ]
21
57
  end
22
58
 
@@ -78,12 +114,13 @@ module Tramway
78
114
 
79
115
  def stimulus_controller_imports
80
116
  [
81
- 'import { TramwaySelect, TableRowPreview, UiCheckbox, Tooltip } from "@tramway/tramway"'
117
+ 'import { Navbar, TramwaySelect, TableRowPreview, UiCheckbox, Tooltip } from "@tramway/tramway"'
82
118
  ]
83
119
  end
84
120
 
85
121
  def stimulus_controller_registrations
86
122
  [
123
+ "application.register('tramway-navbar', Navbar)",
87
124
  "application.register('tramway-select', TramwaySelect)",
88
125
  "application.register('table-row-preview', TableRowPreview)",
89
126
  "application.register('ui--checkbox', UiCheckbox)",
@@ -95,28 +132,6 @@ module Tramway
95
132
  @agents_file_path ||= File.join(destination_root, 'AGENTS.md')
96
133
  end
97
134
 
98
- def application_layout_haml_path
99
- @application_layout_haml_path ||= File.join(destination_root, 'app/views/layouts/application.html.haml')
100
- end
101
-
102
- def application_layout_erb_path
103
- @application_layout_erb_path ||= File.join(destination_root, 'app/views/layouts/application.html.erb')
104
- end
105
-
106
- def trix_haml_tags
107
- " = stylesheet_link_tag \"trix\", \"data-turbo-track\": \"reload\"\n " \
108
- "= javascript_include_tag \"trix\", \"data-turbo-track\": \"reload\", defer: true\n"
109
- end
110
-
111
- def trix_erb_tags
112
- " <%= stylesheet_link_tag \"trix\", \"data-turbo-track\": \"reload\" %>\n " \
113
- "<%= javascript_include_tag \"trix\", \"data-turbo-track\": \"reload\", defer: true %>\n"
114
- end
115
-
116
- def trix_already_present?(content)
117
- content.include?('stylesheet_link_tag "trix"') || content.include?("stylesheet_link_tag 'trix'")
118
- end
119
-
120
135
  def codex_agents_instruction
121
136
  'If you are Codex, use the tramway-skill skill for Tramway/Rails work in this project. ' \
122
137
  'If tramway-skill is not installed, install it from https://github.com/Purple-Magic/tramway-skill/.'
@@ -134,15 +149,16 @@ module Tramway
134
149
  end
135
150
 
136
151
  def safelist_section(content)
137
- match = content.match(/safelist\s*:\s*\[(.*?)\]\s*,?/m)
138
- match&.[](1)
152
+ open_index, close_index = safelist_bracket_range(content)
153
+ return nil unless open_index
154
+
155
+ content[(open_index + 1)...close_index]
139
156
  end
140
157
 
141
158
  def insert_entries(content, entries)
142
- match = content.match(/safelist\s*:\s*\[(.*?)\](\s*,?)/m)
143
- return content unless match
159
+ _open_index, closing_index = safelist_bracket_range(content)
160
+ return content unless closing_index
144
161
 
145
- closing_index = match.begin(0) + match[0].rindex(']')
146
162
  indentation = closing_indentation(content, closing_index)
147
163
  formatted_entries = entries.map { |entry| "#{indentation}'#{entry}',\n" }.join
148
164
 
@@ -154,6 +170,33 @@ module Tramway
154
170
  content.dup.insert(closing_index, insertion)
155
171
  end
156
172
 
173
+ # Finds the `[`/`]` pair that delimits the `safelist` array, ignoring brackets that
174
+ # appear inside quoted entries (e.g. `'md:transition-[width]'`) since a naive regex
175
+ # would otherwise stop at the first `]`, wherever it occurs.
176
+ def safelist_bracket_range(content)
177
+ start_index = content =~ /safelist\s*:\s*\[/
178
+ return [nil, nil] unless start_index
179
+
180
+ open_index = content.index('[', start_index)
181
+ close_index = matching_bracket_index(content, open_index)
182
+
183
+ [open_index, close_index]
184
+ end
185
+
186
+ def matching_bracket_index(content, open_index)
187
+ depth = 0
188
+
189
+ content[open_index..].to_enum(:scan, /'[^']*'|"[^"]*"|[\[\]]/).each do
190
+ token = Regexp.last_match[0]
191
+ next unless ['[', ']'].include?(token)
192
+
193
+ depth += token == '[' ? 1 : -1
194
+ return open_index + Regexp.last_match.end(0) - 1 if depth.zero?
195
+ end
196
+
197
+ nil
198
+ end
199
+
157
200
  def closing_indentation(content, index)
158
201
  line_start = content.rindex("\n", index - 1) || 0
159
202
  line = content[line_start..index]
@@ -171,30 +214,39 @@ module Tramway
171
214
  content.end_with?("\n") ? "\n" : "\n\n"
172
215
  end
173
216
 
174
- # rubocop:disable Metrics/MethodLength
175
217
  def append_missing_imports(content)
176
218
  content = remove_legacy_stimulus_imports(content)
219
+ content = normalize_tramway_stimulus_imports(content)
177
220
  missing_imports = stimulus_controller_imports.reject { |line| content.include?(line) }
178
221
  return content if missing_imports.empty?
179
222
 
180
223
  import_lines = content.each_line.with_index.filter_map do |line, index|
181
224
  index if line.lstrip.start_with?('import ')
182
225
  end
183
- insertion = "#{missing_imports.join("\n")}\n"
226
+ insert_missing_imports(content, missing_imports.join("\n"), import_lines)
227
+ end
228
+
229
+ def insert_missing_imports(content, imports, import_lines)
230
+ insertion = "#{imports}\n"
184
231
  updated = content.dup
185
232
 
186
- if import_lines.any?
187
- insertion_index = updated.lines[0..import_lines.max].join.length
188
- updated.insert(insertion_index, insertion)
189
- else
190
- updated.prepend(insertion)
191
- end
233
+ return updated.prepend(insertion) unless import_lines.any?
192
234
 
235
+ insertion_index = updated.lines[0..import_lines.max].join.length
236
+ updated.insert(insertion_index, insertion)
193
237
  updated
194
238
  end
195
- # rubocop:enable Metrics/MethodLength
239
+
240
+ def normalize_tramway_stimulus_imports(content)
241
+ tramway_import_lines = content.each_line.select { |line| tramway_stimulus_import_line?(line) }
242
+ return content unless tramway_import_lines.any?
243
+ return content if normalized_tramway_import_lines?(tramway_import_lines)
244
+
245
+ content.each_line.reject { |line| tramway_stimulus_import_line?(line) }.join
246
+ end
196
247
 
197
248
  def append_missing_registrations(content)
249
+ content = normalize_tramway_stimulus_registrations(content)
198
250
  missing_registrations = stimulus_controller_registrations.reject { |line| content.include?(line) }
199
251
  return content if missing_registrations.empty?
200
252
 
@@ -221,6 +273,36 @@ module Tramway
221
273
  content.each_line.reject { |line| legacy_imports.include?(line.strip) }.join
222
274
  end
223
275
 
276
+ def tramway_stimulus_import_line?(line)
277
+ line.strip.match?(%r{^import \{.*\} from ["']@tramway/tramway["']$})
278
+ end
279
+
280
+ def normalize_tramway_stimulus_registrations(content)
281
+ tramway_registration_lines = content.each_line.select { |line| tramway_stimulus_registration_line?(line) }
282
+ return content if tramway_registration_lines.sort == stimulus_controller_registrations.sort
283
+ return content unless tramway_registration_lines.any?
284
+
285
+ content.each_line.reject { |line| tramway_stimulus_registration_line?(line) }.join
286
+ end
287
+
288
+ def tramway_stimulus_registration_line?(line)
289
+ tramway_registration_prefixes.any? { |prefix| line.strip.start_with?(prefix) }
290
+ end
291
+
292
+ def normalized_tramway_import_lines?(tramway_import_lines)
293
+ tramway_import_lines.one? && tramway_import_lines.first.strip == stimulus_controller_imports.first
294
+ end
295
+
296
+ def tramway_registration_prefixes
297
+ [
298
+ "application.register('tramway-navbar'",
299
+ "application.register('tramway-select'",
300
+ "application.register('table-row-preview'",
301
+ "application.register('ui--checkbox'",
302
+ "application.register('tramway-tooltip'"
303
+ ]
304
+ end
305
+
224
306
  def remove_legacy_importmap_pins(content)
225
307
  content.each_line.reject { |line| legacy_importmap_pins.include?(line.strip) }.join
226
308
  end
@@ -255,12 +337,12 @@ module Tramway
255
337
  say_status(:warning, "Skipping AGENTS.md update: #{e.message}")
256
338
  end
257
339
  end
258
- # rubocop:enable Metrics/ModuleLength
259
340
 
260
- # Running `rails generate tramway:install` will invoke this generator
261
- #
341
+ # Installs Tramway dependencies and app wiring for a host Rails app.
262
342
  class InstallGenerator < Rails::Generators::Base
263
343
  include InstallGeneratorHelpers
344
+ include InstallGeneratorFontAwesomeHelpers
345
+ include InstallGeneratorLayoutHelpers
264
346
 
265
347
  desc 'Installs Tramway dependencies and Tailwind safelist configuration.'
266
348
 
@@ -310,15 +392,14 @@ module Tramway
310
392
  path = tailwind_application_stylesheet_path
311
393
  FileUtils.mkdir_p(File.dirname(path))
312
394
 
313
- return create_file(path, "#{tailwind_css_import_line}\n") unless File.exist?(path)
395
+ return create_file(path, TAILWIND_APPLICATION_STYLESHEET_CONTENT) unless File.exist?(path)
314
396
 
315
397
  content = File.read(path)
316
- return if content.include?(tailwind_css_import_line)
398
+ return if content.include?(tailwind_css_import_line) && content.include?(TAILWIND_SCROLLBAR_UTILITY)
317
399
 
318
- File.open(path, 'a') do |file|
319
- file.write("\n") unless content.empty? || content.end_with?("\n")
320
- file.write("#{tailwind_css_import_line}\n")
321
- end
400
+ updated = content.include?(tailwind_css_import_line) ? content : "#{content}\n#{tailwind_css_import_line}\n"
401
+ updated = "#{updated}\n#{TAILWIND_SCROLLBAR_UTILITY}\n" unless updated.include?(TAILWIND_SCROLLBAR_UTILITY)
402
+ File.write(path, updated)
322
403
  end
323
404
 
324
405
  def ensure_importmap_pin
@@ -348,30 +429,16 @@ module Tramway
348
429
 
349
430
  def ensure_trix_in_application_layout
350
431
  if File.exist?(application_layout_haml_path)
432
+ ensure_font_awesome_in_haml_layout
351
433
  ensure_trix_in_haml_layout
352
434
  elsif File.exist?(application_layout_erb_path)
435
+ ensure_font_awesome_in_erb_layout
353
436
  ensure_trix_in_erb_layout
354
437
  end
355
438
  end
356
439
 
357
- private
358
-
359
- def ensure_trix_in_haml_layout
360
- content = File.read(application_layout_haml_path)
361
- return if trix_already_present?(content)
362
- return unless content.match?(/^\s+%body/)
363
-
364
- updated = content.sub(/^(\s+%body)/, "#{trix_haml_tags}\\1")
365
- File.write(application_layout_haml_path, updated)
366
- end
367
-
368
- def ensure_trix_in_erb_layout
369
- content = File.read(application_layout_erb_path)
370
- return if trix_already_present?(content)
371
- return unless content.include?('</head>')
372
-
373
- updated = content.sub('</head>', "#{trix_erb_tags} </head>")
374
- File.write(application_layout_erb_path, updated)
440
+ def ensure_navbar_sidebar_offset_in_application_layout
441
+ ensure_navbar_sidebar_offset
375
442
  end
376
443
  end
377
444
  end
@@ -0,0 +1,95 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Generators
5
+ # Layout helpers for Tramway generator template updates.
6
+ module InstallGeneratorLayoutHelpers
7
+ private
8
+
9
+ def application_layout_haml_path
10
+ @application_layout_haml_path ||= File.join(destination_root, 'app/views/layouts/application.html.haml')
11
+ end
12
+
13
+ def application_layout_erb_path
14
+ @application_layout_erb_path ||= File.join(destination_root, 'app/views/layouts/application.html.erb')
15
+ end
16
+
17
+ def trix_haml_tags
18
+ " = stylesheet_link_tag \"trix\", \"data-turbo-track\": \"reload\"\n " \
19
+ "= javascript_include_tag \"trix\", \"data-turbo-track\": \"reload\", defer: true\n"
20
+ end
21
+
22
+ def trix_erb_tags
23
+ " <%= stylesheet_link_tag \"trix\", \"data-turbo-track\": \"reload\" %>\n " \
24
+ "<%= javascript_include_tag \"trix\", \"data-turbo-track\": \"reload\", defer: true %>\n"
25
+ end
26
+
27
+ def trix_already_present?(content)
28
+ content.include?('stylesheet_link_tag "trix"') || content.include?("stylesheet_link_tag 'trix'")
29
+ end
30
+
31
+ def font_awesome_already_present?(content)
32
+ content.include?('stylesheet_link_tag "font-awesome"') ||
33
+ content.include?("stylesheet_link_tag 'font-awesome'")
34
+ end
35
+
36
+ def navbar_sidebar_offset_class
37
+ 'md:pl-72 transition-all duration-300 ease-in-out'
38
+ end
39
+
40
+ def ensure_trix_in_haml_layout
41
+ content = File.read(application_layout_haml_path)
42
+ return if trix_already_present?(content)
43
+ return unless content.match?(/^\s+%body/)
44
+
45
+ updated = content.sub(/^(\s+%body)/, "#{trix_haml_tags}\\1")
46
+ File.write(application_layout_haml_path, updated)
47
+ end
48
+
49
+ def ensure_trix_in_erb_layout
50
+ content = File.read(application_layout_erb_path)
51
+ return if trix_already_present?(content)
52
+ return unless content.include?('</head>')
53
+
54
+ updated = content.sub('</head>', "#{trix_erb_tags} </head>")
55
+ File.write(application_layout_erb_path, updated)
56
+ end
57
+
58
+ def ensure_navbar_sidebar_offset
59
+ if File.exist?(application_layout_haml_path)
60
+ ensure_navbar_sidebar_offset_in_haml_layout
61
+ elsif File.exist?(application_layout_erb_path)
62
+ ensure_navbar_sidebar_offset_in_erb_layout
63
+ end
64
+ end
65
+
66
+ def ensure_navbar_sidebar_offset_in_haml_layout
67
+ content = File.read(application_layout_haml_path)
68
+ return if content.include?(navbar_sidebar_offset_class)
69
+ return unless content.match?(/= tramway_main_container(?: class: '[^']*')? do/)
70
+
71
+ File.write(
72
+ application_layout_haml_path,
73
+ content.sub(
74
+ /= tramway_main_container(?: class: '[^']*')? do/,
75
+ "= tramway_main_container class: '#{navbar_sidebar_offset_class}' do"
76
+ )
77
+ )
78
+ end
79
+
80
+ def ensure_navbar_sidebar_offset_in_erb_layout
81
+ content = File.read(application_layout_erb_path)
82
+ return if content.include?(navbar_sidebar_offset_class)
83
+ return unless content.match?(/<%= tramway_main_container(?: class: '[^']*')? do %>/)
84
+
85
+ File.write(
86
+ application_layout_erb_path,
87
+ content.sub(
88
+ /<%= tramway_main_container(?: class: '[^']*')? do %>/,
89
+ "<%= tramway_main_container class: '#{navbar_sidebar_offset_class}' do %>"
90
+ )
91
+ )
92
+ end
93
+ end
94
+ end
95
+ end
@@ -9,6 +9,7 @@ module Tramway
9
9
  attribute :action, Types::Coercible::String
10
10
  attribute? :scope, Types::Coercible::String
11
11
  attribute? :search, Types::Bool
12
+ attribute? :includes, Types::Array.default([].freeze)
12
13
  end
13
14
  end
14
15
  end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'font-awesome-rails'
4
+
3
5
  module Tramway
4
6
  # Rails plugin is the Engine
5
7
  #
@@ -17,8 +19,14 @@ module Tramway
17
19
  end
18
20
 
19
21
  initializer 'tramway.assets.precompile' do |app|
22
+ font_awesome_font_paths.each do |path|
23
+ path_string = path.to_s
24
+ app.config.assets.paths << path_string unless app.config.assets.paths.include?(path_string)
25
+ end
26
+
20
27
  app.config.assets.precompile += %w[
21
28
  tramway/tramway.js
29
+ font-awesome.css
22
30
  ]
23
31
  end
24
32
 
@@ -88,6 +96,10 @@ module Tramway
88
96
  end
89
97
  end
90
98
 
99
+ def font_awesome_font_paths
100
+ [FontAwesome::Rails::Engine.root.join('app/assets/fonts')]
101
+ end
102
+
91
103
  def configure_pagination
92
104
  ActiveSupport.on_load(:action_controller) do
93
105
  # Detecting tramway views path
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ # Namespace for Tramway-specific error classes
5
+ module Errors
6
+ # Raised when a Tramway feature depends on infrastructure (e.g. a specific
7
+ # database adapter) that the current application does not provide, so the
8
+ # developer gets an actionable error instead of a raw, confusing failure
9
+ # deep inside the underlying gem.
10
+ class UnsupportedDatabaseAdapterError < StandardError
11
+ def initialize(feature:, model_class:, adapter:, required_adapter: 'PostgreSQL')
12
+ super(<<~MESSAGE)
13
+ Tramway #{feature} requires #{required_adapter}, but #{model_class} is connected \
14
+ through the "#{adapter}" adapter.
15
+
16
+ This feature relies on the `pg_search` gem, which needs PostgreSQL's full-text \
17
+ search functions (to_tsvector/to_tsquery) and does not work on other adapters.
18
+
19
+ To fix this:
20
+ - switch #{model_class}'s database connection to PostgreSQL, or
21
+ - define a custom `#{model_class}.search(query)` scope that works with your \
22
+ adapter; Tramway will use it instead of the built-in pg_search fallback.
23
+ MESSAGE
24
+ end
25
+ end
26
+
27
+ # Raised when a Tramway feature depends on a gem that could not be loaded or used, so the
28
+ # developer (or an AI agent) gets a precise, actionable fix instead of a raw NoMethodError
29
+ # or LoadError deep inside the missing gem's call chain.
30
+ class MissingGemError < StandardError
31
+ def initialize(feature:, model_class:, gem_name:, original_error:)
32
+ super(<<~MESSAGE)
33
+ Tramway #{feature} for #{model_class} could not use the `#{gem_name}` gem \
34
+ (#{original_error.class}: #{original_error.message}).
35
+
36
+ To fix this:
37
+ - add `gem "#{gem_name}"` to your Gemfile, run `bundle install`, and restart your \
38
+ server (or re-run `bin/rails g tramway:install`, which adds it automatically), or
39
+ - define a custom `#{model_class}.search(query)` scope that does not depend on \
40
+ `#{gem_name}`; Tramway will use it instead of the built-in fallback.
41
+ MESSAGE
42
+ end
43
+ end
44
+ end
45
+ end
@@ -16,11 +16,10 @@ module Tramway
16
16
  def __initialize_validations(_subclass); end
17
17
  end
18
18
 
19
- # rubocop:disable Naming/PredicateMethod
19
+ # rubocop:disable-next Naming/PredicateMethod
20
20
  def __apply_validations(_params)
21
21
  valid?
22
22
  end
23
- # rubocop:enable Naming/PredicateMethod
24
23
  end
25
24
  end
26
25
  end
@@ -6,11 +6,13 @@ module Tramway
6
6
  module Helpers
7
7
  # Provides navbar helpers for ActionView
8
8
  module NavbarHelper
9
- def tramway_navbar(with_entities: true, **options)
9
+ def tramway_navbar(with_entities: true, direction: :vertical, **options)
10
+ @tramway_navbar_direction = direction.presence&.to_sym || :vertical
10
11
  initialize_navbar(with_entities:)
11
12
 
12
13
  yield @navbar if block_given?
13
14
 
15
+ options[:direction] = direction if options[:direction].nil?
14
16
  assign_navbar_items(options)
15
17
 
16
18
  render_navbar_component(options)
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Tramway
4
+ module Helpers
5
+ # Provides size helpers for Tramway tables
6
+ module TableHelper
7
+ TABLE_SIZES = %i[small medium large].freeze
8
+
9
+ def tramway_table_size
10
+ context = tramway_table_context
11
+ return :medium unless context.respond_to?(:tramway_table_size)
12
+
13
+ TABLE_SIZES.include?(context.tramway_table_size) ? context.tramway_table_size : :medium
14
+ end
15
+
16
+ def normalize_table_size(size)
17
+ TABLE_SIZES.include?(size) ? size : :medium
18
+ end
19
+
20
+ def tramway_table_context
21
+ return __vc_original_view_context if respond_to?(:__vc_original_view_context)
22
+
23
+ view_context if respond_to?(:view_context)
24
+ end
25
+ end
26
+ end
27
+ end
@@ -1,10 +1,13 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tramway/helpers/table_helper'
4
+
3
5
  module Tramway
4
6
  module Helpers
5
7
  # Provides view-oriented helpers for ActionView
6
8
  module ViewsHelper
7
9
  include Tramway::Helpers::ComponentHelper
10
+ include Tramway::Helpers::TableHelper
8
11
 
9
12
  FORM_SIZES = %i[small medium large].freeze
10
13
 
@@ -19,8 +22,16 @@ module Tramway
19
22
  ), &)
20
23
  end
21
24
 
22
- def tramway_table(**options, &)
23
- component 'tramway/table', options:, &
25
+ def tramway_table(size: :medium, **options, &)
26
+ component 'tramway/table', size: normalize_table_size(size), options:, &
27
+ end
28
+
29
+ def tramway_grid(rows:, columns:, outline: false, **options, &)
30
+ component 'tramway/grid', rows:, columns:, outline:, options:, &
31
+ end
32
+
33
+ def tramway_card(size: [1, 1], **options, &)
34
+ component 'tramway/grid/card', size:, options:, &
24
35
  end
25
36
 
26
37
  def tramway_header(headers: nil, columns: nil, **options, &)
@@ -63,6 +74,8 @@ module Tramway
63
74
  end
64
75
 
65
76
  def tramway_main_container(**options, &)
77
+ options[:id] ||= 'tramway-main-container'
78
+ options[:class] = tramway_main_container_classes(options[:class])
66
79
  component 'tramway/containers/main', options:, &
67
80
  end
68
81
 
@@ -102,6 +115,17 @@ module Tramway
102
115
  def normalize_form_size(size)
103
116
  FORM_SIZES.include?(size) ? size : :medium
104
117
  end
118
+
119
+ def tramway_main_container_classes(value)
120
+ classes = Array(value).flat_map { _1.to_s.split }
121
+
122
+ return classes.join(' ') if @tramway_navbar_direction.nil?
123
+
124
+ classes = classes.reject { _1.start_with?('md:pl-') || _1.start_with?('md:pr-') }
125
+ classes << 'md:pl-72' if @tramway_navbar_direction == :vertical
126
+
127
+ classes.join(' ')
128
+ end
105
129
  end
106
130
  end
107
131
  end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'tramway/errors'
4
+
5
+ module Tramway
6
+ # Wires up a default, generic search across all string/text columns using the
7
+ # `pg_search` gem, so entities#index search works out of the box for any model
8
+ # that does not define its own `.search` scope.
9
+ #
10
+ # This is only supported on PostgreSQL, since pg_search depends on PostgreSQL's
11
+ # full-text search functions. On any other adapter it raises a clear,
12
+ # actionable error instead of failing with a confusing SQL error deep inside
13
+ # pg_search.
14
+ module PgSearchable
15
+ module_function
16
+
17
+ def call(model_class, query)
18
+ ensure_postgres!(model_class)
19
+ ensure_pg_search_scope!(model_class)
20
+
21
+ model_class.tramway_pg_search(query)
22
+ end
23
+
24
+ def ensure_postgres!(model_class)
25
+ adapter = model_class.connection.adapter_name
26
+
27
+ return if adapter.casecmp('postgresql').zero?
28
+
29
+ raise Tramway::Errors::UnsupportedDatabaseAdapterError.new(
30
+ feature: 'the default entities#index search',
31
+ model_class:,
32
+ adapter:
33
+ )
34
+ end
35
+
36
+ def ensure_pg_search_scope!(model_class)
37
+ return if model_class.respond_to?(:tramway_pg_search)
38
+
39
+ define_pg_search_scope!(model_class)
40
+ rescue LoadError, NameError => e
41
+ raise missing_pg_search_gem_error(model_class, e)
42
+ end
43
+
44
+ def define_pg_search_scope!(model_class)
45
+ require 'pg_search'
46
+
47
+ model_class.include(PgSearch::Model)
48
+ model_class.pg_search_scope :tramway_pg_search,
49
+ against: model_class.searchable_column_names,
50
+ using: { tsearch: { prefix: true } }
51
+ end
52
+
53
+ def missing_pg_search_gem_error(model_class, original_error)
54
+ Tramway::Errors::MissingGemError.new(
55
+ feature: 'the default entities#index search',
56
+ model_class:,
57
+ gem_name: 'pg_search',
58
+ original_error:
59
+ )
60
+ end
61
+ end
62
+ end