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
data/README.md ADDED
@@ -0,0 +1,530 @@
1
+ # ruby_ui_scaffold
2
+
3
+ Rails scaffold generator that outputs **Phlex views built with [ruby_ui](https://github.com/ruby-ui/ruby_ui) components**, plus a smart `seed` command to populate models with fake data.
4
+
5
+ Drop-in alternative to `rails g scaffold` — generates model, migration, controller, routes, tests, and Phlex view classes wired to ruby_ui components.
6
+
7
+ ![RubyUI Scaffold](/docs/ruby_ui_scaffold.gif)
8
+
9
+ ## Requirements
10
+
11
+ - Rails 7.1+
12
+ - Tailwind CSS (any Rails Tailwind setup — `tailwindcss-rails` is fine)
13
+
14
+ ## Disclaimer
15
+
16
+ `ruby_ui_scaffold` is a development-only generator built on the [`ruby_ui`](https://github.com/ruby-ui/ruby_ui) component library — you add both to your app. [`faker`](https://github.com/faker-ruby/faker) and [`lucide-rails`](https://github.com/heyvito/lucide-rails) come along automatically as runtime dependencies of `ruby_ui_scaffold` — `faker` powers the `seed` command, and `lucide-rails` renders the icons in the generated views (the index action menu plus the New / Edit / Create / Update buttons).
17
+
18
+ > **If you later remove `ruby_ui_scaffold`**, add `gem "lucide-rails"` to your Gemfile (default group) yourself. The generated views you keep still call `lucide_icon`, and `ruby_ui` doesn't depend on `lucide-rails` — so dropping the scaffold gem would otherwise take `lucide-rails` with it and break the icons. (`faker` only matters if you still run the `seed` command.)
19
+
20
+ ---
21
+
22
+ ## First-time setup
23
+
24
+ On a fresh Rails app, add **both** gems to your `Gemfile` — `ruby_ui` (the component library, used at runtime) and `ruby_ui_scaffold` (the generator, development only):
25
+
26
+ ```ruby
27
+ # Gemfile
28
+ gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false
29
+
30
+ group :development do
31
+ gem "ruby_ui_scaffold"
32
+ end
33
+ ```
34
+
35
+ Then bundle and run the installer:
36
+
37
+ ```bash
38
+ $ bundle install
39
+ $ bin/rails g ruby_ui_scaffold:install
40
+ ```
41
+
42
+ That's it. The installer wires up phlex, ruby_ui, and the base scaffold components — it's fully idempotent, so re-running it only touches what's actually missing.
43
+
44
+ > **Convenience fallbacks** (so a missed step never blocks you):
45
+ >
46
+ > - If you skip the `ruby_ui` line above, the installer adds it to your `Gemfile` and runs `bundle install` for you.
47
+ > - If you jump straight to `bin/rails g ruby_ui_scaffold ...` without running the installer, the first scaffold auto-runs `ruby_ui_scaffold:install` before writing the views (pass [`--skip-install`](#--skip-install) to only warn instead).
48
+ >
49
+ > Doing the explicit setup above just keeps things predictable.
50
+
51
+ <details>
52
+ <summary>What the installer does</summary>
53
+
54
+ 0. **ruby_ui gem bootstrap** — if `ruby_ui` isn't loadable, adds `gem "ruby_ui", github: "ruby-ui/ruby_ui", branch: "main", require: false` to the Gemfile (unless an entry already exists) and runs `bundle install`. Skipped entirely once the gem loads.
55
+ 1. **`phlex:install`** — creates `app/views/base.rb` (`Views::Base`), `app/components/base.rb` (`Components::Base`), and `config/initializers/phlex.rb` (wires the `Views::` autoloader). Skipped if `app/views/base.rb` already exists.
56
+ 2. **`ruby_ui:install`** — mixes `include RubyUI` into `Components::Base`, adds `config/initializers/ruby_ui.rb`, and the Tailwind preset. Skipped if `Components::Base` already includes RubyUI.
57
+ 3. **Base components** — installs the components every scaffold's shell uses regardless of columns/flags: `table`, `link`, `button`, `card`, `typography`, `dropdown_menu`, `alert_dialog`, `form`, `input`. Each is skipped if already present. The installer does **not** run `ruby_ui:component:all` — column/flag-specific components are installed on demand (see below).
58
+
59
+ On the first scaffold generation, `register_output_helper :lucide_icon` is also injected into `Components::Base` (idempotent), so the index dropdown trigger renders out of the box.
60
+
61
+ **On-demand components.** Instead of installing every ruby*ui component upfront, each `rails g ruby_ui_scaffold ...` installs just the components \_that scaffold* references, right after writing the view files — skipping anything already present. So an app only carries the components it actually uses:
62
+
63
+ | Column / flag | Installed on demand |
64
+ | ------------- | -------------------------------------------------------------- |
65
+ | `boolean` | `badge`, `checkbox` |
66
+ | `text` | `textarea` |
67
+ | `references` | `combobox`, `select` |
68
+ | `date` | `date_picker` (pulls `calendar` + `popover`) |
69
+ | `--datatable` | `data_table` (pulls `table`, `native_select`, `pagination`, …) |
70
+
71
+ `ruby_ui:component` resolves transitive dependencies itself, so installing `date_picker`/`data_table` brings their sub-components along automatically. Pass `--skip-install` to opt out of all automatic installation.
72
+
73
+ </details>
74
+
75
+ ## Quick start
76
+
77
+ ```bash
78
+ # 1. Generate a CRUD scaffold for any model
79
+ $ bin/rails g ruby_ui_scaffold Buddy name:string email:string admin:boolean bio:text birthday:date
80
+ $ bin/rails db:migrate
81
+
82
+ # 2. Seed it with 50 fake records
83
+ $ bin/rails ruby_ui_scaffold:seed Buddy --count 50
84
+
85
+ # 3. Open /buddies in your browser
86
+ ```
87
+
88
+ ![RubyUI Scaffold](/docs/ruby_ui_scaffold-01.png)
89
+
90
+ That's it. You get:
91
+
92
+ - `BuddiesController` with full CRUD wired
93
+ - Index: plain ruby_ui `Table` with header + body. Pass [`--datatable`](#--datatable) for the full DataTable (search + per-page + sort + pagination).
94
+ - Phlex views (`app/views/buddies/{index,show,new,edit,form}.rb`)
95
+ - New/Edit form: submit button plus a `Back` link (returns to `request.referer`, falling back to the index)
96
+ - Lucide icons on the action buttons — `plus` (New), `pencil` (Edit), and `plus`/`check` on the form's Create/Update submit
97
+ - Action column: `DropdownMenu` (Lucide `more-horizontal` trigger) with Show / Edit / Delete
98
+ - Delete confirmation via ruby_ui `AlertDialog` (no JS browser confirm)
99
+ - Cells truncate with hover-to-see-full
100
+ - Realistic fake data via Faker (real names, emails, dates, paragraphs)
101
+
102
+ ---
103
+
104
+ ## `belongs_to` (1×N) — Books + Authors walkthrough
105
+
106
+ Generate the parent first, then the child with `:references`:
107
+
108
+ ```bash
109
+ $ bin/rails g ruby_ui_scaffold Author name:string bio:text
110
+ $ bin/rails g ruby_ui_scaffold Book title:string pages:integer published:boolean author:references
111
+ $ bin/rails db:migrate
112
+
113
+ # Seed parent first — Book requires Author records to exist
114
+ $ bin/rails ruby_ui_scaffold:seed Author --count 10
115
+ $ bin/rails ruby_ui_scaffold:seed Book --count 25
116
+ ```
117
+
118
+ What you get automatically:
119
+
120
+ - **Form** — switches between `Combobox` (searchable, when the parent table has more than `COMBOBOX_THRESHOLD = 100` records) and `Select` (for smaller lists). Both are populated from `Author.all` with a label fallback: `record.try(:name) → :title → :display_name → "Author #id"`. On edit, the current value is pre-selected.
121
+ - **Index** and **Show** — display the friendly assoc label (`book.author&.try(:name) || ...`) instead of the raw foreign key.
122
+ - **Controller** — auto-eager-loads via `scope.includes(:author)` to avoid N+1 on the index.
123
+ - **Preflight** — seeding `Book` before any `Author` exists aborts with a clear message:
124
+
125
+ ```
126
+ ERROR: Book requires Author records first. Run `rails ruby_ui_scaffold:seed Author --count 10` first.
127
+ ```
128
+
129
+ ### Snippet of the generated form for `author:references`
130
+
131
+ ```ruby
132
+ # app/views/books/form.rb
133
+ class Views::Books::Form < Views::Base
134
+ COMBOBOX_THRESHOLD = 100 # tune per-form
135
+
136
+ def view_template
137
+ form_with(...) do |form|
138
+ FormField do
139
+ FormFieldLabel(for: "book_author_id") { "Author" }
140
+
141
+ if Author.count > COMBOBOX_THRESHOLD
142
+ Combobox do
143
+ ComboboxTrigger(placeholder: "Select Author")
144
+ ComboboxPopover do
145
+ ComboboxSearchInput(placeholder: "Search Author...")
146
+ ComboboxList do
147
+ Author.all.each do |record|
148
+ ComboboxItem do
149
+ ComboboxRadio(value: record.id.to_s, name: "book[author_id]", checked: record.id == @book.author_id)
150
+ span { (record.try(:name) || record.try(:title) || record.try(:display_name) || "Author #{record.id}").to_s }
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ else
157
+ current_author_label = if @book.author
158
+ assoc = @book.author
159
+ (assoc.try(:name) || assoc.try(:title) || assoc.try(:display_name) || "Author #{assoc.id}").to_s
160
+ end
161
+ Select do
162
+ SelectInput(name: "book[author_id]", value: @book.author_id)
163
+ SelectTrigger { SelectValue(placeholder: "Select Author") { current_author_label } }
164
+ SelectContent do
165
+ Author.all.each do |record|
166
+ SelectItem(value: record.id.to_s, aria_selected: (record.id == @book.author_id).to_s) do
167
+ (record.try(:name) || record.try(:title) || record.try(:display_name) || "Author #{record.id}").to_s
168
+ end
169
+ end
170
+ end
171
+ end
172
+ end
173
+
174
+ FormFieldError { @book.errors.messages_for(:author_id).to_sentence }
175
+ end
176
+ end
177
+ end
178
+ end
179
+ ```
180
+
181
+ **Trade-off**: each form render does one `Author.count` query per `belongs_to` field. Fine for typical scaffold use; tune `COMBOBOX_THRESHOLD` (or memoize at the controller level) if it becomes a hot path.
182
+
183
+ **Polymorphic associations** (`commentable:references{polymorphic}`) fall back to a plain integer input with a TODO comment — populate the `*_type` and `*_id` manually for now.
184
+
185
+ ---
186
+
187
+ ## Optional flags
188
+
189
+ | Flag | What it does |
190
+ | ---------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
191
+ | `--datatable` | Wrap the index in ruby_ui `DataTable` — adds search input, per-page select, sortable headers, and manual pagination. The controller is upgraded with `SORTABLE_COLUMNS` allowlist, params parsing, and scope building. |
192
+ | `--literal` | Emit views using [`literal`](https://literal.fun)'s `prop` macros instead of `def initialize` + `@ivar` assignments. Idempotently injects `extend Literal::Properties` into `app/components/base.rb` on first use. Combines with `--datatable`. |
193
+ | `--phlex-layout=ClassName` | Wrap every view in `render(ClassName) do ... end` + emit `layout false` in the controller. Use when your app has a Phlex layout (with `include Phlex::Rails::Layout`) instead of the default `application.html.erb`. |
194
+ | `--skip-install` | Don't auto-run `ruby_ui_scaffold:install` when phlex/ruby_ui aren't detected — only print a warning (the pre-auto-install behavior). |
195
+ | `--skip-model` | Skip model/migration/fixtures generation and only (re)generate the controller, views, and route. For re-runs against an existing model. Implies `--force` (overwrites generated files, bypasses the collision check). |
196
+ | `--skip-routes`, `--no-test-framework`, `--no-helper`, ... | All standard `rails g scaffold` flags work — inherited from `Rails::Generators::ScaffoldGenerator`. |
197
+
198
+ ### `--datatable`
199
+
200
+ ```bash
201
+ $ bin/rails g ruby_ui_scaffold Buddy name:string email:string birthday:date --datatable
202
+ ```
203
+
204
+ ![RubyUI Scaffold](/docs/ruby_ui_scaffold-02.png)
205
+
206
+ Generates an index built around `DataTable`:
207
+
208
+ ```ruby
209
+ # app/views/buddies/index.rb (excerpt with --datatable)
210
+ DataTable(id: "buddies_data_table") do
211
+ DataTableToolbar do
212
+ DataTableSearch(path: buddies_path, frame_id: "buddies_data_table", value: @search, ...)
213
+ DataTablePerPageSelect(path: buddies_path, ..., value: @per_page)
214
+ end
215
+
216
+ Table(class: "table-fixed") do
217
+ TableHeader do
218
+ TableRow do
219
+ DataTableSortHead(label: "Name", column_key: "name", sort: @sort, direction: @direction, ...)
220
+ DataTableSortHead(label: "Email", column_key: "email", ...)
221
+ ...
222
+ end
223
+ end
224
+ TableBody do
225
+ @buddies.each do |buddy|
226
+ TableRow do
227
+ # truncated cells + action DropdownMenu (same as default)
228
+ end
229
+ end
230
+ end
231
+ end
232
+
233
+ DataTablePaginationBar do
234
+ Text(...) { "Showing #{...} of #{@total_count}" }
235
+ DataTablePagination(page: @page, per_page: @per_page, total_count: @total_count, ...)
236
+ end
237
+ end
238
+ ```
239
+
240
+ And the controller gains:
241
+
242
+ ```ruby
243
+ # app/controllers/buddies_controller.rb (with --datatable)
244
+ SORTABLE_COLUMNS = %w[name email birthday].freeze
245
+ DEFAULT_PER_PAGE = 10
246
+ MAX_PER_PAGE = 100
247
+
248
+ def index
249
+ @per_page = clamp_per_page(params[:per_page])
250
+ @page = [params[:page].to_i, 1].max
251
+ @search = params[:search].to_s
252
+ @sort = params[:sort] if SORTABLE_COLUMNS.include?(params[:sort])
253
+ @direction = %w[asc desc].include?(params[:direction]) ? params[:direction] : "asc"
254
+
255
+ scope = Buddy.all
256
+ # ... eager loading + LIKE search + order + limit/offset ...
257
+
258
+ @total_count = scope.count
259
+ @buddies = scope.limit(@per_page).offset(@per_page * (@page - 1))
260
+
261
+ render ::Views::Buddies::Index.new(buddies:, page:, per_page:, total_count:, search:, sort:, direction:)
262
+ end
263
+ ```
264
+
265
+ Without `--datatable`, the controller's `index` is the bare minimum (`Buddy.all` plus `.includes` when there are `belongs_to` references) and the view receives a single `buddies:` kwarg. Use the flag when you need a list larger than ~50 rows to be navigable; skip it for tiny CRUDs.
266
+
267
+ ### `--literal`
268
+
269
+ ```bash
270
+ $ bin/rails g ruby_ui_scaffold Buddy name:string species:string --literal
271
+ ```
272
+
273
+ Generated views use [`literal`](https://literal.fun)'s `prop` macros — same behavior, less boilerplate, runtime type checking:
274
+
275
+ ```ruby
276
+ # app/views/buddies/show.rb (with --literal)
277
+ class Views::Buddies::Show < Views::Base
278
+ prop :buddy, Buddy
279
+
280
+ def view_template
281
+ # @buddy is available — Literal generates the initialize + ivar
282
+ end
283
+ end
284
+
285
+ # app/views/buddies/form.rb
286
+ class Views::Buddies::Form < Views::Base
287
+ prop :buddy, Buddy
288
+ prop :url, String
289
+ prop :method, String
290
+ # ...
291
+ end
292
+ ```
293
+
294
+ Combines with `--datatable` — the DataTable index gets the full typed prop set:
295
+
296
+ ```ruby
297
+ # app/views/buddies/index.rb (with --datatable --literal)
298
+ class Views::Buddies::Index < Views::Base
299
+ prop :buddies, _Any
300
+ prop :page, Integer
301
+ prop :per_page, Integer
302
+ prop :total_count, Integer
303
+ prop :search, _Nilable(String), default: nil
304
+ prop :sort, _Nilable(String), default: nil
305
+ prop :direction, _Nilable(String), default: nil
306
+ # ...
307
+ end
308
+ ```
309
+
310
+ First scaffold with `--literal` injects `extend Literal::Properties` into `app/components/base.rb` (idempotent — re-runs don't duplicate it). Controllers don't change: `render Views::Buddies::Show.new(buddy: @buddy)` still works since Literal generates a compatible `initialize`.
311
+
312
+ The `literal` gem ships as a runtime dependency of `ruby_ui_scaffold`, so Bundler pulls it in automatically — no extra setup.
313
+
314
+ > **If you later remove `ruby_ui_scaffold`**, add `gem "literal"` to your Gemfile (default group) yourself. Views generated with `--literal` keep using its `prop` macros at runtime, and nothing else pulls `literal` in — so dropping the scaffold gem would otherwise break those views.
315
+
316
+ ### `--phlex-layout=ApplicationLayout`
317
+
318
+ ```bash
319
+ $ bin/rails g ruby_ui_scaffold Buddy name:string --phlex-layout=ApplicationLayout
320
+ ```
321
+
322
+ Generates:
323
+
324
+ ```ruby
325
+ # app/controllers/buddies_controller.rb
326
+ class BuddiesController < ApplicationController
327
+ layout false # skip Rails' default layout
328
+ # ...
329
+ end
330
+
331
+ # app/views/buddies/index.rb (and show/new/edit)
332
+ def view_template
333
+ render(ApplicationLayout) do
334
+ # ... all the view content ...
335
+ end
336
+ end
337
+ ```
338
+
339
+ Use this when your app has **two layouts** (e.g. one ERB layout for guest pages, one Phlex layout for the authenticated dashboard with Stimulus controllers). Without `--phlex-layout`, scaffolded pages would inherit the default Rails layout — which might load the wrong JS bundle and break Stimulus controllers.
340
+
341
+ ### `--skip-install`
342
+
343
+ By default, the scaffold generator checks for phlex (`app/views/base.rb`) and ruby_ui (`RubyUI` mixed into `app/components/base.rb`) before writing views. If either is missing, it auto-runs `ruby_ui_scaffold:install` first (idempotent) so the generated views work out of the box:
344
+
345
+ ```bash
346
+ $ bin/rails g ruby_ui_scaffold Buddy name:string
347
+ → phlex + ruby_ui not detected — running `ruby_ui_scaffold:install` first.
348
+ (idempotent; pass --skip-install to only warn instead)
349
+ ...
350
+ ```
351
+
352
+ Pass `--skip-install` to suppress that and only print a warning instead:
353
+
354
+ ```bash
355
+ $ bin/rails g ruby_ui_scaffold Buddy name:string --skip-install
356
+ ```
357
+
358
+ Auto-install only fires when there's an app `bin/rails` to drive it; if the `ruby_ui` gem isn't bundled, the installer aborts with instructions and the scaffold falls back to a warning.
359
+
360
+ ### `--skip-model`
361
+
362
+ Re-running `rails g ruby_ui_scaffold Buddy ...` with the same name normally trips up on the model: Rails would try to recreate the model and add a **duplicate migration**, and the run aborts on the controller's class-collision check. `--skip-model` is for exactly this re-run case — it skips the whole model step (model, migration, model test, fixtures) and only (re)generates the controller, views, and route:
363
+
364
+ ```bash
365
+ # First run — full scaffold
366
+ $ bin/rails g ruby_ui_scaffold Buddy name:string email:string
367
+ $ bin/rails db:migrate
368
+
369
+ # Later: refresh the views, or switch to the DataTable index, without
370
+ # touching the model or creating a second migration
371
+ $ bin/rails g ruby_ui_scaffold Buddy name:string email:string --datatable --skip-model
372
+ ```
373
+
374
+ `--skip-model` **implies `--force`**: it overwrites the regenerated controller/views without per-file prompts and bypasses the collision check (the re-run would otherwise abort because `Buddy`/`BuddiesController` already exist). The route is left untouched if it already exists (idempotent).
375
+
376
+ The mental model: **the model is your code** (associations, validations, scopes) and is never touched on a re-run; **the controller and views are generated** and get overwritten. Put custom logic in the model. Note `--skip-model` is meant for re-runs — using it on a model that doesn't exist yet leaves you with a controller/views but no model.
377
+
378
+ ---
379
+
380
+ ## Reference
381
+
382
+ ### Generated files (full scaffold)
383
+
384
+ ```
385
+ invoke active_record
386
+ create db/migrate/20XXXXXXXXXXXX_create_buddies.rb
387
+ create app/models/buddy.rb
388
+ invoke test_unit
389
+ create test/models/buddy_test.rb
390
+ create test/fixtures/buddies.yml
391
+ invoke ruby_ui_scaffold:scaffold_controller
392
+ create app/controllers/buddies_controller.rb
393
+ invoke ruby_ui_scaffold:scaffold
394
+ create app/views/buddies/index.rb
395
+ create app/views/buddies/show.rb
396
+ create app/views/buddies/new.rb
397
+ create app/views/buddies/edit.rb
398
+ create app/views/buddies/form.rb
399
+ inject app/components/base.rb (adds `register_output_helper :lucide_icon`)
400
+ invoke test_unit
401
+ create test/controllers/buddies_controller_test.rb
402
+ invoke helper
403
+ create app/helpers/buddies_helper.rb
404
+ invoke resource_route
405
+ route resources :buddies
406
+ ```
407
+
408
+ ### Generated namespacing
409
+
410
+ Views live under the `Views::` module — matching the convention installed by `phlex:install` (which creates `Views::Base` and wires `app/views/` as the `Views::` namespace root):
411
+
412
+ ```ruby
413
+ # app/views/buddies/index.rb
414
+ class Views::Buddies::Index < Views::Base
415
+ ...
416
+ end
417
+
418
+ # For namespaced resources (rails g ruby_ui_scaffold Admin::Buddy ...):
419
+ # app/views/admin/buddies/index.rb
420
+ class Views::Admin::Buddies::Index < Views::Base
421
+ ...
422
+ end
423
+ ```
424
+
425
+ Controllers render via the fully-qualified constant — `render ::Views::Buddies::Index.new(...)` — so there's no ambiguity with non-Phlex `app/views/` siblings (e.g. ERB partials).
426
+
427
+ ### Type → ruby_ui component mapping (form inputs)
428
+
429
+ | Rails column type | ruby_ui component |
430
+ | ------------------------- | -------------------------------------------------------------------- |
431
+ | `string` | `Input(type: "text")` |
432
+ | `text` | `Textarea(rows: 4)` |
433
+ | `integer` | `Input(type: "number", step: 1)` |
434
+ | `float`, `decimal` | `Input(type: "number", step: "any")` |
435
+ | `boolean` | `Checkbox` + hidden `"0"` |
436
+ | `date` | `DatePicker` (ruby_ui — Popover + Calendar over a submittable input) |
437
+ | `time` | `Input(type: "time")` |
438
+ | `datetime`, `timestamp` | `Input(type: "datetime-local")` |
439
+ | `password_digest` | `Input(type: "password")` |
440
+ | `references`/`belongs_to` | `Combobox` (if `parent.count > 100`) or `Select` |
441
+ | polymorphic `references` | `Input(type: "number")` + TODO |
442
+ | `attachment(s)` | `Input(type: "file")` |
443
+
444
+ ### Index features
445
+
446
+ **Always present (both modes):**
447
+
448
+ - `Table(class: "table-fixed")` + per-cell `truncate` so long values don't break the layout
449
+ - Boolean columns rendered as `Badge` (success/outline)
450
+ - `belongs_to` columns rendered with the friendly assoc label (`name → title → display_name`)
451
+ - Action column: `DropdownMenu(options: { strategy: "fixed" })` triggered by Lucide `more-horizontal`
452
+ - Delete inside an `AlertDialog` (Title + Description + Cancel + Delete `form_with(method: :delete)`)
453
+ - Wrapped in `div(class: "h-dvh overflow-y-auto")` so absolute-positioned popovers don't get clipped by ancestors with `overflow: hidden` (common in dashboard layouts)
454
+
455
+ **Only with `--datatable`:**
456
+
457
+ - `DataTable` wrapper with `DataTableToolbar` (`DataTableSearch` + `DataTablePerPageSelect`)
458
+ - `DataTableSortHead` for sortable columns (excludes `:text`, `:rich_text`, `:json`/`:jsonb`, `:binary`, attachments)
459
+ - `DataTablePagination` — manual `page`/`per_page`/`total_count` adapter, no pagy/kaminari dependency
460
+ - Controller bakes `SORTABLE_COLUMNS`, `params[:search]` LIKE clause, `params[:sort]` allowlist, `params[:page]`/`[:per_page]` clamp
461
+
462
+ ### Seed command
463
+
464
+ ```bash
465
+ $ bin/rails ruby_ui_scaffold:seed MODEL [--count N] [--reset] [--dry-run]
466
+ ```
467
+
468
+ | Flag | Behavior |
469
+ | ------------------- | ----------------------------------------------- |
470
+ | `--count N`, `-c N` | Number of records to create (defaults to 10) |
471
+ | `--reset` | Runs `Model.destroy_all` before seeding |
472
+ | `--dry-run` | Prints one sample attribute hash without saving |
473
+
474
+ #### Inference chain
475
+
476
+ For each column the value comes from the first source that matches:
477
+
478
+ 1. **`belongs_to` foreign key** — samples an existing parent record (`Author.ids.sample`).
479
+ 2. **`ActiveRecord::Enum`** — samples a key from `Model.defined_enums[col]`.
480
+ 3. **`validates :col, inclusion: { in: [...] }`** — samples from the allowlist.
481
+ 4. **`validates :col, numericality: { greater_than: X, less_than: Y }`** — respects the range.
482
+ 5. **Column name heuristic** — see table below.
483
+ 6. **Column type fallback** — `:integer` → `rand(1..1000)`, `:boolean` → `[true, false].sample`, etc.
484
+
485
+ #### Column-name heuristics
486
+
487
+ | Column name | Generator |
488
+ | ----------------------------------------------------------- | --------------------------------------------------- |
489
+ | `email`, `*_email` | `Faker::Internet.unique.email` |
490
+ | `first_name`, `last_name`, `name`, `full_name` | `Faker::Name.*` |
491
+ | `username`, `login`, `handle` | `Faker::Internet.unique.username` |
492
+ | `phone`, `phone_number`, `*_phone` | `Faker::PhoneNumber.cell_phone` |
493
+ | `address`, `street`, `city`, `state`, `country`, `zip` | `Faker::Address.*` |
494
+ | `url`, `website`, `homepage` | `Faker::Internet.url` |
495
+ | `title` | `Faker::Lorem.sentence(word_count: 4)` |
496
+ | `body`, `content`, `description`, `bio`, `summary`, `notes` | `Faker::Lorem.paragraph` |
497
+ | `company`, `company_name` | `Faker::Company.name` |
498
+ | `slug` | `Faker::Internet.unique.slug` |
499
+ | `uuid` | `SecureRandom.uuid` |
500
+ | `birthdate`, `birthday`, `dob`, `date_of_birth` | `Faker::Date.birthday` |
501
+ | `age` | `rand(18..80)` |
502
+ | `color` | `Faker::Color.color_name` |
503
+ | `latitude` / `longitude` | `Faker::Address.latitude / .longitude` |
504
+ | `price`, `amount` | `(rand * 1000).round(2)` |
505
+ | `quantity`, `qty` | `rand(1..100)` |
506
+ | `password`, `password_digest` | `"password123"` (let `has_secure_password` hash it) |
507
+
508
+ #### Skipped columns
509
+
510
+ Never assigned:
511
+
512
+ - `id`
513
+ - `created_at`, `updated_at`
514
+ - Counter caches (`*_count`)
515
+ - STI inheritance column (default `type`)
516
+ - Polymorphic `*_type` columns
517
+
518
+ #### Failure handling
519
+
520
+ Each record gets up to 3 retries with newly-generated attributes. After that, it's counted as skipped and the run continues. Final summary shows how many were created vs. skipped and the first 3 unique error messages.
521
+
522
+ ---
523
+
524
+ ## Changelog
525
+
526
+ See [CHANGELOG.md](CHANGELOG.md).
527
+
528
+ ## License
529
+
530
+ MIT