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,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUiScaffold
|
|
4
|
+
# Shared helpers used by both the scaffold_controller and views generators
|
|
5
|
+
# to identify which attributes participate in sort and search.
|
|
6
|
+
module AttributeHelpers
|
|
7
|
+
EXCLUDED_FROM_SORT = %i[text rich_text json jsonb binary attachment attachments].freeze
|
|
8
|
+
|
|
9
|
+
# Columns we'll allowlist for sorting. Excludes large/blob types and
|
|
10
|
+
# password digests, since sorting on them makes no sense.
|
|
11
|
+
def sortable_columns
|
|
12
|
+
attributes.reject { |a|
|
|
13
|
+
EXCLUDED_FROM_SORT.include?(a.type) ||
|
|
14
|
+
(a.respond_to?(:password_digest?) && a.password_digest?) ||
|
|
15
|
+
(a.respond_to?(:attachment?) && (a.attachment? || a.attachments?))
|
|
16
|
+
}.map(&:column_name)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Only string columns are searchable via a LIKE clause. Excludes
|
|
20
|
+
# password_digest. Boolean/integer/date columns aren't useful with LIKE.
|
|
21
|
+
def searchable_columns
|
|
22
|
+
attributes.select { |a|
|
|
23
|
+
a.type == :string &&
|
|
24
|
+
!(a.respond_to?(:password_digest?) && a.password_digest?)
|
|
25
|
+
}.map(&:column_name)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# Non-polymorphic belongs_to attributes — used to:
|
|
29
|
+
# 1. Eager-load via `scope.includes(*reference_associations)` in the controller
|
|
30
|
+
# 2. Display friendly labels (assoc.name vs assoc_id) in index/show
|
|
31
|
+
def reference_associations
|
|
32
|
+
attributes.select do |a|
|
|
33
|
+
a.respond_to?(:reference?) && a.reference? &&
|
|
34
|
+
!(a.respond_to?(:polymorphic?) && a.polymorphic?)
|
|
35
|
+
end.map { |a| a.name.to_sym }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUiScaffold
|
|
4
|
+
# Shared helpers for generators that install ruby_ui components. Mixed into
|
|
5
|
+
# both the install generator (base set) and the scaffold generator
|
|
6
|
+
# (column/flag-specific set). Relies on the including class exposing
|
|
7
|
+
# `destination_root` (every Rails generator does).
|
|
8
|
+
module ComponentInstaller
|
|
9
|
+
# A ruby_ui component is considered installed once its package directory
|
|
10
|
+
# (or single-file component) exists under app/components/ruby_ui/. Guarding
|
|
11
|
+
# on this is essential: `ruby_ui:component` copies files WITHOUT --force,
|
|
12
|
+
# so re-installing a present component prompts interactively — which would
|
|
13
|
+
# hang a non-interactive subprocess.
|
|
14
|
+
def component_installed?(name)
|
|
15
|
+
base = File.join(destination_root, "app/components/ruby_ui", name)
|
|
16
|
+
Dir.exist?(base) || File.exist?("#{base}.rb")
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# The subset of `names` not yet installed, preserving order.
|
|
20
|
+
def uninstalled_components(names)
|
|
21
|
+
names.reject { |name| component_installed?(name) }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUiScaffold
|
|
4
|
+
# Resolves which ruby_ui components a scaffold references, given its
|
|
5
|
+
# attributes and options. Only DIRECT references are listed — the
|
|
6
|
+
# `ruby_ui:component NAME` generator resolves transitive dependencies
|
|
7
|
+
# itself (e.g. `date_picker` pulls `calendar` + `popover` + `input`;
|
|
8
|
+
# `data_table` pulls `table`, `checkbox`, `native_select`, `pagination`,
|
|
9
|
+
# `dropdown_menu`, `input`, `button`). See ruby_ui's `dependencies.yml`.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# RubyUiScaffold::ComponentResolver.call(attributes: attrs, datatable: false)
|
|
13
|
+
# # => ["alert_dialog", "badge", "button", ...]
|
|
14
|
+
module ComponentResolver
|
|
15
|
+
# Components every generated scaffold uses, regardless of columns/flags.
|
|
16
|
+
# The install generator pre-installs these so a bare `:install` leaves the
|
|
17
|
+
# ground ready; the scaffold then adds the column/flag-specific ones.
|
|
18
|
+
#
|
|
19
|
+
# index → table, link, button, dropdown_menu, alert_dialog
|
|
20
|
+
# show → card, typography (Text), link, button
|
|
21
|
+
# form → form (FormField*), input, button
|
|
22
|
+
BASE = %w[
|
|
23
|
+
table
|
|
24
|
+
link
|
|
25
|
+
button
|
|
26
|
+
card
|
|
27
|
+
typography
|
|
28
|
+
dropdown_menu
|
|
29
|
+
alert_dialog
|
|
30
|
+
form
|
|
31
|
+
input
|
|
32
|
+
].freeze
|
|
33
|
+
|
|
34
|
+
module_function
|
|
35
|
+
|
|
36
|
+
# The full set of ruby_ui component generator names a scaffold with these
|
|
37
|
+
# attributes/options references — BASE plus the column/flag conditionals.
|
|
38
|
+
# Returns a sorted, de-duplicated array.
|
|
39
|
+
def call(attributes:, datatable: false)
|
|
40
|
+
components = BASE.dup
|
|
41
|
+
components << "data_table" if datatable
|
|
42
|
+
|
|
43
|
+
attributes.each do |attribute|
|
|
44
|
+
components.concat(components_for_attribute(attribute))
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
components.uniq.sort
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# Conditional components a single attribute pulls in:
|
|
51
|
+
# boolean → Badge (index/show) + Checkbox (form)
|
|
52
|
+
# text → Textarea (form)
|
|
53
|
+
# reference → Combobox + Select (form) — polymorphic falls back to a
|
|
54
|
+
# plain number Input, so it adds nothing
|
|
55
|
+
# date → DatePicker (form)
|
|
56
|
+
# Everything else (string/integer/float/decimal/time/datetime/password/
|
|
57
|
+
# attachment/polymorphic reference) maps to the base `input`.
|
|
58
|
+
def components_for_attribute(attribute)
|
|
59
|
+
return %w[combobox select] if reference?(attribute)
|
|
60
|
+
|
|
61
|
+
case attribute.type
|
|
62
|
+
when :boolean then %w[badge checkbox]
|
|
63
|
+
when :text then %w[textarea]
|
|
64
|
+
when :date then %w[date_picker]
|
|
65
|
+
else []
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def reference?(attribute)
|
|
70
|
+
attribute.respond_to?(:reference?) && attribute.reference? &&
|
|
71
|
+
!(attribute.respond_to?(:polymorphic?) && attribute.polymorphic?)
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUiScaffold
|
|
4
|
+
# Maps a Rails::Generators::GeneratedAttribute to a ruby_ui input
|
|
5
|
+
# component, emitted as a Ruby code snippet for interpolation into
|
|
6
|
+
# a Phlex view template.
|
|
7
|
+
#
|
|
8
|
+
# Multi-line snippets are returned with newline separators and NO
|
|
9
|
+
# leading indentation; the caller is responsible for indenting
|
|
10
|
+
# subsequent lines to match its context. The generator wraps this
|
|
11
|
+
# via `ruby_ui_input_for(attribute, indent:)`.
|
|
12
|
+
#
|
|
13
|
+
# @example
|
|
14
|
+
# RubyUiScaffold::FieldTypeMapper.render(attribute, model_var: "user")
|
|
15
|
+
# # => 'Input(type: "text", id: "user_name", name: "user[name]", value: @user.name)'
|
|
16
|
+
class FieldTypeMapper
|
|
17
|
+
def self.render(attribute, model_var:)
|
|
18
|
+
new(attribute, model_var).render
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def initialize(attribute, model_var)
|
|
22
|
+
@attr = attribute
|
|
23
|
+
@model_var = model_var.to_s
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def render
|
|
27
|
+
return password_input if @attr.respond_to?(:password_digest?) && @attr.password_digest?
|
|
28
|
+
return file_input if @attr.respond_to?(:attachment?) && (@attr.attachment? || @attr.attachments?)
|
|
29
|
+
return reference_input if @attr.respond_to?(:reference?) && @attr.reference?
|
|
30
|
+
|
|
31
|
+
type_input
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def type_input
|
|
37
|
+
case @attr.type
|
|
38
|
+
when :text then textarea
|
|
39
|
+
when :boolean then checkbox
|
|
40
|
+
when :integer then input("number", step: 1)
|
|
41
|
+
when :float, :decimal then input("number", step: "any")
|
|
42
|
+
when :date then date_picker
|
|
43
|
+
when :time then input("time", value_suffix: %q{&.strftime("%H:%M")})
|
|
44
|
+
when :datetime, :timestamp then input("datetime-local", value_suffix: %q{&.strftime("%Y-%m-%dT%H:%M")})
|
|
45
|
+
else input("text")
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def id_attr
|
|
50
|
+
"#{@model_var}_#{@attr.column_name}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def name_attr
|
|
54
|
+
"#{@model_var}[#{@attr.column_name}]"
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def value_ref
|
|
58
|
+
"@#{@model_var}.#{@attr.column_name}"
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def input(type, step: nil, value_suffix: nil)
|
|
62
|
+
value_expr = value_suffix ? "#{value_ref}#{value_suffix}" : value_ref
|
|
63
|
+
step_part = step ? %Q{, step: #{step.is_a?(String) ? %Q{"#{step}"} : step}} : ""
|
|
64
|
+
%Q{Input(type: "#{type}", id: "#{id_attr}", name: "#{name_attr}", value: #{value_expr}#{step_part})}
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# ruby_ui DatePicker renders its own submittable <input> internally (name/
|
|
68
|
+
# value), wrapped in a Popover + Calendar. We pass the Date straight to
|
|
69
|
+
# `selected_date:` — the component derives the input's string value via
|
|
70
|
+
# `selected_date.to_s`, which is already ISO (yyyy-MM-dd), matching its
|
|
71
|
+
# default `date_format`. `label: nil` suppresses the component's built-in
|
|
72
|
+
# label, since the form template already emits a FormFieldLabel.
|
|
73
|
+
def date_picker
|
|
74
|
+
%Q{DatePicker(id: "#{id_attr}", name: "#{name_attr}", selected_date: #{value_ref}, label: nil)}
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def textarea
|
|
78
|
+
<<~RUBY.chomp
|
|
79
|
+
Textarea(rows: 4, id: "#{id_attr}", name: "#{name_attr}") do
|
|
80
|
+
#{value_ref}.to_s
|
|
81
|
+
end
|
|
82
|
+
RUBY
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def checkbox
|
|
86
|
+
<<~RUBY.chomp
|
|
87
|
+
input(type: "hidden", name: "#{name_attr}", value: "0")
|
|
88
|
+
Checkbox(id: "#{id_attr}", name: "#{name_attr}", value: "1", checked: !!#{value_ref})
|
|
89
|
+
RUBY
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def password_input
|
|
93
|
+
%Q{Input(type: "password", id: "#{id_attr}", name: "#{name_attr}", value: "")}
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def file_input
|
|
97
|
+
if @attr.attachments?
|
|
98
|
+
%Q{Input(type: "file", id: "#{id_attr}", name: "#{@model_var}[#{@attr.column_name}][]", multiple: true)}
|
|
99
|
+
else
|
|
100
|
+
%Q{Input(type: "file", id: "#{id_attr}", name: "#{name_attr}")}
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def reference_input
|
|
105
|
+
if @attr.respond_to?(:polymorphic?) && @attr.polymorphic?
|
|
106
|
+
return polymorphic_reference_input
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
target_class = @attr.name.to_s.classify
|
|
110
|
+
assoc_name = @attr.name
|
|
111
|
+
<<~RUBY.chomp
|
|
112
|
+
if #{target_class}.count > COMBOBOX_THRESHOLD
|
|
113
|
+
# Searchable Combobox for large parent lists
|
|
114
|
+
# The Combobox controller auto-syncs trigger text from the checked
|
|
115
|
+
# radio on connect (`updateTriggerContent`), so we only need to mark
|
|
116
|
+
# the current selection via `checked:`.
|
|
117
|
+
Combobox do
|
|
118
|
+
ComboboxTrigger(placeholder: "Select #{target_class}")
|
|
119
|
+
ComboboxPopover do
|
|
120
|
+
ComboboxSearchInput(placeholder: "Search #{target_class}...")
|
|
121
|
+
ComboboxList do
|
|
122
|
+
#{target_class}.all.each do |record|
|
|
123
|
+
ComboboxItem do
|
|
124
|
+
ComboboxRadio(value: record.id.to_s, name: "#{name_attr}", checked: record.id == #{value_ref})
|
|
125
|
+
span { (record.try(:name) || record.try(:title) || record.try(:display_name) || "#{target_class} \#{record.id}").to_s }
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
else
|
|
132
|
+
# Plain Select for small lists. Unlike Combobox, the Select controller
|
|
133
|
+
# does NOT auto-sync the trigger label from the hidden input on connect,
|
|
134
|
+
# so we render the current selection's label inline (via block on
|
|
135
|
+
# SelectValue) and mark the matching SelectItem with aria_selected.
|
|
136
|
+
current_#{assoc_name}_label = if @#{@model_var}.#{assoc_name}
|
|
137
|
+
assoc = @#{@model_var}.#{assoc_name}
|
|
138
|
+
(assoc.try(:name) || assoc.try(:title) || assoc.try(:display_name) || "#{target_class} \#{assoc.id}").to_s
|
|
139
|
+
end
|
|
140
|
+
Select do
|
|
141
|
+
SelectInput(name: "#{name_attr}", value: #{value_ref})
|
|
142
|
+
SelectTrigger do
|
|
143
|
+
SelectValue(placeholder: "Select #{target_class}") { current_#{assoc_name}_label }
|
|
144
|
+
end
|
|
145
|
+
SelectContent do
|
|
146
|
+
#{target_class}.all.each do |record|
|
|
147
|
+
SelectItem(value: record.id.to_s, aria_selected: (record.id == #{value_ref}).to_s) do
|
|
148
|
+
(record.try(:name) || record.try(:title) || record.try(:display_name) || "#{target_class} \#{record.id}").to_s
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
RUBY
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def polymorphic_reference_input
|
|
158
|
+
<<~RUBY.chomp
|
|
159
|
+
# TODO: polymorphic association — fill in target type and id manually
|
|
160
|
+
Input(type: "number", id: "#{id_attr}", name: "#{name_attr}", value: #{value_ref})
|
|
161
|
+
RUBY
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module RubyUiScaffold
|
|
4
|
+
class Railtie < ::Rails::Railtie
|
|
5
|
+
# The `generators` block runs only when Rails actually needs generators
|
|
6
|
+
# (i.e. on `rails g`), not at app boot — important because
|
|
7
|
+
# Rails::Generators isn't loaded at boot.
|
|
8
|
+
generators do
|
|
9
|
+
# IMPORTANT: set option defaults BEFORE requiring the generator
|
|
10
|
+
# classes. `class_option(...)` (called by `hook_for`) freezes the
|
|
11
|
+
# default at class-definition time via `default_value_for_option`,
|
|
12
|
+
# which reads from Rails::Generators.options.
|
|
13
|
+
#
|
|
14
|
+
# We only need to override template_engine — the scaffold_controller
|
|
15
|
+
# default falls back to :scaffold_controller (Rails-wide), which
|
|
16
|
+
# combined with our base_name routes correctly to ours.
|
|
17
|
+
::Rails::Generators.options[:ruby_ui_scaffold] ||= {}
|
|
18
|
+
::Rails::Generators.options[:ruby_ui_scaffold][:template_engine] = "ruby_ui_scaffold"
|
|
19
|
+
|
|
20
|
+
require "generators/ruby_ui_scaffold/ruby_ui_scaffold_generator"
|
|
21
|
+
require "generators/ruby_ui_scaffold/scaffold_controller/scaffold_controller_generator"
|
|
22
|
+
require "generators/ruby_ui_scaffold/scaffold/scaffold_generator"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_ui_scaffold/value_generator"
|
|
4
|
+
|
|
5
|
+
module RubyUiScaffold
|
|
6
|
+
class SeederError < StandardError; end
|
|
7
|
+
|
|
8
|
+
# Orchestrates seeding N records for a given ActiveRecord model.
|
|
9
|
+
# Delegates per-attribute value generation to ValueGenerator.
|
|
10
|
+
#
|
|
11
|
+
# @example
|
|
12
|
+
# seeder = RubyUiScaffold::Seeder.new(Buddy, count: 50)
|
|
13
|
+
# seeder.run # => prints progress, returns count of created records
|
|
14
|
+
class Seeder
|
|
15
|
+
MAX_RETRIES = 3
|
|
16
|
+
PROGRESS_EVERY = 10
|
|
17
|
+
|
|
18
|
+
def initialize(model_class, count:, reset: false, dry_run: false, io: $stdout)
|
|
19
|
+
@model_class = model_class
|
|
20
|
+
@count = count
|
|
21
|
+
@reset = reset
|
|
22
|
+
@dry_run = dry_run
|
|
23
|
+
@io = io
|
|
24
|
+
@created = 0
|
|
25
|
+
@failed = 0
|
|
26
|
+
@errors = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def run
|
|
30
|
+
return dry_run! if @dry_run
|
|
31
|
+
|
|
32
|
+
preflight!
|
|
33
|
+
reset_table! if @reset
|
|
34
|
+
|
|
35
|
+
started = Time.now
|
|
36
|
+
@io.puts "Seeding #{@count} #{@model_class} records..."
|
|
37
|
+
|
|
38
|
+
@count.times do |i|
|
|
39
|
+
record = attempt_create
|
|
40
|
+
if record
|
|
41
|
+
@created += 1
|
|
42
|
+
report_progress(i + 1, record)
|
|
43
|
+
else
|
|
44
|
+
@failed += 1
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
report_summary(Time.now - started)
|
|
49
|
+
@created
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def attempt_create
|
|
55
|
+
MAX_RETRIES.times do
|
|
56
|
+
attrs = ValueGenerator.attributes_for(@model_class)
|
|
57
|
+
record = @model_class.new(attrs)
|
|
58
|
+
return record if record.save
|
|
59
|
+
|
|
60
|
+
@errors << record.errors.full_messages.join(", ")
|
|
61
|
+
end
|
|
62
|
+
nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def preflight!
|
|
66
|
+
@model_class.reflect_on_all_associations(:belongs_to).each do |assoc|
|
|
67
|
+
next if assoc.options[:polymorphic]
|
|
68
|
+
next if assoc.options[:optional]
|
|
69
|
+
next if assoc.klass.unscoped.exists?
|
|
70
|
+
|
|
71
|
+
raise SeederError,
|
|
72
|
+
"#{@model_class} requires #{assoc.klass} records first. " \
|
|
73
|
+
"Run `rails ruby_ui_scaffold:seed #{assoc.klass} --count 10` first."
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def reset_table!
|
|
78
|
+
@io.puts "Resetting #{@model_class}.destroy_all..."
|
|
79
|
+
@model_class.destroy_all
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def dry_run!
|
|
83
|
+
sample = ValueGenerator.attributes_for(@model_class)
|
|
84
|
+
@io.puts "Dry run — would create #{@model_class} with attributes:"
|
|
85
|
+
sample.each { |k, v| @io.puts " #{k}: #{v.inspect}" }
|
|
86
|
+
0
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def report_progress(idx, record)
|
|
90
|
+
return unless (idx % PROGRESS_EVERY).zero? || idx == @count
|
|
91
|
+
|
|
92
|
+
label = display_label(record)
|
|
93
|
+
@io.puts " [#{idx}/#{@count}] last: #{@model_class}(id: #{record.id}, #{label})"
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def display_label(record)
|
|
97
|
+
%i[name title display_name email].each do |attr|
|
|
98
|
+
next unless record.respond_to?(attr)
|
|
99
|
+
|
|
100
|
+
value = record.public_send(attr)
|
|
101
|
+
return "#{attr}: #{value.inspect}" if value.present?
|
|
102
|
+
end
|
|
103
|
+
"id: #{record.id}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def report_summary(elapsed)
|
|
107
|
+
@io.puts
|
|
108
|
+
@io.puts "Created #{@created} of #{@count} #{@model_class} records in #{elapsed.round(2)}s."
|
|
109
|
+
return if @failed.zero?
|
|
110
|
+
|
|
111
|
+
@io.puts "Skipped: #{@failed}. First validation errors:"
|
|
112
|
+
@errors.uniq.first(3).each { |e| @io.puts " - #{e}" }
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "securerandom"
|
|
4
|
+
require "date"
|
|
5
|
+
require "faker"
|
|
6
|
+
|
|
7
|
+
module RubyUiScaffold
|
|
8
|
+
# Produces a single attribute value for a given ActiveRecord column,
|
|
9
|
+
# using an inference chain: belongs_to FK → enum → inclusion validator →
|
|
10
|
+
# name-based heuristic → type-based fallback.
|
|
11
|
+
#
|
|
12
|
+
# Faker is a runtime dependency of this gem, so realistic fake data
|
|
13
|
+
# (names, emails, addresses, etc.) is always available.
|
|
14
|
+
#
|
|
15
|
+
# @example Build a full attribute hash for a model
|
|
16
|
+
# attrs = RubyUiScaffold::ValueGenerator.attributes_for(User)
|
|
17
|
+
# User.new(attrs)
|
|
18
|
+
class ValueGenerator
|
|
19
|
+
SKIPPED_COLUMN_SUFFIXES = %w[_count].freeze
|
|
20
|
+
SKIPPED_COLUMN_NAMES = %w[id created_at updated_at].freeze
|
|
21
|
+
|
|
22
|
+
class << self
|
|
23
|
+
# @param model_class [Class] an ActiveRecord::Base subclass
|
|
24
|
+
# @return [Hash{String => Object}] attributes safe to assign via Model.new
|
|
25
|
+
def attributes_for(model_class)
|
|
26
|
+
seedable_columns(model_class).each_with_object({}) do |column, hash|
|
|
27
|
+
hash[column.name] = new(column, model_class).call
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def seedable_columns(model_class)
|
|
32
|
+
inheritance_column = model_class.inheritance_column
|
|
33
|
+
polymorphic_types = polymorphic_type_columns(model_class)
|
|
34
|
+
|
|
35
|
+
model_class.columns.reject do |c|
|
|
36
|
+
SKIPPED_COLUMN_NAMES.include?(c.name) ||
|
|
37
|
+
SKIPPED_COLUMN_SUFFIXES.any? { |s| c.name.end_with?(s) } ||
|
|
38
|
+
c.name == inheritance_column ||
|
|
39
|
+
polymorphic_types.include?(c.name)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def polymorphic_type_columns(model_class)
|
|
46
|
+
model_class.reflect_on_all_associations(:belongs_to).filter_map do |a|
|
|
47
|
+
"#{a.name}_type" if a.options[:polymorphic]
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def initialize(column, model_class)
|
|
53
|
+
@column = column
|
|
54
|
+
@model_class = model_class
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def call
|
|
58
|
+
belongs_to_value ||
|
|
59
|
+
enum_value ||
|
|
60
|
+
inclusion_value ||
|
|
61
|
+
numericality_value ||
|
|
62
|
+
name_based_value ||
|
|
63
|
+
type_based_value
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
# --- inference layers ---
|
|
69
|
+
|
|
70
|
+
def belongs_to_value
|
|
71
|
+
assoc = @model_class.reflect_on_all_associations(:belongs_to)
|
|
72
|
+
.find { |a| !a.options[:polymorphic] && a.foreign_key == @column.name }
|
|
73
|
+
return nil unless assoc
|
|
74
|
+
|
|
75
|
+
ids = assoc.klass.unscoped.ids
|
|
76
|
+
return ids.sample if ids.any?
|
|
77
|
+
|
|
78
|
+
raise SeederError,
|
|
79
|
+
"#{@model_class} belongs_to :#{assoc.name} but no #{assoc.klass} records exist. " \
|
|
80
|
+
"Run `rails ruby_ui_scaffold:seed #{assoc.klass} --count 10` first."
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def enum_value
|
|
84
|
+
enum = (@model_class.defined_enums || {})[@column.name]
|
|
85
|
+
enum&.keys&.sample
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def inclusion_value
|
|
89
|
+
v = @model_class.validators_on(@column.name).find do |x|
|
|
90
|
+
x.is_a?(ActiveModel::Validations::InclusionValidator)
|
|
91
|
+
end
|
|
92
|
+
return nil unless v
|
|
93
|
+
|
|
94
|
+
list = v.options[:in] || v.options[:within]
|
|
95
|
+
return nil unless list.respond_to?(:to_a)
|
|
96
|
+
|
|
97
|
+
list.to_a.sample
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def numericality_value
|
|
101
|
+
return nil unless %i[integer bigint float decimal].include?(@column.type)
|
|
102
|
+
|
|
103
|
+
v = @model_class.validators_on(@column.name).find do |x|
|
|
104
|
+
x.is_a?(ActiveModel::Validations::NumericalityValidator)
|
|
105
|
+
end
|
|
106
|
+
return nil unless v
|
|
107
|
+
|
|
108
|
+
opts = v.options
|
|
109
|
+
min = opts[:greater_than_or_equal_to] || (opts[:greater_than] && opts[:greater_than] + 1) || 1
|
|
110
|
+
max = opts[:less_than_or_equal_to] || (opts[:less_than] && opts[:less_than] - 1) || (min + 1000)
|
|
111
|
+
|
|
112
|
+
if @column.type == :integer || @column.type == :bigint
|
|
113
|
+
rand(min.to_i..max.to_i)
|
|
114
|
+
else
|
|
115
|
+
rand(min.to_f..max.to_f).round(2)
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def name_based_value
|
|
120
|
+
case @column.name
|
|
121
|
+
when "email", /_email\z/ then ::Faker::Internet.unique.email
|
|
122
|
+
when "first_name" then ::Faker::Name.first_name
|
|
123
|
+
when "last_name" then ::Faker::Name.last_name
|
|
124
|
+
when "name", "full_name" then ::Faker::Name.name
|
|
125
|
+
when "username", "login", "handle" then ::Faker::Internet.unique.username
|
|
126
|
+
when "phone", "phone_number", /_phone\z/ then ::Faker::PhoneNumber.cell_phone
|
|
127
|
+
when "address", "street", "street_address" then ::Faker::Address.street_address
|
|
128
|
+
when "city" then ::Faker::Address.city
|
|
129
|
+
when "state", "province" then ::Faker::Address.state
|
|
130
|
+
when "country" then ::Faker::Address.country
|
|
131
|
+
when "zip", "zipcode", "postal_code" then ::Faker::Address.zip
|
|
132
|
+
when "url", "website", "homepage" then ::Faker::Internet.url
|
|
133
|
+
when "title" then ::Faker::Lorem.sentence(word_count: 4)
|
|
134
|
+
when "body", "content", "description", "bio",
|
|
135
|
+
"summary", "notes" then ::Faker::Lorem.paragraph(sentence_count: 3)
|
|
136
|
+
when "company", "company_name" then ::Faker::Company.name
|
|
137
|
+
when "slug" then ::Faker::Internet.unique.slug
|
|
138
|
+
when "uuid" then SecureRandom.uuid
|
|
139
|
+
when "birthdate", "birthday", "dob", "date_of_birth" then ::Faker::Date.birthday(min_age: 18, max_age: 80)
|
|
140
|
+
when "age" then rand(18..80)
|
|
141
|
+
when "color" then ::Faker::Color.color_name
|
|
142
|
+
when "latitude" then ::Faker::Address.latitude.to_f
|
|
143
|
+
when "longitude" then ::Faker::Address.longitude.to_f
|
|
144
|
+
when "price", "amount" then (rand * 1000).round(2)
|
|
145
|
+
when "quantity", "qty" then rand(1..100)
|
|
146
|
+
when /\Apassword(_digest)?\z/
|
|
147
|
+
# let model's has_secure_password handle this; assign a literal password
|
|
148
|
+
"password123"
|
|
149
|
+
end
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def type_based_value
|
|
153
|
+
case @column.type
|
|
154
|
+
when :string then ::Faker::Lorem.word.capitalize
|
|
155
|
+
when :text then ::Faker::Lorem.paragraph(sentence_count: 3)
|
|
156
|
+
when :integer then rand(1..1000)
|
|
157
|
+
when :bigint then rand(1..1_000_000)
|
|
158
|
+
when :float, :decimal then (rand * 1000).round(2)
|
|
159
|
+
when :boolean then [true, false].sample
|
|
160
|
+
when :date then ::Faker::Date.between(from: Date.today - 365, to: Date.today)
|
|
161
|
+
when :datetime, :timestamp then ::Faker::Time.between(from: Time.now - (60 * 60 * 24 * 365), to: Time.now)
|
|
162
|
+
when :time then Time.now
|
|
163
|
+
when :json, :jsonb then {}
|
|
164
|
+
when :uuid then SecureRandom.uuid
|
|
165
|
+
end
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "ruby_ui_scaffold/version"
|
|
4
|
+
require "ruby_ui_scaffold/field_type_mapper"
|
|
5
|
+
require "ruby_ui_scaffold/attribute_helpers"
|
|
6
|
+
require "ruby_ui_scaffold/component_resolver"
|
|
7
|
+
require "ruby_ui_scaffold/component_installer"
|
|
8
|
+
require "ruby_ui_scaffold/value_generator"
|
|
9
|
+
require "ruby_ui_scaffold/seeder"
|
|
10
|
+
|
|
11
|
+
# Make sure transitive deps' Railties fire when the gem is loaded via
|
|
12
|
+
# Bundler.require. Without these, the helpers (lucide_icon, Faker) aren't
|
|
13
|
+
# registered in the host app, the `phlex:install` / `ruby_ui:component`
|
|
14
|
+
# generators aren't discoverable, and the generated views/seed command crash.
|
|
15
|
+
require "phlex-rails"
|
|
16
|
+
require "literal"
|
|
17
|
+
require "lucide-rails"
|
|
18
|
+
|
|
19
|
+
require "ruby_ui_scaffold/railtie" if defined?(Rails::Railtie)
|
|
20
|
+
|
|
21
|
+
module RubyUiScaffold
|
|
22
|
+
end
|