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
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
|
+

|
|
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
|
+

|
|
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
|
+

|
|
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
|