ruby_ui_scaffold 0.1.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 (27) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +343 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +530 -0
  5. data/lib/generators/ruby_ui_scaffold/install/install_generator.rb +188 -0
  6. data/lib/generators/ruby_ui_scaffold/ruby_ui_scaffold_generator.rb +119 -0
  7. data/lib/generators/ruby_ui_scaffold/scaffold/scaffold_generator.rb +252 -0
  8. data/lib/generators/ruby_ui_scaffold/scaffold/templates/edit.rb.tt +34 -0
  9. data/lib/generators/ruby_ui_scaffold/scaffold/templates/form.rb.tt +50 -0
  10. data/lib/generators/ruby_ui_scaffold/scaffold/templates/index.rb.tt +108 -0
  11. data/lib/generators/ruby_ui_scaffold/scaffold/templates/index_data_table.rb.tt +187 -0
  12. data/lib/generators/ruby_ui_scaffold/scaffold/templates/new.rb.tt +34 -0
  13. data/lib/generators/ruby_ui_scaffold/scaffold/templates/show.rb.tt +55 -0
  14. data/lib/generators/ruby_ui_scaffold/scaffold_controller/scaffold_controller_generator.rb +43 -0
  15. data/lib/generators/ruby_ui_scaffold/scaffold_controller/templates/controller.rb.tt +75 -0
  16. data/lib/generators/ruby_ui_scaffold/scaffold_controller/templates/controller_data_table.rb.tt +110 -0
  17. data/lib/rails/commands/ruby_ui_scaffold/seed_command.rb +62 -0
  18. data/lib/ruby_ui_scaffold/attribute_helpers.rb +38 -0
  19. data/lib/ruby_ui_scaffold/component_installer.rb +24 -0
  20. data/lib/ruby_ui_scaffold/component_resolver.rb +74 -0
  21. data/lib/ruby_ui_scaffold/field_type_mapper.rb +164 -0
  22. data/lib/ruby_ui_scaffold/railtie.rb +25 -0
  23. data/lib/ruby_ui_scaffold/seeder.rb +115 -0
  24. data/lib/ruby_ui_scaffold/value_generator.rb +168 -0
  25. data/lib/ruby_ui_scaffold/version.rb +5 -0
  26. data/lib/ruby_ui_scaffold.rb +22 -0
  27. metadata +197 -0
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/base"
4
+ require "ruby_ui_scaffold/component_resolver"
5
+ require "ruby_ui_scaffold/component_installer"
6
+
7
+ module RubyUiScaffold
8
+ module Generators
9
+ # One-shot installer that wires up the prerequisites every scaffold needs
10
+ # on a fresh Rails app: the ruby_ui gem, phlex, ruby_ui, and the BASE set
11
+ # of components every generated scaffold uses (index/show/form shell).
12
+ #
13
+ # Column/flag-specific components (badge, checkbox, textarea, combobox,
14
+ # select, date_picker, data_table) are NOT installed here — the scaffold
15
+ # generator installs those on demand, so apps only carry what they use.
16
+ #
17
+ # Invoked as: `bin/rails g ruby_ui_scaffold:install`
18
+ #
19
+ # Every step is idempotent — re-running the installer is safe and only
20
+ # touches what's actually missing.
21
+ class InstallGenerator < ::Rails::Generators::Base
22
+ include ::RubyUiScaffold::ComponentInstaller
23
+
24
+ desc "Install the ruby_ui gem, phlex, ruby_ui, and the base scaffold components."
25
+
26
+ def check_phlex_rails_gem
27
+ return if Gem.loaded_specs.key?("phlex-rails")
28
+
29
+ say "\n ❌ The `phlex-rails` gem isn't bundled in this app.", :red
30
+ say " ruby_ui_scaffold declares it as a runtime dependency — running", :red
31
+ say " `bundle install` should pull it in. If you've excluded it via", :red
32
+ say " Bundler groups, add it back and retry:\n", :red
33
+ say %( gem "phlex-rails"), :cyan
34
+ say " bundle install"
35
+ say " bin/rails g ruby_ui_scaffold:install\n", :cyan
36
+ exit(1)
37
+ end
38
+
39
+ # Ensure the `ruby_ui` gem is available. Unlike `phlex-rails` (a declared
40
+ # runtime dependency of this gem), `ruby_ui` is distributed via GitHub and
41
+ # can't be a gemspec dependency — so on a fresh app it usually isn't
42
+ # bundled yet. Rather than abort, add it to the Gemfile and `bundle
43
+ # install` automatically, then let the rest of the installer proceed:
44
+ # the subsequent `ruby_ui:install` / `ruby_ui:component` steps run in
45
+ # subprocesses (via run_rails_generator!) that boot with the freshly
46
+ # updated bundle, so they find the gem even though THIS process didn't
47
+ # load it. Idempotent — skips the Gemfile edit when an entry already
48
+ # exists, and does nothing at all once the gem loads.
49
+ def ensure_ruby_ui_gem
50
+ return if ruby_ui_loadable?
51
+
52
+ gemfile = File.join(destination_root, "Gemfile")
53
+ abort_ruby_ui_unavailable! unless File.exist?(gemfile)
54
+
55
+ if File.read(gemfile).match?(/^\s*gem\s+["']ruby_ui["']/)
56
+ say "\n → ruby_ui is in the Gemfile but not bundled yet — running `bundle install`.", :cyan
57
+ else
58
+ say "\n → ruby_ui gem not found — adding it to your Gemfile.", :cyan
59
+ gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false
60
+ end
61
+
62
+ run_bundle_install!
63
+ end
64
+
65
+ def install_phlex
66
+ if File.exist?(File.join(destination_root, "app/views/base.rb"))
67
+ say " ✓ phlex already installed (app/views/base.rb exists)", :green
68
+ return
69
+ end
70
+
71
+ say "\n → Running `phlex:install`", :cyan
72
+ run_rails_generator!("phlex:install")
73
+ end
74
+
75
+ def install_ruby_ui
76
+ components_base = File.join(destination_root, "app/components/base.rb")
77
+ if File.exist?(components_base) && File.read(components_base).include?("RubyUI")
78
+ say " ✓ ruby_ui already installed (Components::Base includes RubyUI)", :green
79
+ return
80
+ end
81
+
82
+ say "\n → Running `ruby_ui:install`", :cyan
83
+ run_rails_generator!("ruby_ui:install")
84
+ end
85
+
86
+ # Install the BASE components every scaffold uses (the index/show/form
87
+ # shell), so a bare `:install` leaves the ground ready. We deliberately
88
+ # don't run `ruby_ui:component:all` — column/flag-specific components are
89
+ # installed on demand by the scaffold generator. `ruby_ui:component`
90
+ # resolves transitive dependencies itself (e.g. alert_dialog → button),
91
+ # so the BASE list only names what the scaffold references directly.
92
+ def install_base_components
93
+ missing = uninstalled_components(::RubyUiScaffold::ComponentResolver::BASE)
94
+
95
+ if missing.empty?
96
+ say " ✓ Base scaffold components already installed", :green
97
+ return
98
+ end
99
+
100
+ say "\n → Installing #{missing.size} base component(s) every scaffold uses", :cyan
101
+ missing.each do |component|
102
+ say " • #{component}", :cyan
103
+ run_rails_generator!("ruby_ui:component", component)
104
+ end
105
+ end
106
+
107
+ # Tailwind v4 auto-detection misses `.rb` files by default, so the
108
+ # Phlex view + component class names never make it into the compiled
109
+ # stylesheet — meaning `mx-auto`, `max-w-prose`, etc. render with no
110
+ # effect. Inject explicit `@source` directives so Tailwind scans
111
+ # `app/views/**/*.rb` and `app/components/**/*.rb`. Idempotent.
112
+ def inject_tailwind_sources
113
+ css_path = File.join(destination_root, "app/assets/tailwind/application.css")
114
+ return unless File.exist?(css_path)
115
+
116
+ contents = File.read(css_path)
117
+ sources_to_add = []
118
+ sources_to_add << %(@source "../../views/**/*.rb";) unless contents.include?("../../views/**/*.rb")
119
+ sources_to_add << %(@source "../../components/**/*.rb";) unless contents.include?("../../components/**/*.rb")
120
+ return if sources_to_add.empty?
121
+
122
+ say "\n → Adding Tailwind @source directives for Phlex views/components", :cyan
123
+ inject_into_file css_path, after: /@import "tailwindcss";\n/ do
124
+ "\n" + sources_to_add.join("\n") + "\n"
125
+ end
126
+ end
127
+
128
+ def done
129
+ say "\n ✅ ruby_ui_scaffold install complete.", :green
130
+ say "\n Generate your first scaffold:\n", :cyan
131
+ say " bin/rails g ruby_ui_scaffold MyModel name:string\n", :cyan
132
+ end
133
+
134
+ private
135
+
136
+ # No Gemfile to add ruby_ui to — fall back to the manual instructions.
137
+ def abort_ruby_ui_unavailable!
138
+ say "\n ❌ The `ruby_ui` gem isn't available and no Gemfile was found to add it to.", :red
139
+ say " Add it to your Gemfile, then bundle and retry:\n", :red
140
+ say %( gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false), :cyan
141
+ say " bundle install"
142
+ say " bin/rails g ruby_ui_scaffold:install\n", :cyan
143
+ exit(1)
144
+ end
145
+
146
+ def run_bundle_install!
147
+ say_status :run, "bundle install", :cyan
148
+ success = in_root { system("bundle", "install") }
149
+ return if success
150
+
151
+ say "\n ❌ `bundle install` failed after adding ruby_ui to the Gemfile.", :red
152
+ say " Resolve the error above, then re-run `bin/rails g ruby_ui_scaffold:install` —", :red
153
+ say " it's idempotent, so anything already installed stays installed.", :yellow
154
+ exit(1)
155
+ end
156
+
157
+ # ruby_ui ships with `require: false` in the recommended Gemfile entry,
158
+ # so it may not be autoloaded yet — try requiring it before giving up.
159
+ def ruby_ui_loadable?
160
+ return true if defined?(::RubyUI)
161
+
162
+ require "ruby_ui"
163
+ true
164
+ rescue LoadError
165
+ false
166
+ end
167
+
168
+ # Shell-out to `bin/rails generate NAME [ARGS...]` and abort if the
169
+ # subprocess exits non-zero. We don't use Thor's `generate` action
170
+ # because its `abort_on_failure` doesn't reliably propagate when the
171
+ # failure originates inside the inner Rails command (e.g. Bundler
172
+ # bootstrap errors swallow the exit code). Without explicit checks,
173
+ # one bad step would silently cascade into dozens of follow-up
174
+ # failures with the same root cause.
175
+ def run_rails_generator!(name, *args)
176
+ cmd = ["bin/rails", "generate", name, *args].compact
177
+ say_status :run, cmd.join(" "), :cyan
178
+ success = in_root { system(*cmd) }
179
+ return if success
180
+
181
+ say "\n ❌ `#{cmd.join(" ")}` failed (exit #{$?.exitstatus}).", :red
182
+ say " Aborting ruby_ui_scaffold:install. Fix the error above and retry —", :red
183
+ say " every step is idempotent, so what's already installed stays installed.", :yellow
184
+ exit(1)
185
+ end
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,119 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/rails/scaffold/scaffold_generator"
5
+
6
+ module RubyUiScaffold
7
+ module Generators
8
+ # Entry point: `rails g ruby_ui_scaffold User name:string ...`
9
+ #
10
+ # Inherits the full scaffold pipeline from Rails (model, migration,
11
+ # resource route, tests, helper), but redirects the scaffold_controller
12
+ # hook to our subclass — which in turn redirects the template_engine
13
+ # hook to our Phlex views generator.
14
+ class RubyUiScaffoldGenerator < ::Rails::Generators::ScaffoldGenerator
15
+ # Override the auto-derived namespace ("ruby_ui_scaffold:ruby_ui_scaffold")
16
+ # to flat "ruby_ui_scaffold". This is critical because find_by_namespace
17
+ # iterates lookups in order — leaving the namespace at the nested form
18
+ # would cause `ruby_ui_scaffold:ruby_ui_scaffold` to shadow lookups for
19
+ # `ruby_ui_scaffold:scaffold` (our views generator) and `ruby_ui_scaffold:
20
+ # scaffold_controller` (our controller generator), creating silent
21
+ # recursion when those hooks fire.
22
+ namespace "ruby_ui_scaffold"
23
+
24
+ # Redirect scaffold_controller hook to our subclass. `as: :scaffold_controller`
25
+ # makes the find_by_namespace lookup resolve `ruby_ui_scaffold:scaffold_controller`
26
+ # (via its name:context form) instead of `ruby_ui_scaffold:ruby_ui_scaffold`.
27
+ remove_hook_for :scaffold_controller
28
+ hook_for :scaffold_controller, as: :scaffold_controller, required: true
29
+
30
+ # Wrap each generated view_template in `render(<ClassName>) do ... end`
31
+ # and emit `layout false` in the controller. Use when your app has a
32
+ # Phlex layout class (with `include Phlex::Rails::Layout`) that you
33
+ # want every scaffolded page to render inside.
34
+ class_option :phlex_layout, type: :string, default: nil, banner: "LayoutClass",
35
+ desc: "Wrap each generated view in `render(LayoutClass) do ... end` and skip the default Rails layout"
36
+
37
+ # When true, the index uses ruby_ui's `DataTable` (search, per-page,
38
+ # sortable headers, manual pagination) and the controller bakes in the
39
+ # params parsing + scope building. Default: plain `Table` (no toolbar,
40
+ # no pagination — controller just does Model.all).
41
+ class_option :datatable, type: :boolean, default: false,
42
+ desc: "Generate the index using ruby_ui DataTable (search + sort + pagination) instead of a plain Table"
43
+
44
+ # When true, the generated Phlex views use Literal's `prop` macros
45
+ # instead of explicit `def initialize` + `@ivar` assignments. Less
46
+ # boilerplate per view; runtime type-checking included. Also injects
47
+ # `extend Literal::Properties` into `app/components/base.rb` on first
48
+ # use (idempotent).
49
+ class_option :literal, type: :boolean, default: false,
50
+ desc: "Use Literal's `prop` macros instead of explicit `def initialize` blocks (https://literal.fun)"
51
+
52
+ # By default, generating a scaffold auto-runs the idempotent
53
+ # `ruby_ui_scaffold:install` when phlex/ruby_ui aren't detected yet, so
54
+ # the generated views work out of the box. Pass --skip-install to only
55
+ # print a warning instead (the pre-auto-install behavior).
56
+ class_option :skip_install, type: :boolean, default: false,
57
+ desc: "Don't auto-run `ruby_ui_scaffold:install` when phlex/ruby_ui aren't detected — only warn"
58
+
59
+ # Skip model/migration/model-test/fixtures generation (the whole `:orm`
60
+ # hook), regenerating only the controller, views, helper, and route.
61
+ # Intended for RE-RUNS against a model that already exists — e.g. to
62
+ # refresh the views after a template change, or to add `--datatable`
63
+ # without Rails creating a duplicate migration or clobbering the model.
64
+ #
65
+ # Implies `--force`: a re-run otherwise aborts on the controller's class
66
+ # collision check (the controller is already defined), and the point is
67
+ # to overwrite the regenerated files without per-file prompts. The model
68
+ # is never touched (its hook is skipped), so custom model code is safe —
69
+ # the mental model is "model = your code, controller/views = generated".
70
+ class_option :skip_model, type: :boolean, default: false,
71
+ desc: "Skip model/migration/fixtures and only regenerate controller/views/routes (implies --force; for re-runs)"
72
+
73
+ # --skip-model implies --force: a re-run otherwise aborts on the
74
+ # controller's class-collision check (the controller already exists), and
75
+ # the intent is to overwrite the regenerated controller/views. We inject
76
+ # "--force" into the RAW options before Thor parses them — not by mutating
77
+ # `options` afterward — because the controller/views sub-generators are
78
+ # invoked by re-parsing the original init options stored in Thor's
79
+ # `@_initializer` (see Thor::Invocation#_parse_initialization_options). A
80
+ # post-parse mutation of `options` never reaches them; a real flag does.
81
+ def initialize(args, local_options = {}, config = {})
82
+ local_options = imply_force(local_options) if skip_model_requested?(local_options)
83
+ super
84
+ end
85
+
86
+ # Command for the inherited `:orm` hook (model + migration + model test +
87
+ # fixtures). Thor generates it as `_invoke_from_option_orm`; we override
88
+ # it to no-op under --skip-model while leaving `options[:orm]` intact, so
89
+ # orm helpers and option propagation to the controller are unaffected.
90
+ def _invoke_from_option_orm
91
+ return if options[:skip_model]
92
+
93
+ super
94
+ end
95
+
96
+ private
97
+
98
+ # Detect --skip-model from the raw init options, which may be the CLI
99
+ # option-string array (the usual `rails g` path) or a pre-parsed hash
100
+ # (programmatic instantiation / tests).
101
+ def skip_model_requested?(local_options)
102
+ if local_options.is_a?(Array)
103
+ local_options.include?("--skip-model") || local_options.include?("--skip_model")
104
+ else
105
+ !!(local_options[:skip_model] || local_options["skip_model"])
106
+ end
107
+ end
108
+
109
+ # Add force to the raw options (array or hash) without duplicating it.
110
+ def imply_force(local_options)
111
+ if local_options.is_a?(Array)
112
+ local_options.include?("--force") ? local_options : local_options + ["--force"]
113
+ else
114
+ local_options.merge(force: true)
115
+ end
116
+ end
117
+ end
118
+ end
119
+ end
@@ -0,0 +1,252 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators/named_base"
4
+ require "rails/generators/resource_helpers"
5
+ require "ruby_ui_scaffold/field_type_mapper"
6
+ require "ruby_ui_scaffold/attribute_helpers"
7
+ require "ruby_ui_scaffold/component_resolver"
8
+ require "ruby_ui_scaffold/component_installer"
9
+
10
+ module RubyUiScaffold
11
+ module Generators
12
+ # The "template engine" — generates the Phlex view classes
13
+ # for index, show, new, edit, and a shared form under the
14
+ # `Views::` namespace (matches the convention installed by
15
+ # `phlex:install`, which creates `Views::Base` and wires the
16
+ # `app/views/` autoloader).
17
+ #
18
+ # Invoked indirectly via `ruby_ui_scaffold:scaffold_controller`,
19
+ # which sets template_engine default to `ruby_ui_scaffold`.
20
+ class ScaffoldGenerator < ::Rails::Generators::NamedBase
21
+ include ::Rails::Generators::ResourceHelpers
22
+ include ::RubyUiScaffold::AttributeHelpers
23
+ include ::RubyUiScaffold::ComponentInstaller
24
+
25
+ source_root File.expand_path("templates", __dir__)
26
+
27
+ argument :attributes, type: :array, default: [], banner: "field:type field:type"
28
+
29
+ class_option :api, type: :boolean, default: false
30
+ class_option :force_plural, type: :boolean, default: false
31
+ class_option :phlex_layout, type: :string, default: nil, banner: "LayoutClass",
32
+ desc: "Wrap each generated view in `render(LayoutClass) do ... end`"
33
+ class_option :datatable, type: :boolean, default: false,
34
+ desc: "Emit the DataTable index variant (search + sort + pagination) instead of a plain Table"
35
+ class_option :literal, type: :boolean, default: false,
36
+ desc: "Use Literal's `prop` macros instead of `def initialize` (https://literal.fun)"
37
+ class_option :skip_install, type: :boolean, default: false,
38
+ desc: "Don't auto-run `ruby_ui_scaffold:install` when phlex/ruby_ui aren't detected — only warn"
39
+
40
+ # Generator action — runs before view files are written. The generated
41
+ # views need phlex (`Views::Base`) and ruby_ui (`RubyUI` mixed into
42
+ # `Components::Base`); without them, requests 500 at runtime. When either
43
+ # is missing we auto-run the idempotent `ruby_ui_scaffold:install` so the
44
+ # scaffold works out of the box. Falls back to a non-blocking warning when
45
+ # auto-install isn't possible (no app `bin/rails`) or is opted out with
46
+ # `--skip-install`.
47
+ def preflight_checks
48
+ return if behavior == :revoke
49
+
50
+ ensure_phlex_and_ruby_ui_installed
51
+ end
52
+
53
+ def create_view_files
54
+ empty_directory File.join("app/views", controller_file_path)
55
+
56
+ # Index has two flavors: plain Table (default) or DataTable (--datatable).
57
+ # Both compile down to `app/views/<resource>/index.rb`.
58
+ index_template = options[:datatable] ? "index_data_table.rb.tt" : "index.rb.tt"
59
+ template index_template, File.join("app/views", controller_file_path, "index.rb")
60
+
61
+ %w[show new edit form].each do |view|
62
+ template "#{view}.rb.tt", File.join("app/views", controller_file_path, "#{view}.rb")
63
+ end
64
+ end
65
+
66
+ # Inject the Phlex helpers the scaffold-generated views rely on into
67
+ # `Components::Base`. `phlex:install` / `ruby_ui:install` create the
68
+ # file but only include `Phlex::Rails::Helpers::Routes` + RubyUI — the
69
+ # scaffold also needs `form_with`, `link_to`, `button_to`, `request`
70
+ # (for the form's "Back" link), and `lucide_icon`. When `--literal` is
71
+ # passed, also `extend
72
+ # Literal::Properties` so the generated views' `prop` macros resolve.
73
+ # Each line is added only if missing, so re-running is safe.
74
+ def inject_scaffold_helpers_into_components_base
75
+ return if behavior == :revoke
76
+
77
+ components_base = File.join(destination_root, "app/components/base.rb")
78
+ return unless File.exist?(components_base)
79
+
80
+ contents = File.read(components_base)
81
+
82
+ helpers = []
83
+ helpers << " extend Literal::Properties" if options[:literal] && !contents.include?("Literal::Properties")
84
+ helpers << " include Phlex::Rails::Helpers::FormWith" unless contents.include?("Phlex::Rails::Helpers::FormWith")
85
+ helpers << " include Phlex::Rails::Helpers::LinkTo" unless contents.include?("Phlex::Rails::Helpers::LinkTo")
86
+ helpers << " include Phlex::Rails::Helpers::ButtonTo" unless contents.include?("Phlex::Rails::Helpers::ButtonTo")
87
+ helpers << " include Phlex::Rails::Helpers::Request" unless contents.include?("Phlex::Rails::Helpers::Request")
88
+ helpers << " register_output_helper :lucide_icon" unless contents.include?("register_output_helper :lucide_icon")
89
+
90
+ return if helpers.empty?
91
+
92
+ inject_into_file components_base, after: /class Components::Base.*\n/ do
93
+ helpers.join("\n") + "\n"
94
+ end
95
+ end
96
+
97
+ # Install the ruby_ui components THIS scaffold references, on demand,
98
+ # after the views are written. The BASE set is already installed by
99
+ # `ruby_ui_scaffold:install`; here we add the column/flag-specific ones
100
+ # (badge, checkbox, textarea, combobox, select, date_picker, data_table)
101
+ # — but request the full set and skip whatever's already present, so it
102
+ # works whether or not `:install` ran first. Non-blocking: a component
103
+ # that fails to install just warns (the view files already exist). Gated
104
+ # like the auto-install: skipped on --skip-install or when there's no app
105
+ # `bin/rails` to drive `ruby_ui:component` (e.g. the test harness).
106
+ def install_required_components
107
+ return if behavior == :revoke
108
+ return if options[:skip_install] || !app_bin_rails?
109
+
110
+ needed = ::RubyUiScaffold::ComponentResolver.call(
111
+ attributes: attributes, datatable: options[:datatable]
112
+ )
113
+ missing = uninstalled_components(needed)
114
+ return if missing.empty?
115
+
116
+ say "\n → Installing #{missing.size} ruby_ui component(s) this scaffold uses", :cyan
117
+ missing.each do |component|
118
+ say " • #{component}", :cyan
119
+ install_ruby_ui_component(component)
120
+ end
121
+ end
122
+
123
+ private
124
+
125
+ # Shell out to `ruby_ui:component NAME` in a clean process (which boots
126
+ # with the current bundle and resolves the component's transitive deps).
127
+ # Warn-and-continue on failure — unlike the installer's abort, the views
128
+ # are already written, so a single missing component shouldn't tear down
129
+ # the run.
130
+ def install_ruby_ui_component(component)
131
+ ok = in_root { system("bin/rails", "generate", "ruby_ui:component", component) }
132
+ return if ok
133
+
134
+ say " ⚠️ Couldn't install ruby_ui component `#{component}`.", :yellow
135
+ say " Run `bin/rails g ruby_ui:component #{component}` manually.", :yellow
136
+ end
137
+
138
+ # Auto-run `ruby_ui_scaffold:install` when phlex/ruby_ui aren't set up.
139
+ # The installer is idempotent, so it's safe even if one of the two is
140
+ # already present. Falls back to a warning when opted out or when there's
141
+ # no app `bin/rails` to drive the installer (e.g. the generator test
142
+ # harness, or a non-standard app layout).
143
+ def ensure_phlex_and_ruby_ui_installed
144
+ return if phlex_installed? && ruby_ui_installed?
145
+
146
+ if options[:skip_install] || !app_bin_rails?
147
+ warn_missing_setup
148
+ return
149
+ end
150
+
151
+ missing = []
152
+ missing << "phlex" unless phlex_installed?
153
+ missing << "ruby_ui" unless ruby_ui_installed?
154
+ say "\n → #{missing.join(" + ")} not detected — running `ruby_ui_scaffold:install` first.", :cyan
155
+ say " (idempotent; pass --skip-install to only warn instead)\n", :cyan
156
+
157
+ run_install!
158
+ end
159
+
160
+ # Shell out to the installer in a clean process. We don't `invoke` it
161
+ # in-process because the installer calls `exit(1)` on unrecoverable
162
+ # errors (e.g. the ruby_ui gem isn't bundled) — a subprocess keeps that
163
+ # from tearing down the scaffold run.
164
+ def run_install!
165
+ installed = in_root { system("bin/rails", "generate", "ruby_ui_scaffold:install") }
166
+ return if installed && phlex_installed? && ruby_ui_installed?
167
+
168
+ say "\n ⚠️ Auto-install didn't finish the setup. Generating the scaffold files", :yellow
169
+ say " anyway — run `bin/rails g ruby_ui_scaffold:install` and resolve the", :yellow
170
+ say " error above before booting the app.\n", :yellow
171
+ end
172
+
173
+ def warn_missing_setup
174
+ check_phlex_install
175
+ check_ruby_ui_install
176
+ end
177
+
178
+ # Warn (don't abort) if `phlex:install` hasn't been run. Without it,
179
+ # `Views::Base` doesn't exist and the autoloader isn't wired to resolve
180
+ # `Views::<Resource>::Index` from `app/views/<resource>/index.rb`.
181
+ def check_phlex_install
182
+ return if phlex_installed?
183
+
184
+ say "\n ⚠️ phlex doesn't look installed (missing app/views/base.rb).", :yellow
185
+ say " Before requests will succeed, run:\n", :yellow
186
+ say " bin/rails g phlex:install\n", :cyan
187
+ end
188
+
189
+ # Warn (don't abort) if ruby_ui doesn't look installed. The generated
190
+ # views use components like `Table`, `Link`, `DropdownMenu` etc., which
191
+ # are mixed into Components::Base by `rails g ruby_ui:install`; without
192
+ # it, requests 500 with NoMethodError on the first component call.
193
+ def check_ruby_ui_install
194
+ return if ruby_ui_installed?
195
+
196
+ say "\n ⚠️ ruby_ui doesn't look installed (missing app/components/base.rb or RubyUI mixin).", :yellow
197
+ say " Before requests will succeed, run:\n", :yellow
198
+ say " bin/rails g ruby_ui:install\n", :cyan
199
+ end
200
+
201
+ def phlex_installed?
202
+ File.exist?(File.join(destination_root, "app/views/base.rb"))
203
+ end
204
+
205
+ def ruby_ui_installed?
206
+ components_base = File.join(destination_root, "app/components/base.rb")
207
+ File.exist?(components_base) && File.read(components_base).include?("RubyUI")
208
+ end
209
+
210
+ # Whether there's an app `bin/rails` we can shell out to. Absent in the
211
+ # generator test harness (temp destination) and in any non-standard
212
+ # layout — in those cases we warn instead of attempting auto-install.
213
+ def app_bin_rails?
214
+ File.exist?(File.join(destination_root, "bin/rails"))
215
+ end
216
+
217
+ # Called from form.rb.tt to emit the ruby_ui input snippet for a given
218
+ # attribute. Handles multi-line snippets by indenting subsequent lines
219
+ # to match the caller's column.
220
+ def ruby_ui_input_for(attribute, indent: 6)
221
+ snippet = RubyUiScaffold::FieldTypeMapper.render(attribute, model_var: singular_table_name)
222
+ pad = " " * indent
223
+ lines = snippet.split("\n", -1)
224
+ return lines.first if lines.length == 1
225
+
226
+ ([lines.first] + lines[1..].map { |line| pad + line }).join("\n")
227
+ end
228
+
229
+ # Phlex view class namespace, e.g. "Users" or "Admin::Users".
230
+ def view_namespace
231
+ controller_class_name
232
+ end
233
+
234
+ # First-level segment for the link helper, e.g. "users" -> "user_path".
235
+ def show_path_helper(record_var)
236
+ "#{singular_route_name}_url(#{record_var})"
237
+ end
238
+
239
+ def index_path_helper
240
+ "#{plural_route_name}_url"
241
+ end
242
+
243
+ def new_path_helper
244
+ "new_#{singular_route_name}_url"
245
+ end
246
+
247
+ def edit_path_helper(record_var)
248
+ "edit_#{singular_route_name}_url(#{record_var})"
249
+ end
250
+ end
251
+ end
252
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::Edit < Views::Base
4
+ <% if options[:literal] -%>
5
+ prop :<%= singular_table_name %>, <%= class_name %>
6
+ <% else -%>
7
+ def initialize(<%= singular_table_name %>:)
8
+ @<%= singular_table_name %> = <%= singular_table_name %>
9
+ end
10
+ <% end -%>
11
+
12
+ def view_template
13
+ <% if options[:phlex_layout] -%>
14
+ render(<%= options[:phlex_layout] %>) do
15
+ <% end -%>
16
+ # Outer scroll container — same pattern as the index. Without it,
17
+ # absolute-positioned popovers from Select/Combobox get clipped by
18
+ # ancestors that set `overflow: hidden`.
19
+ div(class: "h-dvh w-full overflow-y-auto") do
20
+ div(class: "mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 space-y-6") do
21
+ h1(class: "text-2xl font-bold") { "Edit <%= human_name %>" }
22
+
23
+ render Views::<%= controller_class_name %>::Form.new(
24
+ <%= singular_table_name %>: @<%= singular_table_name %>,
25
+ url: <%= singular_route_name %>_path(@<%= singular_table_name %>),
26
+ method: "patch"
27
+ )
28
+ end
29
+ end
30
+ <% if options[:phlex_layout] -%>
31
+ end
32
+ <% end -%>
33
+ end
34
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::Form < Views::Base
4
+ <% if reference_associations.any? -%>
5
+ # Switch to ruby_ui Combobox (with search) when an associated table has more
6
+ # than this many rows. Below the threshold, a plain Select is rendered.
7
+ # Tune per-form by editing this constant.
8
+ COMBOBOX_THRESHOLD = 100
9
+
10
+ <% end -%>
11
+ <% if options[:literal] -%>
12
+ prop :<%= singular_table_name %>, <%= class_name %>
13
+ prop :url, String
14
+ prop :method, String
15
+ <% else -%>
16
+ def initialize(<%= singular_table_name %>:, url:, method:)
17
+ @<%= singular_table_name %> = <%= singular_table_name %>
18
+ @url = url
19
+ @method = method
20
+ end
21
+ <% end -%>
22
+
23
+ def view_template
24
+ form_with(model: @<%= singular_table_name %>, url: @url, method: @method, class: "flex flex-col gap-4 w-full max-w-prose mx-auto") do |form|
25
+ <% attributes.each do |attribute| -%>
26
+ FormField do
27
+ FormFieldLabel(for: "<%= "#{singular_table_name}_#{attribute.column_name}" %>") do
28
+ "<%= attribute.human_name %>"
29
+ end
30
+
31
+ <%= ruby_ui_input_for(attribute, indent: 8) %>
32
+
33
+ FormFieldError do
34
+ @<%= singular_table_name %>.errors.messages_for(:<%= attribute.column_name %>).to_sentence
35
+ end
36
+ end
37
+
38
+ <% end -%>
39
+ div(class: "flex items-center gap-2") do
40
+ Button(type: "submit", class: "gap-2") do
41
+ lucide_icon(@<%= singular_table_name %>.new_record? ? "plus" : "check", class: "size-4")
42
+ span { @<%= singular_table_name %>.new_record? ? "Create <%= human_name %>" : "Update <%= human_name %>" }
43
+ end
44
+ # Back to wherever the user came from; falls back to the index when
45
+ # there's no referer (direct navigation, stripped Referer header, ...).
46
+ Link(href: request&.referer || <%= plural_route_name %>_path, variant: "ghost") { "Back" }
47
+ end
48
+ end
49
+ end
50
+ end