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
@@ -1,6 +1,25 @@
1
1
  module Panda
2
2
  module Core
3
3
  module OAuthProviders
4
+ # Provider metadata for display purposes
5
+ PROVIDER_INFO = {
6
+ microsoft_graph: {
7
+ name: "Microsoft",
8
+ icon: "fa-brands fa-microsoft",
9
+ color: "#00a4ef"
10
+ },
11
+ google_oauth2: {
12
+ name: "Google",
13
+ icon: "fa-brands fa-google",
14
+ color: "#4285f4"
15
+ },
16
+ github: {
17
+ name: "GitHub",
18
+ icon: "fa-brands fa-github",
19
+ color: "#333"
20
+ }
21
+ }.freeze
22
+
4
23
  def self.setup
5
24
  providers = []
6
25
 
@@ -27,6 +46,14 @@ module Panda
27
46
 
28
47
  providers
29
48
  end
49
+
50
+ def self.info(provider)
51
+ PROVIDER_INFO[provider.to_sym] || {
52
+ name: provider.to_s.titleize,
53
+ icon: "fa-solid fa-circle-user",
54
+ color: "#6b7280"
55
+ }
56
+ end
30
57
  end
31
58
  end
32
59
  end
@@ -0,0 +1,89 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ # Registry for controller-level permission requirements.
6
+ #
7
+ # Gems and host apps register which permissions are needed for each
8
+ # controller action. The {AdminAuthorization} concern reads this registry
9
+ # at request time to enforce access control.
10
+ #
11
+ # == Usage
12
+ #
13
+ # # In a gem engine (config.to_prepare or initializer):
14
+ # Panda::Core::PermissionRegistry.register(
15
+ # "Panda::CMS::Admin::PagesController",
16
+ # index: :edit_content,
17
+ # edit: :edit_content,
18
+ # destroy: :delete_content
19
+ # )
20
+ #
21
+ # # In a host app initializer:
22
+ # Panda::Core::PermissionRegistry.register(
23
+ # "Admin::HealthcareProvidersController",
24
+ # index: :manage_providers,
25
+ # destroy: :manage_providers
26
+ # )
27
+ #
28
+ # Actions not registered are allowed for any authenticated admin user.
29
+ # Controllers not registered are also allowed (open by default).
30
+ #
31
+ # == How enforcement works
32
+ #
33
+ # The {AdminAuthorization} concern calls {.permission_for} to look up
34
+ # the required permission, then delegates to +authorize!+ (from
35
+ # {Authorizable}) which checks +config.authorization_policy+.
36
+ #
37
+ # Without panda-cms-pro, the default policy is +user.admin?+ — so only
38
+ # admins pass permission checks. With Pro loaded, the policy uses the
39
+ # role system, allowing non-admin users with the right permissions.
40
+ class PermissionRegistry
41
+ class << self
42
+ # Register permission requirements for a controller.
43
+ #
44
+ # Merges with any previously registered permissions for the same
45
+ # controller, so multiple gems can contribute to the same controller.
46
+ #
47
+ # @param controller_name [String] Fully-qualified controller class name
48
+ # @param permissions [Hash{Symbol => Symbol}] action → permission_key mapping
49
+ def register(controller_name, **permissions)
50
+ registry[controller_name] = (registry[controller_name] || {}).merge(permissions)
51
+ end
52
+
53
+ # Look up the required permission for a specific controller action.
54
+ #
55
+ # @param controller_name [String] Fully-qualified controller class name
56
+ # @param action [Symbol] The controller action name
57
+ # @return [Symbol, nil] The required permission key, or nil if not registered
58
+ def permission_for(controller_name, action)
59
+ registry.dig(controller_name, action)
60
+ end
61
+
62
+ # Return all registered permissions for a controller.
63
+ #
64
+ # @param controller_name [String] Fully-qualified controller class name
65
+ # @return [Hash{Symbol => Symbol}, nil] action → permission_key mapping
66
+ def permissions_for(controller_name)
67
+ registry[controller_name]
68
+ end
69
+
70
+ # Return the full registry (deep copy).
71
+ # @return [Hash]
72
+ def all
73
+ registry.transform_values(&:dup)
74
+ end
75
+
76
+ # Clear all registrations (for test isolation).
77
+ def reset!
78
+ @registry = {}
79
+ end
80
+
81
+ private
82
+
83
+ def registry
84
+ @registry ||= {}
85
+ end
86
+ end
87
+ end
88
+ end
89
+ end
@@ -0,0 +1,69 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class SearchRegistry
6
+ @providers = []
7
+
8
+ class << self
9
+ attr_reader :providers
10
+
11
+ # Register a search provider (idempotent — overwrites any existing provider with the same name)
12
+ # @param name [String] Provider name (e.g., "pages", "posts")
13
+ # @param search_class [Class] Class that responds to .editor_search(query, limit:)
14
+ # and optionally .admin_search(query, limit:) for richer global search results
15
+ def register(name:, search_class:)
16
+ @providers.reject! { |p| p[:name] == name }
17
+ @providers << {name: name, search_class: search_class}
18
+ end
19
+
20
+ # Search all registered providers (editor format)
21
+ # @param query [String] Search query
22
+ # @param limit [Integer] Max results per provider
23
+ # @return [Array<Hash>] Array of { href:, name:, description: }
24
+ def search(query, limit: 5)
25
+ return [] if query.blank?
26
+
27
+ @providers.flat_map do |provider|
28
+ provider[:search_class].editor_search(query, limit: limit)
29
+ rescue => e
30
+ Rails.logger.warn("[panda-core] Search provider #{provider[:name]} failed: #{e.message}") if defined?(Rails.logger)
31
+ []
32
+ end
33
+ end
34
+
35
+ # Search all registered providers (admin format with grouping)
36
+ # @param query [String] Search query
37
+ # @param limit [Integer] Max results per provider
38
+ # @return [Hash] { groups: [{ name:, icon:, results: [{ href:, name:, description: }] }] }
39
+ def admin_search(query, limit: 5)
40
+ return {groups: []} if query.blank?
41
+
42
+ groups = @providers.filter_map do |provider|
43
+ klass = provider[:search_class]
44
+ next unless klass.respond_to?(:admin_search)
45
+
46
+ results = klass.admin_search(query, limit: limit)
47
+ next if results.empty?
48
+
49
+ config = klass.respond_to?(:searchable_config) ? klass.searchable_config : {}
50
+ {
51
+ name: config[:group] || provider[:name].to_s.titleize,
52
+ icon: config[:icon],
53
+ results: results
54
+ }
55
+ rescue => e
56
+ Rails.logger.warn("[panda-core] Admin search provider #{provider[:name]} failed: #{e.message}") if defined?(Rails.logger)
57
+ nil
58
+ end
59
+
60
+ {groups: groups}
61
+ end
62
+
63
+ def reset!
64
+ @providers = []
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Seeds
6
+ module FileCategories
7
+ DEFAULTS = [
8
+ {name: "Media Library", slug: "media-library", icon: "fa-solid fa-photo-film", position: 0},
9
+ {name: "Page Images", slug: "page-images", icon: "fa-solid fa-file-image", position: 1},
10
+ {name: "Post Images", slug: "post-images", icon: "fa-solid fa-newspaper", position: 2},
11
+ {name: "User Avatars", slug: "user-avatars", icon: "fa-solid fa-user-circle", position: 3},
12
+ {name: "Website Avatars", slug: "website-avatars", icon: "fa-solid fa-users", position: 4},
13
+ {name: "Form Uploads", slug: "form-uploads", icon: "fa-solid fa-file-arrow-up", position: 5},
14
+ {name: "Social Media", slug: "social-media", icon: "fab fa-instagram", position: 6}
15
+ ].freeze
16
+
17
+ def self.seed!
18
+ DEFAULTS.each do |attrs|
19
+ Panda::Core::FileCategory.find_or_create_by!(slug: attrs[:slug]) do |category|
20
+ category.name = attrs[:name]
21
+ category.icon = attrs[:icon]
22
+ category.position = attrs[:position]
23
+ category.system = true
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -9,13 +9,9 @@ module Panda
9
9
  extend ActiveSupport::Concern
10
10
 
11
11
  included do
12
- # Load inflections early to ensure proper constant resolution
13
12
  initializer "panda.inflections", before: :load_config_initializers do
14
13
  ActiveSupport::Inflector.inflections(:en) do |inflect|
15
- inflect.acronym "CMS"
16
- inflect.acronym "SEO"
17
- inflect.acronym "AI"
18
- inflect.acronym "UUID"
14
+ Panda::Core::ACRONYMS.each { |acronym| inflect.acronym acronym }
19
15
  end
20
16
  end
21
17
  end
@@ -16,6 +16,32 @@ require "shoulda/matchers"
16
16
  require "capybara"
17
17
  require "capybara/rspec"
18
18
  require "puma"
19
+ require "action_controller/test_case"
20
+ require "view_component/test_helpers"
21
+ require_relative "view_component_test_controller"
22
+
23
+ # Configure ViewComponent to use our test controller
24
+ if defined?(ViewComponent) && Rails.application && Rails.application.config.respond_to?(:view_component)
25
+ Rails.application.config.view_component.test_controller = "ViewComponentTestController"
26
+ end
27
+
28
+ # Ensure test schema is up to date, auto-registering engine migration versions
29
+ # that db:schema:load doesn't track. When schema:load creates tables from
30
+ # schema.rb, it only records the max version in schema_migrations. Engine
31
+ # migrations appear as "pending" even though their columns already exist.
32
+ begin
33
+ ActiveRecord::Migration.maintain_test_schema!
34
+ rescue ActiveRecord::PendingMigrationError
35
+ sm = ActiveRecord::SchemaMigration.new(ActiveRecord::Base.connection_pool)
36
+ existing = sm.versions.map(&:to_i).to_set
37
+ migrator = ActiveRecord::MigrationContext.new(ActiveRecord::Migrator.migrations_paths)
38
+ missing = migrator.migrations.map(&:version).reject { |v| existing.include?(v) }
39
+ if missing.any?
40
+ missing.each { |v| sm.create_version(v.to_s) }
41
+ else
42
+ raise
43
+ end
44
+ end
19
45
 
20
46
  # Configure fixtures
21
47
  ActiveRecord::FixtureSet.context_class.send :include, ActiveSupport::Testing::TimeHelpers
@@ -75,6 +101,13 @@ RSpec.configure do |config|
75
101
  config.include Rails::Controller::Testing::Integration, type: :controller
76
102
  end
77
103
 
104
+ # ViewComponent testing support (if view_component is available)
105
+ if defined?(ViewComponent::TestHelpers)
106
+ config.include ViewComponent::TestHelpers, type: :component
107
+ config.include Capybara::RSpecMatchers, type: :component
108
+ config.include Panda::Core::AssetHelper, type: :component
109
+ end
110
+
78
111
  # Reset column information before suite
79
112
  config.before(:suite) do
80
113
  if defined?(Panda::Core::User)
@@ -163,6 +196,60 @@ RSpec.configure do |config|
163
196
  # OmniAuth test mode
164
197
  OmniAuth.config.test_mode = true if defined?(OmniAuth)
165
198
 
199
+ # Stub Panda Core helpers for component tests
200
+ config.before(:each, type: :component) do
201
+ if defined?(ViewComponent::TestHelpers)
202
+ allow_any_instance_of(ActionView::Base).to receive(:panda_core_stylesheet).and_return("")
203
+ allow_any_instance_of(ActionView::Base).to receive(:panda_core_javascript).and_return("")
204
+ allow_any_instance_of(ActionView::Base).to receive(:csrf_meta_tags).and_return("")
205
+ allow_any_instance_of(ActionView::Base).to receive(:csp_meta_tag).and_return("")
206
+ allow_any_instance_of(ActionView::Base).to receive(:controller).and_return(
207
+ double(
208
+ class: double(name: "Test"),
209
+ protect_against_forgery?: false,
210
+ content_security_policy?: false
211
+ )
212
+ )
213
+
214
+ # Stub link_to and button_to to avoid routing complexity in component tests
215
+ allow_any_instance_of(ActionView::Base).to receive(:link_to) do |*args, &block|
216
+ href = args[0].is_a?(Hash) ? "#" : args[0]
217
+ options = args.find { |a| a.is_a?(Hash) } || {}
218
+ css_class = options[:class] || ""
219
+ content = block ? block.call : args[1] || href
220
+ "<a href=\"#{href}\" class=\"#{css_class}\">#{content}</a>".html_safe
221
+ end
222
+
223
+ allow_any_instance_of(ActionView::Base).to receive(:button_to) do |*args, &block|
224
+ args[0]
225
+ options = args[1] || {}
226
+ css_class = options[:class] || ""
227
+ content = block ? block.call : "Button"
228
+ id_attr = options[:id] ? " id=\"#{options[:id]}\"" : ""
229
+ "<button class=\"#{css_class}\"#{id_attr}>#{content}</button>".html_safe
230
+ end
231
+
232
+ # Stub panda_core routes to return simple paths
233
+ panda_core_routes = double("panda_core_routes")
234
+ allow(panda_core_routes).to receive(:admin_logout_path).and_return("/admin/logout")
235
+ allow(panda_core_routes).to receive(:admin_my_profile_path).and_return("/admin/my_profile")
236
+ allow_any_instance_of(ActionView::Base).to receive(:panda_core).and_return(panda_core_routes)
237
+
238
+ # Stub current_user
239
+ mock_user = double(
240
+ "User",
241
+ name: "Test User",
242
+ avatar: double(attached?: false),
243
+ image_url: ""
244
+ )
245
+ allow_any_instance_of(ActionView::Base).to receive(:current_user).and_return(mock_user)
246
+
247
+ # Stub main_app routes
248
+ main_app_routes = double(url_for: "/test")
249
+ allow_any_instance_of(ActionView::Base).to receive(:main_app).and_return(main_app_routes)
250
+ end
251
+ end
252
+
166
253
  # DatabaseCleaner configuration
167
254
  config.around(:each) do |example|
168
255
  DatabaseCleaner.strategy = (example.metadata[:type] == :system) ? :truncation : :transaction
@@ -18,30 +18,34 @@ module Panda
18
18
  def create_admin_user(attributes = {})
19
19
  ensure_columns_loaded
20
20
  admin_id = "8f481fcb-d9c8-55d7-ba17-5ea5d9ed8b7a"
21
- Panda::Core::User.find_or_create_by!(id: admin_id) do |user|
22
- user.email = attributes[:email] || "admin@example.com"
23
- user.name = attributes[:name] || "Admin User"
24
- user.image_url = attributes[:image_url] || default_image_url
25
- user.admin = attributes[:admin] || true
26
- # Only set OAuth fields if they exist on the model
27
- user.uid = attributes[:uid] || "admin_oauth_uid_123" if user.respond_to?(:uid=)
28
- user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
29
- end
21
+ user = Panda::Core::User.find_or_initialize_by(id: admin_id)
22
+ user.email = attributes[:email] || "admin@example.com"
23
+ user.name = attributes[:name] || "Admin User"
24
+ user.image_url = attributes[:image_url] || default_image_url
25
+ user.admin = attributes.fetch(:admin, true)
26
+ user.enabled = attributes.fetch(:enabled, true) if user.respond_to?(:enabled=)
27
+ # Only set OAuth fields if they exist on the model
28
+ user.uid = attributes[:uid] || "admin_oauth_uid_123" if user.respond_to?(:uid=)
29
+ user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
30
+ user.save! if user.new_record? || user.changed?
31
+ user
30
32
  end
31
33
 
32
34
  # Create a regular user with fixed ID for consistent fixture references
33
35
  def create_regular_user(attributes = {})
34
36
  ensure_columns_loaded
35
37
  regular_id = "9a8b7c6d-5e4f-3a2b-1c0d-9e8f7a6b5c4d"
36
- Panda::Core::User.find_or_create_by!(id: regular_id) do |user|
37
- user.email = attributes[:email] || "user@example.com"
38
- user.name = attributes[:name] || "Regular User"
39
- user.image_url = attributes[:image_url] || default_image_url(dark: true)
40
- user.admin = attributes[:admin] || false
41
- # Only set OAuth fields if they exist on the model
42
- user.uid = attributes[:uid] || "user_oauth_uid_456" if user.respond_to?(:uid=)
43
- user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
44
- end
38
+ user = Panda::Core::User.find_or_initialize_by(id: regular_id)
39
+ user.email = attributes[:email] || "user@example.com"
40
+ user.name = attributes[:name] || "Regular User"
41
+ user.image_url = attributes[:image_url] || default_image_url(dark: true)
42
+ user.admin = attributes.fetch(:admin, false)
43
+ user.enabled = attributes.fetch(:enabled, true) if user.respond_to?(:enabled=)
44
+ # Only set OAuth fields if they exist on the model
45
+ user.uid = attributes[:uid] || "user_oauth_uid_456" if user.respond_to?(:uid=)
46
+ user.provider = attributes[:provider] || "google_oauth2" if user.respond_to?(:provider=)
47
+ user.save! if user.new_record? || user.changed?
48
+ user
45
49
  end
46
50
 
47
51
  # Backwards compatibility with fixture access patterns
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "pathname"
3
+ require "shellwords"
4
4
 
5
5
  # Helper methods for Cuprite-based system tests
6
6
  #
@@ -74,9 +74,9 @@ module Panda
74
74
  File.binwrite(File.join(png_dir, "frame-%05d.png" % i), Base64.decode64(data))
75
75
  end
76
76
 
77
- system <<~CMD
78
- ffmpeg -y -framerate 8 -pattern_type glob -i '#{png_dir}/*.png' -c:v libx264 -pix_fmt yuv420p '#{path}'
79
- CMD
77
+ escaped_png_dir = Shellwords.escape(File.join(png_dir, "*.png"))
78
+ escaped_path = Shellwords.escape(path.to_s)
79
+ system("ffmpeg -y -framerate 8 -pattern_type glob -i #{escaped_png_dir} -c:v libx264 -pix_fmt yuv420p #{escaped_path}")
80
80
  end
81
81
 
82
82
  puts "🎥 Saved video: #{path}"
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Simple controller for ViewComponent testing
4
+ class ViewComponentTestController < ActionController::Base
5
+ # Disable CSRF protection for test controller
6
+ # This is intentional as this controller is only used for component testing
7
+ protect_from_forgery with: :null_session
8
+
9
+ # Include necessary helpers
10
+ include Rails.application.routes.url_helpers if defined?(Rails.application)
11
+
12
+ # Set up a basic layout
13
+ layout false
14
+
15
+ # Make helpers available
16
+ def helpers
17
+ ActionController::Base.helpers
18
+ end
19
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Panda
4
4
  module Core
5
- VERSION = "0.12.5"
5
+ VERSION = "1.1.0"
6
6
  end
7
7
  end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class WidgetRegistry
6
+ @widgets = []
7
+
8
+ class << self
9
+ attr_reader :widgets
10
+
11
+ # Register a dashboard widget.
12
+ # Duplicate labels are replaced (last registration wins).
13
+ # @param label [String] Widget label (used for identification and deduplication)
14
+ # @param component [Proc] Lambda receiving user, returns an instantiated component
15
+ # @param visible [Proc, nil] Lambda receiving user, hides widget when false
16
+ # @param position [Integer] Sort order (lower numbers appear first)
17
+ def register(label, component:, visible: nil, position: 0)
18
+ widget = {label: label, component: component, visible: visible, position: position}
19
+ if (index = @widgets.index { |w| w[:label] == label })
20
+ @widgets[index] = widget
21
+ else
22
+ @widgets << widget
23
+ end
24
+ end
25
+
26
+ # Build the widget list for the current user.
27
+ # Combines the legacy admin_dashboard_widgets lambda with registered widgets.
28
+ # @param user [Object] The current user
29
+ # @return [Array] Instantiated widget components
30
+ def build(user)
31
+ # Legacy backward-compatible widgets from the lambda
32
+ legacy = Panda::Core.config.admin_dashboard_widgets&.call(user) || []
33
+
34
+ # Registered widgets — filter by visibility, sort by position, instantiate
35
+ registered = @widgets
36
+ .select { |w| w[:visible].nil? || w[:visible].call(user) }
37
+ .sort_by { |w| w[:position] }
38
+ .map { |w| w[:component].call(user) }
39
+
40
+ legacy + registered
41
+ end
42
+
43
+ # Clear all registrations (for test isolation).
44
+ def reset!
45
+ @widgets = []
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
data/lib/panda/core.rb CHANGED
@@ -4,18 +4,19 @@ require "rails"
4
4
  # require "active_support/core_ext/module/attribute_accessors"
5
5
 
6
6
  # Load inflections early so they're available for generators
7
- # This must happen before any code tries to camelize/classify strings
8
- # containing "cms", "seo", "ai", or "uuid"
9
7
  require "active_support/inflector"
10
- ActiveSupport::Inflector.inflections(:en) do |inflect|
11
- inflect.acronym "CMS"
12
- inflect.acronym "SEO"
13
- inflect.acronym "AI"
14
- inflect.acronym "UUID"
15
- end
16
8
 
17
9
  module Panda
18
10
  module Core
11
+ # Single source of truth for acronyms used across all Panda gems.
12
+ # Referenced by Shared::InflectionsConfig (engine initializer) and
13
+ # applied eagerly below for generator/CLI contexts.
14
+ ACRONYMS = %w[CMS CSV SEO AI URL UUID].freeze
15
+
16
+ # Session key for storing authenticated Panda Core admin user ID
17
+ # Namespaced to avoid conflicts with host application user sessions
18
+ ADMIN_SESSION_KEY = :panda_core_user_id
19
+
19
20
  def self.root
20
21
  File.expand_path("../..", __FILE__)
21
22
  end
@@ -25,8 +26,16 @@ module Panda
25
26
  end
26
27
  end
27
28
 
29
+ # Apply acronyms eagerly for generators and CLI contexts
30
+ ActiveSupport::Inflector.inflections(:en) do |inflect|
31
+ Panda::Core::ACRONYMS.each { |acronym| inflect.acronym acronym }
32
+ end
33
+
28
34
  require_relative "core/version"
29
35
  require_relative "core/configuration"
36
+ require_relative "core/navigation_registry"
37
+ require_relative "core/permission_registry"
38
+ require_relative "core/widget_registry"
30
39
  require_relative "core/asset_loader"
31
40
  require_relative "core/debug"
32
41
  require_relative "core/services/base_service"
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ namespace :panda do
4
+ namespace :core do
5
+ namespace :file_categories do
6
+ desc "Seed default file categories"
7
+ task seed: [:environment] do
8
+ require "panda/core/seeds/file_categories"
9
+ Panda::Core::Seeds::FileCategories.seed!
10
+ puts "Default file categories seeded."
11
+ end
12
+
13
+ desc "Categorize existing blobs based on their attachments"
14
+ task categorize_existing: [:environment] do
15
+ require "panda/core/seeds/file_categories"
16
+ Panda::Core::Seeds::FileCategories.seed!
17
+
18
+ categorizer = Panda::Core::FileCategorizer.new
19
+ categorized = 0
20
+ skipped = 0
21
+
22
+ ActiveStorage::Blob.find_each do |blob|
23
+ if Panda::Core::FileCategorization.exists?(blob_id: blob.id)
24
+ skipped += 1
25
+ next
26
+ end
27
+
28
+ attachment = ActiveStorage::Attachment.where(blob_id: blob.id).first
29
+ if attachment
30
+ categorizer.categorize_attachment(attachment)
31
+ else
32
+ categorizer.categorize_blob(blob, category_slug: "media-library")
33
+ end
34
+ categorized += 1
35
+ end
36
+
37
+ puts "Categorized #{categorized} blobs, skipped #{skipped} already-categorized."
38
+ end
39
+ end
40
+ end
41
+ end