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
@@ -0,0 +1,164 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ # Concern for models with a JSONB `metadata` column.
6
+ #
7
+ # Provides a DSL to declare typed metadata fields that auto-generate
8
+ # scopes, predicates, and virtual attribute setters.
9
+ #
10
+ # class User < ApplicationRecord
11
+ # include HasMetadata
12
+ #
13
+ # metadata_field :internal, type: :boolean, filterable: true,
14
+ # label: "Visibility", default_scope: :external,
15
+ # filter_options: [["All Users", ""], ["Staff Users", "internal"], ["External Users", "external"]]
16
+ # end
17
+ #
18
+ # User.internal # => scope
19
+ # User.external # => scope
20
+ # user.internal? # => true/false
21
+ # user.mark_as_internal! # => saves
22
+ # user.mark_as_external! # => saves
23
+ # user.internal = "1" # => virtual setter for forms
24
+ #
25
+ module HasMetadata
26
+ extend ActiveSupport::Concern
27
+
28
+ included do
29
+ class_attribute :_metadata_fields, default: {}
30
+
31
+ scope :with_metadata, ->(key, value) {
32
+ if connection.adapter_name == "PostgreSQL"
33
+ where("metadata @> ?", {key.to_s => value}.to_json)
34
+ else
35
+ where("json_extract(metadata, ?) = ?", "$.#{key}", json_cast(value))
36
+ end
37
+ }
38
+ end
39
+
40
+ class_methods do
41
+ # Declare a metadata field with auto-generated scopes and accessors.
42
+ #
43
+ # Options:
44
+ # type: :boolean (default). Only :boolean supported currently.
45
+ # filterable: When true, the field appears in admin filter dropdowns.
46
+ # label: Human-readable label for the filter dropdown header.
47
+ # default_scope: Scope name for the false/absent case (default: not_<key>).
48
+ # filter_options: Array of [label, value] pairs for the dropdown. Values must
49
+ # match scope names (e.g. "internal", "external").
50
+ def metadata_field(key, type: :boolean, filterable: false, label: nil, default_scope: nil, filter_options: nil)
51
+ config = {
52
+ type: type,
53
+ filterable: filterable,
54
+ label: label || key.to_s.humanize,
55
+ filter_options: filter_options
56
+ }
57
+ self._metadata_fields = _metadata_fields.merge(key.to_s => config)
58
+
59
+ case type
60
+ when :boolean
61
+ define_boolean_metadata_field(key, default_scope: default_scope)
62
+ end
63
+ end
64
+
65
+ # Returns only fields marked as filterable.
66
+ def filterable_metadata_fields
67
+ _metadata_fields.select { |_, config| config[:filterable] }
68
+ end
69
+
70
+ # Apply all filterable metadata filters from request params to a scope.
71
+ # Only values listed in filter_options are allowed (safe from arbitrary scope calls).
72
+ def apply_metadata_filters(scope, params)
73
+ filterable_metadata_fields.each do |key, config|
74
+ value = params[key.to_sym]
75
+ next if value.blank?
76
+
77
+ allowed = (config[:filter_options] || []).map(&:last).reject(&:blank?)
78
+ next unless allowed.include?(value)
79
+
80
+ scope = scope.send(value.to_sym) if scope.respond_to?(value.to_sym)
81
+ end
82
+ scope
83
+ end
84
+
85
+ # Returns true if any filterable metadata param is present.
86
+ def metadata_filter_active?(params)
87
+ filterable_metadata_fields.any? { |key, _| params[key.to_sym].present? }
88
+ end
89
+
90
+ private
91
+
92
+ def define_boolean_metadata_field(key, default_scope: nil)
93
+ default_scope_name = default_scope || :"not_#{key}"
94
+
95
+ # Scope for records where key is true
96
+ scope key, -> {
97
+ if connection.adapter_name == "PostgreSQL"
98
+ where("metadata @> ?", {key.to_s => true}.to_json)
99
+ else
100
+ where("json_extract(metadata, ?) = 1", "$.#{key}")
101
+ end
102
+ }
103
+
104
+ # Scope for records where key is absent or not true
105
+ scope default_scope_name, -> {
106
+ if connection.adapter_name == "PostgreSQL"
107
+ where.not("metadata @> ?", {key.to_s => true}.to_json)
108
+ else
109
+ where("json_extract(metadata, ?) IS NULL OR json_extract(metadata, ?) != 1", "$.#{key}", "$.#{key}")
110
+ end
111
+ }
112
+
113
+ # Predicate
114
+ define_method(:"#{key}?") { metadata_value(key.to_s) == true }
115
+
116
+ # Reader (for form builders)
117
+ define_method(key) { metadata_value(key.to_s) }
118
+
119
+ # Bang methods
120
+ define_method(:"mark_as_#{key}!") { set_metadata(key.to_s, true) }
121
+ define_method(:"mark_as_#{default_scope_name}!") { remove_metadata(key.to_s) }
122
+
123
+ # Virtual attribute setter — handles checkbox "1"/"0" strings
124
+ define_method(:"#{key}=") do |value|
125
+ cast_value = ActiveRecord::Type::Boolean.new.cast(value)
126
+ if cast_value
127
+ set_metadata_attribute(key.to_s, true)
128
+ else
129
+ self.metadata = (self[:metadata] || {}).except(key.to_s)
130
+ end
131
+ end
132
+ end
133
+
134
+ def json_cast(value)
135
+ case value
136
+ when true then 1
137
+ when false then 0
138
+ else value
139
+ end
140
+ end
141
+ end
142
+
143
+ # -- Instance methods for generic metadata access --
144
+
145
+ def metadata_value(key)
146
+ (self[:metadata] || {})[key.to_s]
147
+ end
148
+
149
+ def set_metadata(key, value)
150
+ self.metadata = (self[:metadata] || {}).merge(key.to_s => value)
151
+ save!
152
+ end
153
+
154
+ def set_metadata_attribute(key, value)
155
+ self.metadata = (self[:metadata] || {}).merge(key.to_s => value)
156
+ end
157
+
158
+ def remove_metadata(key)
159
+ self.metadata = (self[:metadata] || {}).except(key.to_s)
160
+ save!
161
+ end
162
+ end
163
+ end
164
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Importable
6
+ extend ActiveSupport::Concern
7
+
8
+ class FieldDefinition
9
+ attr_reader :name, :label, :required, :transform
10
+
11
+ def initialize(name, label:, required: false, transform: nil)
12
+ @name = name
13
+ @label = label
14
+ @required = required
15
+ @transform = transform || ->(v) { v }
16
+ end
17
+ end
18
+
19
+ class Configuration
20
+ attr_reader :field_definitions
21
+
22
+ def initialize
23
+ @field_definitions = []
24
+ end
25
+
26
+ def field(name, label: name.to_s.humanize, required: false, &transform)
27
+ @field_definitions << FieldDefinition.new(
28
+ name, label: label, required: required,
29
+ transform: block_given? ? transform : nil
30
+ )
31
+ end
32
+ end
33
+
34
+ included do
35
+ class_attribute :import_configuration, instance_writer: false
36
+ self.import_configuration = Configuration.new
37
+ end
38
+
39
+ class_methods do
40
+ def importable(&block)
41
+ raise ArgumentError, "importable requires a block to define fields" unless block
42
+ config = Configuration.new
43
+ block.call(config)
44
+ self.import_configuration = config
45
+ end
46
+
47
+ def import_field_definitions
48
+ import_configuration.field_definitions
49
+ end
50
+
51
+ def import_row(row_data, column_mapping)
52
+ attrs = {}
53
+ errors = []
54
+
55
+ column_mapping.each do |csv_col, field_name|
56
+ next if field_name.blank?
57
+ defn = import_field_definitions.find { |d| d.name.to_s == field_name.to_s }
58
+ next unless defn
59
+
60
+ raw_value = row_data[csv_col]
61
+ begin
62
+ attrs[defn.name] = defn.transform.call(raw_value)
63
+ rescue => e
64
+ errors << "#{defn.label}: #{e.message}"
65
+ end
66
+ end
67
+
68
+ import_field_definitions.select(&:required).each do |defn|
69
+ if attrs[defn.name].blank?
70
+ errors << "#{defn.label} is required"
71
+ end
72
+ end
73
+
74
+ [attrs, errors]
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Searchable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ class_attribute :searchable_config, default: {}
10
+ end
11
+
12
+ class_methods do
13
+ def searchable(&block)
14
+ config = SearchableConfig.new
15
+ block.call(config)
16
+ self.searchable_config = config.to_h
17
+
18
+ # Auto-register with SearchRegistry
19
+ Panda::Core::SearchRegistry.register(
20
+ name: searchable_config[:group]&.parameterize || model_name.plural,
21
+ search_class: self
22
+ )
23
+ end
24
+
25
+ def admin_search(query, limit: 5)
26
+ config = searchable_config
27
+ return [] if config[:fields].blank?
28
+
29
+ sanitized_query = "%#{sanitize_sql_like(query)}%"
30
+ conditions = config[:fields].map { |f| "#{table_name}.#{f} ILIKE :q" }.join(" OR ")
31
+ records = where(conditions, q: sanitized_query).limit(limit)
32
+
33
+ records.map do |record|
34
+ {
35
+ name: config[:display]&.call(record) || record.to_s,
36
+ description: config[:description]&.call(record),
37
+ href: config[:path]&.call(record)
38
+ }
39
+ end
40
+ end
41
+ end
42
+ end
43
+
44
+ class SearchableConfig
45
+ KEYS = %i[fields display description path icon group].freeze
46
+
47
+ def initialize
48
+ @config = {}
49
+ end
50
+
51
+ KEYS.each do |key|
52
+ define_method(key) do |value = nil|
53
+ if value.nil?
54
+ @config[key]
55
+ else
56
+ @config[key] = value
57
+ end
58
+ end
59
+ end
60
+
61
+ def to_h
62
+ @config.dup
63
+ end
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Taggable
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ has_many :taggings, class_name: "Panda::Core::Tagging",
10
+ as: :taggable, dependent: :destroy
11
+ has_many :tags, through: :taggings, class_name: "Panda::Core::Tag"
12
+
13
+ scope :tagged_with, ->(tag_ids) {
14
+ joins(:taggings)
15
+ .where(panda_core_taggings: {tag_id: Array(tag_ids)})
16
+ .distinct
17
+ }
18
+
19
+ scope :tagged_with_all, ->(tag_ids) {
20
+ tag_ids = Array(tag_ids)
21
+ joins(:taggings)
22
+ .where(panda_core_taggings: {tag_id: tag_ids})
23
+ .group(arel_table[:id])
24
+ .having("COUNT(DISTINCT panda_core_taggings.tag_id) = ?", tag_ids.size)
25
+ }
26
+ end
27
+
28
+ def tag_list
29
+ tags.map(&:name).sort.join(", ")
30
+ end
31
+
32
+ def tag_ids=(ids)
33
+ self.tags = Panda::Core::Tag.where(id: Array(ids).reject(&:blank?))
34
+ end
35
+ end
36
+ end
37
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class FeatureFlag < ApplicationRecord
6
+ self.table_name = "panda_core_feature_flags"
7
+
8
+ validates :key, presence: true, uniqueness: true
9
+
10
+ # Check whether a feature flag is enabled.
11
+ # Returns false for unknown keys (safe default).
12
+ # Results are cached for 1 minute to avoid repeated DB lookups.
13
+ def self.enabled?(key)
14
+ Rails.cache.fetch(cache_key_for(key), expires_in: 1.minute) do
15
+ where(key: key, enabled: true).exists?
16
+ end
17
+ end
18
+
19
+ # Enable a feature flag by key.
20
+ def self.enable!(key)
21
+ flag = find_by!(key: key)
22
+ flag.update!(enabled: true)
23
+ Rails.cache.write(cache_key_for(key), true, expires_in: 1.minute)
24
+ end
25
+
26
+ # Disable a feature flag by key.
27
+ def self.disable!(key)
28
+ flag = find_by!(key: key)
29
+ flag.update!(enabled: false)
30
+ Rails.cache.write(cache_key_for(key), false, expires_in: 1.minute)
31
+ end
32
+
33
+ # Toggle a feature flag by key.
34
+ def self.toggle!(key)
35
+ flag = find_by!(key: key)
36
+ flag.update!(enabled: !flag.enabled)
37
+ Rails.cache.write(cache_key_for(key), flag.enabled, expires_in: 1.minute)
38
+ end
39
+
40
+ # Register a feature flag idempotently.
41
+ # Only sets description and enabled on initial creation — existing
42
+ # flags keep their current state so admin changes are preserved.
43
+ def self.register(key, description: nil, enabled: false)
44
+ find_or_create_by(key: key) do |flag|
45
+ flag.description = description
46
+ flag.enabled = enabled
47
+ end
48
+ end
49
+
50
+ def self.cache_key_for(key)
51
+ "panda_core:feature_flag:#{key}"
52
+ end
53
+ private_class_method :cache_key_for
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class FileCategorization < ApplicationRecord
6
+ self.table_name = "panda_core_file_categorizations"
7
+
8
+ belongs_to :file_category, class_name: "Panda::Core::FileCategory"
9
+ belongs_to :blob, class_name: "ActiveStorage::Blob"
10
+
11
+ validates :file_category_id, uniqueness: {scope: :blob_id}
12
+ end
13
+ end
14
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class FileCategory < ApplicationRecord
6
+ self.table_name = "panda_core_file_categories"
7
+
8
+ belongs_to :parent, class_name: "Panda::Core::FileCategory", optional: true
9
+ has_many :children, class_name: "Panda::Core::FileCategory", foreign_key: :parent_id, dependent: :nullify
10
+ has_many :file_categorizations, class_name: "Panda::Core::FileCategorization", dependent: :destroy
11
+ has_many :blobs, through: :file_categorizations
12
+
13
+ validates :name, presence: true
14
+ validates :slug, presence: true, uniqueness: true,
15
+ format: {with: /\A[a-z0-9-]+\z/, message: "must contain only lowercase letters, numbers, and hyphens"}
16
+
17
+ before_validation :generate_slug, if: -> { slug.blank? && name.present? }
18
+ before_destroy :prevent_system_deletion
19
+
20
+ scope :roots, -> { where(parent_id: nil) }
21
+ scope :ordered, -> { order(:position, :name) }
22
+ scope :system_categories, -> { where(system: true) }
23
+ scope :custom_categories, -> { where(system: false) }
24
+
25
+ # Returns all blob IDs for this category and its children
26
+ def all_blob_ids
27
+ category_ids = [id] + children.pluck(:id)
28
+ Panda::Core::FileCategorization.where(file_category_id: category_ids).pluck(:blob_id)
29
+ end
30
+
31
+ private
32
+
33
+ def generate_slug
34
+ self.slug = name.parameterize
35
+ end
36
+
37
+ def prevent_system_deletion
38
+ return unless system?
39
+
40
+ errors.add(:base, "System categories cannot be deleted")
41
+ throw(:abort)
42
+ end
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class ImportSession < ApplicationRecord
6
+ self.table_name = "panda_core_import_sessions"
7
+
8
+ STATUSES = %w[pending mapping previewing importing complete failed].freeze
9
+
10
+ belongs_to :user, class_name: "Panda::Core::User"
11
+ belongs_to :tenant, polymorphic: true, optional: true
12
+ has_one_attached :import_file
13
+
14
+ validates :importable_type, presence: true
15
+ validates :status, inclusion: {in: STATUSES}
16
+ validate :importable_type_must_be_importable
17
+
18
+ scope :recent, -> { order(created_at: :desc) }
19
+
20
+ def importable_class
21
+ klass = importable_type.safe_constantize
22
+ raise ArgumentError, "Unknown importable type: #{importable_type}" unless klass
23
+ raise ArgumentError, "#{importable_type} is not importable" unless klass.include?(Panda::Core::Importable)
24
+ klass
25
+ end
26
+
27
+ def file_parser
28
+ return nil unless import_file.attached?
29
+ @file_parser ||= FileParser.new(import_file.filename.to_s, import_file.download)
30
+ end
31
+
32
+ def file_headers
33
+ file_parser&.headers || []
34
+ end
35
+
36
+ def file_rows
37
+ file_parser&.rows || []
38
+ end
39
+
40
+ def preview_rows(limit: 5)
41
+ file_rows.first(limit)
42
+ end
43
+
44
+ def column_options
45
+ return [] unless importable_class.respond_to?(:import_field_definitions)
46
+ importable_class.import_field_definitions
47
+ end
48
+
49
+ def progress_percentage
50
+ return 0 if total_rows.zero?
51
+ ((processed_rows.to_f / total_rows) * 100).round
52
+ end
53
+
54
+ def complete?
55
+ status == "complete"
56
+ end
57
+
58
+ def importing?
59
+ status == "importing"
60
+ end
61
+
62
+ def failed?
63
+ status == "failed"
64
+ end
65
+
66
+ private
67
+
68
+ def importable_type_must_be_importable
69
+ return if importable_type.blank?
70
+ klass = importable_type.safe_constantize
71
+ if klass.nil?
72
+ errors.add(:importable_type, "is not a valid class")
73
+ elsif !klass.include?(Panda::Core::Importable)
74
+ errors.add(:importable_type, "is not importable")
75
+ end
76
+ end
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,24 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class Presence < ApplicationRecord
6
+ include HasUUID
7
+
8
+ self.table_name = "panda_core_presences"
9
+
10
+ PRESENCE_TTL = 30.seconds
11
+
12
+ belongs_to :presenceable, polymorphic: true
13
+ belongs_to :user, class_name: "Panda::Core::User"
14
+
15
+ validates :last_seen_at, presence: true
16
+
17
+ scope :for_resource, ->(resource) {
18
+ where(presenceable_type: resource.class.name, presenceable_id: resource.id)
19
+ }
20
+ scope :active, ->(ttl = PRESENCE_TTL) { where(last_seen_at: ttl.ago..) }
21
+ scope :stale, ->(ttl = PRESENCE_TTL) { where(last_seen_at: ...ttl.ago) }
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class Tag < ApplicationRecord
6
+ self.table_name = "panda_core_tags"
7
+
8
+ belongs_to :tenant, polymorphic: true, optional: true
9
+ has_many :taggings, class_name: "Panda::Core::Tagging", dependent: :destroy
10
+
11
+ validates :name, presence: true,
12
+ uniqueness: {scope: [:tenant_type, :tenant_id], case_sensitive: false}
13
+ validates :colour, format: {with: /\A#[0-9a-fA-F]{6}\z/, message: "must be a hex colour (e.g. #ff0000)"},
14
+ allow_blank: true
15
+
16
+ before_validation :normalize_name
17
+
18
+ scope :for_tenant, ->(tenant) { tenant ? where(tenant: tenant) : where(tenant_type: nil) }
19
+ scope :ordered, -> { order(:name) }
20
+ scope :search_by_name, ->(q) { where("name ILIKE ?", "%#{sanitize_sql_like(q)}%") }
21
+
22
+ def display_colour
23
+ colour.presence || "#6b7280"
24
+ end
25
+
26
+ private
27
+
28
+ def normalize_name
29
+ self.name = name&.strip
30
+ end
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ class Tagging < ApplicationRecord
6
+ self.table_name = "panda_core_taggings"
7
+
8
+ belongs_to :tag, class_name: "Panda::Core::Tag", counter_cache: true
9
+ belongs_to :taggable, polymorphic: true
10
+
11
+ validates :tag_id, uniqueness: {scope: [:taggable_type, :taggable_id]}
12
+ end
13
+ end
14
+ end