Skip to content

Commit b434e37

Browse files
jacksonpiresclaude
andauthored
[Feature] Allow ruby_ui:component to accept multiple components (#416)
* [Feature] Allow ruby_ui:component to accept multiple components The component generator now accepts multiple component names in a single invocation (e.g. `bin/rails g ruby_ui:component Button Link Input Textarea`), matching the command already produced by the MCP add-command tool. - Switch the generator argument to an array and validate all names upfront, reporting every missing component at once. - Run the Stimulus manifest update a single time at the end instead of once per component. - Generate all components via a single invocation in the `:all` generator. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [Bug Fix] Exit with non-zero status when components are missing `validate_components!` used a bare `exit`, which in Ruby is `exit(true)` and yields exit status 0. A failed generation therefore reported success to the shell, so CI steps and scripts gating on the exit code treated missing-component errors as passing runs. Use `exit 1` so the failure is observable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * [Bug Fix] Select only directories in ruby_ui:component:all The batch generator filtered out only `.rb` files, so any other non-directory entry at the source root (e.g. a macOS `.DS_Store`) survived and was passed as a component name. Since the names are now generated in a single invocation, `validate_components!` would reject the whole batch and abort the entire all-components run. Components are always directories, so select directories only — this excludes `.rb` files, dotfiles, and any stray file at once. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent a896333 commit b434e37

4 files changed

Lines changed: 84 additions & 35 deletions

File tree

gem/README.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,12 @@ You can generate your components using `ruby_ui:component` generator.
5555
bin/rails g ruby_ui:component Accordion
5656
```
5757

58+
You can also generate multiple components at once.
59+
60+
```bash
61+
bin/rails g ruby_ui:component Button Link Input Textarea
62+
```
63+
5864
You also can generate all components using `ruby_ui:component:all` generator
5965

6066
## Documentation 📖

gem/lib/generators/ruby_ui/component/all_generator.rb

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,13 @@ class AllGenerator < Rails::Generators::Base
1010
def generate_components
1111
say "Generating all components..."
1212

13-
Dir.children(self.class.source_root).each do |folder_name|
14-
next if folder_name.ends_with?(".rb")
15-
16-
run "bin/rails generate ruby_ui:component #{folder_name} --force #{options["force"]}"
13+
# Each component lives in its own directory; select directories only so stray
14+
# files (e.g. base.rb or a macOS .DS_Store) are never passed as component names.
15+
folder_names = Dir.children(self.class.source_root).select do |entry|
16+
File.directory?(File.join(self.class.source_root, entry))
1717
end
18+
19+
run "bin/rails generate ruby_ui:component #{folder_names.join(" ")} --force #{options["force"]}"
1820
end
1921
end
2022
end

gem/lib/generators/ruby_ui/component_generator.rb

Lines changed: 51 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,69 +7,89 @@ class ComponentGenerator < Rails::Generators::Base
77
namespace "ruby_ui:component"
88

99
source_root File.expand_path("../../ruby_ui", __dir__)
10-
argument :component_name, type: :string, required: true
10+
argument :component_names, type: :array, required: true, banner: "Button Link Input"
1111
class_option :force, type: :boolean, default: false
1212
class_option :with_docs, type: :boolean, default: false
1313

14-
def generate_component
15-
if component_not_found?
16-
say "Component not found: #{component_name}", :red
17-
exit
14+
def generate_components
15+
validate_components!
16+
17+
component_names.each do |component_name|
18+
say "Generating #{component_name} files..."
19+
copy_related_component_files(component_name)
20+
copy_js_files(component_name)
21+
install_dependencies(component_name)
1822
end
1923

20-
say "Generating #{component_name} files..."
24+
update_stimulus_manifest
2125
end
2226

23-
def copy_related_component_files
27+
private
28+
29+
def validate_components!
30+
missing = component_names.reject { |name| component_exists?(name) }
31+
return if missing.empty?
32+
33+
say "Component(s) not found: #{missing.join(", ")}", :red
34+
exit 1
35+
end
36+
37+
def copy_related_component_files(component_name)
2438
say "Generating components"
2539

26-
components_file_paths.each do |file_path|
40+
components_file_paths(component_name).each do |file_path|
2741
component_file_name = file_path.split("/").last
28-
copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name, component_file_name), force: options["force"]
42+
copy_file file_path, Rails.root.join("app/components/ruby_ui", component_folder_name(component_name), component_file_name), force: options["force"]
2943
end
3044
end
3145

32-
def copy_js_files
33-
return if js_controller_file_paths.empty?
46+
def copy_js_files(component_name)
47+
paths = js_controller_file_paths(component_name)
48+
return if paths.empty?
3449

3550
say "Generating Stimulus controllers"
3651

37-
js_controller_file_paths.each do |file_path|
52+
paths.each do |file_path|
3853
controller_file_name = file_path.split("/").last
3954
copy_file file_path, Rails.root.join("app/javascript/controllers/ruby_ui", controller_file_name), force: options["force"]
4055
end
4156

57+
@stimulus_controllers_added = true
58+
end
59+
60+
def update_stimulus_manifest
61+
return unless @stimulus_controllers_added
62+
4263
# Importmap doesn't have controller manifest, instead it uses `eagerLoadControllersFrom("controllers", application)`
43-
if !using_importmap?
44-
say "Updating Stimulus controllers manifest"
45-
run "rake stimulus:manifest:update"
46-
end
64+
return if using_importmap?
65+
66+
say "Updating Stimulus controllers manifest"
67+
run "rake stimulus:manifest:update"
4768
end
4869

49-
def install_dependencies
50-
return if dependencies.blank?
70+
def install_dependencies(component_name)
71+
deps = dependencies(component_name)
72+
return if deps.blank?
5173

5274
say "Installing dependencies"
5375

54-
install_components_dependencies(dependencies["components"])
55-
install_gems_dependencies(dependencies["gems"])
56-
install_js_packages(dependencies["js_packages"])
76+
install_components_dependencies(deps["components"])
77+
install_gems_dependencies(deps["gems"])
78+
install_js_packages(deps["js_packages"])
5779
end
5880

59-
private
60-
61-
def component_not_found? = !Dir.exist?(component_folder_path)
81+
def component_exists?(component_name) = Dir.exist?(component_folder_path(component_name))
6282

63-
def component_folder_name = component_name.underscore
83+
def component_folder_name(component_name) = component_name.underscore
6484

65-
def component_folder_path = File.join(self.class.source_root, component_folder_name)
85+
def component_folder_path(component_name) = File.join(self.class.source_root, component_folder_name(component_name))
6686

67-
def components_file_paths
68-
files = Dir.glob(File.join(component_folder_path, "*.rb"))
87+
def components_file_paths(component_name)
88+
files = Dir.glob(File.join(component_folder_path(component_name), "*.rb"))
6989
options["with_docs"] ? files : files.reject { |f| f.end_with?("_docs.rb") }
7090
end
7191

72-
def js_controller_file_paths = Dir.glob(File.join(component_folder_path, "*.js"))
92+
def js_controller_file_paths(component_name) = Dir.glob(File.join(component_folder_path(component_name), "*.js"))
7393

7494
def install_components_dependencies(components)
7595
components&.each do |component|
@@ -89,10 +109,10 @@ def install_js_packages(js_packages)
89109
end
90110
end
91111

92-
def dependencies
112+
def dependencies(component_name)
93113
@dependencies ||= YAML.load_file(File.join(__dir__, "dependencies.yml")).freeze
94114

95-
@dependencies[component_folder_name]
115+
@dependencies[component_folder_name(component_name)]
96116
end
97117
end
98118
end

gem/test/generators/component_generator_test.rb

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,4 +82,25 @@ def test_tooltip_installs_typography_for_text_component
8282

8383
assert_includes dependencies.fetch("tooltip").fetch("components"), "Typography"
8484
end
85+
86+
def test_resolves_component_folders_for_multiple_components
87+
source_root = File.expand_path("../../lib/ruby_ui", __dir__)
88+
89+
# Folder names mirror ComponentGenerator's `component_name.underscore`.
90+
{"Button" => "button", "Link" => "link", "Input" => "input", "Textarea" => "textarea"}.each do |component_name, folder_name|
91+
folder_path = File.join(source_root, folder_name)
92+
93+
assert(Dir.exist?(folder_path),
94+
"Expected folder for #{component_name} to exist at #{folder_path}")
95+
end
96+
end
97+
98+
def test_validation_collects_all_missing_components
99+
source_root = File.expand_path("../../lib/ruby_ui", __dir__)
100+
folder_names = {"Button" => "button", "NotARealComponent" => "not_a_real_component", "AnotherFakeOne" => "another_fake_one"}
101+
102+
missing = folder_names.reject { |_name, folder| Dir.exist?(File.join(source_root, folder)) }.keys
103+
104+
assert_equal %w[NotARealComponent AnotherFakeOne], missing
105+
end
85106
end

0 commit comments

Comments
 (0)