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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +343 -0
- data/LICENSE.txt +21 -0
- data/README.md +530 -0
- data/lib/generators/ruby_ui_scaffold/install/install_generator.rb +188 -0
- data/lib/generators/ruby_ui_scaffold/ruby_ui_scaffold_generator.rb +119 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/scaffold_generator.rb +252 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/edit.rb.tt +34 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/form.rb.tt +50 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/index.rb.tt +108 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/index_data_table.rb.tt +187 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/new.rb.tt +34 -0
- data/lib/generators/ruby_ui_scaffold/scaffold/templates/show.rb.tt +55 -0
- data/lib/generators/ruby_ui_scaffold/scaffold_controller/scaffold_controller_generator.rb +43 -0
- data/lib/generators/ruby_ui_scaffold/scaffold_controller/templates/controller.rb.tt +75 -0
- data/lib/generators/ruby_ui_scaffold/scaffold_controller/templates/controller_data_table.rb.tt +110 -0
- data/lib/rails/commands/ruby_ui_scaffold/seed_command.rb +62 -0
- data/lib/ruby_ui_scaffold/attribute_helpers.rb +38 -0
- data/lib/ruby_ui_scaffold/component_installer.rb +24 -0
- data/lib/ruby_ui_scaffold/component_resolver.rb +74 -0
- data/lib/ruby_ui_scaffold/field_type_mapper.rb +164 -0
- data/lib/ruby_ui_scaffold/railtie.rb +25 -0
- data/lib/ruby_ui_scaffold/seeder.rb +115 -0
- data/lib/ruby_ui_scaffold/value_generator.rb +168 -0
- data/lib/ruby_ui_scaffold/version.rb +5 -0
- data/lib/ruby_ui_scaffold.rb +22 -0
- 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
|