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.
- checksums.yaml +7 -0
- data/MIT-LICENSE +20 -0
- data/README.md +165 -0
- data/app/assets/stylesheets/ruby_ui_admin/application.tailwind.css +111 -0
- data/app/components/ruby_ui_admin/ui/base.rb +26 -0
- data/app/components/ruby_ui_admin/ui/icon.rb +79 -0
- data/app/components/ruby_ui_admin/ui/native_select.rb +37 -0
- data/app/components/ruby_ui_admin/ui/native_select_group.rb +18 -0
- data/app/components/ruby_ui_admin/ui/native_select_icon.rb +42 -0
- data/app/components/ruby_ui_admin/ui/native_select_option.rb +18 -0
- data/app/components/ruby_ui_admin/ui/pagination.rb +62 -0
- data/app/components/ruby_ui_admin/ui/pagination_content.rb +18 -0
- data/app/components/ruby_ui_admin/ui/pagination_ellipsis.rb +45 -0
- data/app/components/ruby_ui_admin/ui/pagination_item.rb +32 -0
- data/app/components/ruby_ui_admin/ui/select.rb +29 -0
- data/app/components/ruby_ui_admin/ui/toast_action.rb +30 -0
- data/app/components/ruby_ui_admin/ui/toast_cancel.rb +30 -0
- data/app/components/ruby_ui_admin/ui/toast_close.rb +43 -0
- data/app/components/ruby_ui_admin/ui/toast_description.rb +21 -0
- data/app/components/ruby_ui_admin/ui/toast_icon.rb +69 -0
- data/app/components/ruby_ui_admin/ui/toast_item.rb +75 -0
- data/app/components/ruby_ui_admin/ui/toast_region.rb +147 -0
- data/app/components/ruby_ui_admin/ui/toast_title.rb +21 -0
- data/app/components/ruby_ui_admin/ui.rb +12 -0
- data/app/components/ruby_ui_admin/views/action.rb +37 -0
- data/app/components/ruby_ui_admin/views/action_form.rb +104 -0
- data/app/components/ruby_ui_admin/views/action_trigger.rb +148 -0
- data/app/components/ruby_ui_admin/views/actions_menu.rb +34 -0
- data/app/components/ruby_ui_admin/views/base.rb +301 -0
- data/app/components/ruby_ui_admin/views/card_component.rb +69 -0
- data/app/components/ruby_ui_admin/views/dashboard.rb +40 -0
- data/app/components/ruby_ui_admin/views/docs.rb +91 -0
- data/app/components/ruby_ui_admin/views/field_input.rb +156 -0
- data/app/components/ruby_ui_admin/views/field_value.rb +301 -0
- data/app/components/ruby_ui_admin/views/filter_bar.rb +111 -0
- data/app/components/ruby_ui_admin/views/form.rb +151 -0
- data/app/components/ruby_ui_admin/views/home.rb +35 -0
- data/app/components/ruby_ui_admin/views/index.rb +324 -0
- data/app/components/ruby_ui_admin/views/path_helpers.rb +36 -0
- data/app/components/ruby_ui_admin/views/rails_helpers.rb +15 -0
- data/app/components/ruby_ui_admin/views/show.rb +77 -0
- data/app/components/ruby_ui_admin/views/show_fields.rb +37 -0
- data/app/components/ruby_ui_admin/views/show_tab.rb +58 -0
- data/app/components/ruby_ui_admin/views/structure_renderer.rb +148 -0
- data/app/components/ruby_ui_admin/views/translation.rb +12 -0
- data/app/controllers/ruby_ui_admin/actions_controller.rb +162 -0
- data/app/controllers/ruby_ui_admin/application_controller.rb +81 -0
- data/app/controllers/ruby_ui_admin/dashboards_controller.rb +12 -0
- data/app/controllers/ruby_ui_admin/docs_controller.rb +166 -0
- data/app/controllers/ruby_ui_admin/home_controller.rb +18 -0
- data/app/controllers/ruby_ui_admin/resources_controller.rb +259 -0
- data/config/locales/ruby_ui_admin.en.yml +59 -0
- data/config/locales/ruby_ui_admin.pt-BR.yml +59 -0
- data/config/routes/dynamic_routes.rb +11 -0
- data/config/routes.rb +23 -0
- data/docs/README.md +53 -0
- data/docs/actions/overview.md +112 -0
- data/docs/authorization/action-policy.md +72 -0
- data/docs/authorization/adapters.md +101 -0
- data/docs/authorization/explicit-mode.md +45 -0
- data/docs/authorization/field-authorization.md +53 -0
- data/docs/authorization/policies.md +62 -0
- data/docs/authorization/scopes.md +48 -0
- data/docs/customization/controllers.md +62 -0
- data/docs/customization/ejecting.md +55 -0
- data/docs/customization/javascript.md +161 -0
- data/docs/customization/theming-rubyui.md +80 -0
- data/docs/dashboards/overview.md +65 -0
- data/docs/fields/overview.md +94 -0
- data/docs/filters/overview.md +73 -0
- data/docs/generators-and-tasks/overview.md +42 -0
- data/docs/getting-started/authentication.md +98 -0
- data/docs/getting-started/configuration.md +174 -0
- data/docs/getting-started/installation.md +201 -0
- data/docs/getting-started/internationalization.md +50 -0
- data/docs/getting-started/practical-guide.md +310 -0
- data/docs/resources/associations.md +66 -0
- data/docs/resources/index-customization.md +68 -0
- data/docs/resources/overview.md +50 -0
- data/docs/resources/scopes.md +54 -0
- data/docs/resources/tabs-panels.md +65 -0
- data/lib/generators/ruby_ui_admin/action/action_generator.rb +21 -0
- data/lib/generators/ruby_ui_admin/action/templates/action.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/assets/assets_generator.rb +71 -0
- data/lib/generators/ruby_ui_admin/card/card_generator.rb +28 -0
- data/lib/generators/ruby_ui_admin/card/templates/chart_card.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/card/templates/metric_card.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/card/templates/partial_card.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/components/components_generator.rb +43 -0
- data/lib/generators/ruby_ui_admin/controller/controller_generator.rb +30 -0
- data/lib/generators/ruby_ui_admin/controller/templates/controller.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/dashboard/dashboard_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/dashboard/templates/dashboard.rb.tt +14 -0
- data/lib/generators/ruby_ui_admin/eject/eject_generator.rb +55 -0
- data/lib/generators/ruby_ui_admin/filter/filter_generator.rb +28 -0
- data/lib/generators/ruby_ui_admin/filter/templates/boolean_filter.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/filter/templates/select_filter.rb.tt +19 -0
- data/lib/generators/ruby_ui_admin/filter/templates/text_filter.rb.tt +13 -0
- data/lib/generators/ruby_ui_admin/install/install_generator.rb +62 -0
- data/lib/generators/ruby_ui_admin/install/templates/initializer.rb.tt +41 -0
- data/lib/generators/ruby_ui_admin/locales/locales_generator.rb +22 -0
- data/lib/generators/ruby_ui_admin/policy/policy_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/policy/templates/policy.rb.tt +24 -0
- data/lib/generators/ruby_ui_admin/resource/resource_generator.rb +84 -0
- data/lib/generators/ruby_ui_admin/resource/templates/resource.rb.tt +15 -0
- data/lib/generators/ruby_ui_admin/scope/scope_generator.rb +18 -0
- data/lib/generators/ruby_ui_admin/scope/templates/scope.rb.tt +11 -0
- data/lib/ruby_ui_admin/authorization/action_policy_adapter.rb +69 -0
- data/lib/ruby_ui_admin/authorization/adapter.rb +80 -0
- data/lib/ruby_ui_admin/authorization/can_can_can_adapter.rb +70 -0
- data/lib/ruby_ui_admin/authorization/pundit_adapter.rb +86 -0
- data/lib/ruby_ui_admin/authorization.rb +40 -0
- data/lib/ruby_ui_admin/base_action.rb +211 -0
- data/lib/ruby_ui_admin/base_dashboard.rb +70 -0
- data/lib/ruby_ui_admin/base_policy.rb +38 -0
- data/lib/ruby_ui_admin/base_resource.rb +503 -0
- data/lib/ruby_ui_admin/cards/base_card.rb +59 -0
- data/lib/ruby_ui_admin/cards/chart_card.rb +16 -0
- data/lib/ruby_ui_admin/cards/metric_card.rb +22 -0
- data/lib/ruby_ui_admin/cards/partial_card.rb +14 -0
- data/lib/ruby_ui_admin/configuration.rb +177 -0
- data/lib/ruby_ui_admin/current.rb +20 -0
- data/lib/ruby_ui_admin/dashboard_manager.rb +52 -0
- data/lib/ruby_ui_admin/engine.rb +80 -0
- data/lib/ruby_ui_admin/execution_context.rb +36 -0
- data/lib/ruby_ui_admin/fields/association_field.rb +67 -0
- data/lib/ruby_ui_admin/fields/badge_field.rb +19 -0
- data/lib/ruby_ui_admin/fields/base_field.rb +208 -0
- data/lib/ruby_ui_admin/fields/belongs_to_field.rb +49 -0
- data/lib/ruby_ui_admin/fields/boolean_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/boolean_group_field.rb +54 -0
- data/lib/ruby_ui_admin/fields/code_field.rb +18 -0
- data/lib/ruby_ui_admin/fields/date_field.rb +34 -0
- data/lib/ruby_ui_admin/fields/date_time_field.rb +37 -0
- data/lib/ruby_ui_admin/fields/field_manager.rb +27 -0
- data/lib/ruby_ui_admin/fields/file_field.rb +67 -0
- data/lib/ruby_ui_admin/fields/files_field.rb +62 -0
- data/lib/ruby_ui_admin/fields/has_and_belongs_to_many_field.rb +9 -0
- data/lib/ruby_ui_admin/fields/has_many_field.rb +24 -0
- data/lib/ruby_ui_admin/fields/has_one_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/hidden_field.rb +14 -0
- data/lib/ruby_ui_admin/fields/id_field.rb +19 -0
- data/lib/ruby_ui_admin/fields/key_value_field.rb +41 -0
- data/lib/ruby_ui_admin/fields/number_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/password_field.rb +21 -0
- data/lib/ruby_ui_admin/fields/record_link_field.rb +45 -0
- data/lib/ruby_ui_admin/fields/select_field.rb +71 -0
- data/lib/ruby_ui_admin/fields/status_field.rb +21 -0
- data/lib/ruby_ui_admin/fields/text_field.rb +14 -0
- data/lib/ruby_ui_admin/fields/textarea_field.rb +13 -0
- data/lib/ruby_ui_admin/fields/url_field.rb +21 -0
- data/lib/ruby_ui_admin/filters/base_filter.rb +81 -0
- data/lib/ruby_ui_admin/filters/boolean_filter.rb +9 -0
- data/lib/ruby_ui_admin/filters/multiple_select_filter.rb +11 -0
- data/lib/ruby_ui_admin/filters/select_filter.rb +9 -0
- data/lib/ruby_ui_admin/filters/text_filter.rb +9 -0
- data/lib/ruby_ui_admin/menu/builder.rb +94 -0
- data/lib/ruby_ui_admin/resource_manager.rb +82 -0
- data/lib/ruby_ui_admin/scopes/base_scope.rb +107 -0
- data/lib/ruby_ui_admin/services/authorization_service.rb +38 -0
- data/lib/ruby_ui_admin/structure.rb +40 -0
- data/lib/ruby_ui_admin/tailwind_source.rb +65 -0
- data/lib/ruby_ui_admin/version.rb +5 -0
- data/lib/ruby_ui_admin/view.rb +34 -0
- data/lib/ruby_ui_admin.rb +110 -0
- data/lib/tasks/ruby_ui_admin.rake +71 -0
- data/public/ruby-ui-admin-assets/application.css +2 -0
- data/public/ruby-ui-admin-assets/controllers/index.js +36 -0
- data/public/ruby-ui-admin-assets/controllers/rua--bulk-select_controller.js +19 -0
- data/public/ruby-ui-admin-assets/controllers/rua--confirm_controller.js +51 -0
- data/public/ruby-ui-admin-assets/controllers/rua--dialog_controller.js +55 -0
- data/public/ruby-ui-admin-assets/controllers/rua--row-link_controller.js +16 -0
- data/public/ruby-ui-admin-assets/controllers/rua--tabs_controller.js +36 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--combobox_controller.js +197 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_content_controller.js +7 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sheet_controller.js +15 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--sidebar_controller.js +67 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--toast_controller.js +151 -0
- data/public/ruby-ui-admin-assets/controllers/ruby_ui--toaster_controller.js +314 -0
- data/public/ruby-ui-admin-assets/vendor/stimulus.js +2567 -0
- data/public/ruby-ui-admin-assets/vendor/turbo.js +7178 -0
- 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
|