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,503 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/core_ext/class/attribute"
4
+ require "active_support/core_ext/string"
5
+ require "active_support/core_ext/object/blank"
6
+
7
+ module RubyUIAdmin
8
+ # Base class for all resources. Provides the declarative resource DSL
9
+ # (fields, panels, tabs, scopes, actions and filters).
10
+ class BaseResource
11
+ class_attribute :title, instance_accessor: false, default: :id
12
+ class_attribute :includes, instance_accessor: false, default: []
13
+ class_attribute :authorization_policy, instance_accessor: false, default: nil
14
+ class_attribute :index_query, instance_accessor: false, default: nil
15
+ class_attribute :index_controls, instance_accessor: false, default: nil
16
+ class_attribute :visible_on_sidebar, instance_accessor: false, default: true
17
+ class_attribute :record_selector, instance_accessor: false, default: true
18
+ class_attribute :default_view_type, instance_accessor: false, default: :table
19
+ class_attribute :description, instance_accessor: false, default: nil
20
+ # When true, the index hides the "All" scope tab (use with a default scope).
21
+ class_attribute :remove_scope_all, instance_accessor: false, default: false
22
+ # Optional proc `->(record) { ... }` rendering extra per-row controls on the index.
23
+ class_attribute :row_controls, instance_accessor: false, default: nil
24
+ # Layout for the per-row controls cell: `{ placement: :left|:right, float: bool, show_on_hover: bool }`.
25
+ class_attribute :row_controls_config, instance_accessor: false, default: {}
26
+ # When true, the index paginates without a COUNT query (pagy countless) — for big tables.
27
+ class_attribute :countless, instance_accessor: false, default: false
28
+
29
+ class << self
30
+ def model_class=(value)
31
+ @model_class = value.is_a?(String) ? value.constantize : value
32
+ end
33
+
34
+ def model_class
35
+ return @model_class if defined?(@model_class) && @model_class
36
+
37
+ @model_class = derive_model_class
38
+ end
39
+
40
+ def derive_model_class
41
+ name.to_s.demodulize.constantize
42
+ rescue NameError
43
+ nil
44
+ end
45
+
46
+ def abstract?
47
+ self == RubyUIAdmin::BaseResource
48
+ end
49
+
50
+ # "buyer"
51
+ def resource_name
52
+ name.to_s.demodulize.underscore
53
+ end
54
+
55
+ # "buyers"
56
+ def resource_name_plural
57
+ resource_name.pluralize
58
+ end
59
+
60
+ # Route key used by the dynamic router and path helpers, e.g. "buyers".
61
+ # Derived from the RESOURCE name (not the model) so multiple resources backed by the
62
+ # same model get distinct routes. Override per-resource if needed.
63
+ def route_key
64
+ resource_name_plural
65
+ end
66
+
67
+ def singular_route_key
68
+ resource_name
69
+ end
70
+
71
+ def navigation_label
72
+ resource_name_plural.titleize
73
+ end
74
+ end
75
+
76
+ attr_accessor :record, :view, :user, :params
77
+
78
+ def initialize(record: nil, view: nil, user: nil, params: nil)
79
+ @record = record
80
+ @view = view
81
+ @user = user
82
+ @params = params
83
+ end
84
+
85
+ def hydrate(record: nil, view: nil, user: nil, params: nil)
86
+ @record = record unless record.nil?
87
+ @view = view unless view.nil?
88
+ @user = user unless user.nil?
89
+ @params = params unless params.nil?
90
+ self
91
+ end
92
+
93
+ def model_class = self.class.model_class
94
+ def route_key = self.class.route_key
95
+ def singular_route_key = self.class.singular_route_key
96
+ def resource_name = self.class.resource_name
97
+ # Alias so resource DSL (e.g. field defaults/options evaluated eagerly) can use
98
+ # `current_user`. `user` is set on hydrate.
99
+ def current_user = user
100
+ def navigation_label = self.class.navigation_label
101
+
102
+ # ---- Fields DSL ----
103
+
104
+ def field(id, as: :text, **options, &block)
105
+ field_class = Fields.field_class_for(as)
106
+ instance = field_class.new(id, resource: self, **options, &block)
107
+ current_collector << instance
108
+ instance
109
+ end
110
+
111
+ # Groups fields in a card-like panel. Accepts the name either positionally (`panel "X"`)
112
+ # or as a keyword (`panel name: "X"`) for Avo compatibility.
113
+ def panel(name = nil, **opts, &block)
114
+ name = opts[:name] if name.nil?
115
+ container = Structure::Panel.new(name)
116
+ current_collector << container
117
+ within(container.items, &block)
118
+ container
119
+ end
120
+
121
+ # Declares a set of tabs. Use `tab` inside the block.
122
+ def tabs(&block)
123
+ group = Structure::TabGroup.new
124
+ current_collector << group
125
+ within(group.tabs, &block)
126
+ group
127
+ end
128
+
129
+ # A single tab inside a `tabs` block.
130
+ def tab(name, description: nil, &block)
131
+ container = Structure::Tab.new(name, description: description)
132
+ current_collector << container
133
+ within(container.items, &block)
134
+ container
135
+ end
136
+
137
+ def divider(**); end
138
+
139
+ # Auto-declares fields from the model's database columns.
140
+ # discover_columns(only: %i[id name], except: %i[token])
141
+ def discover_columns(only: nil, except: [])
142
+ except = Array(except).map(&:to_sym)
143
+ only = only && Array(only).map(&:to_sym)
144
+
145
+ model_class.columns.each do |column|
146
+ name = column.name.to_sym
147
+ next if except.include?(name)
148
+ next if only && !only.include?(name)
149
+ next if name.to_s.end_with?("_id") && only.nil? # foreign keys -> use discover_associations
150
+
151
+ field name, as: field_type_for_column(column, name)
152
+ end
153
+ end
154
+
155
+ # Auto-declares fields from the model's associations.
156
+ def discover_associations(only: nil, except: [])
157
+ except = Array(except).map(&:to_sym)
158
+ only = only && Array(only).map(&:to_sym)
159
+
160
+ model_class.reflect_on_all_associations.each do |association|
161
+ name = association.name
162
+ next if except.include?(name)
163
+ next if only && !only.include?(name)
164
+
165
+ type = association_field_type(association)
166
+ field name, as: type if type
167
+ end
168
+ end
169
+
170
+ # Overridden by subclasses to declare fields.
171
+ def fields; end
172
+
173
+ # The structured item tree (fields + panels + tabs) for the given view.
174
+ def field_structure(view: nil)
175
+ build_items
176
+ filter_item_tree(@root, view)
177
+ end
178
+
179
+ # Flat list of field leaves visible in the given view (used by the index,
180
+ # param permitting and record filling). Filters by view visibility AND by
181
+ # field-level authorization.
182
+ def get_fields(view: nil)
183
+ build_items
184
+ leaves = flatten_fields(@root)
185
+ leaves = leaves.select { |field| field.visible_in_view?(view) && field.visible?(view: view) && field_authorized?(field, view) } if view
186
+ leaves
187
+ end
188
+
189
+ def find_field(id)
190
+ get_fields.find { |field| field.id == id.to_sym }
191
+ end
192
+
193
+ # Applies field-level `default:` values to a new record (literal or proc).
194
+ def apply_defaults(record)
195
+ get_fields(view: :new).each do |field|
196
+ next unless field.has_default?
197
+
198
+ field.fill_value(record, field.default_value(record))
199
+ end
200
+
201
+ record
202
+ end
203
+
204
+ # Whether the current user may see this field in the given view.
205
+ #
206
+ # A field is visible unless the resource's policy explicitly defines a matching
207
+ # rule that returns false. Candidate rules, most specific first:
208
+ # index -> index_<id>? ; show -> show_<id>? ; new -> new_<id>?/edit_<id>? ;
209
+ # edit -> edit_<id>? ; then the generic view_<id>? for any view.
210
+ # An undefined rule means "no opinion" and the field stays visible (so enabling
211
+ # explicit_authorization does NOT silently hide every unconfigured field).
212
+ def field_authorized?(field, view)
213
+ return true unless RubyUIAdmin.configuration.authorization_enabled?
214
+
215
+ policy = self.class.authorization_policy
216
+ return true if policy.nil?
217
+
218
+ service = Services::AuthorizationService.new(user, @record, policy_class: policy)
219
+ rule = candidate_field_rules(field.id, view).find { |candidate| service.defines_rule?(candidate) }
220
+ return true if rule.nil?
221
+
222
+ service.authorize_action(rule, record: @record, raise_exception: false)
223
+ end
224
+
225
+ # ---- Scopes DSL ----
226
+
227
+ # `scope Klass` or `scope Klass, default: true` (mark default at attachment time).
228
+ def scope(klass, **options)
229
+ scope_items << {klass: klass, options: options}
230
+ end
231
+
232
+ def scope_items
233
+ @scope_items ||= []
234
+ end
235
+
236
+ # Overridden by subclasses to declare named index scopes.
237
+ def scopes; end
238
+
239
+ def get_scopes
240
+ @scope_items = []
241
+ @remove_scope_all_called = false
242
+ scopes
243
+ scope_items.map do |entry|
244
+ entry[:klass].new(params: params || {}, default_override: entry[:options][:default])
245
+ end
246
+ end
247
+
248
+ # Callable inside `def scopes` to hide the "All" tab. The class
249
+ # attribute `self.remove_scope_all = true` also works.
250
+ def remove_scope_all
251
+ @remove_scope_all_called = true
252
+ end
253
+
254
+ def remove_scope_all?
255
+ get_scopes # ensures `scopes` ran (which may call `remove_scope_all`)
256
+ @remove_scope_all_called || self.class.remove_scope_all
257
+ end
258
+
259
+ def scopes?
260
+ get_scopes.any?
261
+ end
262
+
263
+ def find_scope(key)
264
+ get_scopes.find { |scope| scope.key == key.to_s }
265
+ end
266
+
267
+ def default_scope_entry
268
+ get_scopes.find(&:default?)
269
+ end
270
+
271
+ private
272
+
273
+ COLUMN_TYPE_MAP = {
274
+ string: :text,
275
+ text: :textarea,
276
+ integer: :number,
277
+ float: :number,
278
+ decimal: :number,
279
+ boolean: :boolean,
280
+ date: :date,
281
+ datetime: :date_time,
282
+ timestamp: :date_time,
283
+ json: :code,
284
+ jsonb: :code
285
+ }.freeze
286
+
287
+ NAME_TYPE_MAP = {
288
+ "id" => :id,
289
+ "email" => :text,
290
+ "password" => :password,
291
+ "password_digest" => :password
292
+ }.freeze
293
+
294
+ def field_type_for_column(column, name)
295
+ return :id if name == :id
296
+ return NAME_TYPE_MAP.fetch(name.to_s) if NAME_TYPE_MAP.key?(name.to_s)
297
+
298
+ COLUMN_TYPE_MAP.fetch(column.type, :text)
299
+ end
300
+
301
+ def association_field_type(association)
302
+ case association.macro
303
+ when :belongs_to then :belongs_to
304
+ when :has_one then :has_one
305
+ when :has_many then :has_many
306
+ when :has_and_belongs_to_many then :has_and_belongs_to_many
307
+ end
308
+ end
309
+
310
+ def build_items
311
+ @root = []
312
+ @collector_stack = [@root]
313
+ fields
314
+ @root
315
+ end
316
+
317
+ def current_collector
318
+ (@collector_stack ||= [(@root ||= [])]).last
319
+ end
320
+
321
+ def within(collection)
322
+ @collector_stack.push(collection)
323
+ yield if block_given?
324
+ ensure
325
+ @collector_stack.pop
326
+ end
327
+
328
+ def flatten_fields(items)
329
+ items.flat_map do |item|
330
+ if item.is_a?(Fields::BaseField)
331
+ [item]
332
+ elsif item.respond_to?(:items)
333
+ flatten_fields(item.items)
334
+ else
335
+ []
336
+ end
337
+ end
338
+ end
339
+
340
+ # Candidate field-authorization rules for a field id in a given view, most
341
+ # specific first, ending with the generic `view_<id>?`.
342
+ def candidate_field_rules(id, view)
343
+ case view&.to_sym
344
+ when :index then [:"index_#{id}?", :"view_#{id}?"]
345
+ when :show then [:"show_#{id}?", :"view_#{id}?"]
346
+ when :new then [:"new_#{id}?", :"edit_#{id}?", :"view_#{id}?"]
347
+ when :edit then [:"edit_#{id}?", :"view_#{id}?"]
348
+ else [:"view_#{id}?"]
349
+ end
350
+ end
351
+
352
+ # Returns a copy of the tree with field leaves filtered by view visibility and
353
+ # field-level authorization.
354
+ def filter_item_tree(items, view)
355
+ items.each_with_object([]) do |item, result|
356
+ if item.is_a?(Fields::BaseField)
357
+ result << item if (view.nil? || item.visible_in_view?(view)) && item.visible?(view: view) && field_authorized?(item, view)
358
+ elsif item.is_a?(Structure::TabGroup)
359
+ # A child may be a Tab, or a bare field/panel placed directly
360
+ # inside `tabs do`. Normalize each into [name, description, children] so bare
361
+ # children become implicit tabs (labelled by the field/panel name).
362
+ normalized = item.tabs.map do |child|
363
+ if child.is_a?(Structure::Tab)
364
+ [child.name, child.description, filter_item_tree(child.items, view)]
365
+ elsif child.is_a?(Structure::Panel)
366
+ [child.name, nil, filter_item_tree(child.items, view)]
367
+ elsif child.is_a?(Fields::BaseField)
368
+ keep = (view.nil? || child.visible_in_view?(view)) && child.visible?(view: view) && field_authorized?(child, view)
369
+ [child.name, nil, keep ? [child] : []]
370
+ else
371
+ [nil, nil, []]
372
+ end
373
+ end
374
+ kept = normalized.reject { |(_name, _desc, children)| children.empty? }
375
+ unless kept.empty?
376
+ group = Structure::TabGroup.new
377
+ kept.each do |(name, description, children)|
378
+ new_tab = Structure::Tab.new(name, description: description)
379
+ children.each { |child| new_tab.items << child }
380
+ group.tabs << new_tab
381
+ end
382
+ result << group
383
+ end
384
+ elsif item.is_a?(Structure::Panel)
385
+ children = filter_item_tree(item.items, view)
386
+ unless children.empty?
387
+ panel = Structure::Panel.new(item.name)
388
+ children.each { |child| panel.items << child }
389
+ result << panel
390
+ end
391
+ end
392
+ end
393
+ end
394
+
395
+ public
396
+
397
+ # ---- Actions DSL ----
398
+
399
+ def action(klass, **options)
400
+ action_items << {klass: klass, options: options}
401
+ end
402
+
403
+ def action_items
404
+ @action_items ||= []
405
+ end
406
+
407
+ def actions; end
408
+
409
+ def get_actions
410
+ @action_items = []
411
+ actions
412
+ action_items
413
+ end
414
+
415
+ # Instantiated actions visible in the given view (optionally for a record).
416
+ def actions_for(view: nil, record: nil)
417
+ get_actions.filter_map do |entry|
418
+ action = instantiate_action(entry, view: view, record: record)
419
+ action if action.visible_in_view?(view)
420
+ end
421
+ end
422
+
423
+ def find_action_entry(key)
424
+ get_actions.find { |entry| entry[:klass].action_key == key.to_s }
425
+ end
426
+
427
+ def instantiate_action(entry, view: nil, record: nil, records: nil)
428
+ records = records || (record ? [record] : [])
429
+ # Expose the acted-on record as `resource.record` inside the action's `fields`
430
+ # (a single-record/show action sees its record; a bulk action sees the
431
+ # first selected record as a representative).
432
+ hydrate(record: records.first) if records.first
433
+
434
+ action = entry[:klass].new(
435
+ view: view,
436
+ resource: self,
437
+ user: user,
438
+ arguments: entry.dig(:options, :arguments) || {}
439
+ )
440
+ action.records = records
441
+ action
442
+ end
443
+
444
+ # ---- Filters DSL ----
445
+
446
+ def filter(klass, **options)
447
+ filter_items << {klass: klass, options: options}
448
+ end
449
+
450
+ def filter_items
451
+ @filter_items ||= []
452
+ end
453
+
454
+ def filters; end
455
+
456
+ def get_filters
457
+ @filter_items = []
458
+ filters
459
+ filter_items.map do |entry|
460
+ entry[:klass].new(arguments: entry.dig(:options, :arguments) || {})
461
+ end
462
+ end
463
+
464
+ # ---- Querying ----
465
+
466
+ def base_query
467
+ query = model_class.all
468
+ query = query.includes(*self.class.includes) if self.class.includes.present?
469
+
470
+ if self.class.index_query
471
+ query = ExecutionContext.new(
472
+ target: self.class.index_query,
473
+ query: query,
474
+ params: params
475
+ ).handle
476
+ end
477
+
478
+ query
479
+ end
480
+
481
+ def find_record(id, query: nil)
482
+ (query || base_query).find(id)
483
+ end
484
+
485
+ def new_record
486
+ model_class.new
487
+ end
488
+
489
+ def record_title(target = record)
490
+ return nil if target.nil?
491
+
492
+ title_attr = self.class.title
493
+
494
+ if title_attr.respond_to?(:call)
495
+ ExecutionContext.new(target: title_attr, record: target, resource: self).handle
496
+ elsif target.respond_to?(title_attr)
497
+ target.public_send(title_attr)
498
+ else
499
+ "#{model_class.model_name.human} ##{target.id}"
500
+ end
501
+ end
502
+ end
503
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Cards
5
+ # Base class for dashboard cards. Subclasses override `query` to compute their
6
+ # value/data. Mirrors the Filters pattern: base types live here and host cards
7
+ # (RubyUIAdmin::Cards::*) inherit them.
8
+ class BaseCard
9
+ class << self
10
+ attr_writer :label, :width
11
+
12
+ def label
13
+ (defined?(@label) && @label) || to_s.demodulize.titleize
14
+ end
15
+
16
+ def width
17
+ (defined?(@width) && @width) || 1
18
+ end
19
+
20
+ # Inherited down the chain (subclasses of MetricCard are :metric, etc.).
21
+ def card_type
22
+ return @card_type if defined?(@card_type) && @card_type
23
+ return superclass.card_type if superclass.respond_to?(:card_type)
24
+
25
+ nil
26
+ end
27
+
28
+ def register_type(type)
29
+ @card_type = type.to_sym
30
+ end
31
+ end
32
+
33
+ attr_reader :options
34
+
35
+ def initialize(**options)
36
+ @options = options
37
+ end
38
+
39
+ def label
40
+ self.class.label
41
+ end
42
+
43
+ def width
44
+ self.class.width
45
+ end
46
+
47
+ def type
48
+ self.class.card_type
49
+ end
50
+
51
+ def card_id
52
+ self.class.to_s.demodulize.underscore
53
+ end
54
+
55
+ # Override in subclasses to compute the card's value/data.
56
+ def query; end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Cards
5
+ # Displays a simple bar chart from a {label => number} Hash returned by `query`.
6
+ # (No charting JS dependency — rendered as horizontal bars.)
7
+ class ChartCard < BaseCard
8
+ register_type :chart
9
+
10
+ def data
11
+ result = query
12
+ result.is_a?(Hash) ? result : {}
13
+ end
14
+ end
15
+ end
16
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Cards
5
+ # Displays a single value (e.g. a count). Override `query` to return it.
6
+ class MetricCard < BaseCard
7
+ register_type :metric
8
+
9
+ def value
10
+ query
11
+ end
12
+
13
+ def prefix
14
+ options[:prefix]
15
+ end
16
+
17
+ def suffix
18
+ options[:suffix]
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Cards
5
+ # Renders arbitrary HTML returned by `query` (or `body`).
6
+ class PartialCard < BaseCard
7
+ register_type :partial
8
+
9
+ def body
10
+ query
11
+ end
12
+ end
13
+ end
14
+ end