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,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 -%>
|
data/lib/generators/ruby_ui_scaffold/scaffold_controller/templates/controller_data_table.rb.tt
ADDED
|
@@ -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
|