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,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a form input for a field, dispatching on the field type.
6
+ class FieldInput < Phlex::HTML
7
+ include RubyUIAdmin::UI
8
+ include Translation
9
+
10
+ def initialize(field:, record:, name_prefix: "record")
11
+ @field = field
12
+ @record = record
13
+ @name_prefix = name_prefix
14
+ end
15
+
16
+ def view_template
17
+ case @field.type
18
+ when :boolean then render_boolean
19
+ when :number then render_number
20
+ when :date then render_date
21
+ when :date_time then render_date_time
22
+ when :textarea then render_textarea
23
+ when :code then render_code
24
+ when :key_value then render_key_value
25
+ when :select then render_select
26
+ when :url then render_url
27
+ when :hidden then render_hidden
28
+ when :password then render_password
29
+ when :boolean_group then render_boolean_group
30
+ when :belongs_to then render_belongs_to
31
+ when :file then render_file
32
+ when :files then render_files
33
+ else render_text
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def input_name
40
+ "#{@name_prefix}[#{@field.permitted_param}]"
41
+ end
42
+
43
+ def render_text
44
+ render RubyUI::Input.new(type: :text, name: input_name, value: @field.value(@record), placeholder: @field.placeholder)
45
+ end
46
+
47
+ def render_number
48
+ render RubyUI::Input.new(type: :number, name: input_name, value: @field.value(@record))
49
+ end
50
+
51
+ def render_url
52
+ render RubyUI::Input.new(type: :url, name: input_name, value: @field.value(@record), placeholder: @field.placeholder)
53
+ end
54
+
55
+ def render_password
56
+ render RubyUI::Input.new(type: :password, name: input_name, autocomplete: "new-password")
57
+ end
58
+
59
+ def render_hidden
60
+ input(type: :hidden, name: input_name, value: @field.value(@record))
61
+ end
62
+
63
+ def render_date
64
+ value = @field.value(@record)
65
+ formatted = value.respond_to?(:strftime) ? value.strftime("%Y-%m-%d") : value
66
+ render RubyUI::Input.new(type: :date, name: input_name, value: formatted)
67
+ end
68
+
69
+ def render_date_time
70
+ value = @field.value(@record)
71
+ formatted = value.respond_to?(:strftime) ? value.strftime("%Y-%m-%dT%H:%M") : value
72
+ render RubyUI::Input.new(type: "datetime-local", name: input_name, value: formatted)
73
+ end
74
+
75
+ def render_textarea
76
+ render RubyUI::Textarea.new(name: input_name, rows: @field.rows) { @field.value(@record).to_s }
77
+ end
78
+
79
+ def render_code
80
+ render RubyUI::Textarea.new(name: input_name, rows: 8, class: "font-mono") { @field.value(@record).to_s }
81
+ end
82
+
83
+ def render_key_value
84
+ render RubyUI::Textarea.new(name: input_name, rows: 6, class: "font-mono") { @field.value_as_json(@record) }
85
+ end
86
+
87
+ def render_boolean
88
+ input(type: :hidden, name: input_name, value: "0")
89
+ render RubyUI::Checkbox.new(name: input_name, value: "1", checked: !!@field.value(@record))
90
+ end
91
+
92
+ def render_boolean_group
93
+ div(class: "flex flex-col gap-2") do
94
+ @field.group_options.each do |key, label_text|
95
+ option_name = "#{input_name}[#{key}]"
96
+ label(class: "flex items-center gap-2 text-sm") do
97
+ # Paired hidden "0" so unchecked boxes are submitted (and persisted as false).
98
+ input(type: :hidden, name: option_name, value: "0")
99
+ render RubyUI::Checkbox.new(name: option_name, value: "1", checked: @field.checked?(@record, key))
100
+ plain label_text
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ def render_select
107
+ render RubyUIAdmin::UI::Select.new(
108
+ name: input_name,
109
+ options: @field.select_options(@record),
110
+ selected: @field.value(@record),
111
+ include_blank: @field.include_blank?
112
+ )
113
+ end
114
+
115
+ def render_belongs_to
116
+ render RubyUIAdmin::UI::Select.new(
117
+ name: input_name,
118
+ options: @field.options_for_select,
119
+ selected: @field.foreign_key_value(@record),
120
+ include_blank: true
121
+ )
122
+ end
123
+
124
+ def render_file
125
+ div(class: "space-y-2") do
126
+ if @field.respond_to?(:attached?) && @field.attached?(@record)
127
+ blob = @field.attachment(@record).blob
128
+ div(class: "flex items-center gap-2 text-sm text-muted-foreground") do
129
+ render RubyUIAdmin::UI::Icon.new(:file, class: "size-4 shrink-0")
130
+ span { blob.filename.to_s }
131
+ end
132
+ label(class: "flex items-center gap-2 text-sm text-muted-foreground") do
133
+ render RubyUI::Checkbox.new(name: "#{@name_prefix}[#{@field.remove_param}]", value: "1")
134
+ plain rua_t("fields.file.remove")
135
+ end
136
+ end
137
+ render RubyUI::Input.new(type: :file, name: input_name, accept: @field.accept)
138
+ end
139
+ end
140
+
141
+ def render_files
142
+ div(class: "space-y-2") do
143
+ @field.attachments(@record).each do |att|
144
+ label(class: "flex items-center gap-2 text-sm text-muted-foreground") do
145
+ render RubyUI::Checkbox.new(name: "#{@name_prefix}[#{@field.remove_ids_param}][]", value: att.id.to_s)
146
+ render RubyUIAdmin::UI::Icon.new(:file, class: "size-4 shrink-0")
147
+ span { att.blob.filename.to_s }
148
+ end
149
+ end
150
+ # New uploads are appended (not replacing the existing ones); check a box above to remove.
151
+ render RubyUI::Input.new(type: :file, name: "#{input_name}[]", accept: @field.accept, multiple: true)
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a field's value for index/show views, dispatching on the field type.
6
+ class FieldValue < Phlex::HTML
7
+ include RubyUIAdmin::UI
8
+ include RailsHelpers
9
+ include PathHelpers
10
+ include Translation
11
+
12
+ def initialize(field:, record:, link: nil)
13
+ @field = field
14
+ @record = record
15
+ @link = link
16
+ end
17
+
18
+ def view_template
19
+ case @field.type
20
+ when :boolean then render_boolean
21
+ when :belongs_to then render_belongs_to
22
+ when :has_one then render_has_one
23
+ when :has_many, :has_and_belongs_to_many then render_has_many
24
+ when :record_link then render_record_link
25
+ when :url then render_url
26
+ when :badge then render_badge
27
+ when :status then render_status
28
+ when :code then render_code
29
+ when :key_value then render_key_value
30
+ when :boolean_group then render_boolean_group
31
+ when :file then render_file
32
+ when :files then render_files
33
+ else render_text
34
+ end
35
+ end
36
+
37
+ private
38
+
39
+ def render_text
40
+ value = @field.formatted_value(@record, view_context: view_context)
41
+ return plain("—") if value.nil?
42
+
43
+ # Computed blocks may return HTML (e.g. `link_to` -> html_safe); `as_html: true` opts a
44
+ # plain string into raw rendering. Everything else is escaped.
45
+ if (value.respond_to?(:html_safe?) && value.html_safe?) || (@field.respond_to?(:as_html?) && @field.as_html?)
46
+ # User-rendered HTML (e.g. `link_to` in a field block) carries no admin styling.
47
+ # The `contents` wrapper is layout-neutral and scopes `.rua-rich a` so raw links
48
+ # underline on hover, matching the framework's own links.
49
+ span(class: "rua-rich contents") { raw(safe(value.to_s)) }
50
+ elsif @link && @field.link_to_record?
51
+ render RubyUI::InlineLink.new(href: @link) { value.to_s }
52
+ else
53
+ plain value.to_s
54
+ end
55
+ end
56
+
57
+ def render_boolean
58
+ if @field.value(@record)
59
+ render RubyUI::Badge.new(variant: :success) { rua_t("booleans.true") }
60
+ else
61
+ render RubyUI::Badge.new(variant: :gray) { rua_t("booleans.false") }
62
+ end
63
+ end
64
+
65
+ def render_belongs_to
66
+ associated = @field.value(@record)
67
+ return plain("—") if associated.nil?
68
+
69
+ render_record_link_to(associated, @field.display_label(associated))
70
+ end
71
+
72
+ def render_has_one
73
+ associated = @field.associated_record(@record)
74
+ return plain("—") if associated.nil?
75
+
76
+ render_record_link_to(associated, @field.display_label(associated))
77
+ end
78
+
79
+ def render_has_many
80
+ count = @field.count(@record)
81
+ return plain("—") if count.zero?
82
+
83
+ shown = @field.associated_records(@record, limit: 10).to_a
84
+ # Columns come from the record's own resource (the fields must match the record's model);
85
+ # the row link routes through `use_resource:` when given, else the record's own resource.
86
+ column_resource = record_resource_class(shown.first)
87
+ link_resource = (@field.respond_to?(:use_resource) && @field.use_resource) || column_resource
88
+ columns = association_columns(column_resource)
89
+
90
+ div(class: "space-y-2") do
91
+ span(class: "text-xs font-medium text-muted-foreground") { rua_t("associations.count", count: count) }
92
+ render_association_table(shown, link_resource, columns)
93
+
94
+ if count > shown.size
95
+ p(class: "text-xs text-muted-foreground") { rua_t("associations.more", count: count - shown.size) }
96
+ end
97
+ end
98
+ end
99
+
100
+ # Renders associated records as a RubyUI Table. When the associated model has a registered
101
+ # resource, its index fields become the columns (Avo-style) and each row links to the
102
+ # record's show page. Otherwise it falls back to a single column of record links.
103
+ def render_association_table(records, resource_class, columns)
104
+ render RubyUI::Table.new do
105
+ if columns
106
+ render RubyUI::TableHeader.new do
107
+ render RubyUI::TableRow.new do
108
+ columns.each { |field| render RubyUI::TableHead.new { field.name } }
109
+ end
110
+ end
111
+ end
112
+
113
+ render RubyUI::TableBody.new do
114
+ records.each do |rec|
115
+ path = association_show_path(resource_class, rec)
116
+ attrs = if path
117
+ {class: "cursor-pointer", data: {controller: "rua--row-link", action: "click->rua--row-link#navigate", rua__row_link_url_value: path}}
118
+ else
119
+ {}
120
+ end
121
+ render RubyUI::TableRow.new(**attrs) do
122
+ if columns
123
+ columns.each_with_index do |field, index|
124
+ render RubyUI::TableCell.new(class: ("font-medium" if index.zero?)) do
125
+ link = field.link_to_record? ? path : nil
126
+ render RubyUIAdmin::Views::FieldValue.new(field: field, record: rec, link: link)
127
+ end
128
+ end
129
+ else
130
+ render RubyUI::TableCell.new { render_record_link_to(rec, @field.display_label(rec)) }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
136
+ end
137
+
138
+ # The resource registered for a record's own model (used to build the table columns), or nil.
139
+ def record_resource_class(record)
140
+ return nil if record.nil?
141
+
142
+ RubyUIAdmin.resource_manager.find_for_model(record.class)
143
+ end
144
+
145
+ # The columns for the association table. By default the associated resource's index fields;
146
+ # an explicit `fields:` option on the association field picks a subset (in that order) by
147
+ # field id — e.g. `field :suppliers, as: :has_many, fields: %i[name identifier state]`.
148
+ # Returns nil (single-column fallback) when there's no resource or the fields can't be built.
149
+ def association_columns(resource_class)
150
+ return nil unless resource_class
151
+
152
+ requested = Array(@field.options[:fields]).map(&:to_sym)
153
+ columns =
154
+ if requested.any?
155
+ by_id = resource_class.new.get_fields(view: nil).index_by(&:id)
156
+ requested.filter_map { |fid| by_id[fid] }
157
+ else
158
+ resource_class.new.get_fields(view: :index)
159
+ end
160
+ columns.presence
161
+ rescue StandardError
162
+ nil
163
+ end
164
+
165
+ def association_show_path(resource_class, record)
166
+ return nil unless resource_class
167
+
168
+ ruby_ui_admin.public_send("resources_#{resource_class.singular_route_key}_path", record)
169
+ rescue StandardError
170
+ nil
171
+ end
172
+
173
+ def render_record_link
174
+ linked = @field.linked_record(@record)
175
+ return plain("—") if linked.nil?
176
+
177
+ target = @field.target_resource(linked)
178
+ label = @field.display_label(linked)
179
+ path = target && ruby_ui_admin.public_send("resources_#{target.singular_route_key}_path", linked)
180
+
181
+ if path
182
+ render RubyUI::InlineLink.new(href: path) { label.to_s }
183
+ else
184
+ plain label.to_s
185
+ end
186
+ end
187
+
188
+ def render_url
189
+ url = @field.value(@record)
190
+ return plain("—") if url.blank?
191
+
192
+ render RubyUI::InlineLink.new(href: url, target: @field.target) { @field.link_text(@record).to_s }
193
+ end
194
+
195
+ def render_badge
196
+ value = @field.value(@record)
197
+ return plain("—") if value.blank?
198
+
199
+ render RubyUI::Badge.new(variant: @field.variant_for(@record)) { value.to_s }
200
+ end
201
+
202
+ def render_status
203
+ value = @field.value(@record)
204
+ return plain("—") if value.blank?
205
+
206
+ render RubyUI::Badge.new(variant: @field.variant_for(@record)) { value.to_s }
207
+ end
208
+
209
+ def render_code
210
+ value = @field.value(@record)
211
+ lang = @field.respond_to?(:language) ? @field.language : nil
212
+ pre(class: "rounded-md bg-muted p-3 overflow-x-auto text-xs") do
213
+ # `language-<lang>` + data-language are the conventions syntax highlighters hook into.
214
+ code(class: ("language-#{lang}" if lang), data: {language: lang}) { value.to_s }
215
+ end
216
+ end
217
+
218
+ def render_key_value
219
+ value = @field.value(@record)
220
+ return plain("—") if value.blank?
221
+
222
+ dl(class: "text-sm") do
223
+ value.each do |key, val|
224
+ div(class: "flex gap-2") do
225
+ dt(class: "font-medium text-muted-foreground") { "#{key}:" }
226
+ dd { val.to_s }
227
+ end
228
+ end
229
+ end
230
+ end
231
+
232
+ def render_boolean_group
233
+ div(class: "flex flex-wrap gap-1") do
234
+ @field.group_options.each do |key, label_text|
235
+ on = @field.checked?(@record, key)
236
+ render RubyUI::Badge.new(variant: on ? :success : :gray) { label_text.to_s }
237
+ end
238
+ end
239
+ end
240
+
241
+ def render_file
242
+ return plain("—") unless @field.attached?(@record)
243
+
244
+ render_attachment(@field.attachment(@record))
245
+ end
246
+
247
+ def render_files
248
+ attachments = @field.attachments(@record)
249
+ return plain("—") if attachments.blank?
250
+
251
+ div(class: "flex flex-col gap-2") do
252
+ attachments.each { |attachment| render_attachment(attachment) }
253
+ end
254
+ end
255
+
256
+ # An attached file: images render as a thumbnail linking to the file; everything else is a
257
+ # download link with the filename. Both resolve the URL through the host's ActiveStorage routes.
258
+ def render_attachment(attachment)
259
+ blob = attachment.respond_to?(:blob) ? attachment.blob : attachment
260
+ return plain("—") unless blob
261
+
262
+ if blob.content_type.to_s.start_with?("image/")
263
+ a(href: attachment_url(blob), target: "_blank", rel: "noopener", class: "inline-block") do
264
+ img(src: attachment_url(blob), alt: blob.filename.to_s, **image_preview_attrs)
265
+ end
266
+ else
267
+ a(href: attachment_url(blob, disposition: "attachment"), class: "inline-flex items-center gap-1.5 text-sm text-primary hover:underline") do
268
+ render RubyUIAdmin::UI::Icon.new(:download, class: "size-4 shrink-0")
269
+ plain blob.filename.to_s
270
+ end
271
+ end
272
+ end
273
+
274
+ # Thumbnail sizing. With `preview_size:` on the field, the image is capped at that W×H (px)
275
+ # via an inline max-width/max-height (so large images scale down, preserving aspect). Inline
276
+ # style — not a Tailwind class — because the size is dynamic and wouldn't be in the CSS build.
277
+ def image_preview_attrs
278
+ size = @field.respond_to?(:preview_size) ? @field.preview_size : nil
279
+ return {class: "h-16 w-16 rounded-md border border-border object-cover"} unless size
280
+
281
+ w, h = Array(size).first(2)
282
+ h ||= w
283
+ {class: "rounded-md border border-border object-contain", style: "max-width: #{w.to_i}px; max-height: #{h.to_i}px"}
284
+ end
285
+
286
+ # Links to a record's show page through its registered resource, if any.
287
+ # A field's `use_resource:` overrides the default model→resource lookup.
288
+ def render_record_link_to(record, label)
289
+ rc = (@field.respond_to?(:use_resource) && @field.use_resource) ||
290
+ RubyUIAdmin.resource_manager.find_for_model(record.class)
291
+
292
+ if rc
293
+ path = ruby_ui_admin.public_send("resources_#{rc.singular_route_key}_path", record)
294
+ render RubyUI::InlineLink.new(href: path) { label.to_s }
295
+ else
296
+ plain label.to_s
297
+ end
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,111 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a GET form with one control per filter, above the index table.
6
+ class FilterBar < Phlex::HTML
7
+ include RubyUIAdmin::UI
8
+ include Translation
9
+
10
+ def initialize(filters:, values:, action_path:, scope_param: nil)
11
+ @filters = filters
12
+ @values = values || {}
13
+ @action_path = action_path
14
+ @scope_param = scope_param
15
+ end
16
+
17
+ def view_template
18
+ return if @filters.empty?
19
+
20
+ render RubyUI::Card.new(class: "mb-4") do
21
+ render RubyUI::CardContent.new(class: "pt-6") do
22
+ form(method: "get", action: @action_path, class: "flex flex-wrap items-end gap-4") do
23
+ # Preserve the active scope when applying filters.
24
+ input(type: "hidden", name: "scope", value: @scope_param) if @scope_param.present?
25
+
26
+ @filters.each { |filter| render_filter(filter) }
27
+ render_buttons
28
+ end
29
+ end
30
+ end
31
+ end
32
+
33
+ private
34
+
35
+ def render_filter(filter)
36
+ key = filter.param_key
37
+
38
+ div(class: "space-y-1 min-w-48") do
39
+ label(class: "text-xs font-medium text-muted-foreground") { filter.name }
40
+
41
+ case filter.type
42
+ when :select
43
+ render RubyUIAdmin::UI::Select.new(name: "filters[#{key}]", options: option_pairs(filter), selected: @values[key], include_blank: true)
44
+ when :multiple_select
45
+ render_checkboxes(filter, key, multiple: true)
46
+ when :boolean
47
+ if filter.options.present?
48
+ render_checkboxes(filter, key, multiple: false)
49
+ else
50
+ render RubyUIAdmin::UI::Select.new(name: "filters[#{key}]", options: [["Yes", "true"], ["No", "false"]], selected: @values[key], include_blank: true)
51
+ end
52
+ else
53
+ render RubyUI::Input.new(type: :text, name: "filters[#{key}]", value: @values[key])
54
+ end
55
+ end
56
+ end
57
+
58
+ # Renders the option set with RubyUI's Combobox (searchable popover with checkboxes),
59
+ # so filters with many options stay compact and match the rest of the RubyUI UI.
60
+ #
61
+ # `multiple: true` -> array value, name `filters[key][]`.
62
+ # `multiple: false` -> hash value (boolean filter), name `filters[key][optkey]` with a
63
+ # paired hidden "false" so unchecked boxes are still submitted.
64
+ def render_checkboxes(filter, key, multiple:)
65
+ selected = @values[key]
66
+
67
+ render RubyUI::Combobox.new(term: rua_t("index.selected_suffix")) do
68
+ render RubyUI::ComboboxTrigger.new(placeholder: rua_t("index.all"))
69
+ render RubyUI::ComboboxPopover.new do
70
+ render RubyUI::ComboboxSearchInput.new(placeholder: rua_t("index.search"))
71
+ render RubyUI::ComboboxList.new do
72
+ render(RubyUI::ComboboxEmptyState.new) { rua_t("index.no_results") }
73
+ option_pairs(filter).each do |(label_text, value)|
74
+ input_name = multiple ? "filters[#{key}][]" : "filters[#{key}][#{value}]"
75
+ render RubyUI::ComboboxItem.new do
76
+ input(type: :hidden, name: input_name, value: "false") unless multiple
77
+ render RubyUI::ComboboxCheckbox.new(
78
+ name: input_name,
79
+ value: multiple ? value.to_s : "true",
80
+ checked: checkbox_checked?(selected, value, multiple)
81
+ )
82
+ span { label_text }
83
+ end
84
+ end
85
+ end
86
+ end
87
+ end
88
+ end
89
+
90
+ def checkbox_checked?(selected, value, multiple)
91
+ if multiple
92
+ Array(selected).map(&:to_s).include?(value.to_s)
93
+ else
94
+ selected.is_a?(Hash) && selected[value.to_s].to_s == "true"
95
+ end
96
+ end
97
+
98
+ def option_pairs(filter)
99
+ options = filter.options
100
+ options.is_a?(Hash) ? options.map { |value, label| [label, value] } : Array(options)
101
+ end
102
+
103
+ def render_buttons
104
+ div(class: "flex items-center gap-2") do
105
+ render(RubyUI::Button.new(type: :submit, variant: :primary)) { rua_t("index.filter") }
106
+ a(href: @action_path, class: "text-sm text-muted-foreground hover:underline") { rua_t("index.clear") }
107
+ end
108
+ end
109
+ end
110
+ end
111
+ end