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
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::Index < Views::Base
4
+ <% if options[:literal] -%>
5
+ prop :<%= plural_table_name %>, _Any
6
+ <% else -%>
7
+ def initialize(<%= plural_table_name %>:)
8
+ @<%= plural_table_name %> = <%= plural_table_name %>
9
+ end
10
+ <% end -%>
11
+
12
+ def view_template
13
+ <% if options[:phlex_layout] -%>
14
+ render(<%= options[:phlex_layout] %>) do
15
+ <% end -%>
16
+ # Outer scroll container — when the host layout uses `body { overflow: hidden }`
17
+ # (e.g. dashboards with fixed sidebar), this ensures the page content scrolls
18
+ # vertically. In apps with natural body scroll, the inner content still scrolls
19
+ # within this container.
20
+ div(class: "h-dvh w-full overflow-y-auto") do
21
+ div(class: "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6") do
22
+ div(class: "flex justify-between items-center") do
23
+ h1(class: "text-2xl font-bold") { "<%= human_name.pluralize %>" }
24
+ Link(href: new_<%= singular_route_name %>_path, variant: "primary", class: "gap-2") do
25
+ lucide_icon("plus", class: "size-4")
26
+ span { "New <%= human_name %>" }
27
+ end
28
+ end
29
+
30
+ Table(class: "table-fixed") do
31
+ TableHeader do
32
+ TableRow do
33
+ <% attributes.reject { |a| a.respond_to?(:password_digest?) && a.password_digest? }.each do |attribute| -%>
34
+ TableHead { "<%= attribute.human_name %>" }
35
+ <% end -%>
36
+ TableHead(class: "text-right whitespace-nowrap w-16") { "Actions" }
37
+ end
38
+ end
39
+
40
+ TableBody do
41
+ @<%= plural_table_name %>.each do |<%= singular_table_name %>|
42
+ TableRow do
43
+ <% attributes.reject { |a| a.respond_to?(:password_digest?) && a.password_digest? }.each do |attribute| -%>
44
+ <% if attribute.type == :boolean -%>
45
+ TableCell do
46
+ Badge(variant: <%= singular_table_name %>.<%= attribute.column_name %>? ? "success" : "outline") do
47
+ <%= singular_table_name %>.<%= attribute.column_name %>? ? "Yes" : "No"
48
+ end
49
+ end
50
+ <% elsif reference_associations.include?(attribute.name.to_sym) -%>
51
+ TableCell do
52
+ <%= singular_table_name %>_<%= attribute.name %>_label = (<%= singular_table_name %>.<%= attribute.name %>&.try(:name) || <%= singular_table_name %>.<%= attribute.name %>&.try(:title) || <%= singular_table_name %>.<%= attribute.column_name %>.to_s).to_s
53
+ div(class: "truncate", title: <%= singular_table_name %>_<%= attribute.name %>_label) { <%= singular_table_name %>_<%= attribute.name %>_label }
54
+ end
55
+ <% else -%>
56
+ TableCell do
57
+ <%= singular_table_name %>_<%= attribute.column_name %>_value = <%= singular_table_name %>.<%= attribute.column_name %>.to_s
58
+ div(class: "truncate", title: <%= singular_table_name %>_<%= attribute.column_name %>_value) { <%= singular_table_name %>_<%= attribute.column_name %>_value }
59
+ end
60
+ <% end -%>
61
+ <% end -%>
62
+ TableCell(class: "text-right whitespace-nowrap w-16") do
63
+ DropdownMenu(options: { strategy: "fixed" }) do
64
+ DropdownMenuTrigger do
65
+ lucide_icon("more-horizontal", class: "size-5 cursor-pointer text-muted-foreground hover:text-foreground")
66
+ end
67
+
68
+ DropdownMenuContent(class: "w-fit") do
69
+ DropdownMenuItem(href: <%= singular_route_name %>_path(<%= singular_table_name %>)) { "Show" }
70
+ DropdownMenuItem(href: edit_<%= singular_route_name %>_path(<%= singular_table_name %>)) { "Edit" }
71
+ DropdownMenuSeparator()
72
+ AlertDialog(class: "w-full") do
73
+ AlertDialogTrigger(class: "w-full") do
74
+ DropdownMenuItem(href: nil, class: "w-full justify-start text-destructive hover:text-destructive focus:text-destructive") do
75
+ "Delete"
76
+ end
77
+ end
78
+
79
+ AlertDialogContent do
80
+ AlertDialogHeader do
81
+ AlertDialogTitle { "Delete <%= human_name %>?" }
82
+ AlertDialogDescription do
83
+ "This action cannot be undone. The <%= human_name.downcase %> will be permanently removed."
84
+ end
85
+ end
86
+
87
+ AlertDialogFooter do
88
+ AlertDialogCancel { "Cancel" }
89
+ form_with(url: <%= singular_route_name %>_path(<%= singular_table_name %>), method: :delete) do
90
+ Button(type: "submit", variant: "destructive") { "Delete" }
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
104
+ <% if options[:phlex_layout] -%>
105
+ end
106
+ <% end -%>
107
+ end
108
+ end
@@ -0,0 +1,187 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::Index < Views::Base
4
+ <% if options[:literal] -%>
5
+ prop :<%= plural_table_name %>, _Any
6
+ prop :page, Integer
7
+ prop :per_page, Integer
8
+ prop :total_count, Integer
9
+ prop :search, _Nilable(String), default: nil
10
+ prop :sort, _Nilable(String), default: nil
11
+ prop :direction, _Nilable(String), default: nil
12
+ <% else -%>
13
+ def initialize(<%= plural_table_name %>:, page:, per_page:, total_count:, search: nil, sort: nil, direction: nil)
14
+ @<%= plural_table_name %> = <%= plural_table_name %>
15
+ @page = page
16
+ @per_page = per_page
17
+ @total_count = total_count
18
+ @search = search
19
+ @sort = sort
20
+ @direction = direction
21
+ end
22
+ <% end -%>
23
+
24
+ def view_template
25
+ <% if options[:phlex_layout] -%>
26
+ render(<%= options[:phlex_layout] %>) do
27
+ <% end -%>
28
+ # Outer scroll container — when the host layout uses `body { overflow: hidden }`
29
+ # (e.g. dashboards with fixed sidebar), this ensures the page content scrolls
30
+ # vertically. In apps with natural body scroll, the inner content still scrolls
31
+ # within this container.
32
+ div(class: "h-dvh w-full overflow-y-auto") do
33
+ div(class: "mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8 space-y-6") do
34
+ div(class: "flex justify-between items-center") do
35
+ h1(class: "text-2xl font-bold") { "<%= human_name.pluralize %>" }
36
+ Link(href: new_<%= singular_route_name %>_path, variant: "primary", class: "gap-2") do
37
+ lucide_icon("plus", class: "size-4")
38
+ span { "New <%= human_name %>" }
39
+ end
40
+ end
41
+
42
+ DataTable(id: "<%= plural_table_name %>_data_table") do
43
+ DataTableToolbar do
44
+ <% if searchable_columns.any? -%>
45
+ DataTableSearch(
46
+ path: <%= plural_route_name %>_path,
47
+ frame_id: "<%= plural_table_name %>_data_table",
48
+ value: @search,
49
+ placeholder: "Search <%= human_name.pluralize.downcase %>...",
50
+ preserved_params: preserved_search_params
51
+ )
52
+ <% else -%>
53
+ div # no searchable string columns
54
+ <% end -%>
55
+ DataTablePerPageSelect(
56
+ path: <%= plural_route_name %>_path,
57
+ frame_id: "<%= plural_table_name %>_data_table",
58
+ value: @per_page
59
+ )
60
+ end
61
+
62
+ Table(class: "table-fixed") do
63
+ TableHeader do
64
+ TableRow do
65
+ <% attributes.reject { |a| a.respond_to?(:password_digest?) && a.password_digest? }.each do |attribute| -%>
66
+ <% if sortable_columns.include?(attribute.column_name) -%>
67
+ DataTableSortHead(
68
+ label: "<%= attribute.human_name %>",
69
+ column_key: "<%= attribute.column_name %>",
70
+ sort: @sort,
71
+ direction: @direction,
72
+ path: <%= plural_route_name %>_path,
73
+ query: query_params
74
+ )
75
+ <% else -%>
76
+ TableHead { "<%= attribute.human_name %>" }
77
+ <% end -%>
78
+ <% end -%>
79
+ TableHead(class: "text-right whitespace-nowrap w-16") { "Actions" }
80
+ end
81
+ end
82
+
83
+ TableBody do
84
+ @<%= plural_table_name %>.each do |<%= singular_table_name %>|
85
+ TableRow do
86
+ <% attributes.reject { |a| a.respond_to?(:password_digest?) && a.password_digest? }.each do |attribute| -%>
87
+ <% if attribute.type == :boolean -%>
88
+ TableCell do
89
+ Badge(variant: <%= singular_table_name %>.<%= attribute.column_name %>? ? "success" : "outline") do
90
+ <%= singular_table_name %>.<%= attribute.column_name %>? ? "Yes" : "No"
91
+ end
92
+ end
93
+ <% elsif reference_associations.include?(attribute.name.to_sym) -%>
94
+ TableCell do
95
+ <%= singular_table_name %>_<%= attribute.name %>_label = (<%= singular_table_name %>.<%= attribute.name %>&.try(:name) || <%= singular_table_name %>.<%= attribute.name %>&.try(:title) || <%= singular_table_name %>.<%= attribute.column_name %>.to_s).to_s
96
+ div(class: "truncate", title: <%= singular_table_name %>_<%= attribute.name %>_label) { <%= singular_table_name %>_<%= attribute.name %>_label }
97
+ end
98
+ <% else -%>
99
+ TableCell do
100
+ <%= singular_table_name %>_<%= attribute.column_name %>_value = <%= singular_table_name %>.<%= attribute.column_name %>.to_s
101
+ div(class: "truncate", title: <%= singular_table_name %>_<%= attribute.column_name %>_value) { <%= singular_table_name %>_<%= attribute.column_name %>_value }
102
+ end
103
+ <% end -%>
104
+ <% end -%>
105
+ TableCell(class: "text-right whitespace-nowrap w-16") do
106
+ DropdownMenu(options: { strategy: "fixed" }) do
107
+ DropdownMenuTrigger do
108
+ lucide_icon("more-horizontal", class: "size-5 cursor-pointer text-muted-foreground hover:text-foreground")
109
+ end
110
+
111
+ DropdownMenuContent(class: "w-fit") do
112
+ DropdownMenuItem(href: <%= singular_route_name %>_path(<%= singular_table_name %>)) { "Show" }
113
+ DropdownMenuItem(href: edit_<%= singular_route_name %>_path(<%= singular_table_name %>)) { "Edit" }
114
+ DropdownMenuSeparator()
115
+ AlertDialog(class: "w-full") do
116
+ AlertDialogTrigger(class: "w-full") do
117
+ DropdownMenuItem(href: nil, class: "w-full justify-start text-destructive hover:text-destructive focus:text-destructive") do
118
+ "Delete"
119
+ end
120
+ end
121
+
122
+ AlertDialogContent do
123
+ AlertDialogHeader do
124
+ AlertDialogTitle { "Delete <%= human_name %>?" }
125
+ AlertDialogDescription do
126
+ "This action cannot be undone. The <%= human_name.downcase %> will be permanently removed."
127
+ end
128
+ end
129
+
130
+ AlertDialogFooter do
131
+ AlertDialogCancel { "Cancel" }
132
+ form_with(url: <%= singular_route_name %>_path(<%= singular_table_name %>), method: :delete) do
133
+ Button(type: "submit", variant: "destructive") { "Delete" }
134
+ end
135
+ end
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
143
+ end
144
+ end
145
+
146
+ DataTablePaginationBar do
147
+ Text(size: "sm", class: "text-muted-foreground") do
148
+ if @total_count.zero?
149
+ "No <%= human_name.pluralize.downcase %>"
150
+ else
151
+ "Showing #{((@page - 1) * @per_page) + 1}-#{[ @page * @per_page, @total_count ].min} of #{@total_count}"
152
+ end
153
+ end
154
+ DataTablePagination(
155
+ page: @page,
156
+ per_page: @per_page,
157
+ total_count: @total_count,
158
+ path: <%= plural_route_name %>_path,
159
+ query: query_params
160
+ )
161
+ end
162
+ end
163
+ end
164
+ end
165
+ <% if options[:phlex_layout] -%>
166
+ end
167
+ <% end -%>
168
+ end
169
+
170
+ private
171
+
172
+ # All current query params except pagination — used to preserve state
173
+ # across sort clicks and pagination navigation.
174
+ def query_params
175
+ {
176
+ "search" => @search,
177
+ "sort" => @sort,
178
+ "direction" => @direction,
179
+ "per_page" => @per_page
180
+ }.reject { |_, v| v.nil? || v.to_s.empty? }
181
+ end
182
+
183
+ # Params to preserve through the search form (excludes search itself).
184
+ def preserved_search_params
185
+ query_params.except("search")
186
+ end
187
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::New < Views::Base
4
+ <% if options[:literal] -%>
5
+ prop :<%= singular_table_name %>, <%= class_name %>
6
+ <% else -%>
7
+ def initialize(<%= singular_table_name %>:)
8
+ @<%= singular_table_name %> = <%= singular_table_name %>
9
+ end
10
+ <% end -%>
11
+
12
+ def view_template
13
+ <% if options[:phlex_layout] -%>
14
+ render(<%= options[:phlex_layout] %>) do
15
+ <% end -%>
16
+ # Outer scroll container — same pattern as the index. Without it,
17
+ # absolute-positioned popovers from Select/Combobox get clipped by
18
+ # ancestors that set `overflow: hidden`.
19
+ div(class: "h-dvh w-full overflow-y-auto") do
20
+ div(class: "mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 space-y-6") do
21
+ h1(class: "text-2xl font-bold") { "New <%= human_name %>" }
22
+
23
+ render Views::<%= controller_class_name %>::Form.new(
24
+ <%= singular_table_name %>: @<%= singular_table_name %>,
25
+ url: <%= plural_route_name %>_path,
26
+ method: "post"
27
+ )
28
+ end
29
+ end
30
+ <% if options[:phlex_layout] -%>
31
+ end
32
+ <% end -%>
33
+ end
34
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Views::<%= controller_class_name %>::Show < Views::Base
4
+ <% if options[:literal] -%>
5
+ prop :<%= singular_table_name %>, <%= class_name %>
6
+ <% else -%>
7
+ def initialize(<%= singular_table_name %>:)
8
+ @<%= singular_table_name %> = <%= singular_table_name %>
9
+ end
10
+ <% end -%>
11
+
12
+ def view_template
13
+ <% if options[:phlex_layout] -%>
14
+ render(<%= options[:phlex_layout] %>) do
15
+ <% end -%>
16
+ # Outer scroll container — same pattern as the index. Without it,
17
+ # absolute-positioned popovers from Select/Combobox get clipped by
18
+ # ancestors that set `overflow: hidden` (e.g. dashboards with
19
+ # `body { overflow: hidden }`).
20
+ div(class: "h-dvh w-full overflow-y-auto") do
21
+ div(class: "mx-auto max-w-3xl px-4 sm:px-6 lg:px-8 py-8 space-y-6") do
22
+ div(class: "flex justify-between items-center") do
23
+ h1(class: "text-2xl font-bold") { "<%= human_name %> ##{@<%= singular_table_name %>.id}" }
24
+ div(class: "flex gap-2") do
25
+ Link(href: edit_<%= singular_route_name %>_path(@<%= singular_table_name %>), variant: "outline", class: "gap-2") do
26
+ lucide_icon("pencil", class: "size-4")
27
+ span { "Edit" }
28
+ end
29
+ Link(href: <%= plural_route_name %>_path, variant: "ghost") { "Back" }
30
+ end
31
+ end
32
+
33
+ Card(class: "p-6 space-y-3 w-full max-w-prose mx-auto") do
34
+ <% attributes.reject { |a| a.respond_to?(:password_digest?) && a.password_digest? }.each do |attribute| -%>
35
+ div do
36
+ Text(weight: "medium") { "<%= attribute.human_name %>:" }
37
+ <% if attribute.type == :boolean -%>
38
+ Badge(variant: @<%= singular_table_name %>.<%= attribute.column_name %>? ? "success" : "outline") do
39
+ @<%= singular_table_name %>.<%= attribute.column_name %>? ? "Yes" : "No"
40
+ end
41
+ <% elsif reference_associations.include?(attribute.name.to_sym) -%>
42
+ Text { (@<%= singular_table_name %>.<%= attribute.name %>&.try(:name) || @<%= singular_table_name %>.<%= attribute.name %>&.try(:title) || @<%= singular_table_name %>.<%= attribute.column_name %>.to_s).to_s }
43
+ <% else -%>
44
+ Text { @<%= singular_table_name %>.<%= attribute.column_name %>.to_s }
45
+ <% end -%>
46
+ end
47
+ <% end -%>
48
+ end
49
+ end
50
+ end
51
+ <% if options[:phlex_layout] -%>
52
+ end
53
+ <% end -%>
54
+ end
55
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/generators"
4
+ require "rails/generators/rails/scaffold_controller/scaffold_controller_generator"
5
+ require "ruby_ui_scaffold/attribute_helpers"
6
+
7
+ module RubyUiScaffold
8
+ module Generators
9
+ # Inherits Rails' ScaffoldControllerGenerator but:
10
+ # (1) Overrides its controller template (controller.rb.tt) to render
11
+ # Phlex view classes instead of ERB partials.
12
+ # (2) Redirects the template_engine hook to `ruby_ui_scaffold:scaffold`
13
+ # so our Phlex view generator runs (instead of erb:scaffold).
14
+ class ScaffoldControllerGenerator < ::Rails::Generators::ScaffoldControllerGenerator
15
+ include ::RubyUiScaffold::AttributeHelpers
16
+
17
+ source_root File.expand_path("templates", __dir__)
18
+
19
+ class_option :phlex_layout, type: :string, default: nil, banner: "LayoutClass",
20
+ desc: "Emit `layout false` so generated views can wrap themselves in a Phlex layout class"
21
+ class_option :datatable, type: :boolean, default: false,
22
+ desc: "Emit the DataTable-aware controller variant (params parsing + scope building for search/sort/pagination)"
23
+ # Declared only so the option passes cleanly down the hook chain to the
24
+ # views generator (which actually acts on it); the controller ignores it.
25
+ class_option :skip_install, type: :boolean, default: false,
26
+ desc: "Don't auto-run `ruby_ui_scaffold:install` when phlex/ruby_ui aren't detected — only warn"
27
+
28
+ remove_hook_for :template_engine
29
+ hook_for :template_engine, as: :scaffold, default: "ruby_ui_scaffold" do |template_engine|
30
+ invoke template_engine unless options.api?
31
+ end
32
+
33
+ # Pick the right controller template based on --datatable. Default is
34
+ # the simple controller (Model.all + render Index.new(models:)). With
35
+ # --datatable, emit the variant with SORTABLE_COLUMNS + params parsing +
36
+ # scope building.
37
+ def create_controller_files
38
+ source = options[:datatable] ? "controller_data_table.rb" : "controller.rb"
39
+ template source, File.join("app/controllers", controller_class_path, "#{controller_file_name}_controller.rb")
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,75 @@
1
+ <% module_namespacing do -%>
2
+ class <%= controller_class_name %>Controller < ApplicationController
3
+ <% if options[:phlex_layout] -%>
4
+ layout false
5
+
6
+ <% end -%>
7
+ before_action :set_<%= singular_table_name %>, only: %i[ show edit update destroy ]
8
+
9
+ # GET <%= route_url %>
10
+ def index
11
+ @<%= plural_table_name %> = <%= orm_class.all(class_name) %>
12
+ <% if reference_associations.any? -%>
13
+ @<%= plural_table_name %> = @<%= plural_table_name %>.includes(<%= reference_associations.map { |a| ":#{a}" }.join(", ") %>)
14
+ <% end -%>
15
+ render ::Views::<%= controller_class_name %>::Index.new(<%= plural_table_name %>: @<%= plural_table_name %>)
16
+ end
17
+
18
+ # GET <%= route_url %>/1
19
+ def show
20
+ render ::Views::<%= controller_class_name %>::Show.new(<%= singular_table_name %>: @<%= singular_table_name %>)
21
+ end
22
+
23
+ # GET <%= route_url %>/new
24
+ def new
25
+ @<%= singular_table_name %> = <%= orm_class.build(class_name) %>
26
+ render ::Views::<%= controller_class_name %>::New.new(<%= singular_table_name %>: @<%= singular_table_name %>)
27
+ end
28
+
29
+ # GET <%= route_url %>/1/edit
30
+ def edit
31
+ render ::Views::<%= controller_class_name %>::Edit.new(<%= singular_table_name %>: @<%= singular_table_name %>)
32
+ end
33
+
34
+ # POST <%= route_url %>
35
+ def create
36
+ @<%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %>
37
+
38
+ if @<%= orm_instance.save %>
39
+ redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully created.") %>
40
+ else
41
+ render ::Views::<%= controller_class_name %>::New.new(<%= singular_table_name %>: @<%= singular_table_name %>), status: <%= ActionDispatch::Constants::UNPROCESSABLE_CONTENT.inspect %>
42
+ end
43
+ end
44
+
45
+ # PATCH/PUT <%= route_url %>/1
46
+ def update
47
+ if @<%= orm_instance.update("#{singular_table_name}_params") %>
48
+ redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully updated.") %>, status: :see_other
49
+ else
50
+ render ::Views::<%= controller_class_name %>::Edit.new(<%= singular_table_name %>: @<%= singular_table_name %>), status: <%= ActionDispatch::Constants::UNPROCESSABLE_CONTENT.inspect %>
51
+ end
52
+ end
53
+
54
+ # DELETE <%= route_url %>/1
55
+ def destroy
56
+ @<%= orm_instance.destroy %>
57
+ redirect_to <%= index_helper %>_path, notice: <%= %("#{human_name} was successfully destroyed.") %>, status: :see_other
58
+ end
59
+
60
+ private
61
+ # Use callbacks to share common setup or constraints between actions.
62
+ def set_<%= singular_table_name %>
63
+ @<%= singular_table_name %> = <%= orm_class.find(class_name, "params.expect(:id)") %>
64
+ end
65
+
66
+ # Only allow a list of trusted parameters through.
67
+ def <%= "#{singular_table_name}_params" %>
68
+ <%- if attributes_names.empty? -%>
69
+ params.fetch(:<%= singular_table_name %>, {})
70
+ <%- else -%>
71
+ params.expect(<%= singular_table_name %>: [ <%= permitted_params %> ])
72
+ <%- end -%>
73
+ end
74
+ end
75
+ <% end -%>
@@ -0,0 +1,110 @@
1
+ <% module_namespacing do -%>
2
+ class <%= controller_class_name %>Controller < ApplicationController
3
+ <% if options[:phlex_layout] -%>
4
+ layout false
5
+
6
+ <% end -%>
7
+ SORTABLE_COLUMNS = %w[<%= sortable_columns.join(" ") %>].freeze
8
+ DEFAULT_PER_PAGE = 10
9
+ MAX_PER_PAGE = 100
10
+
11
+ before_action :set_<%= singular_table_name %>, only: %i[ show edit update destroy ]
12
+
13
+ # GET <%= route_url %>
14
+ def index
15
+ @per_page = clamp_per_page(params[:per_page])
16
+ @page = [ params[:page].to_i, 1 ].max
17
+ @search = params[:search].to_s
18
+ @sort = params[:sort] if SORTABLE_COLUMNS.include?(params[:sort])
19
+ @direction = %w[ asc desc ].include?(params[:direction]) ? params[:direction] : "asc"
20
+
21
+ scope = <%= orm_class.all(class_name) %>
22
+ <% if reference_associations.any? -%>
23
+ scope = scope.includes(<%= reference_associations.map { |a| ":#{a}" }.join(", ") %>)
24
+ <% end -%>
25
+ <% if searchable_columns.any? -%>
26
+ if @search.present?
27
+ pattern = "%#{@search.downcase}%"
28
+ clauses = %w[ <%= searchable_columns.join(" ") %> ].map { |c| "LOWER(#{c}) LIKE :q" }
29
+ scope = scope.where(clauses.join(" OR "), q: pattern)
30
+ end
31
+ <% end -%>
32
+ scope = scope.order(@sort => @direction) if @sort
33
+
34
+ @total_count = scope.count
35
+ @<%= plural_table_name %> = scope.limit(@per_page).offset(@per_page * (@page - 1))
36
+
37
+ render ::Views::<%= controller_class_name %>::Index.new(
38
+ <%= plural_table_name %>: @<%= plural_table_name %>,
39
+ page: @page,
40
+ per_page: @per_page,
41
+ total_count: @total_count,
42
+ search: @search,
43
+ sort: @sort,
44
+ direction: @direction
45
+ )
46
+ end
47
+
48
+ # GET <%= route_url %>/1
49
+ def show
50
+ render ::Views::<%= controller_class_name %>::Show.new(<%= singular_table_name %>: @<%= singular_table_name %>)
51
+ end
52
+
53
+ # GET <%= route_url %>/new
54
+ def new
55
+ @<%= singular_table_name %> = <%= orm_class.build(class_name) %>
56
+ render ::Views::<%= controller_class_name %>::New.new(<%= singular_table_name %>: @<%= singular_table_name %>)
57
+ end
58
+
59
+ # GET <%= route_url %>/1/edit
60
+ def edit
61
+ render ::Views::<%= controller_class_name %>::Edit.new(<%= singular_table_name %>: @<%= singular_table_name %>)
62
+ end
63
+
64
+ # POST <%= route_url %>
65
+ def create
66
+ @<%= singular_table_name %> = <%= orm_class.build(class_name, "#{singular_table_name}_params") %>
67
+
68
+ if @<%= orm_instance.save %>
69
+ redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully created.") %>
70
+ else
71
+ render ::Views::<%= controller_class_name %>::New.new(<%= singular_table_name %>: @<%= singular_table_name %>), status: <%= ActionDispatch::Constants::UNPROCESSABLE_CONTENT.inspect %>
72
+ end
73
+ end
74
+
75
+ # PATCH/PUT <%= route_url %>/1
76
+ def update
77
+ if @<%= orm_instance.update("#{singular_table_name}_params") %>
78
+ redirect_to <%= redirect_resource_name %>, notice: <%= %("#{human_name} was successfully updated.") %>, status: :see_other
79
+ else
80
+ render ::Views::<%= controller_class_name %>::Edit.new(<%= singular_table_name %>: @<%= singular_table_name %>), status: <%= ActionDispatch::Constants::UNPROCESSABLE_CONTENT.inspect %>
81
+ end
82
+ end
83
+
84
+ # DELETE <%= route_url %>/1
85
+ def destroy
86
+ @<%= orm_instance.destroy %>
87
+ redirect_to <%= index_helper %>_path, notice: <%= %("#{human_name} was successfully destroyed.") %>, status: :see_other
88
+ end
89
+
90
+ private
91
+ # Use callbacks to share common setup or constraints between actions.
92
+ def set_<%= singular_table_name %>
93
+ @<%= singular_table_name %> = <%= orm_class.find(class_name, "params.expect(:id)") %>
94
+ end
95
+
96
+ def clamp_per_page(value)
97
+ n = value.to_i
98
+ n.between?(1, MAX_PER_PAGE) ? n : DEFAULT_PER_PAGE
99
+ end
100
+
101
+ # Only allow a list of trusted parameters through.
102
+ def <%= "#{singular_table_name}_params" %>
103
+ <%- if attributes_names.empty? -%>
104
+ params.fetch(:<%= singular_table_name %>, {})
105
+ <%- else -%>
106
+ params.expect(<%= singular_table_name %>: [ <%= permitted_params %> ])
107
+ <%- end -%>
108
+ end
109
+ end
110
+ <% end -%>
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rails/command"
4
+
5
+ module Rails
6
+ module Command
7
+ module RubyUiScaffold
8
+ # `rails ruby_ui_scaffold:seed MODEL [--count N] [--reset] [--dry-run]`
9
+ class SeedCommand < ::Rails::Command::Base
10
+ DEFAULT_COUNT = 10
11
+
12
+ desc "seed MODEL", "Seed records for MODEL with smart fake data (use --count to control how many)"
13
+
14
+ class_option :count, type: :numeric, aliases: "-c", default: DEFAULT_COUNT,
15
+ desc: "Number of records to create"
16
+ class_option :reset, type: :boolean, default: false,
17
+ desc: "Destroy all existing records before seeding"
18
+ class_option :dry_run, type: :boolean, default: false,
19
+ desc: "Print one sample attribute hash without saving"
20
+
21
+ def perform(model_name = nil)
22
+ unless model_name
23
+ say_error "Missing MODEL argument. Usage: rails ruby_ui_scaffold:seed MODEL [--count N]"
24
+ exit 1
25
+ end
26
+
27
+ boot_application!
28
+ require "ruby_ui_scaffold/seeder"
29
+ run_seed(model_name)
30
+ end
31
+
32
+ private
33
+
34
+ def run_seed(model_name)
35
+ klass = resolve_model(model_name)
36
+
37
+ ::RubyUiScaffold::Seeder.new(
38
+ klass,
39
+ count: options[:count].to_i,
40
+ reset: options[:reset],
41
+ dry_run: options[:dry_run]
42
+ ).run
43
+ rescue ::RubyUiScaffold::SeederError => e
44
+ say_error e.message
45
+ exit 1
46
+ end
47
+
48
+ def resolve_model(name)
49
+ klass = name.constantize
50
+ unless klass < ::ActiveRecord::Base
51
+ say_error "#{name} is not an ActiveRecord model."
52
+ exit 1
53
+ end
54
+ klass
55
+ rescue NameError
56
+ say_error "Model '#{name}' not found. Did you typo or forget to add a `belongs_to`?"
57
+ exit 1
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end