ruby_ui_admin 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 (182) hide show
  1. checksums.yaml +7 -0
  2. data/MIT-LICENSE +20 -0
  3. data/README.md +165 -0
  4. data/app/assets/stylesheets/ruby_ui_admin/application.tailwind.css +111 -0
  5. data/app/components/ruby_ui_admin/ui/base.rb +26 -0
  6. data/app/components/ruby_ui_admin/ui/icon.rb +79 -0
  7. data/app/components/ruby_ui_admin/ui/native_select.rb +37 -0
  8. data/app/components/ruby_ui_admin/ui/native_select_group.rb +18 -0
  9. data/app/components/ruby_ui_admin/ui/native_select_icon.rb +42 -0
  10. data/app/components/ruby_ui_admin/ui/native_select_option.rb +18 -0
  11. data/app/components/ruby_ui_admin/ui/pagination.rb +62 -0
  12. data/app/components/ruby_ui_admin/ui/pagination_content.rb +18 -0
  13. data/app/components/ruby_ui_admin/ui/pagination_ellipsis.rb +45 -0
  14. data/app/components/ruby_ui_admin/ui/pagination_item.rb +32 -0
  15. data/app/components/ruby_ui_admin/ui/select.rb +29 -0
  16. data/app/components/ruby_ui_admin/ui/toast_action.rb +30 -0
  17. data/app/components/ruby_ui_admin/ui/toast_cancel.rb +30 -0
  18. data/app/components/ruby_ui_admin/ui/toast_close.rb +43 -0
  19. data/app/components/ruby_ui_admin/ui/toast_description.rb +21 -0
  20. data/app/components/ruby_ui_admin/ui/toast_icon.rb +69 -0
  21. data/app/components/ruby_ui_admin/ui/toast_item.rb +75 -0
  22. data/app/components/ruby_ui_admin/ui/toast_region.rb +147 -0
  23. data/app/components/ruby_ui_admin/ui/toast_title.rb +21 -0
  24. data/app/components/ruby_ui_admin/ui.rb +12 -0
  25. data/app/components/ruby_ui_admin/views/action.rb +37 -0
  26. data/app/components/ruby_ui_admin/views/action_form.rb +104 -0
  27. data/app/components/ruby_ui_admin/views/action_trigger.rb +148 -0
  28. data/app/components/ruby_ui_admin/views/actions_menu.rb +34 -0
  29. data/app/components/ruby_ui_admin/views/base.rb +301 -0
  30. data/app/components/ruby_ui_admin/views/card_component.rb +69 -0
  31. data/app/components/ruby_ui_admin/views/dashboard.rb +40 -0
  32. data/app/components/ruby_ui_admin/views/docs.rb +91 -0
  33. data/app/components/ruby_ui_admin/views/field_input.rb +156 -0
  34. data/app/components/ruby_ui_admin/views/field_value.rb +301 -0
  35. data/app/components/ruby_ui_admin/views/filter_bar.rb +111 -0
  36. data/app/components/ruby_ui_admin/views/form.rb +151 -0
  37. data/app/components/ruby_ui_admin/views/home.rb +35 -0
  38. data/app/components/ruby_ui_admin/views/index.rb +324 -0
  39. data/app/components/ruby_ui_admin/views/path_helpers.rb +36 -0
  40. data/app/components/ruby_ui_admin/views/rails_helpers.rb +15 -0
  41. data/app/components/ruby_ui_admin/views/show.rb +77 -0
  42. data/app/components/ruby_ui_admin/views/show_fields.rb +37 -0
  43. data/app/components/ruby_ui_admin/views/show_tab.rb +58 -0
  44. data/app/components/ruby_ui_admin/views/structure_renderer.rb +148 -0
  45. data/app/components/ruby_ui_admin/views/translation.rb +12 -0
  46. data/app/controllers/ruby_ui_admin/actions_controller.rb +162 -0
  47. data/app/controllers/ruby_ui_admin/application_controller.rb +81 -0
  48. data/app/controllers/ruby_ui_admin/dashboards_controller.rb +12 -0
  49. data/app/controllers/ruby_ui_admin/docs_controller.rb +166 -0
  50. data/app/controllers/ruby_ui_admin/home_controller.rb +18 -0
  51. data/app/controllers/ruby_ui_admin/resources_controller.rb +259 -0
  52. data/config/locales/ruby_ui_admin.en.yml +59 -0
  53. data/config/locales/ruby_ui_admin.pt-BR.yml +59 -0
  54. data/config/routes/dynamic_routes.rb +11 -0
  55. data/config/routes.rb +23 -0
  56. data/docs/README.md +53 -0
  57. data/docs/actions/overview.md +112 -0
  58. data/docs/authorization/action-policy.md +72 -0
  59. data/docs/authorization/adapters.md +101 -0
  60. data/docs/authorization/explicit-mode.md +45 -0
  61. data/docs/authorization/field-authorization.md +53 -0
  62. data/docs/authorization/policies.md +62 -0
  63. data/docs/authorization/scopes.md +48 -0
  64. data/docs/customization/controllers.md +62 -0
  65. data/docs/customization/ejecting.md +55 -0
  66. data/docs/customization/javascript.md +161 -0
  67. data/docs/customization/theming-rubyui.md +80 -0
  68. data/docs/dashboards/overview.md +65 -0
  69. data/docs/fields/overview.md +94 -0
  70. data/docs/filters/overview.md +73 -0
  71. data/docs/generators-and-tasks/overview.md +42 -0
  72. data/docs/getting-started/authentication.md +98 -0
  73. data/docs/getting-started/configuration.md +174 -0
  74. data/docs/getting-started/installation.md +201 -0
  75. data/docs/getting-started/internationalization.md +50 -0
  76. data/docs/getting-started/practical-guide.md +310 -0
  77. data/docs/resources/associations.md +66 -0
  78. data/docs/resources/index-customization.md +68 -0
  79. data/docs/resources/overview.md +50 -0
  80. data/docs/resources/scopes.md +54 -0
  81. data/docs/resources/tabs-panels.md +65 -0
  82. data/lib/generators/ruby_ui_admin/action/action_generator.rb +21 -0
  83. data/lib/generators/ruby_ui_admin/action/templates/action.rb.tt +24 -0
  84. data/lib/generators/ruby_ui_admin/assets/assets_generator.rb +71 -0
  85. data/lib/generators/ruby_ui_admin/card/card_generator.rb +28 -0
  86. data/lib/generators/ruby_ui_admin/card/templates/chart_card.rb.tt +14 -0
  87. data/lib/generators/ruby_ui_admin/card/templates/metric_card.rb.tt +14 -0
  88. data/lib/generators/ruby_ui_admin/card/templates/partial_card.rb.tt +13 -0
  89. data/lib/generators/ruby_ui_admin/components/components_generator.rb +43 -0
  90. data/lib/generators/ruby_ui_admin/controller/controller_generator.rb +30 -0
  91. data/lib/generators/ruby_ui_admin/controller/templates/controller.rb.tt +24 -0
  92. data/lib/generators/ruby_ui_admin/dashboard/dashboard_generator.rb +18 -0
  93. data/lib/generators/ruby_ui_admin/dashboard/templates/dashboard.rb.tt +14 -0
  94. data/lib/generators/ruby_ui_admin/eject/eject_generator.rb +55 -0
  95. data/lib/generators/ruby_ui_admin/filter/filter_generator.rb +28 -0
  96. data/lib/generators/ruby_ui_admin/filter/templates/boolean_filter.rb.tt +13 -0
  97. data/lib/generators/ruby_ui_admin/filter/templates/select_filter.rb.tt +19 -0
  98. data/lib/generators/ruby_ui_admin/filter/templates/text_filter.rb.tt +13 -0
  99. data/lib/generators/ruby_ui_admin/install/install_generator.rb +62 -0
  100. data/lib/generators/ruby_ui_admin/install/templates/initializer.rb.tt +41 -0
  101. data/lib/generators/ruby_ui_admin/locales/locales_generator.rb +22 -0
  102. data/lib/generators/ruby_ui_admin/policy/policy_generator.rb +18 -0
  103. data/lib/generators/ruby_ui_admin/policy/templates/policy.rb.tt +24 -0
  104. data/lib/generators/ruby_ui_admin/resource/resource_generator.rb +84 -0
  105. data/lib/generators/ruby_ui_admin/resource/templates/resource.rb.tt +15 -0
  106. data/lib/generators/ruby_ui_admin/scope/scope_generator.rb +18 -0
  107. data/lib/generators/ruby_ui_admin/scope/templates/scope.rb.tt +11 -0
  108. data/lib/ruby_ui_admin/authorization/action_policy_adapter.rb +69 -0
  109. data/lib/ruby_ui_admin/authorization/adapter.rb +80 -0
  110. data/lib/ruby_ui_admin/authorization/can_can_can_adapter.rb +70 -0
  111. data/lib/ruby_ui_admin/authorization/pundit_adapter.rb +86 -0
  112. data/lib/ruby_ui_admin/authorization.rb +40 -0
  113. data/lib/ruby_ui_admin/base_action.rb +211 -0
  114. data/lib/ruby_ui_admin/base_dashboard.rb +70 -0
  115. data/lib/ruby_ui_admin/base_policy.rb +38 -0
  116. data/lib/ruby_ui_admin/base_resource.rb +503 -0
  117. data/lib/ruby_ui_admin/cards/base_card.rb +59 -0
  118. data/lib/ruby_ui_admin/cards/chart_card.rb +16 -0
  119. data/lib/ruby_ui_admin/cards/metric_card.rb +22 -0
  120. data/lib/ruby_ui_admin/cards/partial_card.rb +14 -0
  121. data/lib/ruby_ui_admin/configuration.rb +177 -0
  122. data/lib/ruby_ui_admin/current.rb +20 -0
  123. data/lib/ruby_ui_admin/dashboard_manager.rb +52 -0
  124. data/lib/ruby_ui_admin/engine.rb +80 -0
  125. data/lib/ruby_ui_admin/execution_context.rb +36 -0
  126. data/lib/ruby_ui_admin/fields/association_field.rb +67 -0
  127. data/lib/ruby_ui_admin/fields/badge_field.rb +19 -0
  128. data/lib/ruby_ui_admin/fields/base_field.rb +208 -0
  129. data/lib/ruby_ui_admin/fields/belongs_to_field.rb +49 -0
  130. data/lib/ruby_ui_admin/fields/boolean_field.rb +13 -0
  131. data/lib/ruby_ui_admin/fields/boolean_group_field.rb +54 -0
  132. data/lib/ruby_ui_admin/fields/code_field.rb +18 -0
  133. data/lib/ruby_ui_admin/fields/date_field.rb +34 -0
  134. data/lib/ruby_ui_admin/fields/date_time_field.rb +37 -0
  135. data/lib/ruby_ui_admin/fields/field_manager.rb +27 -0
  136. data/lib/ruby_ui_admin/fields/file_field.rb +67 -0
  137. data/lib/ruby_ui_admin/fields/files_field.rb +62 -0
  138. data/lib/ruby_ui_admin/fields/has_and_belongs_to_many_field.rb +9 -0
  139. data/lib/ruby_ui_admin/fields/has_many_field.rb +24 -0
  140. data/lib/ruby_ui_admin/fields/has_one_field.rb +13 -0
  141. data/lib/ruby_ui_admin/fields/hidden_field.rb +14 -0
  142. data/lib/ruby_ui_admin/fields/id_field.rb +19 -0
  143. data/lib/ruby_ui_admin/fields/key_value_field.rb +41 -0
  144. data/lib/ruby_ui_admin/fields/number_field.rb +13 -0
  145. data/lib/ruby_ui_admin/fields/password_field.rb +21 -0
  146. data/lib/ruby_ui_admin/fields/record_link_field.rb +45 -0
  147. data/lib/ruby_ui_admin/fields/select_field.rb +71 -0
  148. data/lib/ruby_ui_admin/fields/status_field.rb +21 -0
  149. data/lib/ruby_ui_admin/fields/text_field.rb +14 -0
  150. data/lib/ruby_ui_admin/fields/textarea_field.rb +13 -0
  151. data/lib/ruby_ui_admin/fields/url_field.rb +21 -0
  152. data/lib/ruby_ui_admin/filters/base_filter.rb +81 -0
  153. data/lib/ruby_ui_admin/filters/boolean_filter.rb +9 -0
  154. data/lib/ruby_ui_admin/filters/multiple_select_filter.rb +11 -0
  155. data/lib/ruby_ui_admin/filters/select_filter.rb +9 -0
  156. data/lib/ruby_ui_admin/filters/text_filter.rb +9 -0
  157. data/lib/ruby_ui_admin/menu/builder.rb +94 -0
  158. data/lib/ruby_ui_admin/resource_manager.rb +82 -0
  159. data/lib/ruby_ui_admin/scopes/base_scope.rb +107 -0
  160. data/lib/ruby_ui_admin/services/authorization_service.rb +38 -0
  161. data/lib/ruby_ui_admin/structure.rb +40 -0
  162. data/lib/ruby_ui_admin/tailwind_source.rb +65 -0
  163. data/lib/ruby_ui_admin/version.rb +5 -0
  164. data/lib/ruby_ui_admin/view.rb +34 -0
  165. data/lib/ruby_ui_admin.rb +110 -0
  166. data/lib/tasks/ruby_ui_admin.rake +71 -0
  167. data/public/ruby-ui-admin-assets/application.css +2 -0
  168. data/public/ruby-ui-admin-assets/controllers/index.js +36 -0
  169. data/public/ruby-ui-admin-assets/controllers/rua--bulk-select_controller.js +19 -0
  170. data/public/ruby-ui-admin-assets/controllers/rua--confirm_controller.js +51 -0
  171. data/public/ruby-ui-admin-assets/controllers/rua--dialog_controller.js +55 -0
  172. data/public/ruby-ui-admin-assets/controllers/rua--row-link_controller.js +16 -0
  173. data/public/ruby-ui-admin-assets/controllers/rua--tabs_controller.js +36 -0
  174. data/public/ruby-ui-admin-assets/controllers/ruby_ui--combobox_controller.js +197 -0
  175. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_content_controller.js +7 -0
  176. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_controller.js +15 -0
  177. data/public/ruby-ui-admin-assets/controllers/ruby_ui--sidebar_controller.js +67 -0
  178. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toast_controller.js +151 -0
  179. data/public/ruby-ui-admin-assets/controllers/ruby_ui--toaster_controller.js +314 -0
  180. data/public/ruby-ui-admin-assets/vendor/stimulus.js +2567 -0
  181. data/public/ruby-ui-admin-assets/vendor/turbo.js +7178 -0
  182. metadata +379 -0
@@ -0,0 +1,151 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ class Form < Base
6
+ include StructureRenderer
7
+
8
+ def initialize(resource:, record:, view:)
9
+ @resource = resource
10
+ @record = record
11
+ @view = view.to_sym
12
+ end
13
+
14
+ def page_title
15
+ "#{title_prefix} · #{RubyUIAdmin.configuration.app_name}"
16
+ end
17
+
18
+ def content
19
+ render_header
20
+ render_errors_summary
21
+
22
+ form(action: form_action, method: "post", enctype: form_enctype, class: "space-y-6") do
23
+ hidden_method_field
24
+ csrf_field
25
+
26
+ render_structure(@resource.field_structure(view: @view))
27
+
28
+ render_actions
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ # File-upload fields require a multipart form.
35
+ def form_enctype
36
+ @resource.get_fields(view: @view).any? { |f| %i[file files].include?(f.type) } ? "multipart/form-data" : nil
37
+ end
38
+
39
+ def new_record?
40
+ @view == :new
41
+ end
42
+
43
+ def title_prefix
44
+ new_record? ? "New #{@resource.model_class.model_name.human}" : "Edit #{@resource.record_title(@record)}"
45
+ end
46
+
47
+ def form_action
48
+ if new_record?
49
+ resource_index_path(@resource.class)
50
+ else
51
+ resource_show_path(@resource.class, @record)
52
+ end
53
+ end
54
+
55
+ def render_header
56
+ div(class: "mb-6") do
57
+ a(href: resource_index_path(@resource.class), class: "text-sm text-muted-foreground hover:underline") { "← #{@resource.navigation_label}" }
58
+ h1(class: "text-2xl font-semibold tracking-tight mt-1") { title_prefix }
59
+ end
60
+ end
61
+
62
+ def render_errors_summary
63
+ return unless @record.errors.any?
64
+
65
+ div(class: "mb-4 rounded-md border border-destructive/30 bg-destructive/5 p-4") do
66
+ p(class: "text-sm font-medium text-destructive mb-1") { rua_t("form.errors") }
67
+ ul(class: "list-disc list-inside text-sm text-destructive") do
68
+ @record.errors.full_messages.each { |message| li { message } }
69
+ end
70
+ end
71
+ end
72
+
73
+ def render_field(field)
74
+ # Hidden fields carry a value with no visible label/row.
75
+ if field.type == :hidden
76
+ render RubyUIAdmin::Views::FieldInput.new(field: field, record: @record)
77
+ return
78
+ end
79
+
80
+ # A lone checkbox reads as "[ ] Label" — render it inline beside the label
81
+ # instead of stacking (which would leave the checkbox glued to the label text).
82
+ return render_boolean_field(field) if field.type == :boolean && !field.readonly?
83
+
84
+ div(class: "space-y-1.5 w-full md:w-8/12") do
85
+ label(class: "text-sm font-medium leading-none", title: field.description) do
86
+ plain field.name
87
+ span(class: "text-destructive ml-0.5") { "*" } if field.required?
88
+ end
89
+
90
+ if field.readonly?
91
+ div(class: "text-sm text-muted-foreground") do
92
+ render RubyUIAdmin::Views::FieldValue.new(field: field, record: @record)
93
+ end
94
+ else
95
+ render RubyUIAdmin::Views::FieldInput.new(field: field, record: @record)
96
+ end
97
+
98
+ if (help = field.help)
99
+ p(class: "text-xs text-muted-foreground") { help }
100
+ end
101
+
102
+ field_errors(field)
103
+ end
104
+ end
105
+
106
+ def render_boolean_field(field)
107
+ div(class: "space-y-1.5 w-full md:w-8/12") do
108
+ label(class: "flex items-center gap-2 text-sm font-medium leading-none", title: field.description) do
109
+ render RubyUIAdmin::Views::FieldInput.new(field: field, record: @record)
110
+ plain field.name
111
+ span(class: "text-destructive ml-0.5") { "*" } if field.required?
112
+ end
113
+
114
+ if (help = field.help)
115
+ p(class: "text-xs text-muted-foreground") { help }
116
+ end
117
+
118
+ field_errors(field)
119
+ end
120
+ end
121
+
122
+ def field_errors(field)
123
+ messages = @record.errors[field.database_id]
124
+ return if messages.blank?
125
+
126
+ p(class: "text-xs text-destructive") { messages.join(", ") }
127
+ end
128
+
129
+ def render_actions
130
+ div(class: "flex items-center gap-3 pt-2") do
131
+ button(
132
+ type: "submit",
133
+ class: "inline-flex items-center h-9 px-4 rounded-md bg-primary text-primary-foreground text-sm font-medium shadow hover:bg-primary/90"
134
+ ) { new_record? ? rua_t("actions.create") : rua_t("actions.update") }
135
+
136
+ a(href: resource_index_path(@resource.class), class: "text-sm text-muted-foreground hover:underline") { rua_t("actions.cancel") }
137
+ end
138
+ end
139
+
140
+ def hidden_method_field
141
+ return if new_record?
142
+
143
+ input(type: "hidden", name: "_method", value: "patch")
144
+ end
145
+
146
+ def csrf_field
147
+ input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
148
+ end
149
+ end
150
+ end
151
+ end
@@ -0,0 +1,35 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ class Home < Base
6
+ def initialize(resources:)
7
+ @resources = resources
8
+ end
9
+
10
+ def content
11
+ h1(class: "text-2xl font-semibold tracking-tight mb-6") { RubyUIAdmin.configuration.app_name }
12
+
13
+ if @resources.empty?
14
+ p(class: "text-muted-foreground") { "No resources defined yet. Create one in app/ruby_ui_admin/resources." }
15
+ else
16
+ div(class: "grid gap-4 sm:grid-cols-2 lg:grid-cols-3") do
17
+ @resources.each { |rc| render_card(rc) }
18
+ end
19
+ end
20
+ end
21
+
22
+ private
23
+
24
+ def render_card(rc)
25
+ a(href: resource_index_path(rc), class: "block") do
26
+ render RubyUI::Card.new(class: "hover:shadow-md transition-shadow") do
27
+ render RubyUI::CardHeader.new do
28
+ render RubyUI::CardTitle.new { rc.navigation_label }
29
+ end
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+ end
@@ -0,0 +1,324 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ class Index < Base
6
+ include ActionsMenu
7
+
8
+ def initialize(resource:, records:, pagy:, filters: [], filter_values: {}, scopes: [], current_scope_key: nil, scope_param: nil, remove_scope_all: false, query_params: {}, sort_by: nil, sort_direction: nil)
9
+ @resource = resource
10
+ @records = records
11
+ @pagy = pagy
12
+ @filters = filters
13
+ @filter_values = filter_values
14
+ @scopes = scopes
15
+ @current_scope_key = current_scope_key
16
+ @scope_param = scope_param
17
+ @remove_scope_all = remove_scope_all
18
+ @query_params = query_params
19
+ @sort_by = sort_by
20
+ @sort_direction = sort_direction
21
+ end
22
+
23
+ def page_title
24
+ "#{@resource.navigation_label} · #{RubyUIAdmin.configuration.app_name}"
25
+ end
26
+
27
+ BULK_FORM_ID = "rua-bulk-form"
28
+
29
+ def content
30
+ render_header
31
+ render_scope_tabs
32
+ render_filter_bar
33
+ render_bulk_toolbar
34
+ # The `rua--bulk-select` controller (select-all + checked-ids) scopes to this table card.
35
+ card_attrs = {class: "p-4"}
36
+ card_attrs[:data] = {controller: "rua--bulk-select"} if bulk?
37
+ render RubyUI::Card.new(**card_attrs) do
38
+ render_table
39
+ end
40
+ render_pagination
41
+ end
42
+
43
+ private
44
+
45
+ def policy_class
46
+ @resource.class.authorization_policy
47
+ end
48
+
49
+ def fields
50
+ @fields ||= @resource.get_fields(view: :index)
51
+ end
52
+
53
+ # Non-standalone actions become bulk actions (run against selected rows).
54
+ def bulk_actions
55
+ return [] unless authorized_to?(:act_on, @resource.model_class, policy_class: policy_class)
56
+
57
+ @bulk_actions ||= @resource.actions_for(view: :index).reject(&:standalone?)
58
+ end
59
+
60
+ def bulk?
61
+ @resource.class.record_selector && bulk_actions.any?
62
+ end
63
+
64
+ def render_header
65
+ div(class: "flex items-center justify-between mb-6") do
66
+ div do
67
+ h1(class: "text-2xl font-semibold tracking-tight") { @resource.navigation_label }
68
+ if (description = @resource.class.description)
69
+ p(class: "text-sm text-muted-foreground mt-1") { description }
70
+ end
71
+ end
72
+
73
+ div(class: "flex items-center gap-2") do
74
+ render_index_controls
75
+ render_standalone_actions
76
+
77
+ if authorized_to?(:create, @resource.model_class, policy_class: policy_class)
78
+ render(RubyUI::Link.new(href: resource_new_path(@resource.class), variant: :primary)) do
79
+ rua_t("actions.new", model: @resource.model_class.model_name.human)
80
+ end
81
+ end
82
+ end
83
+ end
84
+ end
85
+
86
+ # Renders the resource's `self.index_controls` block (custom header buttons/links).
87
+ # The block is evaluated in this component, so it can use Phlex (`a`, `button`, …),
88
+ # the `control_link` helper, route helpers (`ruby_ui_admin.*`) and `@resource`.
89
+ def render_index_controls
90
+ controls = @resource.class.index_controls
91
+ return if controls.nil?
92
+
93
+ instance_exec(&controls)
94
+ end
95
+
96
+ # `actions_list` is accepted inside `index_controls` for compatibility, but standalone
97
+ # actions are already rendered in the header (`render_standalone_actions`), so it is a
98
+ # no-op here.
99
+ def actions_list = nil
100
+
101
+ # Convenience helper for `index_controls`: a button-styled link (RubyUI Button as `<a>`).
102
+ def control_link(label, href, variant: :outline)
103
+ render(RubyUI::Link.new(href: href, variant: (variant == :primary) ? :primary : :outline)) { label }
104
+ end
105
+
106
+ # Link helpers for use inside `index_controls`/`row_controls` blocks.
107
+ def show_button(record, label: nil)
108
+ a(href: resource_show_path(@resource.class, record), class: "text-sm text-primary hover:underline") { label || rua_t("actions.show") }
109
+ end
110
+
111
+ def edit_button(record, label: nil)
112
+ a(href: resource_edit_path(@resource.class, record), class: "text-sm text-primary hover:underline") { label || rua_t("actions.edit") }
113
+ end
114
+
115
+ def create_button(label: nil)
116
+ control_link(label || rua_t("actions.new", model: @resource.model_class.model_name.human), resource_new_path(@resource.class), variant: :primary)
117
+ end
118
+
119
+ # Standalone actions (no record selection needed) shown on the index header.
120
+ def render_standalone_actions
121
+ return unless authorized_to?(:act_on, @resource.model_class, policy_class: policy_class)
122
+
123
+ actions = @resource.actions_for(view: :index).select(&:standalone?)
124
+ return if actions.empty?
125
+
126
+ render_actions_menu(actions, bulk: false)
127
+ end
128
+
129
+ # Bulk actions: an empty form (referenced by row checkboxes via the HTML `form`
130
+ # attribute) plus an "Actions" dropdown of the bulk-action triggers.
131
+ def render_bulk_toolbar
132
+ return unless bulk?
133
+
134
+ form(id: BULK_FORM_ID, method: "get")
135
+
136
+ div(class: "flex items-center justify-end gap-2 mb-4") do
137
+ span(class: "text-sm text-muted-foreground") { rua_t("index.with_selected") }
138
+ render_actions_menu(bulk_actions, bulk: true, form_id: BULK_FORM_ID)
139
+ end
140
+ end
141
+
142
+ def render_table
143
+ render RubyUI::Table.new do
144
+ render RubyUI::TableHeader.new do
145
+ render RubyUI::TableRow.new do
146
+ render RubyUI::TableHead.new(class: "w-10") { select_all_checkbox } if bulk?
147
+ fields.each do |field|
148
+ render RubyUI::TableHead.new { field.name }
149
+ end
150
+ render RubyUI::TableHead.new(class: "text-right") { rua_t("index.actions") }
151
+ end
152
+ end
153
+
154
+ render RubyUI::TableBody.new do
155
+ if @records.empty?
156
+ render RubyUI::TableRow.new do
157
+ render RubyUI::TableCell.new(colspan: column_count, class: "text-center text-muted-foreground py-8") { rua_t("index.empty") }
158
+ end
159
+ else
160
+ @records.each { |record| render_row(record) }
161
+ end
162
+ end
163
+ end
164
+ end
165
+
166
+ def column_count
167
+ fields.size + 1 + (bulk? ? 1 : 0)
168
+ end
169
+
170
+ # Hidden until the JS reveals it (progressive enhancement).
171
+ def select_all_checkbox
172
+ render RubyUI::Checkbox.new(
173
+ hidden: true,
174
+ data: {rua__bulk_select_target: "selectAll", action: "change->rua--bulk-select#toggleAll"}
175
+ )
176
+ end
177
+
178
+ def row_attrs(record)
179
+ return {} unless RubyUIAdmin.configuration.click_row_to_view_record
180
+
181
+ {
182
+ class: "cursor-pointer",
183
+ data: {
184
+ controller: "rua--row-link",
185
+ action: "click->rua--row-link#navigate",
186
+ rua__row_link_url_value: resource_show_path(@resource.class, record)
187
+ }
188
+ }
189
+ end
190
+
191
+ def render_row(record)
192
+ render RubyUI::TableRow.new(**row_attrs(record)) do
193
+ if bulk?
194
+ render RubyUI::TableCell.new do
195
+ render RubyUI::Checkbox.new(
196
+ name: "record_ids[]",
197
+ value: record.id,
198
+ form: BULK_FORM_ID,
199
+ data: {rua_row_select: true}
200
+ )
201
+ end
202
+ end
203
+
204
+ fields.each_with_index do |field, index|
205
+ render RubyUI::TableCell.new(class: ("font-medium" if index.zero?)) do
206
+ link = field.link_to_record? ? resource_show_path(@resource.class, record) : nil
207
+ render RubyUIAdmin::Views::FieldValue.new(field: field, record: record, link: link)
208
+ end
209
+ end
210
+
211
+ render RubyUI::TableCell.new(class: "text-right whitespace-nowrap") do
212
+ render_row_actions(record)
213
+ end
214
+ end
215
+ end
216
+
217
+ def render_row_actions(record)
218
+ cfg = @resource.class.row_controls_config || {}
219
+ placement = (cfg[:placement] == :left) ? "justify-start" : "justify-end"
220
+ modes = [("float" if cfg[:float]), ("hover" if cfg[:show_on_hover])].compact.join(" ")
221
+ data = modes.empty? ? {} : {rua_row_controls: modes}
222
+
223
+ div(class: "inline-flex items-center gap-2 #{placement}", data: data) do
224
+ render_row_controls(record)
225
+
226
+ label = rua_t("actions.show")
227
+ a(href: resource_show_path(@resource.class, record), title: label, aria_label: label,
228
+ class: "inline-flex p-1 text-muted-foreground hover:text-foreground transition-colors") do
229
+ render RubyUIAdmin::UI::Icon.new(:eye)
230
+ end
231
+
232
+ if authorized_to?(:update, record, policy_class: policy_class)
233
+ label = rua_t("actions.edit")
234
+ a(href: resource_edit_path(@resource.class, record), title: label, aria_label: label,
235
+ class: "inline-flex p-1 text-muted-foreground hover:text-foreground transition-colors") do
236
+ render RubyUIAdmin::UI::Icon.new(:pencil)
237
+ end
238
+ end
239
+
240
+ if authorized_to?(:destroy, record, policy_class: policy_class)
241
+ render_delete_button(record)
242
+ end
243
+ end
244
+ end
245
+
246
+ # Resource `self.row_controls = ->(record) { ... }` — extra per-row buttons/links.
247
+ # Evaluated in this component (Phlex methods, `control_link`, route helpers available).
248
+ def render_row_controls(record)
249
+ controls = @resource.class.row_controls
250
+ return if controls.nil?
251
+
252
+ instance_exec(record, &controls)
253
+ end
254
+
255
+ def render_delete_button(record)
256
+ form(action: resource_show_path(@resource.class, record), method: "post", class: "inline") do
257
+ input(type: "hidden", name: "_method", value: "delete")
258
+ input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
259
+ # `rua--confirm#request` opens the shared AlertDialog before submitting (JS); without
260
+ # JS the form submits directly. The message names the record being deleted.
261
+ label = rua_t("actions.destroy")
262
+ button(
263
+ type: "submit",
264
+ title: label,
265
+ aria_label: label,
266
+ data: {
267
+ action: "click->rua--confirm#request",
268
+ rua__confirm_message_param: rua_t("confirm.destroy", model: @resource.record_title(record)),
269
+ rua__confirm_heading_param: rua_t("confirm.destroy_title")
270
+ },
271
+ class: "inline-flex p-1 text-destructive hover:text-destructive/80 transition-colors"
272
+ ) { render RubyUIAdmin::UI::Icon.new(:trash) }
273
+ end
274
+ end
275
+
276
+ def render_scope_tabs
277
+ scopes = @scopes.select { |scope| scope.visible?(user: RubyUIAdmin::Current.user) }
278
+ return if scopes.empty?
279
+
280
+ base = resource_index_path(@resource.class)
281
+
282
+ nav(class: "flex items-center gap-1 mb-4 border-b border-border") do
283
+ unless @remove_scope_all
284
+ scope_tab(rua_t("index.all"), "#{base}?scope=all", active: @current_scope_key.nil?)
285
+ end
286
+ scopes.each do |scope|
287
+ scope_tab(scope.name, "#{base}?scope=#{scope.key}", active: @current_scope_key == scope.key, title: scope.description)
288
+ end
289
+ end
290
+ end
291
+
292
+ def scope_tab(label, href, active:, title: nil)
293
+ state = active ? "border-primary text-foreground" : "border-transparent text-muted-foreground hover:text-foreground"
294
+ a(href: href, title: title, class: "px-3 py-2 -mb-px border-b-2 text-sm font-medium #{state}") { label }
295
+ end
296
+
297
+ def render_filter_bar
298
+ render RubyUIAdmin::Views::FilterBar.new(
299
+ filters: @filters,
300
+ values: @filter_values,
301
+ action_path: resource_index_path(@resource.class),
302
+ scope_param: @scope_param
303
+ )
304
+ end
305
+
306
+ def countless_pagy?
307
+ defined?(Pagy::Countless) && @pagy.is_a?(Pagy::Countless)
308
+ end
309
+
310
+ def render_pagination
311
+ return if @pagy.nil?
312
+ # Countless has no total `pages`; render prev/next only.
313
+ return if countless_pagy? ? !(@pagy.prev || @pagy.next) : @pagy.pages <= 1
314
+
315
+ base = resource_index_path(@resource.class)
316
+ render RubyUIAdmin::UI::Pagination.new(
317
+ pagy: @pagy,
318
+ countless: countless_pagy?,
319
+ url_for_page: ->(page) { "#{base}?#{(@query_params || {}).merge("page" => page).to_query}" }
320
+ )
321
+ end
322
+ end
323
+ end
324
+ end
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Resolves the dynamic per-resource engine routes from inside Phlex views.
6
+ # `rc` is a resource class. Engine routes are reached through the mounted
7
+ # `ruby_ui_admin` proxy (provided by RailsHelpers / Routes).
8
+ module PathHelpers
9
+ def resource_index_path(rc)
10
+ ruby_ui_admin.public_send("resources_#{rc.route_key}_path")
11
+ end
12
+
13
+ def resource_new_path(rc)
14
+ ruby_ui_admin.public_send("new_resources_#{rc.singular_route_key}_path")
15
+ end
16
+
17
+ def resource_show_path(rc, record)
18
+ ruby_ui_admin.public_send("resources_#{rc.singular_route_key}_path", record)
19
+ end
20
+
21
+ def resource_edit_path(rc, record)
22
+ ruby_ui_admin.public_send("edit_resources_#{rc.singular_route_key}_path", record)
23
+ end
24
+
25
+ # Path to an ActiveStorage blob. Generated via the application's url helpers (NOT the
26
+ # request-bound `main_app` proxy): ActiveStorage's blob route is a `direct` route, and inside
27
+ # a mounted engine the proxy prepends the engine's SCRIPT_NAME (e.g. `/admin/rails/...`),
28
+ # which 404s — AS lives at the host root. `disposition: "attachment"` forces a download.
29
+ def attachment_url(blob, disposition: nil)
30
+ opts = {only_path: true}
31
+ opts[:disposition] = disposition if disposition
32
+ Rails.application.routes.url_helpers.rails_blob_path(blob, **opts)
33
+ end
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Bundles the non-deprecated phlex-rails helper adapters used across views.
6
+ # `Routes` brings the host app url helpers plus mounted-engine proxies, so
7
+ # engine routes are reached via the `ruby_ui_admin` proxy.
8
+ module RailsHelpers
9
+ include Phlex::Rails::Helpers::Routes
10
+ include Phlex::Rails::Helpers::Flash
11
+ include Phlex::Rails::Helpers::FormAuthenticityToken
12
+ include Phlex::Rails::Helpers::Request
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ class Show < Base
6
+ include StructureRenderer
7
+ include ShowFields
8
+ include ActionsMenu
9
+
10
+ def initialize(resource:, record:)
11
+ @resource = resource
12
+ @record = record
13
+ end
14
+
15
+ def page_title
16
+ "#{@resource.record_title(@record)} · #{RubyUIAdmin.configuration.app_name}"
17
+ end
18
+
19
+ def content
20
+ render_header
21
+ # `space-y-6` separates the structure's top-level sections (the fields card, panels
22
+ # and tab groups) so they aren't glued together — mirrors the form's `space-y-6`.
23
+ div(class: "space-y-6") do
24
+ render_structure(@resource.field_structure(view: :show))
25
+ end
26
+ end
27
+
28
+ # Lazy tabs (config): non-active show tabs fetch their content when first opened.
29
+ def lazy_tabs?
30
+ RubyUIAdmin.configuration.lazy_tabs
31
+ end
32
+
33
+ # URL a lazy tab fetches — the show path with the tab's flat index and `fragment=1`,
34
+ # so only that tab's structure is rendered (and its queries run) on demand.
35
+ def tab_fragment_url(flat_index)
36
+ "#{resource_show_path(@resource.class, @record)}?tab=#{flat_index}&fragment=1"
37
+ end
38
+
39
+ private
40
+
41
+ def policy_class
42
+ @resource.class.authorization_policy
43
+ end
44
+
45
+ def render_header
46
+ div(class: "flex items-center justify-between mb-6") do
47
+ div do
48
+ a(href: resource_index_path(@resource.class), class: "text-sm text-muted-foreground hover:underline") { "← #{@resource.navigation_label}" }
49
+ h1(class: "text-2xl font-semibold tracking-tight mt-1") { @resource.record_title(@record).to_s }
50
+ end
51
+
52
+ div(class: "flex items-center gap-2") do
53
+ render_record_actions
54
+
55
+ if authorized_to?(:update, @record, policy_class: policy_class)
56
+ a(
57
+ href: resource_edit_path(@resource.class, @record),
58
+ class: "inline-flex items-center h-9 px-4 rounded-md border border-input bg-background text-sm font-medium shadow-sm hover:bg-accent"
59
+ ) { rua_t("actions.edit") }
60
+ end
61
+ end
62
+ end
63
+ end
64
+
65
+ # Actions available for this single record, shown on the show view. Collapsed into the
66
+ # searchable Combobox (same as the index) so many actions don't sprawl across the header.
67
+ def render_record_actions
68
+ return unless authorized_to?(:act_on, @record, policy_class: policy_class)
69
+
70
+ actions = @resource.actions_for(view: :show, record: @record)
71
+ return if actions.empty?
72
+
73
+ render_actions_menu(actions, record_ids: [@record.id])
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a show field as a label/value row. Shared by the full Show view and the lazy
6
+ # tab fragment (ShowTab) so both render fields identically. Expects `@record` to be set.
7
+ module ShowFields
8
+ # Called by StructureRenderer for each field.
9
+ def render_field(field)
10
+ render_field_row(field)
11
+ end
12
+
13
+ def render_field_row(field)
14
+ # Collection associations render as a full-width table, so the label goes above it
15
+ # (a left label column would squeeze the table into a third of the width).
16
+ return render_association_row(field) if %i[has_many has_and_belongs_to_many].include?(field.type)
17
+
18
+ div(class: "grid grid-cols-3 gap-4 py-3 border-b border-border last:border-b-0") do
19
+ dt(class: "text-sm font-medium text-muted-foreground", title: field.description) { field.name }
20
+ dd(class: "text-sm col-span-2") do
21
+ render RubyUIAdmin::Views::FieldValue.new(field: field, record: @record)
22
+ end
23
+ end
24
+ end
25
+
26
+ def render_association_row(field)
27
+ div(class: "py-3 space-y-2 border-b border-border last:border-b-0") do
28
+ div do
29
+ span(class: "text-sm font-medium text-muted-foreground") { field.name }
30
+ p(class: "text-xs text-muted-foreground") { field.description } if field.description
31
+ end
32
+ render RubyUIAdmin::Views::FieldValue.new(field: field, record: @record)
33
+ end
34
+ end
35
+ end
36
+ end
37
+ end