terrazzo 0.6.0 → 0.7.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 (98) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +101 -0
  3. data/Rakefile +1 -0
  4. data/app/controllers/terrazzo/application_controller.rb +63 -14
  5. data/app/helpers/terrazzo/collection_actions_helper.rb +38 -13
  6. data/app/helpers/terrazzo/resource_paths_helper.rb +55 -0
  7. data/app/views/terrazzo/application/_edit_base.json.props +7 -4
  8. data/app/views/terrazzo/application/_index_base.json.props +83 -7
  9. data/app/views/terrazzo/application/_navigation.json.props +7 -4
  10. data/app/views/terrazzo/application/_new_base.json.props +6 -3
  11. data/app/views/terrazzo/application/_show_base.json.props +7 -5
  12. data/lib/generators/terrazzo/dashboard/dashboard_generator.rb +48 -24
  13. data/lib/generators/terrazzo/dashboard/templates/controller.rb.erb +8 -4
  14. data/lib/generators/terrazzo/dashboard/templates/dashboard.rb.erb +33 -25
  15. data/lib/generators/terrazzo/eject/eject_generator.rb +223 -113
  16. data/lib/generators/terrazzo/field/field_generator.rb +51 -7
  17. data/lib/generators/terrazzo/field/templates/FormField.jsx.erb +2 -3
  18. data/lib/generators/terrazzo/install/install_generator.rb +641 -4
  19. data/lib/generators/terrazzo/install/templates/admin.css +2 -0
  20. data/lib/generators/terrazzo/install/templates/application.js.erb +3 -0
  21. data/lib/generators/terrazzo/install/templates/components.json.erb +17 -0
  22. data/lib/generators/terrazzo/install/templates/custom_page_mapping.js.erb +5 -0
  23. data/lib/generators/terrazzo/install/templates/generated_page_mapping.js.erb +10 -0
  24. data/lib/generators/terrazzo/install/templates/jsconfig.json +8 -0
  25. data/lib/generators/terrazzo/install/templates/page_to_page_mapping.js.erb +9 -1
  26. data/lib/generators/terrazzo/install/templates/superglue.html.erb.erb +2 -2
  27. data/lib/generators/terrazzo/routes/routes_generator.rb +55 -20
  28. data/lib/generators/terrazzo/views/edit_generator.rb +16 -10
  29. data/lib/generators/terrazzo/views/eject_compatibility.rb +19 -0
  30. data/lib/generators/terrazzo/views/field_generator.rb +18 -6
  31. data/lib/generators/terrazzo/views/generated_defaults_helper.rb +110 -0
  32. data/lib/generators/terrazzo/views/index_generator.rb +9 -8
  33. data/lib/generators/terrazzo/views/layout_generator.rb +4 -3
  34. data/lib/generators/terrazzo/views/navigation_generator.rb +5 -1
  35. data/lib/generators/terrazzo/views/new_generator.rb +16 -10
  36. data/lib/generators/terrazzo/views/page_mapping_helper.rb +131 -8
  37. data/lib/generators/terrazzo/views/show_generator.rb +7 -6
  38. data/lib/generators/terrazzo/views/templates/components/CollectionFilters.jsx +25 -0
  39. data/lib/generators/terrazzo/views/templates/components/CollectionItemActions.jsx +68 -0
  40. data/lib/generators/terrazzo/views/templates/components/CollectionToolbarActions.jsx +60 -0
  41. data/lib/generators/terrazzo/views/templates/components/HasManyPagination.jsx +36 -0
  42. data/lib/generators/terrazzo/views/templates/components/Layout.jsx +9 -4
  43. data/lib/generators/terrazzo/views/templates/components/Pagination.jsx +32 -24
  44. data/lib/generators/terrazzo/views/templates/components/ResourceTable.jsx +172 -0
  45. data/lib/generators/terrazzo/views/templates/components/SearchBar.jsx +14 -6
  46. data/lib/generators/terrazzo/views/templates/components/SortableHeader.jsx +7 -5
  47. data/lib/generators/terrazzo/views/templates/components/app-sidebar.jsx +1 -1
  48. data/lib/generators/terrazzo/views/templates/components/site-header.jsx +2 -2
  49. data/lib/generators/terrazzo/views/templates/fields/asset/FormField.jsx +15 -0
  50. data/lib/generators/terrazzo/views/templates/fields/asset/IndexField.jsx +5 -0
  51. data/lib/generators/terrazzo/views/templates/fields/asset/ShowField.jsx +13 -0
  52. data/lib/generators/terrazzo/views/templates/fields/belongs_to/FormField.jsx +1 -1
  53. data/lib/generators/terrazzo/views/templates/fields/boolean/FormField.jsx +1 -1
  54. data/lib/generators/terrazzo/views/templates/fields/boolean/IndexField.jsx +1 -1
  55. data/lib/generators/terrazzo/views/templates/fields/has_many/FormField.jsx +3 -3
  56. data/lib/generators/terrazzo/views/templates/fields/has_many/IndexField.jsx +4 -3
  57. data/lib/generators/terrazzo/views/templates/fields/has_many/ShowField.jsx +21 -35
  58. data/lib/generators/terrazzo/views/templates/fields/has_one/FormField.jsx +1 -1
  59. data/lib/generators/terrazzo/views/templates/fields/hstore/FormField.jsx +5 -5
  60. data/lib/generators/terrazzo/views/templates/fields/hstore/IndexField.jsx +0 -2
  61. data/lib/generators/terrazzo/views/templates/fields/hstore/ShowField.jsx +1 -1
  62. data/lib/generators/terrazzo/views/templates/fields/number/IndexField.jsx +1 -1
  63. data/lib/generators/terrazzo/views/templates/fields/number/ShowField.jsx +1 -1
  64. data/lib/generators/terrazzo/views/templates/fields/polymorphic/FormField.jsx +3 -2
  65. data/lib/generators/terrazzo/views/templates/fields/rich_text/FormField.jsx +2 -2
  66. data/lib/generators/terrazzo/views/templates/fields/select/FormField.jsx +1 -1
  67. data/lib/generators/terrazzo/views/templates/fields/select/IndexField.jsx +1 -1
  68. data/lib/generators/terrazzo/views/templates/fields/shared/TextInputFormField.jsx +2 -2
  69. data/lib/generators/terrazzo/views/templates/fields/text/FormField.jsx +2 -2
  70. data/lib/generators/terrazzo/views/templates/pages/_collection.jsx +5 -3
  71. data/lib/generators/terrazzo/views/templates/pages/_form.jsx +37 -30
  72. data/lib/generators/terrazzo/views/templates/pages/_navigation.json.props +14 -4
  73. data/lib/generators/terrazzo/views/templates/pages/edit.jsx +6 -4
  74. data/lib/generators/terrazzo/views/templates/pages/index.jsx +20 -8
  75. data/lib/generators/terrazzo/views/templates/pages/new.jsx +8 -4
  76. data/lib/generators/terrazzo/views/templates/pages/show.jsx +30 -32
  77. data/lib/generators/terrazzo/views/views_generator.rb +9 -5
  78. data/lib/terrazzo/base_dashboard.rb +167 -5
  79. data/lib/terrazzo/csv_export.rb +38 -0
  80. data/lib/terrazzo/field/associative.rb +16 -4
  81. data/lib/terrazzo/field/base.rb +4 -0
  82. data/lib/terrazzo/field/belongs_to.rb +1 -2
  83. data/lib/terrazzo/field/deferred.rb +4 -0
  84. data/lib/terrazzo/field/has_many.rb +79 -11
  85. data/lib/terrazzo/field/password.rb +6 -0
  86. data/lib/terrazzo/field/rich_text.rb +13 -0
  87. data/lib/terrazzo/generator_helpers.rb +1 -1
  88. data/lib/terrazzo/has_many_pagination.rb +9 -1
  89. data/lib/terrazzo/namespace/resource.rb +30 -0
  90. data/lib/terrazzo/namespace.rb +26 -1
  91. data/lib/terrazzo/search.rb +29 -9
  92. data/lib/terrazzo/version.rb +1 -1
  93. data/lib/terrazzo.rb +1 -0
  94. data/terrazzo.gemspec +11 -1
  95. metadata +40 -6
  96. data/lib/generators/terrazzo/install/templates/application.json.props.erb +0 -17
  97. data/lib/generators/terrazzo/views/templates/fields/FieldRenderer.jsx +0 -103
  98. data/lib/generators/terrazzo/views/templates/fields/index.js +0 -81
@@ -1,14 +1,132 @@
1
+ require "json"
2
+ require "pathname"
1
3
  require "rails/generators"
4
+ require "terrazzo/version"
2
5
 
3
6
  module Terrazzo
4
7
  module Generators
5
8
  class InstallGenerator < Rails::Generators::Base
6
9
  source_root File.expand_path("templates", __dir__)
7
10
 
11
+ TAILWIND_PACKAGE_NAME = "tailwindcss"
12
+ TAILWIND_CLI_PACKAGE_NAME = "@tailwindcss/cli"
13
+ TAILWIND_VITE_PACKAGE_NAME = "@tailwindcss/vite"
14
+ TAILWIND_RAILS_GEM_NAME = "tailwindcss-rails"
15
+ TAILWIND_MINIMUM_VERSION = "4.0.0"
16
+
17
+ FRONTEND_DEPENDENCIES = (
18
+ %w[
19
+ terrazzo
20
+ react
21
+ react-dom
22
+ react-redux
23
+ @reduxjs/toolkit
24
+ @thoughtbot/superglue
25
+ @radix-ui/react-avatar
26
+ @radix-ui/react-dialog
27
+ @radix-ui/react-dropdown-menu
28
+ @radix-ui/react-label
29
+ @radix-ui/react-popover
30
+ @radix-ui/react-select
31
+ @radix-ui/react-separator
32
+ @radix-ui/react-slot
33
+ @radix-ui/react-tooltip
34
+ class-variance-authority
35
+ lucide-react
36
+ ] + [TAILWIND_PACKAGE_NAME]
37
+ ).freeze
38
+ FRONTEND_DEPENDENCY_MINIMUMS = {
39
+ "terrazzo" => Terrazzo::VERSION,
40
+ "react" => "18.0.0",
41
+ "react-dom" => "18.0.0",
42
+ "react-redux" => "8.0.0",
43
+ "@reduxjs/toolkit" => "1.9.0",
44
+ "@thoughtbot/superglue" => "1.0.0",
45
+ "@radix-ui/react-avatar" => "1.0.0",
46
+ "@radix-ui/react-dialog" => "1.0.0",
47
+ "@radix-ui/react-dropdown-menu" => "2.0.0",
48
+ "@radix-ui/react-label" => "2.0.0",
49
+ "@radix-ui/react-popover" => "1.0.0",
50
+ "@radix-ui/react-select" => "2.0.0",
51
+ "@radix-ui/react-separator" => "1.0.0",
52
+ "@radix-ui/react-slot" => "1.0.0",
53
+ "@radix-ui/react-tooltip" => "1.0.0",
54
+ "class-variance-authority" => "0.7.0",
55
+ "lucide-react" => "0.300.0",
56
+ TAILWIND_PACKAGE_NAME => TAILWIND_MINIMUM_VERSION,
57
+ }.freeze
58
+ VITE_FRONTEND_DEPENDENCIES = %w[
59
+ vite
60
+ vite-plugin-ruby
61
+ ].freeze
62
+ SUPPORTED_BUNDLERS = %w[vite esbuild].freeze
63
+
64
+ argument :namespace_argument, type: :string, required: false,
65
+ desc: "Admin namespace. Prefer --namespace; this positional form is kept for compatibility."
66
+
8
67
  class_option :namespace, type: :string, default: "admin",
9
68
  desc: "Admin namespace"
10
69
  class_option :bundler, type: :string, default: "vite",
11
- desc: "JavaScript bundler (vite or sprockets)"
70
+ desc: "JavaScript bundler (vite or esbuild)"
71
+
72
+ def validate_bundler
73
+ return if SUPPORTED_BUNDLERS.include?(options[:bundler])
74
+
75
+ raise Thor::Error, <<~MESSAGE
76
+ Unsupported bundler '#{options[:bundler]}'.
77
+
78
+ Terrazzo generates React/JSX admin views and supports Vite or esbuild.
79
+ Re-run with `--bundler=vite` or `--bundler=esbuild`.
80
+ MESSAGE
81
+ end
82
+
83
+ def validate_namespace
84
+ return if namespace_argument.blank?
85
+ return if options[:namespace] == "admin" || options[:namespace] == namespace_argument
86
+
87
+ raise Thor::Error, <<~MESSAGE
88
+ Conflicting admin namespaces: '#{namespace_argument}' and '#{options[:namespace]}'.
89
+
90
+ Use either `rails generate terrazzo:install #{namespace_argument}` or
91
+ `rails generate terrazzo:install --namespace=#{options[:namespace]}`, not both.
92
+ MESSAGE
93
+ end
94
+
95
+ def verify_vite_bundler
96
+ return unless vite?
97
+
98
+ unless vite_rails_installed? && parsed_vite_config
99
+ raise Thor::Error, <<~MESSAGE
100
+ Terrazzo is configured for Vite, but vite_rails is not installed and configured.
101
+
102
+ Add `gem "vite_rails"` and run `bundle exec vite install` first, or re-run Terrazzo with `--bundler=esbuild`.
103
+ MESSAGE
104
+ end
105
+
106
+ missing = missing_vite_frontend_dependencies
107
+ return if missing.empty?
108
+
109
+ raise Thor::Error, <<~MESSAGE
110
+ Terrazzo is configured for Vite, but package.json is missing required Vite dependencies: #{missing.join(", ")}.
111
+
112
+ Run `bundle exec vite install` successfully, install the generated package.json dependencies, or re-run Terrazzo with `--bundler=esbuild`.
113
+ MESSAGE
114
+ end
115
+
116
+ def verify_database_schema
117
+ models = application_models
118
+ raise_no_models_error if models.empty?
119
+
120
+ missing_tables = models.reject { |model| table_exists_for?(model) }
121
+ return if missing_tables.empty?
122
+
123
+ model_names = missing_tables.map(&:name).join(", ")
124
+ raise Thor::Error, <<~MESSAGE
125
+ Terrazzo could not generate dashboards because these models do not have database tables: #{model_names}
126
+
127
+ Run `bin/rails db:prepare` first, then run `bin/rails generate terrazzo:install` again.
128
+ MESSAGE
129
+ end
12
130
 
13
131
  def create_application_controller
14
132
  template "application_controller.rb.erb",
@@ -26,8 +144,47 @@ module Terrazzo
26
144
  end
27
145
 
28
146
  def create_js_entry_point
29
- template "application.js.erb",
30
- "app/javascript/#{namespace_name}/application.jsx"
147
+ template "application.js.erb", js_entry_point_path
148
+ end
149
+
150
+ def create_vite_entry_point
151
+ return unless vite?
152
+ return if vite_entry_point_path == js_entry_point_path
153
+
154
+ create_file vite_entry_point_path,
155
+ %(import "#{relative_import_path(vite_entry_point_directory, js_entry_point_path)}"\n)
156
+ end
157
+
158
+ def configure_vite_watch_paths
159
+ return unless vite?
160
+
161
+ config = parsed_vite_config
162
+ return unless config
163
+
164
+ watch_paths = vite_watch_paths
165
+ return if watch_paths.empty?
166
+
167
+ config["all"] = {} unless config["all"].is_a?(Hash)
168
+ existing_paths = Array(config["all"]["watchAdditionalPaths"])
169
+ updated_paths = existing_paths | watch_paths
170
+ return if updated_paths == existing_paths
171
+
172
+ config["all"]["watchAdditionalPaths"] = updated_paths
173
+ create_file "config/vite.json", "#{JSON.pretty_generate(config)}\n", force: true
174
+ end
175
+
176
+ def configure_vite_dev_server
177
+ return unless vite?
178
+
179
+ ensure_vite_procfile
180
+ ensure_vite_bin_dev
181
+ end
182
+
183
+ def create_esbuild_entry_point
184
+ return unless esbuild?
185
+
186
+ create_file "app/javascript/#{namespace_name}.js",
187
+ %(import "./#{namespace_name}/application.jsx"\n)
31
188
  end
32
189
 
33
190
  def create_store
@@ -40,6 +197,16 @@ module Terrazzo
40
197
  "app/javascript/#{namespace_name}/page_to_page_mapping.js"
41
198
  end
42
199
 
200
+ def create_generated_page_mapping
201
+ template "generated_page_mapping.js.erb",
202
+ "app/javascript/#{namespace_name}/generated_page_mapping.js"
203
+ end
204
+
205
+ def create_custom_page_mapping
206
+ template "custom_page_mapping.js.erb",
207
+ "app/javascript/#{namespace_name}/custom_page_mapping.js"
208
+ end
209
+
43
210
  def create_application_visit
44
211
  template "application_visit.js.erb",
45
212
  "app/javascript/#{namespace_name}/application_visit.js"
@@ -55,6 +222,66 @@ module Terrazzo
55
222
  "app/assets/stylesheets/#{namespace_name}.css"
56
223
  end
57
224
 
225
+ def create_tailwind_build_script
226
+ return unless package_json_file?
227
+ return if tailwind_build_pipeline?
228
+ return unless tailwind_cli_package?
229
+ return unless tailwind_cli_supported?
230
+
231
+ package_json = parsed_package_json
232
+ return if package_json.empty?
233
+
234
+ scripts = package_json["scripts"]
235
+ scripts = package_json["scripts"] = {} unless scripts.is_a?(Hash)
236
+ return if scripts.key?(tailwind_build_script_name)
237
+
238
+ scripts[tailwind_build_script_name] = tailwind_build_command
239
+ create_file "package.json", "#{JSON.pretty_generate(package_json)}\n", force: true
240
+ end
241
+
242
+ def create_vite_build_script
243
+ return unless vite?
244
+ return unless package_json_file?
245
+
246
+ package_json = parsed_package_json
247
+ return if package_json.empty?
248
+
249
+ scripts = package_json["scripts"]
250
+ scripts = package_json["scripts"] = {} unless scripts.is_a?(Hash)
251
+ return if scripts.key?("build")
252
+
253
+ build_commands = ["vite build"]
254
+ if scripts[tailwind_build_script_name] == tailwind_build_command
255
+ build_commands << tailwind_build_command
256
+ end
257
+
258
+ scripts["build"] = build_commands.join(" && ")
259
+ create_file "package.json", "#{JSON.pretty_generate(package_json)}\n", force: true
260
+ end
261
+
262
+ def create_components_json
263
+ if File.exist?(File.join(destination_root, "components.json"))
264
+ say_status :skip, "components.json already exists", :yellow
265
+ return
266
+ end
267
+
268
+ template "components.json.erb", "components.json"
269
+ end
270
+
271
+ def create_jsconfig
272
+ if File.exist?(File.join(destination_root, "jsconfig.json"))
273
+ say_status :skip, "jsconfig.json already exists", :yellow
274
+ return
275
+ end
276
+
277
+ if File.exist?(File.join(destination_root, "tsconfig.json"))
278
+ say_status :skip, "tsconfig.json already exists", :yellow
279
+ return
280
+ end
281
+
282
+ copy_file "jsconfig.json", "jsconfig.json"
283
+ end
284
+
58
285
  def run_views_generator
59
286
  generate "terrazzo:views", "--namespace=#{namespace_name}"
60
287
  end
@@ -69,16 +296,410 @@ module Terrazzo
69
296
  end
70
297
  end
71
298
 
299
+ def verify_frontend_dependencies
300
+ missing = missing_frontend_dependencies
301
+ outdated = outdated_frontend_dependencies
302
+
303
+ if missing.any?
304
+ say_status :warning, "Missing frontend dependencies required by Terrazzo:", :yellow
305
+ say " #{missing.join(" ")}"
306
+ say "Run: #{frontend_install_command(missing)}"
307
+ end
308
+
309
+ return if outdated.empty?
310
+
311
+ say_status :warning, "Frontend dependencies below Terrazzo's supported versions:", :yellow
312
+ outdated.each do |dependency|
313
+ say " #{dependency[:name]} #{dependency[:requirement]} (requires >= #{dependency[:minimum]})"
314
+ end
315
+ say "Run: #{frontend_install_command(outdated.map { |dependency| "#{dependency[:name]}@latest" })}"
316
+ end
317
+
318
+ def verify_tailwind_build_pipeline
319
+ if tailwind_cli_package? && !tailwind_cli_supported?
320
+ say_status :warning, "Installed #{TAILWIND_CLI_PACKAGE_NAME} is below Terrazzo's supported version.", :yellow
321
+ say "Terrazzo generated app/assets/stylesheets/#{namespace_name}.css with Tailwind #{TAILWIND_MINIMUM_VERSION}+ syntax."
322
+ say "Run: #{package_manager_add_dev_command} #{TAILWIND_CLI_PACKAGE_NAME}@latest"
323
+ return
324
+ end
325
+
326
+ if tailwind_rails_gem? && !tailwind_rails_gem_supported?
327
+ say_status :warning, "Installed #{TAILWIND_RAILS_GEM_NAME} is below Terrazzo's supported version.", :yellow
328
+ say "Terrazzo generated app/assets/stylesheets/#{namespace_name}.css with Tailwind #{TAILWIND_MINIMUM_VERSION}+ syntax."
329
+ say "Update #{TAILWIND_RAILS_GEM_NAME} to #{TAILWIND_MINIMUM_VERSION} or newer, or install #{TAILWIND_CLI_PACKAGE_NAME}@latest."
330
+ return
331
+ end
332
+
333
+ return if tailwind_build_pipeline?
334
+
335
+ say_status :warning, "Terrazzo generated app/assets/stylesheets/#{namespace_name}.css but no Tailwind build pipeline was detected.", :yellow
336
+ say "Make sure your app compiles that file with Tailwind before serving the admin UI."
337
+ if tailwind_vite_plugin_package?
338
+ say "#{TAILWIND_VITE_PACKAGE_NAME} is installed, but Terrazzo links #{admin_stylesheet_path} as a Rails stylesheet asset."
339
+ end
340
+ say "For package.json scripts, install the Tailwind CLI and add a build script:"
341
+ say " #{package_manager_add_dev_command} #{TAILWIND_CLI_PACKAGE_NAME}@latest"
342
+ say %( "#{tailwind_build_script_name}": "#{tailwind_build_command}")
343
+ end
344
+
72
345
  private
73
346
 
74
347
  def namespace_name
75
- options[:namespace]
348
+ namespace_argument.presence || options[:namespace]
76
349
  end
77
350
 
78
351
  def vite?
79
352
  options[:bundler] == "vite"
80
353
  end
81
354
 
355
+ def esbuild?
356
+ options[:bundler] == "esbuild"
357
+ end
358
+
359
+ def js_entry_point_path
360
+ "app/javascript/#{namespace_name}/application.jsx"
361
+ end
362
+
363
+ def vite_entry_point_path
364
+ clean_relative_path(File.join(vite_source_code_dir, vite_entrypoints_dir, namespace_name, "application.jsx"))
365
+ end
366
+
367
+ def vite_entry_point_tag_name
368
+ clean_relative_path(File.join(vite_entrypoints_dir, namespace_name, "application.jsx"))
369
+ end
370
+
371
+ def vite_entry_point_directory
372
+ File.dirname(vite_entry_point_path)
373
+ end
374
+
375
+ def vite_source_code_dir
376
+ vite_config.fetch("sourceCodeDir", "app/frontend")
377
+ end
378
+
379
+ def vite_entrypoints_dir
380
+ vite_config.fetch("entrypointsDir", "entrypoints")
381
+ end
382
+
383
+ def vite_config
384
+ @vite_config ||= begin
385
+ raw_config = parsed_vite_config || {}
386
+ raw_config
387
+ .merge(raw_config.fetch("all", {}))
388
+ .merge(raw_config.fetch(vite_environment, {}))
389
+ end
390
+ end
391
+
392
+ def vite_environment
393
+ ENV["RAILS_ENV"].presence || ENV["RACK_ENV"].presence || "development"
394
+ end
395
+
396
+ def parsed_vite_config
397
+ return nil unless vite_config_installed?
398
+
399
+ JSON.parse(File.read(vite_config_path))
400
+ rescue JSON::ParserError
401
+ nil
402
+ end
403
+
404
+ def relative_import_path(from_directory, to_path)
405
+ relative_path = Pathname
406
+ .new(File.join(destination_root, to_path))
407
+ .relative_path_from(Pathname.new(File.join(destination_root, from_directory)))
408
+ .to_s
409
+
410
+ relative_path.start_with?(".") ? relative_path : "./#{relative_path}"
411
+ end
412
+
413
+ def clean_relative_path(path)
414
+ Pathname.new(path).cleanpath.to_s
415
+ end
416
+
417
+ def vite_watch_paths
418
+ [
419
+ "app/javascript/#{namespace_name}/**/*",
420
+ "app/views/#{namespace_name}/**/*",
421
+ ].select { |path| outside_vite_source_code_dir?(path) }
422
+ end
423
+
424
+ def outside_vite_source_code_dir?(path)
425
+ source_dir = clean_relative_path(vite_source_code_dir)
426
+ return false if source_dir == "."
427
+
428
+ watched_root = clean_relative_path(path.delete_suffix("/**/*"))
429
+ watched_root != source_dir && !watched_root.start_with?("#{source_dir}/")
430
+ end
431
+
432
+ def vite_config_installed?
433
+ File.exist?(vite_config_path)
434
+ end
435
+
436
+ def vite_config_path
437
+ File.join(destination_root, "config/vite.json")
438
+ end
439
+
440
+ def ensure_vite_procfile
441
+ if File.exist?(procfile_dev_path)
442
+ return if procfile_runs_vite?(File.read(procfile_dev_path))
443
+
444
+ existing_content = File.read(procfile_dev_path)
445
+ append_to_file "Procfile.dev",
446
+ "#{existing_content.end_with?("\n") ? "" : "\n"}vite: bin/vite dev\n"
447
+ else
448
+ create_file "Procfile.dev", <<~PROCFILE
449
+ web: bin/rails s
450
+ vite: bin/vite dev
451
+ PROCFILE
452
+ end
453
+ end
454
+
455
+ def ensure_vite_bin_dev
456
+ if File.exist?(bin_dev_path)
457
+ content = File.read(bin_dev_path)
458
+ return if bin_dev_runs_procfile?(content)
459
+
460
+ unless default_rails_server_bin_dev?(content)
461
+ say_status :warning, "bin/dev does not run Procfile.dev; start Vite with `bin/vite dev` or update bin/dev.", :yellow
462
+ return
463
+ end
464
+ end
465
+
466
+ create_file "bin/dev", vite_bin_dev_script, force: true
467
+ File.chmod(0o755, bin_dev_path)
468
+ end
469
+
470
+ def procfile_dev_path
471
+ File.join(destination_root, "Procfile.dev")
472
+ end
473
+
474
+ def bin_dev_path
475
+ File.join(destination_root, "bin/dev")
476
+ end
477
+
478
+ def procfile_runs_vite?(content)
479
+ content.match?(/^\s*[^#\n]+:\s*.*(?:bin\/)?vite\s+dev\b/)
480
+ end
481
+
482
+ def bin_dev_runs_procfile?(content)
483
+ content.include?("Procfile.dev")
484
+ end
485
+
486
+ def default_rails_server_bin_dev?(content)
487
+ content.include?('exec "./bin/rails", "server", *ARGV') ||
488
+ content.match?(/exec\s+\.?\/?bin\/rails\s+(server|s)\b/)
489
+ end
490
+
491
+ def vite_bin_dev_script
492
+ <<~SH
493
+ #!/usr/bin/env sh
494
+
495
+ if ! gem list foreman -i --silent; then
496
+ echo "Installing foreman..."
497
+ gem install foreman
498
+ fi
499
+
500
+ exec foreman start -f Procfile.dev "$@"
501
+ SH
502
+ end
503
+
504
+ def vite_rails_installed?
505
+ %w[Gemfile Gemfile.lock].any? do |file_name|
506
+ path = File.join(destination_root, file_name)
507
+ File.exist?(path) && File.read(path).include?("vite_rails")
508
+ end
509
+ end
510
+
511
+ def missing_frontend_dependencies
512
+ installed = package_json_dependencies
513
+ FRONTEND_DEPENDENCIES.reject { |package_name| installed.key?(package_name) }
514
+ end
515
+
516
+ def outdated_frontend_dependencies
517
+ installed = package_json_dependencies
518
+ FRONTEND_DEPENDENCY_MINIMUMS.filter_map do |package_name, minimum|
519
+ requirement = installed[package_name]
520
+ next if requirement.nil?
521
+ next if dependency_requirement_satisfies_minimum?(requirement, minimum)
522
+
523
+ {
524
+ name: package_name,
525
+ requirement: requirement,
526
+ minimum: minimum,
527
+ }
528
+ end
529
+ end
530
+
531
+ def dependency_requirement_satisfies_minimum?(requirement, minimum)
532
+ requirement = requirement.to_s.strip
533
+ return true if requirement.blank? || requirement == "*" || requirement.downcase == "latest"
534
+ return true if non_registry_dependency_requirement?(requirement)
535
+
536
+ minimum_version = Gem::Version.new(minimum)
537
+ requirement.split("||").any? do |range|
538
+ floor = dependency_requirement_floor(range)
539
+ floor && Gem::Version.new(floor) >= minimum_version
540
+ end
541
+ end
542
+
543
+ def dependency_requirement_floor(range)
544
+ range
545
+ .split(/[,\s]+/)
546
+ .filter_map { |token| dependency_requirement_token_version(token) }
547
+ .first
548
+ end
549
+
550
+ def dependency_requirement_token_version(token)
551
+ return nil if token.blank? || token.start_with?("<")
552
+
553
+ match = token.match(/\A(?:>=|>|=|~>|\^|~)?v?(\d+)(?:\.(\d+|x|\*))?(?:\.(\d+|x|\*))?(?:[-+][0-9A-Za-z.-]+)?\z/i)
554
+ return nil unless match
555
+
556
+ [match[1], match[2], match[3]]
557
+ .map { |part| part.presence&.then { |value| %w[x *].include?(value.downcase) ? "0" : value } || "0" }
558
+ .join(".")
559
+ end
560
+
561
+ def non_registry_dependency_requirement?(requirement)
562
+ requirement.match?(/\A(?:file|link|workspace|git|github|http|https|npm):/) ||
563
+ requirement.match?(/\Agit\+/)
564
+ end
565
+
566
+ def missing_vite_frontend_dependencies
567
+ installed = package_json_dependencies
568
+ VITE_FRONTEND_DEPENDENCIES.reject { |package_name| installed.key?(package_name) }
569
+ end
570
+
571
+ def package_json_dependencies
572
+ return {} unless package_json_file?
573
+
574
+ package_json = parsed_package_json
575
+ %w[dependencies devDependencies peerDependencies optionalDependencies]
576
+ .each_with_object({}) do |section, dependencies|
577
+ dependencies.merge!(package_json.fetch(section, {}))
578
+ end
579
+ rescue JSON::ParserError
580
+ {}
581
+ end
582
+
583
+ def package_json_scripts
584
+ scripts = parsed_package_json.fetch("scripts", {})
585
+ scripts.is_a?(Hash) ? scripts : {}
586
+ rescue JSON::ParserError
587
+ {}
588
+ end
589
+
590
+ def parsed_package_json
591
+ @package_json ||= begin
592
+ package_json_file? ? JSON.parse(File.read(package_json_path)) : {}
593
+ end
594
+ rescue JSON::ParserError
595
+ {}
596
+ end
597
+
598
+ def package_json_file?
599
+ File.exist?(package_json_path)
600
+ end
601
+
602
+ def package_json_path
603
+ File.join(destination_root, "package.json")
604
+ end
605
+
606
+ def frontend_install_command(packages)
607
+ "#{package_manager_add_command} #{packages.join(" ")}"
608
+ end
609
+
610
+ def package_manager_add_command
611
+ return "pnpm add" if File.exist?(File.join(destination_root, "pnpm-lock.yaml"))
612
+ return "yarn add" if File.exist?(File.join(destination_root, "yarn.lock"))
613
+ return "bun add" if File.exist?(File.join(destination_root, "bun.lock")) ||
614
+ File.exist?(File.join(destination_root, "bun.lockb"))
615
+
616
+ "npm install"
617
+ end
618
+
619
+ def package_manager_add_dev_command
620
+ return "pnpm add -D" if File.exist?(File.join(destination_root, "pnpm-lock.yaml"))
621
+ return "yarn add -D" if File.exist?(File.join(destination_root, "yarn.lock"))
622
+ return "bun add -d" if File.exist?(File.join(destination_root, "bun.lock")) ||
623
+ File.exist?(File.join(destination_root, "bun.lockb"))
624
+
625
+ "npm install --save-dev"
626
+ end
627
+
628
+ def tailwind_build_pipeline?
629
+ tailwind_package_script? ||
630
+ tailwind_rails_gem_supported?
631
+ end
632
+
633
+ def tailwind_cli_package?
634
+ package_json_dependencies.key?(TAILWIND_CLI_PACKAGE_NAME)
635
+ end
636
+
637
+ def tailwind_cli_supported?
638
+ requirement = package_json_dependencies[TAILWIND_CLI_PACKAGE_NAME]
639
+ requirement.present? && dependency_requirement_satisfies_minimum?(requirement, TAILWIND_MINIMUM_VERSION)
640
+ end
641
+
642
+ def tailwind_package_script?
643
+ package_json_scripts.any? do |_name, command|
644
+ command = command.to_s
645
+ command.include?("tailwindcss") && command.include?(admin_stylesheet_path)
646
+ end
647
+ end
648
+
649
+ def tailwind_build_script_name
650
+ "build:#{namespace_name}:css"
651
+ end
652
+
653
+ def tailwind_build_command
654
+ "tailwindcss -i #{admin_stylesheet_path} -o app/assets/builds/#{namespace_name}.css --minify"
655
+ end
656
+
657
+ def tailwind_vite_plugin_package?
658
+ dependencies = package_json_dependencies
659
+ dependencies.key?(TAILWIND_VITE_PACKAGE_NAME)
660
+ end
661
+
662
+ def tailwind_vite_plugin_supported?
663
+ requirement = package_json_dependencies[TAILWIND_VITE_PACKAGE_NAME]
664
+ requirement.present? && dependency_requirement_satisfies_minimum?(requirement, TAILWIND_MINIMUM_VERSION)
665
+ end
666
+
667
+ def admin_stylesheet_path
668
+ "app/assets/stylesheets/#{namespace_name}.css"
669
+ end
670
+
671
+ def tailwind_rails_gem?
672
+ tailwind_rails_gem_requirement.present?
673
+ end
674
+
675
+ def tailwind_rails_gem_supported?
676
+ requirement = tailwind_rails_gem_requirement
677
+ requirement.present? && dependency_requirement_satisfies_minimum?(requirement, TAILWIND_MINIMUM_VERSION)
678
+ end
679
+
680
+ def tailwind_rails_gem_requirement
681
+ locked_gem_version(TAILWIND_RAILS_GEM_NAME) ||
682
+ gemfile_dependency_requirement(TAILWIND_RAILS_GEM_NAME)
683
+ end
684
+
685
+ def locked_gem_version(name)
686
+ path = File.join(destination_root, "Gemfile.lock")
687
+ return nil unless File.exist?(path)
688
+
689
+ match = File.read(path).match(/^\s{4}#{Regexp.escape(name)} \((?<version>[^)\s]+).*\)$/)
690
+ match && match[:version]
691
+ end
692
+
693
+ def gemfile_dependency_requirement(name)
694
+ path = File.join(destination_root, "Gemfile")
695
+ return nil unless File.exist?(path)
696
+
697
+ line = File.readlines(path).find { |candidate| candidate.match?(/^\s*gem\s+["']#{Regexp.escape(name)}["']/) }
698
+ return nil unless line
699
+
700
+ line.scan(/["']([^"']+)["']/).flatten[1] || "*"
701
+ end
702
+
82
703
  def application_models
83
704
  models_path = Rails.root.join("app", "models")
84
705
  return [] unless models_path.exist?
@@ -91,6 +712,22 @@ module Terrazzo
91
712
  relative.delete_suffix(".rb").camelize.safe_constantize
92
713
  end.select { |klass| klass < ApplicationRecord && !klass.abstract_class? }
93
714
  end
715
+
716
+ def table_exists_for?(model)
717
+ model.table_exists?
718
+ rescue ActiveRecord::ConnectionNotEstablished,
719
+ ActiveRecord::NoDatabaseError,
720
+ ActiveRecord::StatementInvalid
721
+ false
722
+ end
723
+
724
+ def raise_no_models_error
725
+ raise Thor::Error, <<~MESSAGE
726
+ Terrazzo could not find any ApplicationRecord models to generate dashboards.
727
+
728
+ Add at least one model first, then run `bin/rails generate terrazzo:install` again.
729
+ MESSAGE
730
+ end
94
731
  end
95
732
  end
96
733
  end
@@ -1,4 +1,6 @@
1
1
  @import "tailwindcss";
2
+ @source "../../views";
3
+ @source "../../javascript";
2
4
  @source "../../../node_modules/terrazzo/dist";
3
5
 
4
6
  @custom-variant dark (&:is(.dark *));