panda-core 0.12.5 → 1.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 (251) hide show
  1. checksums.yaml +4 -4
  2. data/LICENSE +1 -1
  3. data/README.md +53 -0
  4. data/app/assets/builds/panda_core_admin.css +2 -0
  5. data/app/assets/tailwind/application.css +236 -36
  6. data/app/builders/panda/core/form_builder.rb +120 -18
  7. data/app/components/panda/core/UI/badge.html.erb +10 -0
  8. data/app/components/panda/core/UI/badge.rb +14 -30
  9. data/app/components/panda/core/UI/button.html.erb +5 -0
  10. data/app/components/panda/core/UI/button.rb +11 -15
  11. data/app/components/panda/core/UI/card.html.erb +3 -0
  12. data/app/components/panda/core/UI/card.rb +7 -6
  13. data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
  14. data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
  15. data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
  16. data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
  17. data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
  18. data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
  19. data/app/components/panda/core/admin/button_component.html.erb +9 -0
  20. data/app/components/panda/core/admin/button_component.rb +36 -31
  21. data/app/components/panda/core/admin/callout_component.html.erb +17 -0
  22. data/app/components/panda/core/admin/callout_component.rb +57 -0
  23. data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
  24. data/app/components/panda/core/admin/code_block_component.rb +23 -0
  25. data/app/components/panda/core/admin/container_component.html.erb +27 -0
  26. data/app/components/panda/core/admin/container_component.rb +11 -77
  27. data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
  28. data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
  29. data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
  30. data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
  31. data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
  32. data/app/components/panda/core/admin/delete_button_component.rb +41 -0
  33. data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
  34. data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
  35. data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
  36. data/app/components/panda/core/admin/dropdown_component.rb +65 -0
  37. data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
  38. data/app/components/panda/core/admin/empty_state_component.rb +25 -0
  39. data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
  40. data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
  41. data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
  42. data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
  43. data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
  44. data/app/components/panda/core/admin/flash_message_component.rb +33 -60
  45. data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
  46. data/app/components/panda/core/admin/form_error_component.rb +8 -16
  47. data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
  48. data/app/components/panda/core/admin/form_footer_component.rb +65 -0
  49. data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
  50. data/app/components/panda/core/admin/form_input_component.rb +21 -13
  51. data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
  52. data/app/components/panda/core/admin/form_section_component.rb +64 -0
  53. data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
  54. data/app/components/panda/core/admin/form_select_component.rb +25 -26
  55. data/app/components/panda/core/admin/heading_component.html.erb +26 -0
  56. data/app/components/panda/core/admin/heading_component.rb +23 -60
  57. data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
  58. data/app/components/panda/core/admin/login_form_component.rb +28 -0
  59. data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
  60. data/app/components/panda/core/admin/main_layout_component.rb +19 -0
  61. data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
  62. data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
  63. data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
  64. data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
  65. data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
  66. data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
  67. data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
  68. data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
  69. data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
  70. data/app/components/panda/core/admin/page_header_component.rb +19 -35
  71. data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
  72. data/app/components/panda/core/admin/pagination_component.rb +56 -0
  73. data/app/components/panda/core/admin/panel_component.html.erb +19 -0
  74. data/app/components/panda/core/admin/panel_component.rb +4 -29
  75. data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
  76. data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
  77. data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
  78. data/app/components/panda/core/admin/search_bar_component.rb +31 -0
  79. data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
  80. data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
  81. data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
  82. data/app/components/panda/core/admin/secret_field_component.rb +31 -0
  83. data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
  84. data/app/components/panda/core/admin/sidebar_component.rb +55 -0
  85. data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
  86. data/app/components/panda/core/admin/slideover_component.rb +31 -70
  87. data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
  88. data/app/components/panda/core/admin/statistics_component.rb +6 -8
  89. data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
  90. data/app/components/panda/core/admin/tab_bar_component.rb +32 -67
  91. data/app/components/panda/core/admin/table_component.html.erb +82 -0
  92. data/app/components/panda/core/admin/table_component.rb +54 -87
  93. data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
  94. data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
  95. data/app/components/panda/core/admin/tag_component.html.erb +1 -0
  96. data/app/components/panda/core/admin/tag_component.rb +51 -20
  97. data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
  98. data/app/components/panda/core/admin/tag_input_component.rb +22 -0
  99. data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
  100. data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
  101. data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
  102. data/app/components/panda/core/admin/user_activity_component.rb +9 -22
  103. data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
  104. data/app/components/panda/core/admin/user_display_component.rb +19 -22
  105. data/app/components/panda/core/base.rb +35 -42
  106. data/app/components/panda/core/shared/footer_component.html.erb +2 -0
  107. data/app/components/panda/core/shared/footer_component.rb +11 -0
  108. data/app/components/panda/core/shared/header_component.html.erb +32 -0
  109. data/app/components/panda/core/shared/header_component.rb +19 -0
  110. data/app/constraints/panda/core/admin_constraint.rb +2 -2
  111. data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
  112. data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
  113. data/app/controllers/panda/core/admin/base_controller.rb +62 -3
  114. data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
  115. data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
  116. data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
  117. data/app/controllers/panda/core/admin/files_controller.rb +230 -0
  118. data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
  119. data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
  120. data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
  121. data/app/controllers/panda/core/admin/search_controller.rb +15 -0
  122. data/app/controllers/panda/core/admin/sessions_controller.rb +84 -10
  123. data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
  124. data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
  125. data/app/controllers/panda/core/admin/users_controller.rb +184 -0
  126. data/app/controllers/panda/core/application_controller.rb +13 -1
  127. data/app/helpers/panda/core/active_storage_helper.rb +24 -0
  128. data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
  129. data/app/helpers/panda/core/form_helper.rb +15 -0
  130. data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
  131. data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
  132. data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
  133. data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
  134. data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
  135. data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
  136. data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
  137. data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
  138. data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
  139. data/app/javascript/panda/core/controllers/index.js +36 -0
  140. data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
  141. data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
  142. data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
  143. data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
  144. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
  145. data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
  146. data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
  147. data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
  148. data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
  149. data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
  150. data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
  151. data/app/models/concerns/panda/core/has_metadata.rb +164 -0
  152. data/app/models/concerns/panda/core/importable.rb +79 -0
  153. data/app/models/concerns/panda/core/searchable.rb +66 -0
  154. data/app/models/concerns/panda/core/taggable.rb +37 -0
  155. data/app/models/panda/core/feature_flag.rb +56 -0
  156. data/app/models/panda/core/file_categorization.rb +14 -0
  157. data/app/models/panda/core/file_category.rb +45 -0
  158. data/app/models/panda/core/import_session.rb +79 -0
  159. data/app/models/panda/core/presence.rb +24 -0
  160. data/app/models/panda/core/tag.rb +33 -0
  161. data/app/models/panda/core/tagging.rb +14 -0
  162. data/app/models/panda/core/user.rb +94 -16
  163. data/app/models/panda/core/user_activity.rb +42 -0
  164. data/app/models/panda/core/user_session.rb +35 -0
  165. data/app/services/panda/core/attach_avatar_service.rb +15 -3
  166. data/app/services/panda/core/csv_import_service.rb +65 -0
  167. data/app/services/panda/core/file_categorizer.rb +52 -0
  168. data/app/services/panda/core/file_parser.rb +76 -0
  169. data/app/services/panda/core/invite_user_service.rb +44 -0
  170. data/app/services/panda/core/presence_service.rb +49 -0
  171. data/app/views/layouts/panda/core/admin.html.erb +6 -76
  172. data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
  173. data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
  174. data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
  175. data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
  176. data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
  177. data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
  178. data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
  179. data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
  180. data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
  181. data/app/views/panda/core/admin/files/index.html.erb +61 -0
  182. data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
  183. data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
  184. data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
  185. data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
  186. data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
  187. data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
  188. data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
  189. data/app/views/panda/core/admin/sessions/new.html.erb +1 -37
  190. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
  191. data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
  192. data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
  193. data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
  194. data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
  195. data/app/views/panda/core/admin/tags/index.html.erb +41 -0
  196. data/app/views/panda/core/admin/tags/new.html.erb +4 -0
  197. data/app/views/panda/core/admin/users/activity.html.erb +90 -0
  198. data/app/views/panda/core/admin/users/edit.html.erb +48 -0
  199. data/app/views/panda/core/admin/users/index.html.erb +161 -0
  200. data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
  201. data/app/views/panda/core/admin/users/show.html.erb +224 -0
  202. data/app/views/panda/core/shared/_footer.html.erb +1 -2
  203. data/app/views/panda/core/shared/_header.html.erb +4 -22
  204. data/config/brakeman.ignore +68 -31
  205. data/config/importmap.rb +13 -11
  206. data/config/routes.rb +49 -2
  207. data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
  208. data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
  209. data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
  210. data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
  211. data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
  212. data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
  213. data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
  214. data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
  215. data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
  216. data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
  217. data/lib/generators/panda/core/install_generator.rb +32 -0
  218. data/lib/generators/panda/core/templates/panda.rb +63 -0
  219. data/lib/panda/core/asset_loader.rb +5 -2
  220. data/lib/panda/core/authentication.rb +3 -3
  221. data/lib/panda/core/configuration.rb +195 -1
  222. data/lib/panda/core/engine/admin_controller_config.rb +3 -6
  223. data/lib/panda/core/engine/autoload_config.rb +21 -9
  224. data/lib/panda/core/engine/omniauth_config.rb +108 -38
  225. data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
  226. data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
  227. data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
  228. data/lib/panda/core/engine.rb +90 -25
  229. data/lib/panda/core/middleware.rb +35 -0
  230. data/lib/panda/core/navigation_registry.rb +455 -0
  231. data/lib/panda/core/oauth_providers.rb +27 -0
  232. data/lib/panda/core/permission_registry.rb +89 -0
  233. data/lib/panda/core/search_registry.rb +69 -0
  234. data/lib/panda/core/seeds/file_categories.rb +30 -0
  235. data/lib/panda/core/shared/inflections_config.rb +1 -5
  236. data/lib/panda/core/testing/rails_helper.rb +87 -0
  237. data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
  238. data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
  239. data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
  240. data/lib/panda/core/version.rb +1 -1
  241. data/lib/panda/core/widget_registry.rb +50 -0
  242. data/lib/panda/core.rb +17 -8
  243. data/lib/tasks/panda/core/file_categories.rake +41 -0
  244. data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
  245. data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
  246. data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
  247. data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
  248. data/public/panda-core-assets/panda-core.css +2 -2
  249. data/public/panda-core-assets/vanilla-calendar.css +47 -0
  250. metadata +203 -26
  251. data/lib/panda/core/engine/inflections_config.rb +0 -23
@@ -5,6 +5,7 @@ require "omniauth"
5
5
 
6
6
  require "panda/core/middleware"
7
7
  require "panda/core/module_registry"
8
+ require "panda/core/search_registry"
8
9
 
9
10
  # Shared engine mixins
10
11
  require_relative "shared/inflections_config"
@@ -14,9 +15,11 @@ require_relative "shared/generator_config"
14
15
  require_relative "engine/autoload_config"
15
16
  require_relative "engine/importmap_config"
16
17
  require_relative "engine/omniauth_config"
17
- require_relative "engine/phlex_config"
18
+ require_relative "engine/view_component_config"
18
19
  require_relative "engine/admin_controller_config"
19
20
  require_relative "engine/route_config"
21
+ require_relative "engine/pending_migrations_check"
22
+ require_relative "engine/sqlite_schema_compatibility"
20
23
 
21
24
  module Panda
22
25
  module Core
@@ -35,9 +38,11 @@ module Panda
35
38
  include AutoloadConfig
36
39
  include ImportmapConfig
37
40
  include OmniauthConfig
38
- include PhlexConfig
41
+ include ViewComponentConfig
39
42
  include AdminControllerConfig
40
43
  include RouteConfig
44
+ include PendingMigrationsCheck
45
+ include SqliteSchemaCompatibility
41
46
 
42
47
  #
43
48
  # Misc configuration point
@@ -46,33 +51,93 @@ module Panda
46
51
  # Intentionally quiet — used as a stable anchor point
47
52
  end
48
53
 
54
+ #
55
+ # Clean up FileCategorization records before blob destruction.
56
+ # Without this, ActiveStorage::Blob#purge silently fails because
57
+ # panda_core_file_categorizations has a FK to active_storage_blobs,
58
+ # and Rails rescues the resulting InvalidForeignKey error.
59
+ #
60
+ initializer "panda_core.active_storage_blob_cleanup" do
61
+ ActiveSupport.on_load(:active_storage_blob) do
62
+ before_destroy :cleanup_file_categorizations
63
+
64
+ private
65
+
66
+ def cleanup_file_categorizations
67
+ Panda::Core::FileCategorization.where(blob_id: id).delete_all
68
+ end
69
+ end
70
+ end
71
+
72
+ #
73
+ # Auto-categorize files when ActiveStorage attachments are created.
74
+ # Maps known attachment types (user avatars, page images, etc.) to
75
+ # their corresponding file categories via FileCategorizer.
76
+ #
77
+ initializer "panda_core.active_storage_categorization" do
78
+ ActiveSupport.on_load(:active_storage_attachment) do
79
+ after_create_commit :auto_categorize_blob
80
+
81
+ private
82
+
83
+ def auto_categorize_blob
84
+ Panda::Core::FileCategorizer.new.categorize_attachment(self)
85
+ rescue => e
86
+ Rails.logger.warn("[Panda::Core] Auto-categorization failed for blob #{blob_id}: #{e.message}")
87
+ end
88
+ end
89
+ end
90
+
91
+ #
92
+ # Ensure system file categories exist on every boot.
93
+ # Skipped in test env to avoid interfering with fixtures.
94
+ # Uses find_or_create_by! so it's idempotent.
95
+ #
96
+ initializer "panda_core.ensure_file_categories", after: "panda_core.active_storage_categorization" do
97
+ config.after_initialize do
98
+ next if Rails.env.test?
99
+ if ActiveRecord::Base.connection.table_exists?("panda_core_file_categories")
100
+ require "panda/core/seeds/file_categories"
101
+ Panda::Core::Seeds::FileCategories.seed!
102
+ end
103
+ rescue ActiveRecord::NoDatabaseError, ActiveRecord::ConnectionNotEstablished
104
+ # Skip when DB isn't available (asset precompilation, etc.)
105
+ end
106
+ end
107
+
49
108
  #
50
109
  # Static asset handling for:
51
110
  # /panda-core-assets
52
111
  #
53
- initializer "panda_core.static_assets" do |app|
54
- Panda::Core::Middleware.use(
55
- app,
56
- Rack::Static,
57
- urls: ["/panda-core-assets"],
58
- root: Panda::Core::Engine.root.join("public"),
59
- header_rules: [
60
- [
61
- :all,
62
- {
63
- "Cache-Control" =>
64
- Rails.env.development? ?
65
- "no-cache, no-store, must-revalidate" :
66
- "public, max-age=31536000"
67
- }
68
- ]
112
+ # Configured during engine load phase to avoid Rails 8.1.2+ frozen middleware stack
113
+ # Use no-cache in development for easier debugging, long cache in production
114
+ config.app_middleware.use(
115
+ Rack::Static,
116
+ urls: ["/panda-core-assets"],
117
+ root: Panda::Core::Engine.root.join("public"),
118
+ header_rules: [
119
+ [
120
+ :all,
121
+ {
122
+ "Cache-Control" => if defined?(::Rails) && ::Rails.env.development?
123
+ "no-cache, no-store, must-revalidate"
124
+ else
125
+ "public, max-age=31536000"
126
+ end
127
+ }
69
128
  ]
70
- )
129
+ ]
130
+ )
131
+
132
+ config.app_middleware.use(Panda::Core::ModuleRegistry::JavaScriptMiddleware)
71
133
 
72
- Panda::Core::Middleware.use(
73
- app,
74
- Panda::Core::ModuleRegistry::JavaScriptMiddleware
75
- )
134
+ #
135
+ # Serve Chartkick vendor assets when the chartkick gem is available
136
+ # Must be inserted BEFORE Rack::Static so it handles chartkick requests
137
+ # before Rack::Static catches all /panda-core-assets/* paths and returns 404
138
+ #
139
+ if Gem.loaded_specs["chartkick"]
140
+ config.app_middleware.insert_before(Rack::Static, Panda::Core::ChartkickAssetMiddleware)
76
141
  end
77
142
  end
78
143
  end
@@ -86,10 +151,10 @@ Panda::Core::ModuleRegistry.register(
86
151
  engine: "Panda::Core::Engine",
87
152
  paths: {
88
153
  builders: "app/builders/panda/core/**/*.rb",
89
- components: "app/components/panda/core/**/*.rb",
154
+ components: "app/components/panda/core/**/*.{rb,erb,js}",
90
155
  helpers: "app/helpers/panda/core/**/*.rb",
91
156
  views: "app/views/panda/core/**/*.erb",
92
157
  layouts: "app/views/layouts/panda/core/**/*.erb",
93
- javascripts: "app/assets/javascript/panda/core/**/*.js"
158
+ javascripts: "app/javascript/panda/core/**/*.js"
94
159
  }
95
160
  )
@@ -142,5 +142,40 @@ module Panda
142
142
  end
143
143
  private_class_method :normalize_stack
144
144
  end
145
+
146
+ # Serves Chartkick vendor JS files (Chart.bundle.js, chartkick.js) from the
147
+ # chartkick gem's vendor directory at /panda-core-assets/chartkick/*.
148
+ class ChartkickAssetMiddleware
149
+ CHARTKICK_PATH_PREFIX = "/panda-core-assets/chartkick/"
150
+ ALLOWED_FILES = %w[Chart.bundle.js chartkick.js].freeze
151
+
152
+ def initialize(app)
153
+ @app = app
154
+ @vendor_root = Pathname.new(Gem.loaded_specs["chartkick"].gem_dir).join("vendor/assets/javascripts")
155
+ end
156
+
157
+ def call(env)
158
+ path = Rack::Utils.unescape_path(env[Rack::PATH_INFO])
159
+
160
+ if path.start_with?(CHARTKICK_PATH_PREFIX)
161
+ filename = path.delete_prefix(CHARTKICK_PATH_PREFIX)
162
+ return @app.call(env) unless ALLOWED_FILES.include?(filename)
163
+
164
+ file_path = @vendor_root.join(filename)
165
+ return @app.call(env) unless file_path.file?
166
+
167
+ content = File.binread(file_path)
168
+ cache = Rails.env.local? ? "no-cache, no-store, must-revalidate" : "public, max-age=31536000"
169
+
170
+ [200, {
171
+ "Content-Type" => "application/javascript; charset=utf-8",
172
+ "Content-Length" => content.bytesize.to_s,
173
+ "Cache-Control" => cache
174
+ }, [content]]
175
+ else
176
+ @app.call(env)
177
+ end
178
+ end
179
+ end
145
180
  end
146
181
  end
@@ -0,0 +1,455 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ # Declarative registry for extending admin sidebar navigation.
6
+ #
7
+ # Gems and host apps register navigation sections and items at boot time,
8
+ # and the registry merges them with the base +admin_navigation_items+ lambda
9
+ # at render time. This avoids the fragile lambda-wrapping pattern where each
10
+ # gem must capture the existing proc, create a new one, and manipulate the
11
+ # resulting array with index lookups and insertions.
12
+ #
13
+ # == Path resolution
14
+ #
15
+ # * +path:+ — auto-prefixed with +admin_path+ (e.g. <tt>path: "feature_flags"</tt>
16
+ # becomes <tt>"/admin/feature_flags"</tt>)
17
+ # * +url:+ — used as-is, for full URLs or absolute paths
18
+ # * +path_helper:+ — a route helper symbol resolved at build time via +helpers+
19
+ # * +target:+ — HTML target attribute (e.g. <tt>"_blank"</tt>), omitted when +nil+
20
+ #
21
+ # == Visibility and filtering
22
+ #
23
+ # * +visible:+ — lambda receiving user, evaluated at build time; item/section
24
+ # hidden when false
25
+ # * +filter()+ — post-build removal of items/sections by label, conditional
26
+ # on a user-receiving lambda
27
+ #
28
+ # == Usage
29
+ #
30
+ # Convenience methods on {Panda::Core::Configuration} delegate here, so the
31
+ # typical usage is inside a +Panda::Core.configure+ block:
32
+ #
33
+ # # In a host app initializer or engine:
34
+ # Panda::Core.configure do |config|
35
+ # # Add a new section with items, positioned after "Website"
36
+ # config.insert_admin_menu_section "Members",
37
+ # icon: "fa-solid fa-users",
38
+ # after: "Website" do |section|
39
+ # section.item "Onboarding", path: "members/onboarding"
40
+ # end
41
+ #
42
+ # # Add an item to an existing section
43
+ # config.insert_admin_menu_item "Feature Flags",
44
+ # section: "Settings",
45
+ # path: "feature_flags"
46
+ #
47
+ # # Permission-based visibility
48
+ # config.insert_admin_menu_item "Suggestions",
49
+ # section: "Tools",
50
+ # path: "cms/content_suggestions",
51
+ # visible: -> (user) { user.admin? || user.has_permission?(:review_content) }
52
+ #
53
+ # # Post-build filter (hides for non-admins)
54
+ # config.filter_admin_menu "Roles",
55
+ # visible: -> (user) { user.admin? }
56
+ #
57
+ # # Bottom section (rendered with UserMenuComponent)
58
+ # config.insert_admin_menu_section "Notifications",
59
+ # position: :bottom do |s|
60
+ # s.item "All", path: "notifications"
61
+ # end
62
+ # end
63
+ #
64
+ # You can also call the class methods directly:
65
+ #
66
+ # Panda::Core::NavigationRegistry.section("Tools", icon: "fa-solid fa-wrench")
67
+ # Panda::Core::NavigationRegistry.item("Database", section: "Tools", path: "tools/database")
68
+ #
69
+ # == Build order
70
+ #
71
+ # {.build} is called once per request by the sidebar component:
72
+ #
73
+ # 1. The base +admin_navigation_items+ lambda is called (backward compatible)
74
+ # 2. Registered sections are inserted (skipped if a section with the same label
75
+ # already exists in the base)
76
+ # 3. Registered items are appended to their target sections
77
+ # 4. Legacy +admin_user_menu_items+ are migrated into the "My Account" bottom section
78
+ # 5. Visibility lambdas are evaluated; items/sections hidden when +visible:+ returns false
79
+ # 6. Post-build filters are applied
80
+ #
81
+ # Paths are resolved at build time, so +admin_path+ can be configured after
82
+ # registrations are made.
83
+ class NavigationRegistry
84
+ # Collects items added inside a {.section} block.
85
+ #
86
+ # @example
87
+ # NavigationRegistry.section("Members", icon: "fa-solid fa-users") do |s|
88
+ # s.item "Onboarding", path: "members/onboarding"
89
+ # s.item "Directory", path: "members/directory"
90
+ # end
91
+ class SectionContext
92
+ attr_reader :items
93
+
94
+ def initialize
95
+ @items = []
96
+ end
97
+
98
+ # Register an item within this section.
99
+ # @param label [String] Display label
100
+ # @param path [String, nil] Relative path (auto-prefixed with admin_path at build time)
101
+ # @param url [String, nil] Absolute URL (used as-is)
102
+ # @param target [String, nil] HTML target attribute (e.g. "_blank")
103
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
104
+ # @param permission [Symbol, nil] Permission key — item hidden unless user is authorized.
105
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
106
+ # @param method [Symbol, nil] HTTP method (e.g. :delete for logout buttons)
107
+ # @param button_options [Hash] Extra options for button_to rendering
108
+ # @param path_helper [Symbol, nil] Route helper name, resolved at build time via helpers
109
+ # rubocop:disable Metrics/ParameterLists
110
+ def item(label, path: nil, url: nil, target: nil, visible: nil, permission: nil,
111
+ method: nil, button_options: {}, path_helper: nil, badge: nil, badge_color: nil)
112
+ effective_visible = visible || permission_to_visible(permission)
113
+ @items << {
114
+ label: label, path: path, url: url, target: target,
115
+ visible: effective_visible, method: method, button_options: button_options,
116
+ path_helper: path_helper, badge: badge, badge_color: badge_color
117
+ }
118
+ end
119
+ # rubocop:enable Metrics/ParameterLists
120
+
121
+ private
122
+
123
+ # Convert a permission key into a visibility lambda.
124
+ # @param permission [Symbol, nil]
125
+ # @return [Proc, nil]
126
+ def permission_to_visible(permission)
127
+ return nil unless permission
128
+
129
+ ->(user) {
130
+ next true if user.respond_to?(:admin?) && user.admin?
131
+ policy = Panda::Core.config.authorization_policy
132
+ policy&.call(user, permission, nil) || false
133
+ }
134
+ end
135
+ end
136
+
137
+ @sections = []
138
+ @items = []
139
+ @filters = []
140
+
141
+ class << self
142
+ attr_reader :sections, :items, :filters
143
+
144
+ # Register a new navigation section.
145
+ #
146
+ # If a section with the same label already exists in the base navigation
147
+ # (from the +admin_navigation_items+ lambda), the registration is skipped
148
+ # at build time — this prevents duplicate sections.
149
+ #
150
+ # @param label [String] Section label displayed in the sidebar
151
+ # @param icon [String] FontAwesome icon class (e.g. "fa-solid fa-users")
152
+ # @param after [String, nil] Insert after the section with this label
153
+ # @param before [String, nil] Insert before the section with this label
154
+ # @param visible [Proc, nil] Lambda receiving user, hides section when false
155
+ # @param permission [Symbol, nil] Permission key — section hidden unless user is authorized.
156
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
157
+ # @param position [Symbol] :top (default) or :bottom — controls sidebar placement
158
+ # @yield [SectionContext] Optional block for adding items to the section
159
+ #
160
+ # @example Add a section with items after "Website"
161
+ # section("Members", icon: "fa-solid fa-users", after: "Website") do |s|
162
+ # s.item "Onboarding", path: "members/onboarding"
163
+ # end
164
+ #
165
+ # @example Add a permission-gated section
166
+ # section("Settings", icon: "fa-solid fa-gear", permission: :manage_settings)
167
+ #
168
+ # @example Add a bottom section (rendered with UserMenuComponent)
169
+ # section("Notifications", position: :bottom) do |s|
170
+ # s.item "All", path: "notifications"
171
+ # end
172
+ # rubocop:disable Metrics/ParameterLists
173
+ def section(label, icon: nil, after: nil, before: nil, visible: nil, permission: nil, position: :top, &block)
174
+ context = SectionContext.new
175
+ yield context if block
176
+
177
+ effective_visible = visible || permission_to_visible(permission)
178
+
179
+ @sections << {
180
+ label: label,
181
+ icon: icon,
182
+ after: after,
183
+ before: before,
184
+ visible: effective_visible,
185
+ position: position,
186
+ items: context.items
187
+ }
188
+ end
189
+ # rubocop:enable Metrics/ParameterLists
190
+
191
+ # Register an item to be appended to a named section.
192
+ #
193
+ # The target section can come from either the base lambda or a
194
+ # previously registered section. If the section doesn't exist at
195
+ # build time, the item is silently skipped.
196
+ #
197
+ # @param label [String] Item label displayed in the sidebar
198
+ # @param section [String] Label of the target section to add to
199
+ # @param path [String, nil] Relative path (auto-prefixed with admin_path)
200
+ # @param url [String, nil] Absolute URL (used as-is, no prefixing)
201
+ # @param target [String, nil] HTML target attribute (e.g. "_blank")
202
+ # @param visible [Proc, nil] Lambda receiving user, hides item when false
203
+ # @param permission [Symbol, nil] Permission key — item hidden unless user is authorized.
204
+ # Checked via +config.authorization_policy+. Overridden by +visible:+ if both given.
205
+ # @param before [Symbol, String, nil] :all to insert at beginning, or label to insert before
206
+ # @param after [Symbol, String, nil] :all to insert at end (default), or label to insert after
207
+ # @param method [Symbol, nil] HTTP method (e.g. :delete for logout buttons)
208
+ # @param button_options [Hash] Extra options for button_to rendering
209
+ # @param path_helper [Symbol, nil] Route helper name, resolved at build time via helpers
210
+ #
211
+ # @example Add to an existing section with auto-prefixed path
212
+ # item("Feature Flags", section: "Settings", path: "feature_flags")
213
+ #
214
+ # @example Permission-gated item
215
+ # item("Roles", section: "Settings", path: "cms/roles",
216
+ # permission: :manage_roles)
217
+ # rubocop:disable Metrics/ParameterLists
218
+ def item(label, section:, path: nil, url: nil, target: nil, visible: nil, permission: nil,
219
+ before: nil, after: nil, method: nil, button_options: {}, path_helper: nil)
220
+ effective_visible = visible || permission_to_visible(permission)
221
+ @items << {
222
+ label: label,
223
+ section: section,
224
+ path: path,
225
+ url: url,
226
+ target: target,
227
+ visible: effective_visible,
228
+ before: before,
229
+ after: after,
230
+ method: method,
231
+ button_options: button_options,
232
+ path_helper: path_helper
233
+ }
234
+ end
235
+ # rubocop:enable Metrics/ParameterLists
236
+
237
+ # Register a post-build filter that conditionally hides items by label.
238
+ # Walks all sections and their children during build().
239
+ # @param label [String] Label to match
240
+ # @param visible [Proc] Lambda receiving user — item hidden when false
241
+ def filter(label, visible:)
242
+ @filters << {label: label, visible: visible}
243
+ end
244
+
245
+ # Build the final navigation array for the current user.
246
+ #
247
+ # Called once per request by the sidebar component. Combines the base
248
+ # +admin_navigation_items+ lambda with all registered sections, items,
249
+ # and filters.
250
+ #
251
+ # @param user [Object] The current authenticated user
252
+ # @param helpers [Object, nil] View helpers for resolving path_helper: symbols
253
+ # @return [Array<Hash>] Navigation items ready for rendering
254
+ def build(user, helpers: nil)
255
+ base = Panda::Core.config.admin_navigation_items&.call(user) || []
256
+ admin_path = Panda::Core.config.admin_path
257
+
258
+ # Convert permission: keys from base lambda items into _visible lambdas
259
+ apply_permission_visibility!(base)
260
+
261
+ # Apply registered sections
262
+ @sections.each do |section|
263
+ # Skip if a section with this label already exists in base
264
+ next if base.any? { |item| item[:label] == section[:label] }
265
+
266
+ entry = {label: section[:label], icon: section[:icon], position: section[:position], _visible: section[:visible]}
267
+ if section[:items].any?
268
+ entry[:children] = section[:items].map { |item|
269
+ resolved = resolve_item(item, admin_path, helpers)
270
+ resolved[:_visible] = item[:visible]
271
+ resolved
272
+ }
273
+ end
274
+
275
+ insert_section(base, entry, after: section[:after], before: section[:before])
276
+ end
277
+
278
+ # Apply registered items to their target sections
279
+ @items.each do |item|
280
+ target_section = base.find { |s| s[:label] == item[:section] }
281
+ next unless target_section
282
+
283
+ target_section[:children] ||= []
284
+ resolved = resolve_item(item, admin_path, helpers)
285
+ resolved[:_visible] = item[:visible]
286
+
287
+ insert_item(target_section[:children], resolved, before: item[:before], after: item[:after])
288
+ end
289
+
290
+ # Migrate legacy admin_user_menu_items into the "My Account" bottom section
291
+ legacy_items = Panda::Core.config.admin_user_menu_items
292
+ if legacy_items&.any?
293
+ my_account = base.find { |s| s[:label] == "My Account" && s[:position] == :bottom }
294
+ if my_account
295
+ legacy_items.each do |menu_item|
296
+ next if menu_item[:path].blank? && menu_item[:url].blank?
297
+ resolved = {label: menu_item[:label], path: menu_item[:path]}
298
+ resolved[:_visible] = menu_item[:visible]
299
+ # Insert before Logout if it exists
300
+ logout_idx = my_account[:children]&.index { |c| c[:label] == "Logout" }
301
+ if logout_idx
302
+ my_account[:children].insert(logout_idx, resolved)
303
+ else
304
+ (my_account[:children] ||= []) << resolved
305
+ end
306
+ end
307
+ end
308
+ end
309
+
310
+ # Apply section-level visible: — remove sections hidden for this user
311
+ base.reject! { |entry| entry[:_visible] && !entry[:_visible].call(user) }
312
+
313
+ # Apply item-level visible: — remove children hidden for this user
314
+ base.each do |entry|
315
+ next unless entry[:children]
316
+ entry[:children].reject! { |child| child[:_visible] && !child[:_visible].call(user) }
317
+ end
318
+
319
+ # Apply registered filters — walk all sections and children
320
+ @filters.each do |filter|
321
+ base.reject! { |entry| entry[:label] == filter[:label] && !filter[:visible].call(user) }
322
+ base.each do |entry|
323
+ next unless entry[:children]
324
+ entry[:children].reject! { |child| child[:label] == filter[:label] && !filter[:visible].call(user) }
325
+ end
326
+ end
327
+
328
+ # Clean up internal keys before returning
329
+ base.each do |entry|
330
+ entry.delete(:_visible)
331
+ entry[:position] ||= :top
332
+ entry[:children]&.each { |child| child.delete(:_visible) }
333
+ end
334
+
335
+ base
336
+ end
337
+
338
+ # Clear all registrations and re-register defaults (for test isolation).
339
+ # This ensures the default "My Account" bottom section is always present
340
+ # after a reset, preventing order-dependent test failures.
341
+ def reset!
342
+ @sections = []
343
+ @items = []
344
+ @filters = []
345
+ Panda::Core.config.send(:register_default_user_menu)
346
+ end
347
+
348
+ private
349
+
350
+ # Convert permission: keys into _visible lambdas for base nav items.
351
+ # Walks sections and their children from the base lambda.
352
+ def apply_permission_visibility!(items)
353
+ items.each do |item|
354
+ if item[:permission] && !item[:_visible]
355
+ item[:_visible] = permission_to_visible(item.delete(:permission))
356
+ end
357
+ next unless item[:children]
358
+ item[:children].each do |child|
359
+ if child[:permission] && !child[:_visible]
360
+ child[:_visible] = permission_to_visible(child.delete(:permission))
361
+ end
362
+ end
363
+ end
364
+ end
365
+
366
+ # Convert a permission key into a visibility lambda.
367
+ # Admin users always pass; others are checked via authorization_policy.
368
+ # @param permission [Symbol, nil]
369
+ # @return [Proc, nil]
370
+ def permission_to_visible(permission)
371
+ return nil unless permission
372
+
373
+ ->(user) {
374
+ next true if user.respond_to?(:admin?) && user.admin?
375
+ policy = Panda::Core.config.authorization_policy
376
+ policy&.call(user, permission, nil) || false
377
+ }
378
+ end
379
+
380
+ # Resolve path:, url:, or path_helper: into a final :path value.
381
+ # Includes :target, :method, :button_options when present.
382
+ def resolve_item(item, admin_path, helpers = nil)
383
+ resolved = {label: item[:label]}
384
+
385
+ if item[:path_helper] && helpers
386
+ resolved[:path] = helpers.panda_core.public_send(item[:path_helper])
387
+ elsif item[:url]
388
+ resolved[:path] = item[:url]
389
+ elsif item[:path]
390
+ resolved[:path] = "#{admin_path}/#{item[:path]}"
391
+ elsif item[:path_helper]
392
+ # Keep path_helper for template-level resolution when helpers unavailable
393
+ resolved[:path_helper] = item[:path_helper]
394
+ end
395
+
396
+ resolved[:target] = item[:target] if item[:target]
397
+ resolved[:method] = item[:method] if item[:method]
398
+ resolved[:button_options] = item[:button_options] if item[:button_options]&.any?
399
+ resolved[:badge] = item[:badge] if item[:badge]
400
+ resolved[:badge_color] = item[:badge_color] if item[:badge_color]
401
+ resolved
402
+ end
403
+
404
+ # Insert a section entry into the base array at the correct position.
405
+ def insert_section(base, entry, after: nil, before: nil)
406
+ if after
407
+ index = base.index { |item| item[:label] == after }
408
+ if index
409
+ base.insert(index + 1, entry)
410
+ return
411
+ end
412
+ end
413
+
414
+ if before
415
+ index = base.index { |item| item[:label] == before }
416
+ if index
417
+ base.insert(index, entry)
418
+ return
419
+ end
420
+ end
421
+
422
+ # Default: append to end
423
+ base << entry
424
+ end
425
+
426
+ # Insert an item into a section's children at the correct position.
427
+ def insert_item(children, entry, before: nil, after: nil)
428
+ if before == :all
429
+ children.insert(0, entry)
430
+ return
431
+ end
432
+
433
+ if before.is_a?(String)
434
+ index = children.index { |child| child[:label] == before }
435
+ if index
436
+ children.insert(index, entry)
437
+ return
438
+ end
439
+ end
440
+
441
+ if after.is_a?(String)
442
+ index = children.index { |child| child[:label] == after }
443
+ if index
444
+ children.insert(index + 1, entry)
445
+ return
446
+ end
447
+ end
448
+
449
+ # Default (including after: :all): append to end
450
+ children << entry
451
+ end
452
+ end
453
+ end
454
+ end
455
+ end