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,58 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a single show tab's content as a standalone fragment (no page chrome), fetched
6
+ # lazily by the JS when the tab is first opened. `tab_index` is the tab's flat position
7
+ # across all tab groups on the show view — the same index the full page assigned.
8
+ class ShowTab < Phlex::HTML
9
+ include RubyUIAdmin::UI
10
+ include RailsHelpers
11
+ include PathHelpers
12
+ include Translation
13
+ include StructureRenderer
14
+ include ShowFields
15
+
16
+ def initialize(resource:, record:, tab_index:)
17
+ @resource = resource
18
+ @record = record
19
+ @tab_index = tab_index
20
+ end
21
+
22
+ def view_template
23
+ tab = flat_tabs[@tab_index]
24
+ return if tab.nil?
25
+
26
+ # Wrapped in the matching `<turbo-frame>` so the lazy frame on the show page swaps this in.
27
+ # `target="_top"` so links/forms inside the tab break out of the frame (else Turbo renders
28
+ # "Content missing" navigating within the frame to a page that has no matching frame).
29
+ tag(:turbo_frame, id: "rua-tab-frame-#{@tab_index}", target: "_top") do
30
+ render_tab_content(tab)
31
+ end
32
+ end
33
+
34
+ private
35
+
36
+ # Walks the show structure in the same depth-first order the full page renders, collecting
37
+ # every tab so `@tab_index` maps to the same tab the page's flat counter assigned.
38
+ def flat_tabs
39
+ tabs = []
40
+ collect_tabs(@resource.field_structure(view: :show), tabs)
41
+ tabs
42
+ end
43
+
44
+ def collect_tabs(items, acc)
45
+ items.each do |item|
46
+ if item.is_a?(RubyUIAdmin::Structure::Panel)
47
+ collect_tabs(item.items, acc)
48
+ elsif item.is_a?(RubyUIAdmin::Structure::TabGroup)
49
+ item.tabs.each do |tab|
50
+ acc << tab
51
+ collect_tabs(tab.items, acc)
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Walks a resource's field structure (fields + panels + tabs) and renders it.
6
+ # The includer defines `render_field(field)` to render a single field row/input.
7
+ # Bare top-level fields are grouped into a default card.
8
+ module StructureRenderer
9
+ # `wrap_bare:` controls whether loose top-level fields get their own card. It's true
10
+ # at the top level (so naked fields aren't unframed) and false inside a panel/tab —
11
+ # which already provide a card — to avoid a redundant nested card layer.
12
+ def render_structure(items, wrap_bare: true)
13
+ buffer = []
14
+
15
+ flush = lambda do
16
+ next if buffer.empty?
17
+
18
+ group = buffer.dup
19
+ buffer.clear
20
+ if wrap_bare
21
+ render RubyUI::Card.new do
22
+ render RubyUI::CardContent.new(class: "pt-6 space-y-4") do
23
+ group.each { |field| render_field(field) }
24
+ end
25
+ end
26
+ else
27
+ group.each { |field| render_field(field) }
28
+ end
29
+ end
30
+
31
+ items.each do |item|
32
+ if item.is_a?(RubyUIAdmin::Fields::BaseField)
33
+ buffer << item
34
+ elsif item.is_a?(RubyUIAdmin::Structure::Panel)
35
+ flush.call
36
+ render_panel(item)
37
+ elsif item.is_a?(RubyUIAdmin::Structure::TabGroup)
38
+ flush.call
39
+ render_tab_group(item)
40
+ end
41
+ end
42
+
43
+ flush.call
44
+ end
45
+
46
+ private
47
+
48
+ def render_panel(panel)
49
+ render RubyUI::Card.new(class: "mb-6") do
50
+ # Show the panel title, except when it merely repeats the name of the tab that
51
+ # contains it — that's pure duplication (the tab bar already labels the section),
52
+ # and the empty header would just push the content down. Distinct names still render.
53
+ if panel.name && panel.name.to_s.strip != @enclosing_tab_name.to_s.strip
54
+ render RubyUI::CardHeader.new do
55
+ render RubyUI::CardTitle.new { panel.name }
56
+ end
57
+ end
58
+ render RubyUI::CardContent.new(class: "pt-6 space-y-4") do
59
+ render_structure(panel.items, wrap_bare: false)
60
+ end
61
+ end
62
+ end
63
+
64
+ # Tabs render as a progressive enhancement, driven by the `rua--tabs` Stimulus controller:
65
+ # * Without JS: the tab bar is hidden and the panels stack (each as a titled card).
66
+ # * With JS: the controller reveals the tab bar, hides the per-panel headings, and shows
67
+ # only the active panel. Keys are matched via `data-rua-tab` / `data-rua-tab-panel`.
68
+ def render_tab_group(group)
69
+ @tab_group_seq = (@tab_group_seq || 0) + 1
70
+ base = "rua-tab-#{@tab_group_seq}"
71
+
72
+ div(data: {controller: "rua--tabs"}, class: "mb-6") do
73
+ nav(data: {rua__tabs_target: "nav"}, hidden: true, class: "flex items-center gap-1 border-b border-border mb-4") do
74
+ group.tabs.each_with_index do |tab, index|
75
+ button(
76
+ type: "button",
77
+ data: {rua__tabs_target: "tab", rua_tab: "#{base}-#{index}", action: "click->rua--tabs#show"},
78
+ class: "px-3 py-2 -mb-px border-b-2 border-transparent text-sm font-medium text-muted-foreground hover:text-foreground"
79
+ ) { tab.name }
80
+ end
81
+ end
82
+
83
+ group.tabs.each_with_index do |tab, index|
84
+ # Flat index across all tab groups on the page — the lazy fragment endpoint uses it
85
+ # to locate this exact tab. Incremented for every tab (eager and lazy) so it stays
86
+ # aligned with the controller's identical walk of the structure.
87
+ flat_index = (@tab_flat_seq ||= -1) + 1
88
+ @tab_flat_seq = flat_index
89
+ # Only non-active tabs (index > 0 within their group) load lazily; the first tab is
90
+ # the active one and always renders eagerly so there's no flash/spinner up front.
91
+ lazy_url = (lazy_tabs? && index.positive?) ? tab_fragment_url(flat_index) : nil
92
+
93
+ panel_data = {rua__tabs_target: "panel", rua_tab_panel: "#{base}-#{index}"}
94
+
95
+ # The tab is just the switchable container — its children (panels) provide the
96
+ # cards, so we don't wrap the tab body in its own Card (which caused a redundant
97
+ # nested-panel layer). `wrap_bare: true` still cards loose fields inside a tab.
98
+ div(data: panel_data, class: "space-y-4") do
99
+ # Heading shown when stacked (no JS); the controller hides it since the tab bar
100
+ # already labels the section.
101
+ h3(data: {rua__tabs_target: "heading"}, class: "text-sm font-semibold uppercase tracking-wide text-muted-foreground") { tab.name }
102
+ # Skip a description that merely repeats the tab name — it's pure duplication
103
+ # (the tab bar already shows the name). Genuine, distinct descriptions still render.
104
+ if tab.description.present? && tab.description.to_s.strip != tab.name.to_s.strip
105
+ p(class: "text-sm text-muted-foreground") { tab.description }
106
+ end
107
+
108
+ if lazy_url
109
+ render_lazy_tab_frame("rua-tab-frame-#{flat_index}", lazy_url)
110
+ else
111
+ render_tab_content(tab)
112
+ end
113
+ end
114
+ end
115
+ end
116
+ end
117
+
118
+ # Renders a tab's inner structure, tracking the enclosing tab name so a child panel named
119
+ # identically to the tab can drop its redundant title (see render_panel).
120
+ def render_tab_content(tab)
121
+ prev_tab_name = @enclosing_tab_name
122
+ @enclosing_tab_name = tab.name
123
+ render_structure(tab.items, wrap_bare: true)
124
+ @enclosing_tab_name = prev_tab_name
125
+ end
126
+
127
+ # A lazy `<turbo-frame loading="lazy">`: Turbo fetches `src` when the frame becomes visible
128
+ # (i.e. when its tab is shown) and swaps in the matching frame from the response (ShowTab).
129
+ # The spinner shows until then; without JS the <noscript> notice replaces it.
130
+ # `target="_top"` so links/forms inside the tab content navigate the whole page (otherwise
131
+ # they'd try to navigate within this frame and Turbo would render "Content missing" — the
132
+ # target page has no matching frame id). It doesn't affect the frame's own `src` lazy load.
133
+ def render_lazy_tab_frame(frame_id, src)
134
+ tag(:turbo_frame, id: frame_id, loading: "lazy", src: src, target: "_top") do
135
+ div(data: {rua_tab_spinner: true}, class: "flex items-center justify-center py-12") do
136
+ div(class: "size-6 rounded-full border-2 border-muted border-t-foreground animate-spin", role: "status", aria_label: rua_t("tabs.loading"))
137
+ end
138
+ noscript { p(class: "text-sm text-muted-foreground py-4") { rua_t("tabs.requires_js") } }
139
+ end
140
+ end
141
+
142
+ # Overridden by the Show view to enable lazy tab loading; eager everywhere else.
143
+ def lazy_tabs? = false
144
+
145
+ def tab_fragment_url(_flat_index) = nil
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Translates framework UI strings under the `ruby_ui_admin.*` i18n namespace.
6
+ module Translation
7
+ def rua_t(key, **options)
8
+ I18n.t("ruby_ui_admin.#{key}", **options)
9
+ end
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,162 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ # Renders an action's form (GET #show) and executes it (POST #run).
5
+ class ActionsController < ApplicationController
6
+ before_action :set_resource
7
+ before_action :set_action_entry
8
+ before_action :set_records
9
+
10
+ def show
11
+ action = build_action
12
+ authorize_action!(:act_on, on: authorization_target, policy_class: policy_class)
13
+
14
+ # `?fragment=1` returns just the form (no page chrome), wrapped in the modal's matching
15
+ # `<turbo-frame id=frame_id>` so Turbo swaps it in. The action's `fields` are only
16
+ # evaluated when the modal is actually opened (the frame is loaded).
17
+ if params[:fragment]
18
+ render RubyUIAdmin::Views::ActionForm.new(
19
+ resource: @resource,
20
+ action: action,
21
+ action_id: params[:action_id],
22
+ record_ids: @record_ids,
23
+ frame_id: params[:frame_id]
24
+ )
25
+ else
26
+ render Views::Action.new(
27
+ resource: @resource,
28
+ action: action,
29
+ action_id: params[:action_id],
30
+ record_ids: @record_ids
31
+ )
32
+ end
33
+ end
34
+
35
+ def run
36
+ action = build_action
37
+ authorize_action!(:act_on, on: authorization_target, policy_class: policy_class)
38
+
39
+ action.records = @records
40
+ action.field_values = field_values(action)
41
+ invoke_handle(action)
42
+
43
+ process_response(action.response)
44
+ end
45
+
46
+ private
47
+
48
+ def set_resource
49
+ resource_class = RubyUIAdmin.resource_manager.find_by_route_key(params[:resource_name])
50
+ raise ActionController::RoutingError, "Unknown resource #{params[:resource_name]}" if resource_class.nil?
51
+
52
+ @resource = resource_class.new.hydrate(user: current_user, params: params)
53
+ end
54
+
55
+ def set_action_entry
56
+ @entry = @resource.find_action_entry(params[:action_id])
57
+ raise ActionController::RoutingError, "Unknown action #{params[:action_id]}" if @entry.nil?
58
+ end
59
+
60
+ def set_records
61
+ @record_ids = Array(params[:record_ids]).reject(&:blank?)
62
+ @records = @record_ids.any? ? @resource.base_query.where(id: @record_ids).to_a : []
63
+ end
64
+
65
+ def policy_class
66
+ @resource.class.authorization_policy
67
+ end
68
+
69
+ def authorization_target
70
+ @records.first || @resource.model_class
71
+ end
72
+
73
+ def action_view
74
+ @records.size == 1 ? :show : :index
75
+ end
76
+
77
+ def build_action
78
+ action = @resource.instantiate_action(@entry, view: action_view, records: @records)
79
+ action.controller = self
80
+ action
81
+ end
82
+
83
+ # Indifferent-access so `handle` can read `fields[:csv_file]` or `fields["csv_file"]`.
84
+ def field_values(action)
85
+ submitted = params[:fields] || {}
86
+ values = action.get_fields.each_with_object({}) do |field, acc|
87
+ acc[field.id.to_s] = submitted[field.id.to_s]
88
+ end
89
+ ActiveSupport::HashWithIndifferentAccess.new(values)
90
+ end
91
+
92
+ # Supports both `handle(query:, fields:, current_user:, resource:, **)` and
93
+ # the positional `handle(args)` style (args[:records], args[:fields], ...).
94
+ def invoke_handle(action)
95
+ kwargs = {
96
+ query: @records,
97
+ records: @records,
98
+ fields: action.field_values,
99
+ current_user: current_user,
100
+ resource: @resource
101
+ }
102
+
103
+ parameters = action.method(:handle).parameters
104
+ keyword_kinds = parameters.map(&:first)
105
+
106
+ if (keyword_kinds & %i[key keyreq keyrest]).any?
107
+ if keyword_kinds.include?(:keyrest)
108
+ action.handle(**kwargs)
109
+ else
110
+ accepted = parameters.select { |(type, _)| %i[key keyreq].include?(type) }.map(&:last)
111
+ action.handle(**kwargs.slice(*accepted))
112
+ end
113
+ else
114
+ action.handle(kwargs)
115
+ end
116
+ end
117
+
118
+ def process_response(response)
119
+ case response[:type]
120
+ when :download
121
+ download = response[:download]
122
+ return send_data(download[:content], filename: download[:filename])
123
+ end
124
+
125
+ apply_flash(messages_for(response))
126
+
127
+ # 303 See Other so Turbo follows the POST→redirect as a GET visit.
128
+ case response[:type]
129
+ when :redirect
130
+ redirect_to resolve_path(response[:path]), status: :see_other
131
+ else # :reload and default
132
+ redirect_back fallback_location: default_back_path, status: :see_other
133
+ end
134
+ end
135
+
136
+ # An action that finishes without setting its own message still confirms it ran (Avo
137
+ # parity) — unless it explicitly opted out with `silent`.
138
+ def messages_for(response)
139
+ messages = Array(response[:messages])
140
+ return messages if messages.any? || response[:silent]
141
+
142
+ [{type: :success, body: I18n.t("ruby_ui_admin.actions.ran_successfully")}]
143
+ end
144
+
145
+ def apply_flash(messages)
146
+ Array(messages).each do |message|
147
+ key = (message[:type] == :error) ? :alert : :notice
148
+ flash[key] = message[:body]
149
+ end
150
+ end
151
+
152
+ def resolve_path(path)
153
+ return default_back_path if path.nil?
154
+
155
+ path.respond_to?(:call) ? instance_exec(&path) : path
156
+ end
157
+
158
+ def default_back_path
159
+ helpers.public_send("resources_#{@resource.route_key}_path")
160
+ end
161
+ end
162
+ end
@@ -0,0 +1,81 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "pagy"
4
+ # `pagy/extras/countless` is required at engine boot (see engine.rb) before host initializers.
5
+
6
+ module RubyUIAdmin
7
+ class ApplicationController < ActionController::Base
8
+ include Pagy::Backend
9
+
10
+ protect_from_forgery with: :exception
11
+
12
+ around_action :with_current_context
13
+ before_action :authenticate_admin!
14
+
15
+ helper_method :current_user
16
+
17
+ rescue_from RubyUIAdmin::NotAuthorizedError do |error|
18
+ render plain: "403 Forbidden — #{error.message}", status: :forbidden
19
+ end
20
+
21
+ private
22
+
23
+ def with_current_context
24
+ RubyUIAdmin::Current.request = request
25
+ RubyUIAdmin::Current.user = current_user
26
+ RubyUIAdmin::Current.true_user = true_user
27
+ with_admin_locale_and_timezone { yield }
28
+ ensure
29
+ RubyUIAdmin::Current.reset
30
+ end
31
+
32
+ # Applies the configured timezone and locale for the duration of the request.
33
+ def with_admin_locale_and_timezone(&block)
34
+ zone = RubyUIAdmin.configuration.timezone
35
+ locale = RubyUIAdmin.configuration.locale
36
+
37
+ localized = lambda do
38
+ locale ? I18n.with_locale(locale, &block) : block.call
39
+ end
40
+
41
+ zone ? Time.use_zone(zone) { localized.call } : localized.call
42
+ end
43
+
44
+ def current_user
45
+ return @current_user if defined?(@current_user)
46
+
47
+ method = RubyUIAdmin.configuration.current_user_method
48
+ @current_user =
49
+ if method
50
+ instance_exec(self, &method)
51
+ else
52
+ # Sensible fallback for Warden/Devise hosts when no method is configured.
53
+ request.env["warden"]&.user
54
+ end
55
+ end
56
+
57
+ # The real (non-impersonated) user; defaults to `current_user` when no method is set.
58
+ def true_user
59
+ return @true_user if defined?(@true_user)
60
+
61
+ method = RubyUIAdmin.configuration.true_user_method
62
+ @true_user = method ? instance_exec(self, &method) : current_user
63
+ end
64
+ helper_method :true_user
65
+
66
+ def authenticate_admin!
67
+ block = RubyUIAdmin.configuration.authenticate_with
68
+ instance_exec(&block) if block
69
+ end
70
+
71
+ # Builds an authorization service for the current user.
72
+ def authorization_for(record = nil, policy_class: nil)
73
+ Services::AuthorizationService.new(current_user, record, policy_class: policy_class, true_user: true_user)
74
+ end
75
+
76
+ # Authorizes a rule or raises NotAuthorizedError.
77
+ def authorize_action!(rule, on:, policy_class: nil)
78
+ authorization_for(on, policy_class: policy_class).authorize_action(rule, raise_exception: true)
79
+ end
80
+ end
81
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ class DashboardsController < ApplicationController
5
+ def show
6
+ dashboard_class = RubyUIAdmin.dashboard_manager.find(params[:dashboard_id])
7
+ raise ActionController::RoutingError, "Unknown dashboard #{params[:dashboard_id]}" if dashboard_class.nil?
8
+
9
+ render Views::Dashboard.new(dashboard: dashboard_class.new)
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,166 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ # In-app documentation browser. Renders the gem's bundled `docs/*.md` files as HTML at
5
+ # `<mount>/docs`. Availability is controlled by `config.docs_enabled` (default `:local`, i.e.
6
+ # development/test only). In local environments it's open (no auth) for convenience; in any other
7
+ # environment (e.g. production, when explicitly enabled) it runs behind the admin's
8
+ # `authenticate_admin!` gate.
9
+ class DocsController < ApplicationController
10
+ skip_before_action :authenticate_admin!, if: -> { Rails.env.local? }
11
+
12
+ # Root of the gem's Markdown documentation.
13
+ DOCS_ROOT = RubyUIAdmin::Engine.root.join("docs").freeze
14
+
15
+ before_action :ensure_docs_enabled!
16
+
17
+ def show
18
+ rel = resolve_relative_path(params[:path])
19
+ return head(:not_found) unless rel
20
+
21
+ abs = File.expand_path(rel, DOCS_ROOT)
22
+ return head(:not_found) unless safe_doc_path?(abs)
23
+
24
+ source = File.read(abs)
25
+ html = render_markdown(source, current_dir: File.dirname(rel))
26
+
27
+ render Views::Docs.new(
28
+ body_html: html,
29
+ title: doc_title(source) || humanize_slug(slug_for(rel)),
30
+ nav_groups: nav_groups,
31
+ current_slug: slug_for(rel)
32
+ )
33
+ rescue MissingDocsDependency => e
34
+ render Views::Docs.new(
35
+ body_html: dependency_help_html(e.message),
36
+ title: "Documentation",
37
+ nav_groups: [],
38
+ current_slug: nil
39
+ )
40
+ end
41
+
42
+ private
43
+
44
+ def ensure_docs_enabled!
45
+ head :not_found unless RubyUIAdmin.configuration.docs_enabled?
46
+ end
47
+
48
+ # Maps a URL path segment to a `docs/`-relative `.md` file. Returns nil for anything that
49
+ # isn't a plain Markdown file (defense in depth alongside `safe_doc_path?`).
50
+ def resolve_relative_path(path)
51
+ slug = path.presence || "README"
52
+ slug = slug.delete_suffix(".md")
53
+ return nil if slug.include?("\0")
54
+
55
+ "#{slug}.md"
56
+ end
57
+
58
+ # True only when `abs` is an existing `.md` file inside DOCS_ROOT (blocks `../` traversal).
59
+ def safe_doc_path?(abs)
60
+ root = DOCS_ROOT.to_s
61
+ abs.start_with?("#{root}/") && abs.end_with?(".md") && File.file?(abs)
62
+ end
63
+
64
+ def slug_for(rel)
65
+ rel.delete_suffix(".md")
66
+ end
67
+
68
+ # ---- Markdown ----------------------------------------------------------------------------
69
+
70
+ def render_markdown(text, current_dir:)
71
+ load_markdown_deps!
72
+
73
+ # No `syntax_highlighter`: kramdown still emits `<pre><code class="language-…">` for fenced
74
+ # blocks (styled by the view's CSS). We skip Rouge on purpose — its kramdown integration uses
75
+ # a deprecated formatter that warns once per code block, flooding the dev log.
76
+ html = Kramdown::Document.new(text, input: "GFM", hard_wrap: false, auto_ids: true).to_html
77
+ rewrite_internal_links(html, current_dir)
78
+ end
79
+
80
+ def load_markdown_deps!
81
+ require "kramdown"
82
+ require "kramdown-parser-gfm"
83
+ rescue LoadError
84
+ raise MissingDocsDependency, "kramdown"
85
+ end
86
+
87
+ # Rewrites relative `*.md` links so they point at the docs route instead of 404-ing.
88
+ # Absolute (http), anchor-only, and mailto links are left untouched.
89
+ def rewrite_internal_links(html, current_dir)
90
+ fragment = Nokogiri::HTML5.fragment(html)
91
+
92
+ fragment.css("a[href]").each do |node|
93
+ href = node["href"]
94
+ next if href.blank? || href.match?(%r{\A(?:[a-z]+:|//|/|#)})
95
+
96
+ path, anchor = href.split("#", 2)
97
+ next unless path&.end_with?(".md")
98
+
99
+ slug = resolve_link_slug(path, current_dir)
100
+ new_href = doc_href(slug)
101
+ new_href += "##{anchor}" if anchor.present?
102
+ node["href"] = new_href
103
+ end
104
+
105
+ fragment.to_html
106
+ end
107
+
108
+ # Resolves a relative `.md` link (e.g. `../fields/overview.md`) against the current doc's
109
+ # directory, returning a clean `docs/`-relative slug.
110
+ def resolve_link_slug(path, current_dir)
111
+ base = current_dir == "." ? "/" : "/#{current_dir}"
112
+ File.expand_path(path, base).delete_prefix("/").delete_suffix(".md")
113
+ end
114
+
115
+ def doc_href(slug)
116
+ slug == "README" ? docs_path : doc_path(path: slug)
117
+ end
118
+
119
+ # ---- Navigation --------------------------------------------------------------------------
120
+
121
+ def nav_groups
122
+ entries = Dir.glob(DOCS_ROOT.join("**/*.md")).sort.map do |abs|
123
+ rel = Pathname.new(abs).relative_path_from(DOCS_ROOT).to_s
124
+ slug = slug_for(rel)
125
+ {
126
+ slug: slug,
127
+ title: doc_title(File.read(abs)) || humanize_slug(slug),
128
+ href: doc_href(slug),
129
+ group: rel.include?("/") ? rel.split("/").first : nil
130
+ }
131
+ end
132
+
133
+ root = entries.select { |e| e[:group].nil? }
134
+ grouped = entries.reject { |e| e[:group].nil? }.group_by { |e| e[:group] }
135
+
136
+ result = []
137
+ result << {label: nil, items: root} if root.any?
138
+ grouped.sort.each { |dir, items| result << {label: humanize_slug(dir), items: items} }
139
+ result
140
+ end
141
+
142
+ # ---- Helpers -----------------------------------------------------------------------------
143
+
144
+ # The first level-1 heading of a Markdown document, if any.
145
+ def doc_title(source)
146
+ source[/^\#\s+(.+)$/, 1]&.strip
147
+ end
148
+
149
+ def humanize_slug(slug)
150
+ slug.split("/").last.tr("-_", " ").split.map(&:capitalize).join(" ")
151
+ end
152
+
153
+ def dependency_help_html(gem_name)
154
+ <<~HTML
155
+ <h1>Documentation viewer needs a Markdown renderer</h1>
156
+ <p>Add these to your app's <code>:development</code> group and <code>bundle install</code>:</p>
157
+ <pre><code>gem "kramdown"
158
+ gem "kramdown-parser-gfm"</code></pre>
159
+ <p>(Missing: <code>#{ERB::Util.html_escape(gem_name)}</code>.)</p>
160
+ HTML
161
+ end
162
+
163
+ # Raised when the Markdown gems aren't installed in the host app.
164
+ class MissingDocsDependency < StandardError; end
165
+ end
166
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ class HomeController < ApplicationController
5
+ def index
6
+ resources = RubyUIAdmin.resource_manager.navigation_resources
7
+
8
+ # Redirect to the first resource's index, or the configured home path.
9
+ if (home = RubyUIAdmin.configuration.home_path)
10
+ redirect_to home
11
+ elsif resources.any?
12
+ redirect_to helpers.public_send("resources_#{resources.first.route_key}_path")
13
+ else
14
+ render Views::Home.new(resources: resources)
15
+ end
16
+ end
17
+ end
18
+ end