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,148 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # An action button that opens an inline modal with the action form. Without JS the
6
+ # button is a plain link to the action page (Views::Action), so it always works.
7
+ class ActionTrigger < Phlex::HTML
8
+ include RubyUIAdmin::UI
9
+ include RailsHelpers
10
+ include PathHelpers
11
+
12
+ # `bulk: true` renders a submit button tied (via the HTML `form` attribute) to the
13
+ # bulk-selection form `form_id`, so without JS it submits the checked rows to the
14
+ # action page. With JS the checked ids are injected into the modal form instead.
15
+ # `part:` controls what is rendered — `:both` (default), `:trigger` (just the button/link)
16
+ # or `:dialog` (just the modal). When triggers live inside the actions Combobox, the dialogs
17
+ # are rendered separately (outside the popover) so closing it doesn't hide the open modal.
18
+ # `as_menu_item: true` styles the trigger to fill a Combobox item row.
19
+ def initialize(resource:, action:, record_ids: [], bulk: false, form_id: nil, part: :both, as_menu_item: false)
20
+ @resource = resource
21
+ @action = action
22
+ @record_ids = record_ids
23
+ @bulk = bulk
24
+ @form_id = form_id
25
+ @part = part
26
+ @as_menu_item = as_menu_item
27
+ end
28
+
29
+ def view_template
30
+ render_trigger unless @part == :dialog
31
+ render_dialog unless @part == :trigger
32
+ end
33
+
34
+ private
35
+
36
+ def render_trigger
37
+ if @bulk
38
+ button(
39
+ type: "submit",
40
+ form: @form_id,
41
+ formaction: page_path,
42
+ formmethod: "get",
43
+ data: trigger_data,
44
+ class: trigger_classes
45
+ ) { @action.name }
46
+ else
47
+ a(href: action_path, data: trigger_data, class: trigger_classes) { @action.name }
48
+ end
49
+ end
50
+
51
+ # Data attributes for the trigger. The `rua--dialog#open` Stimulus action opens the lazy
52
+ # modal (passing the dialog id / bulk flag as action params). Inside the actions Combobox
53
+ # the item is also a combobox `input` target (so the search box filters it) and closes the
54
+ # popover on click — both actions chain on `click`.
55
+ def trigger_data
56
+ actions = ["click->rua--dialog#open"]
57
+ data = {rua__dialog_id_param: dialog_id}
58
+ # String "true" (not boolean) so Phlex renders `...-bulk-param="true"` and Stimulus
59
+ # type-casts the action param back to boolean `true` (a boolean would render an empty
60
+ # attribute, which Stimulus reads as the string "").
61
+ data[:rua__dialog_bulk_param] = "true" if @bulk
62
+ if @as_menu_item
63
+ data[:ruby_ui__combobox_target] = "input"
64
+ actions << "click->ruby-ui--combobox#closePopover"
65
+ end
66
+ data[:action] = actions.join(" ")
67
+ data
68
+ end
69
+
70
+ # Inside the actions Combobox the trigger fills the item row (the surrounding ComboboxItem
71
+ # supplies the hover/padding). Standalone it is a normal outline button.
72
+ def trigger_classes
73
+ if @as_menu_item
74
+ "block w-full whitespace-nowrap text-left text-sm"
75
+ else
76
+ "inline-flex items-center h-9 px-4 rounded-md border border-input bg-background text-sm font-medium shadow-sm hover:bg-accent"
77
+ end
78
+ end
79
+
80
+ def action_key
81
+ @action.class.action_key
82
+ end
83
+
84
+ def dialog_id
85
+ "rua-action-#{action_key}-#{@bulk ? "bulk" : (@record_ids.first || "all")}"
86
+ end
87
+
88
+ def page_path
89
+ ruby_ui_admin.resource_action_path(resource_name: @resource.route_key, action_id: action_key)
90
+ end
91
+
92
+ def action_path
93
+ ids = Array(@record_ids).map { |id| "record_ids[]=#{id}" }
94
+ ids.any? ? "#{page_path}?#{ids.join("&")}" : page_path
95
+ end
96
+
97
+ def frame_id
98
+ "#{dialog_id}-frame"
99
+ end
100
+
101
+ # Fragment URL the modal's `<turbo-frame>` loads (so the action's `fields` are only
102
+ # evaluated when the modal opens). For non-bulk it's a static `src` the frame loads
103
+ # lazily on open; for bulk the live selection isn't known server-side, so the controller
104
+ # builds the `src` from this base + the checked ids when the modal opens.
105
+ def frame_src_base
106
+ return "#{page_path}?fragment=1&frame_id=#{frame_id}" if @bulk
107
+
108
+ sep = action_path.include?("?") ? "&" : "?"
109
+ "#{action_path}#{sep}fragment=1&frame_id=#{frame_id}"
110
+ end
111
+
112
+ # The form is loaded lazily inside a `<turbo-frame>` when the modal opens: non-bulk frames
113
+ # have a `src` + `loading="lazy"` (Turbo loads them when the dialog becomes visible); bulk
114
+ # frames get their `src` set by `rua--dialog#open` from the live selection. Without JS the
115
+ # trigger is a link/submit to the full action page, so it still works.
116
+ def render_dialog
117
+ div(data: {rua_dialog: dialog_id}, hidden: true, class: "fixed inset-0 z-50 flex items-center justify-center p-4") do
118
+ div(data: {action: "click->rua--dialog#close"}, class: "absolute inset-0 bg-black/50")
119
+
120
+ div(class: "relative z-10 w-full max-w-lg rounded-xl border border-border bg-background shadow-lg") do
121
+ div(class: "flex items-center justify-between p-6 pb-2") do
122
+ h2(class: "text-lg font-semibold") { @action.name }
123
+ button(type: "button", data: {action: "click->rua--dialog#close"}, class: "text-muted-foreground hover:text-foreground") { "✕" }
124
+ end
125
+ div(class: "p-6 pt-2") do
126
+ render_dialog_frame
127
+ end
128
+ end
129
+ end
130
+ end
131
+
132
+ def render_dialog_frame
133
+ attrs = {id: frame_id}
134
+ if @bulk
135
+ # No static src — the controller sets it from the checked rows on open.
136
+ attrs[:data] = {rua_frame_base: frame_src_base}
137
+ else
138
+ attrs[:src] = frame_src_base
139
+ attrs[:loading] = "lazy"
140
+ end
141
+
142
+ tag(:turbo_frame, **attrs) do
143
+ p(class: "text-sm text-muted-foreground") { "…" }
144
+ end
145
+ end
146
+ end
147
+ end
148
+ end
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Shared by Index and Show: collapses a set of action triggers into RubyUI's Combobox
6
+ # (searchable popover) so the actions never wrap/sprawl across the header.
7
+ module ActionsMenu
8
+ # Each action is a Combobox item (clickable, opens its modal and closes the popover). The
9
+ # modals are rendered after the combobox (outside the popover) so closing it doesn't hide
10
+ # an open modal. `bulk:` ties triggers to the bulk-selection form (`form_id`); `record_ids:`
11
+ # scopes them to specific records (the single-record show view).
12
+ def render_actions_menu(actions, bulk: false, form_id: nil, record_ids: [])
13
+ render RubyUI::Combobox.new do
14
+ render RubyUI::ComboboxTrigger.new(placeholder: rua_t("index.actions"))
15
+ render RubyUI::ComboboxPopover.new do
16
+ render RubyUI::ComboboxSearchInput.new(placeholder: rua_t("index.search"))
17
+ render RubyUI::ComboboxList.new do
18
+ render(RubyUI::ComboboxEmptyState.new) { rua_t("index.no_results") }
19
+ actions.each do |action|
20
+ render RubyUI::ComboboxItem.new do
21
+ render RubyUIAdmin::Views::ActionTrigger.new(resource: @resource, action: action, record_ids: record_ids, bulk: bulk, form_id: form_id, part: :trigger, as_menu_item: true)
22
+ end
23
+ end
24
+ end
25
+ end
26
+ end
27
+
28
+ actions.each do |action|
29
+ render RubyUIAdmin::Views::ActionTrigger.new(resource: @resource, action: action, record_ids: record_ids, bulk: bulk, form_id: form_id, part: :dialog)
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
@@ -0,0 +1,301 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Full-page layout/chrome for every admin screen. Subclasses implement `content`.
6
+ class Base < Phlex::HTML
7
+ include RubyUIAdmin::UI
8
+ include RailsHelpers
9
+ include PathHelpers
10
+ include Translation
11
+
12
+ def view_template
13
+ doctype
14
+ # `overflow-y: auto` keeps the admin scrollable even when the host's global CSS locks page
15
+ # scroll (e.g. `html, body { overflow: hidden }` for a fixed-viewport app shell) — the admin
16
+ # renders its own document, so it can't rely on the host layout's own scroll override.
17
+ html(lang: "en", style: "overflow-y: auto") do
18
+ head do
19
+ meta(charset: "utf-8")
20
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
21
+ title { page_title }
22
+ render_head_assets
23
+ end
24
+ # Turbo Drive is on (self-hosted, see render_head_assets) for SPA-style navigation — no
25
+ # full-page reloads. `rua--confirm` manages the shared delete-confirmation AlertDialog.
26
+ body(class: "min-h-screen bg-background text-foreground antialiased", data: {controller: "rua--confirm"}) do
27
+ render RubyUI::SidebarWrapper.new do
28
+ render_sidebar
29
+ render RubyUI::SidebarInset.new do
30
+ render_topbar
31
+ main(class: "flex-1 min-w-0 p-6 lg:p-8") do
32
+ # `rua--dialog` manages the lazy action modals rendered anywhere in the page body.
33
+ div(data: {controller: "rua--dialog"}, class: "w-full max-w-7xl mx-auto") { content }
34
+ end
35
+ end
36
+ end
37
+ render_toaster
38
+ render_confirm_dialog
39
+ end
40
+ end
41
+ end
42
+
43
+ # Overridden by subclasses.
44
+ def content; end
45
+
46
+ # Whether the current user is allowed to perform `rule` on `record`.
47
+ def authorized_to?(rule, record, policy_class: nil)
48
+ RubyUIAdmin::Services::AuthorizationService
49
+ .new(RubyUIAdmin::Current.user, record, policy_class: policy_class)
50
+ .allowed?(rule)
51
+ end
52
+
53
+ private
54
+
55
+ def page_title
56
+ RubyUIAdmin.configuration.app_name
57
+ end
58
+
59
+ def navigation_resources
60
+ RubyUIAdmin.resource_manager.navigation_resources
61
+ end
62
+
63
+ # Top bar inside the inset: holds the sidebar trigger (toggles the desktop icon-rail
64
+ # collapse and opens the mobile drawer). Kept minimal — no app-name/user chrome.
65
+ def render_topbar
66
+ header(class: "sticky top-0 z-10 flex h-14 shrink-0 items-center gap-2 border-b bg-background px-4") do
67
+ render RubyUI::SidebarTrigger.new(class: "-ml-1")
68
+ end
69
+ end
70
+
71
+ # The RubyUI Sidebar: fixed/expanded by default, collapses to an icon rail on user
72
+ # action (persisted via cookie), and becomes a drawer on mobile (<768px).
73
+ def render_sidebar
74
+ render RubyUI::Sidebar.new(collapsible: :icon, open: sidebar_open?) do
75
+ render RubyUI::SidebarHeader.new do
76
+ div(class: "flex items-center gap-2 px-2 py-1.5 font-semibold") do
77
+ render RubyUIAdmin::UI::Icon.new(:layout_dashboard, class: "size-5 shrink-0")
78
+ span(class: "truncate group-data-[collapsible=icon]:hidden") { RubyUIAdmin.configuration.app_name }
79
+ end
80
+ end
81
+
82
+ render RubyUI::SidebarContent.new do
83
+ if (menu = main_menu_items)
84
+ render_menu(menu)
85
+ else
86
+ render_auto_menu
87
+ end
88
+ end
89
+
90
+ render_sidebar_footer
91
+
92
+ render RubyUI::SidebarRail.new
93
+ end
94
+ end
95
+
96
+ # Bottom of the sidebar: current user label + a sign-out item. Rendered only when
97
+ # `config.sign_out_path_name` is set (resolved via the host's `main_app` routes).
98
+ def render_sidebar_footer
99
+ path_name = RubyUIAdmin.configuration.sign_out_path_name
100
+ return if path_name.blank?
101
+
102
+ render RubyUI::SidebarFooter.new do
103
+ render RubyUI::SidebarMenu.new do
104
+ if (label = current_user_label)
105
+ render RubyUI::SidebarMenuItem.new do
106
+ div(class: "flex items-center gap-2 px-2 py-1.5 text-xs text-muted-foreground truncate group-data-[collapsible=icon]:hidden") { plain label }
107
+ end
108
+ end
109
+
110
+ render RubyUI::SidebarMenuItem.new do
111
+ render_sign_out_button(path_name)
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ def render_sign_out_button(path_name)
118
+ action = main_app.public_send(path_name)
119
+ method = (RubyUIAdmin.configuration.sign_out_method || :delete).to_s
120
+
121
+ form(action: action, method: "post", class: "w-full") do
122
+ input(type: "hidden", name: "_method", value: method)
123
+ input(type: "hidden", name: "authenticity_token", value: form_authenticity_token)
124
+ render RubyUI::SidebarMenuButton.new(as: :button, type: "submit", title: rua_t("nav.sign_out")) do
125
+ render RubyUIAdmin::UI::Icon.new(:log_out, class: "size-4 shrink-0")
126
+ span(class: "truncate") { rua_t("nav.sign_out") }
127
+ end
128
+ end
129
+ end
130
+
131
+ # Best-effort label for the signed-in user (email → name → to_s).
132
+ def current_user_label
133
+ u = RubyUIAdmin::Current.user
134
+ return nil unless u
135
+
136
+ (u.respond_to?(:email) && u.email.presence) ||
137
+ (u.respond_to?(:name) && u.name.presence) ||
138
+ u.to_s
139
+ end
140
+
141
+ # Reads the persisted collapse state (set client-side by the sidebar controller) so
142
+ # the server renders the same state and there's no flash on navigation.
143
+ def sidebar_open?
144
+ request.cookies["sidebar_state"] != "false"
145
+ rescue
146
+ true
147
+ end
148
+
149
+ # The curated menu tree, or nil when no `config.main_menu` is set (auto navigation).
150
+ def main_menu_items
151
+ block = RubyUIAdmin.configuration.main_menu
152
+ return nil unless block
153
+
154
+ RubyUIAdmin::Menu::Builder.build(&block)
155
+ end
156
+
157
+ # Curated menu: top-level leaves go into one group; each section becomes a group.
158
+ def render_menu(items)
159
+ leaves, sections = items.partition { |item| item.type != :section }
160
+ render_leaf_group(leaves) if leaves.any?
161
+ sections.each { |section| render_menu_section(section) }
162
+ end
163
+
164
+ def render_menu_section(section)
165
+ render RubyUI::SidebarGroup.new do
166
+ if section.label.present?
167
+ render RubyUI::SidebarGroupLabel.new do
168
+ render_menu_icon(section.icon)
169
+ plain section.label.to_s
170
+ end
171
+ end
172
+ leaves = section.items.reject { |item| item.type == :section }
173
+ render RubyUI::SidebarMenu.new do
174
+ leaves.each { |item| render_menu_leaf(item) }
175
+ end
176
+ end
177
+ # Nested sections (rare) render as their own sibling groups.
178
+ section.items.select { |item| item.type == :section }.each { |nested| render_menu_section(nested) }
179
+ end
180
+
181
+ # Auto navigation (no curated menu): a dashboards group (if any) + a resources group.
182
+ def render_auto_menu
183
+ dashboards = RubyUIAdmin.dashboard_manager.dashboards
184
+ render_leaf_group(dashboards.map { |d| RubyUIAdmin::Menu::DashboardItem.new(dashboard: d) }) if dashboards.any?
185
+ render_leaf_group(navigation_resources.map { |rc| RubyUIAdmin::Menu::ResourceItem.new(resource: rc) })
186
+ end
187
+
188
+ def render_leaf_group(leaves)
189
+ render RubyUI::SidebarGroup.new do
190
+ render RubyUI::SidebarMenu.new do
191
+ leaves.each { |item| render_menu_leaf(item) }
192
+ end
193
+ end
194
+ end
195
+
196
+ def render_menu_leaf(item)
197
+ href, label, icon = menu_leaf_attrs(item)
198
+ return if href.nil?
199
+
200
+ # Resource items stay active across the whole resource (index/show/new/edit/actions),
201
+ # so the index path is treated as a prefix. Dashboards/links match the exact path only.
202
+ active = current_path?(href, prefix: item.type == :resource)
203
+
204
+ render RubyUI::SidebarMenuItem.new do
205
+ render RubyUI::SidebarMenuButton.new(as: :a, href: href, title: label.to_s, active: active) do
206
+ render RubyUIAdmin::UI::Icon.new(icon, class: "size-4 shrink-0")
207
+ span(class: "truncate") { plain label.to_s }
208
+ end
209
+ end
210
+ end
211
+
212
+ # Returns [href, label, icon] for a leaf menu item, or nil href if unresolvable.
213
+ def menu_leaf_attrs(item)
214
+ case item.type
215
+ when :resource
216
+ [resource_index_path(item.resource), item.label || item.resource.navigation_label, :circle]
217
+ when :dashboard
218
+ [ruby_ui_admin.dashboard_path(dashboard_id: item.dashboard.id), item.label || item.dashboard.title, :layout_dashboard]
219
+ when :link
220
+ [item.path, item.label, :link]
221
+ end
222
+ end
223
+
224
+ # Exact path match, or (when `prefix:`) any path nested under it — so a resource's nav
225
+ # entry stays active on its show/new/edit/action pages, not just the index.
226
+ def current_path?(href, prefix: false)
227
+ path = href.to_s.split("?").first
228
+ return false if path.nil? || path.empty?
229
+ return true if request.path == path
230
+
231
+ prefix && request.path.start_with?("#{path}/")
232
+ rescue
233
+ false
234
+ end
235
+
236
+ # Renders a menu section icon. A raw SVG/HTML string is rendered as-is; named icons
237
+ # (e.g. "heroicons/outline/table-cells") need a host-provided SVG and are skipped.
238
+ def render_menu_icon(icon)
239
+ return if icon.nil? || icon.to_s.strip.empty?
240
+
241
+ raw(safe(icon.to_s)) if icon.to_s.include?("<svg")
242
+ end
243
+
244
+ # Flash messages render through RubyUI's Toast component (Stimulus-driven). A
245
+ # `<noscript>` fallback keeps them visible (as badges) when JS is off.
246
+ def render_toaster
247
+ messages = flash.to_hash
248
+
249
+ render RubyUIAdmin::UI::ToastRegion.new(
250
+ flash: messages,
251
+ position: :bottom_right,
252
+ close_button: true,
253
+ duration: 5000
254
+ )
255
+
256
+ return if messages.empty?
257
+
258
+ noscript do
259
+ div(class: "fixed top-4 right-4 z-[100] flex flex-col gap-2") do
260
+ messages.each do |type, message|
261
+ next if message.blank?
262
+
263
+ variant = %w[alert error].include?(type.to_s) ? :destructive : :success
264
+ render RubyUI::Badge.new(variant: variant) { message.to_s }
265
+ end
266
+ end
267
+ end
268
+ end
269
+
270
+ # Shared confirmation AlertDialog, driven by the `rua--confirm` Stimulus controller (on
271
+ # <body>). A trigger (e.g. delete) opens it via `click->rua--confirm#request`. Without JS the
272
+ # trigger's form submits directly (the action still works); with JS, this dialog confirms first.
273
+ def render_confirm_dialog
274
+ div(data: {rua__confirm_target: "dialog"}, hidden: true, class: "fixed inset-0 z-50 flex items-center justify-center p-4") do
275
+ div(data: {action: "click->rua--confirm#cancel"}, class: "absolute inset-0 bg-black/50")
276
+ div(role: "alertdialog", "aria-modal": "true", class: "relative z-10 w-full max-w-md rounded-xl border border-border bg-background shadow-lg p-6") do
277
+ h2(data: {rua__confirm_target: "title"}, class: "text-lg font-semibold") { rua_t("confirm.title") }
278
+ p(data: {rua__confirm_target: "message"}, class: "text-sm text-muted-foreground mt-2") { rua_t("confirm.message") }
279
+ div(class: "flex items-center justify-end gap-3 mt-6") do
280
+ render(RubyUI::Button.new(type: :button, variant: :outline, data: {action: "click->rua--confirm#cancel"})) { rua_t("confirm.cancel") }
281
+ render(RubyUI::Button.new(type: :button, variant: :destructive, data: {action: "click->rua--confirm#confirm"})) { rua_t("confirm.confirm") }
282
+ end
283
+ end
284
+ end
285
+ end
286
+
287
+ # The admin renders the host's RubyUI components, so the host owns the head assets — its
288
+ # Tailwind build (CSS, incl. the RubyUI design tokens) and its bundler/importmap (JS that
289
+ # registers the RubyUI Stimulus controllers and ours). The host supplies them via
290
+ # `config.head_assets` (a proc evaluated in the Rails view context). When unset, no assets
291
+ # are emitted (the page still renders; it just won't be styled/enhanced).
292
+ def render_head_assets
293
+ head_assets = RubyUIAdmin.configuration.head_assets
294
+ return unless head_assets
295
+
296
+ markup = view_context.instance_exec(&head_assets)
297
+ raw(safe(markup.to_s)) if markup
298
+ end
299
+ end
300
+ end
301
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Renders a single dashboard card, dispatching on the card type.
6
+ class CardComponent < Phlex::HTML
7
+ include RubyUIAdmin::UI
8
+
9
+ def initialize(card:)
10
+ @card = card
11
+ end
12
+
13
+ def view_template
14
+ render RubyUI::Card.new(class: span_class) do
15
+ render RubyUI::CardHeader.new do
16
+ render RubyUI::CardTitle.new(class: "text-sm font-medium text-muted-foreground") { @card.label }
17
+ end
18
+ render RubyUI::CardContent.new do
19
+ render_body
20
+ end
21
+ end
22
+ end
23
+
24
+ private
25
+
26
+ def span_class
27
+ width = @card.width.to_i
28
+ width > 1 ? "md:col-span-#{width}" : ""
29
+ end
30
+
31
+ def render_body
32
+ case @card.type
33
+ when :metric then render_metric
34
+ when :chart then render_chart
35
+ when :partial then raw(safe(@card.body.to_s))
36
+ else plain(@card.query.to_s)
37
+ end
38
+ end
39
+
40
+ def render_metric
41
+ div(class: "text-3xl font-semibold tracking-tight") do
42
+ plain [@card.prefix, @card.value, @card.suffix].compact.join
43
+ end
44
+ end
45
+
46
+ def render_chart
47
+ data = @card.data
48
+ return plain("—") if data.empty?
49
+
50
+ max = data.values.map(&:to_f).max
51
+ max = 1.0 if max.zero?
52
+
53
+ div(class: "space-y-2") do
54
+ data.each do |label, value|
55
+ div(class: "space-y-1") do
56
+ div(class: "flex justify-between text-xs text-muted-foreground") do
57
+ span { label.to_s }
58
+ span { value.to_s }
59
+ end
60
+ div(class: "h-2 rounded bg-muted") do
61
+ div(class: "h-2 rounded bg-primary", style: "width: #{(value.to_f / max * 100).round(1)}%")
62
+ end
63
+ end
64
+ end
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ class Dashboard < Base
6
+ def initialize(dashboard:)
7
+ @dashboard = dashboard
8
+ end
9
+
10
+ def page_title
11
+ "#{@dashboard.class.title} · #{RubyUIAdmin.configuration.app_name}"
12
+ end
13
+
14
+ def content
15
+ div(class: "mb-6") do
16
+ h1(class: "text-2xl font-semibold tracking-tight") { @dashboard.class.title }
17
+ if (description = @dashboard.class.description)
18
+ p(class: "text-sm text-muted-foreground mt-1") { description }
19
+ end
20
+ end
21
+
22
+ cards = @dashboard.get_cards
23
+ if cards.empty?
24
+ p(class: "text-muted-foreground") { "No cards defined yet." }
25
+ else
26
+ div(class: grid_class) do
27
+ cards.each { |card| render RubyUIAdmin::Views::CardComponent.new(card: card) }
28
+ end
29
+ end
30
+ end
31
+
32
+ private
33
+
34
+ def grid_class
35
+ columns = @dashboard.class.grid_columns.to_i.clamp(1, 4)
36
+ "grid gap-4 md:grid-cols-#{columns}"
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,91 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RubyUIAdmin
4
+ module Views
5
+ # Standalone, self-contained page for the dev-only docs browser. Deliberately does NOT use the
6
+ # admin layout or the host's RubyUI/Tailwind assets — it ships its own minimal CSS so it renders
7
+ # correctly even in a freshly-installed host where the admin's styling isn't wired up yet.
8
+ class Docs < Phlex::HTML
9
+ def initialize(body_html:, title:, nav_groups:, current_slug:)
10
+ @body_html = body_html
11
+ @title = title
12
+ @nav_groups = nav_groups
13
+ @current_slug = current_slug
14
+ end
15
+
16
+ def view_template
17
+ doctype
18
+ html(lang: "en") do
19
+ head do
20
+ meta(charset: "utf-8")
21
+ meta(name: "viewport", content: "width=device-width, initial-scale=1")
22
+ title { "#{@title} · RubyUI Admin docs" }
23
+ style { raw(safe(STYLES)) }
24
+ end
25
+ body do
26
+ div(class: "rua-docs") do
27
+ render_sidebar
28
+ main(class: "rua-docs-main") do
29
+ article(class: "rua-prose") { raw(safe(@body_html)) }
30
+ end
31
+ end
32
+ end
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def render_sidebar
39
+ nav(class: "rua-docs-nav") do
40
+ div(class: "rua-docs-brand") { "RubyUI Admin · Docs" }
41
+ @nav_groups.each do |group|
42
+ div(class: "rua-docs-group") do
43
+ h3 { group[:label] } if group[:label]
44
+ ul do
45
+ group[:items].each do |item|
46
+ li do
47
+ a(href: item[:href], class: item[:slug] == @current_slug ? "active" : nil) { item[:title] }
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+
56
+ STYLES = <<~CSS
57
+ *, *::before, *::after { box-sizing: border-box; }
58
+ body { margin: 0; font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif; color: #1f2328; background: #fff; }
59
+ .rua-docs { display: flex; min-height: 100vh; align-items: stretch; }
60
+ .rua-docs-nav { flex: 0 0 17rem; border-right: 1px solid #e5e7eb; background: #f9fafb; padding: 1.25rem 1rem; overflow-y: auto; position: sticky; top: 0; height: 100vh; }
61
+ .rua-docs-brand { font-weight: 600; font-size: .9rem; margin-bottom: 1rem; color: #111827; }
62
+ .rua-docs-group { margin-bottom: 1rem; }
63
+ .rua-docs-group h3 { font-size: .7rem; text-transform: uppercase; letter-spacing: .05em; color: #6b7280; margin: .75rem 0 .35rem; }
64
+ .rua-docs-nav ul { list-style: none; margin: 0; padding: 0; }
65
+ .rua-docs-nav li { margin: 0; }
66
+ .rua-docs-nav a { display: block; padding: .25rem .5rem; border-radius: .375rem; color: #374151; text-decoration: none; font-size: .85rem; }
67
+ .rua-docs-nav a:hover { background: #eef2ff; color: #1d4ed8; }
68
+ .rua-docs-nav a.active { background: #e0e7ff; color: #1d4ed8; font-weight: 600; }
69
+ .rua-docs-main { flex: 1 1 auto; min-width: 0; display: flex; justify-content: center; padding: 2rem 2.5rem 4rem; }
70
+ .rua-prose { width: 100%; max-width: 48rem; line-height: 1.65; }
71
+ .rua-prose h1, .rua-prose h2, .rua-prose h3, .rua-prose h4 { line-height: 1.25; font-weight: 600; margin-top: 1.75em; margin-bottom: .6em; }
72
+ .rua-prose h1 { font-size: 1.9rem; margin-top: 0; }
73
+ .rua-prose h2 { font-size: 1.45rem; padding-bottom: .3em; border-bottom: 1px solid #e5e7eb; }
74
+ .rua-prose h3 { font-size: 1.2rem; }
75
+ .rua-prose p, .rua-prose ul, .rua-prose ol, .rua-prose blockquote, .rua-prose table { margin: 0 0 1em; }
76
+ .rua-prose a { color: #1d4ed8; text-decoration: underline; text-underline-offset: 2px; }
77
+ .rua-prose ul, .rua-prose ol { padding-left: 1.5em; }
78
+ .rua-prose li { margin: .25em 0; }
79
+ .rua-prose code { font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace; font-size: .875em; background: #f3f4f6; padding: .15em .35em; border-radius: .3em; }
80
+ .rua-prose pre { background: #f6f8fa; border: 1px solid #e5e7eb; border-radius: .5rem; padding: 1rem; overflow-x: auto; }
81
+ .rua-prose pre code { background: none; padding: 0; font-size: .85rem; }
82
+ .rua-prose blockquote { border-left: 3px solid #d1d5db; padding: .1em 1em; color: #4b5563; background: #f9fafb; border-radius: 0 .375rem .375rem 0; }
83
+ .rua-prose table { border-collapse: collapse; width: 100%; display: block; overflow-x: auto; font-size: .9rem; }
84
+ .rua-prose th, .rua-prose td { border: 1px solid #e5e7eb; padding: .5em .75em; text-align: left; }
85
+ .rua-prose th { background: #f9fafb; font-weight: 600; }
86
+ .rua-prose img { max-width: 100%; }
87
+ .rua-prose hr { border: none; border-top: 1px solid #e5e7eb; margin: 2em 0; }
88
+ CSS
89
+ end
90
+ end
91
+ end