panda-core 0.12.2 → 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.
- checksums.yaml +4 -4
- data/LICENSE +1 -1
- data/README.md +53 -0
- data/app/assets/builds/panda_core_admin.css +2 -0
- data/app/assets/tailwind/application.css +413 -131
- data/app/builders/panda/core/form_builder.rb +129 -27
- data/app/components/panda/core/UI/badge.html.erb +10 -0
- data/app/components/panda/core/UI/badge.rb +14 -30
- data/app/components/panda/core/UI/button.html.erb +5 -0
- data/app/components/panda/core/UI/button.rb +15 -19
- data/app/components/panda/core/UI/card.html.erb +3 -0
- data/app/components/panda/core/UI/card.rb +7 -6
- data/app/components/panda/core/admin/attribute_diff_component.html.erb +21 -0
- data/app/components/panda/core/admin/attribute_diff_component.rb +29 -0
- data/app/components/panda/core/admin/breadcrumb_component.html.erb +36 -0
- data/app/components/panda/core/admin/breadcrumb_component.rb +12 -64
- data/app/components/panda/core/admin/breadcrumbs_component.html.erb +16 -0
- data/app/components/panda/core/admin/breadcrumbs_component.rb +26 -0
- data/app/components/panda/core/admin/button_component.html.erb +9 -0
- data/app/components/panda/core/admin/button_component.rb +37 -32
- data/app/components/panda/core/admin/callout_component.html.erb +17 -0
- data/app/components/panda/core/admin/callout_component.rb +57 -0
- data/app/components/panda/core/admin/code_block_component.html.erb +9 -0
- data/app/components/panda/core/admin/code_block_component.rb +23 -0
- data/app/components/panda/core/admin/container_component.html.erb +27 -0
- data/app/components/panda/core/admin/container_component.rb +11 -77
- data/app/components/panda/core/admin/contributors_summary_component.html.erb +15 -0
- data/app/components/panda/core/admin/contributors_summary_component.rb +23 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.html.erb +70 -0
- data/app/components/panda/core/admin/dashboard_default_content_component.rb +32 -0
- data/app/components/panda/core/admin/delete_button_component.html.erb +4 -0
- data/app/components/panda/core/admin/delete_button_component.rb +41 -0
- data/app/components/panda/core/admin/dropdown_button_component.html.erb +28 -0
- data/app/components/panda/core/admin/dropdown_button_component.rb +100 -0
- data/app/components/panda/core/admin/dropdown_component.html.erb +32 -0
- data/app/components/panda/core/admin/dropdown_component.rb +65 -0
- data/app/components/panda/core/admin/empty_state_component.html.erb +16 -0
- data/app/components/panda/core/admin/empty_state_component.rb +25 -0
- data/app/components/panda/core/admin/file_gallery_component.html.erb +58 -0
- data/app/components/panda/core/admin/file_gallery_component.rb +26 -98
- data/app/components/panda/core/admin/filter_pills_component.html.erb +6 -0
- data/app/components/panda/core/admin/filter_pills_component.rb +29 -0
- data/app/components/panda/core/admin/flash_message_component.html.erb +11 -0
- data/app/components/panda/core/admin/flash_message_component.rb +33 -60
- data/app/components/panda/core/admin/form_error_component.html.erb +7 -0
- data/app/components/panda/core/admin/form_error_component.rb +8 -16
- data/app/components/panda/core/admin/form_footer_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_footer_component.rb +65 -0
- data/app/components/panda/core/admin/form_input_component.html.erb +1 -0
- data/app/components/panda/core/admin/form_input_component.rb +21 -13
- data/app/components/panda/core/admin/form_section_component.html.erb +16 -0
- data/app/components/panda/core/admin/form_section_component.rb +64 -0
- data/app/components/panda/core/admin/form_select_component.html.erb +5 -0
- data/app/components/panda/core/admin/form_select_component.rb +25 -26
- data/app/components/panda/core/admin/heading_component.html.erb +26 -0
- data/app/components/panda/core/admin/heading_component.rb +23 -60
- data/app/components/panda/core/admin/login_form_component.html.erb +38 -0
- data/app/components/panda/core/admin/login_form_component.rb +28 -0
- data/app/components/panda/core/admin/main_layout_component.html.erb +88 -0
- data/app/components/panda/core/admin/main_layout_component.rb +19 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.html.erb +41 -0
- data/app/components/panda/core/admin/my_profile/connected_account_component.rb +27 -0
- data/app/components/panda/core/admin/navigation/item_component.html.erb +34 -0
- data/app/components/panda/core/admin/navigation/item_component.rb +69 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.html.erb +9 -0
- data/app/components/panda/core/admin/navigation/sub_item_component.rb +44 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.html.erb +30 -0
- data/app/components/panda/core/admin/navigation/user_menu_component.rb +36 -0
- data/app/components/panda/core/admin/page_header_component.html.erb +28 -0
- data/app/components/panda/core/admin/page_header_component.rb +19 -35
- data/app/components/panda/core/admin/pagination_component.html.erb +33 -0
- data/app/components/panda/core/admin/pagination_component.rb +56 -0
- data/app/components/panda/core/admin/panel_component.html.erb +19 -0
- data/app/components/panda/core/admin/panel_component.rb +4 -29
- data/app/components/panda/core/admin/prose_panel_component.html.erb +6 -0
- data/app/components/panda/core/admin/prose_panel_component.rb +43 -0
- data/app/components/panda/core/admin/search_bar_component.html.erb +19 -0
- data/app/components/panda/core/admin/search_bar_component.rb +31 -0
- data/app/components/panda/core/admin/search_filter_bar_component.html.erb +22 -0
- data/app/components/panda/core/admin/search_filter_bar_component.rb +27 -0
- data/app/components/panda/core/admin/secret_field_component.html.erb +24 -0
- data/app/components/panda/core/admin/secret_field_component.rb +31 -0
- data/app/components/panda/core/admin/sidebar_component.html.erb +160 -0
- data/app/components/panda/core/admin/sidebar_component.rb +55 -0
- data/app/components/panda/core/admin/slideover_component.html.erb +67 -0
- data/app/components/panda/core/admin/slideover_component.rb +31 -70
- data/app/components/panda/core/admin/statistics_component.html.erb +4 -0
- data/app/components/panda/core/admin/statistics_component.rb +6 -8
- data/app/components/panda/core/admin/tab_bar_component.html.erb +48 -0
- data/app/components/panda/core/admin/tab_bar_component.rb +34 -69
- data/app/components/panda/core/admin/table_component.html.erb +82 -0
- data/app/components/panda/core/admin/table_component.rb +54 -87
- data/app/components/panda/core/admin/tag_badge_component.html.erb +7 -0
- data/app/components/panda/core/admin/tag_badge_component.rb +21 -0
- data/app/components/panda/core/admin/tag_component.html.erb +1 -0
- data/app/components/panda/core/admin/tag_component.rb +51 -20
- data/app/components/panda/core/admin/tag_input_component.html.erb +37 -0
- data/app/components/panda/core/admin/tag_input_component.rb +22 -0
- data/app/components/panda/core/admin/tags_panel_component.html.erb +8 -0
- data/app/components/panda/core/admin/tags_panel_component.rb +33 -0
- data/app/components/panda/core/admin/user_activity_component.html.erb +15 -0
- data/app/components/panda/core/admin/user_activity_component.rb +9 -22
- data/app/components/panda/core/admin/user_display_component.html.erb +8 -0
- data/app/components/panda/core/admin/user_display_component.rb +19 -22
- data/app/components/panda/core/base.rb +35 -42
- data/app/components/panda/core/shared/footer_component.html.erb +2 -0
- data/app/components/panda/core/shared/footer_component.rb +11 -0
- data/app/components/panda/core/shared/header_component.html.erb +32 -0
- data/app/components/panda/core/shared/header_component.rb +19 -0
- data/app/constraints/panda/core/admin_constraint.rb +2 -2
- data/app/controllers/concerns/panda/core/admin_authorization.rb +58 -0
- data/app/controllers/concerns/panda/core/authorizable.rb +107 -0
- data/app/controllers/panda/core/admin/base_controller.rb +62 -3
- data/app/controllers/panda/core/admin/editor/search_controller.rb +17 -0
- data/app/controllers/panda/core/admin/feature_flags_controller.rb +35 -0
- data/app/controllers/panda/core/admin/file_categories_controller.rb +84 -0
- data/app/controllers/panda/core/admin/files_controller.rb +230 -0
- data/app/controllers/panda/core/admin/import_sessions_controller.rb +120 -0
- data/app/controllers/panda/core/admin/my_profile/logins_controller.rb +24 -0
- data/app/controllers/panda/core/admin/my_profile_controller.rb +31 -0
- data/app/controllers/panda/core/admin/search_controller.rb +15 -0
- data/app/controllers/panda/core/admin/sessions_controller.rb +85 -8
- data/app/controllers/panda/core/admin/tags_controller.rb +77 -0
- data/app/controllers/panda/core/admin/test_sessions_controller.rb +5 -6
- data/app/controllers/panda/core/admin/users_controller.rb +184 -0
- data/app/controllers/panda/core/application_controller.rb +13 -1
- data/app/helpers/panda/core/active_storage_helper.rb +24 -0
- data/app/helpers/panda/core/feature_flag_helper.rb +11 -0
- data/app/helpers/panda/core/form_helper.rb +15 -0
- data/app/javascript/panda/core/controllers/avatar_upload_controller.js +73 -0
- data/app/javascript/panda/core/controllers/clipboard_controller.js +119 -0
- data/app/javascript/panda/core/controllers/collapsible_item_controller.js +45 -0
- data/app/javascript/panda/core/controllers/custom_select_controller.js +256 -0
- data/app/javascript/panda/core/controllers/datepicker_controller.js +121 -0
- data/app/javascript/panda/core/controllers/daterange_controller.js +193 -0
- data/app/javascript/panda/core/controllers/file_gallery_controller.js +134 -0
- data/app/javascript/panda/core/controllers/file_upload_controller.js +165 -0
- data/app/javascript/panda/core/controllers/global_search_controller.js +184 -0
- data/app/javascript/panda/core/controllers/index.js +36 -0
- data/app/javascript/panda/core/controllers/mobile_sidebar_controller.js +58 -0
- data/app/javascript/panda/core/controllers/navigation_toggle_controller.js +49 -24
- data/app/javascript/panda/core/controllers/row_link_controller.js +27 -0
- data/app/javascript/panda/core/controllers/tag_input_controller.js +183 -0
- data/app/javascript/panda/core/tailwindcss-stimulus-components.js +2 -3
- data/app/javascript/panda/core/vendor/@fortawesome--fontawesome-free@7.2.0.js +8129 -0
- data/app/javascript/panda/core/vendor/@hotwired--turbo.js +7197 -100
- data/app/javascript/panda/core/vendor/@rails--actioncable--src.js +511 -2
- data/app/javascript/panda/core/vendor/@tailwindplus--elements@1.0.22.js +65 -0
- data/app/javascript/panda/core/vendor/cropperjs@2.1.0.js +3 -0
- data/app/javascript/panda/core/vendor/vanilla-calendar-pro.js +2 -0
- data/app/models/concerns/panda/core/has_metadata.rb +164 -0
- data/app/models/concerns/panda/core/importable.rb +79 -0
- data/app/models/concerns/panda/core/searchable.rb +66 -0
- data/app/models/concerns/panda/core/taggable.rb +37 -0
- data/app/models/panda/core/feature_flag.rb +56 -0
- data/app/models/panda/core/file_categorization.rb +14 -0
- data/app/models/panda/core/file_category.rb +45 -0
- data/app/models/panda/core/import_session.rb +79 -0
- data/app/models/panda/core/presence.rb +24 -0
- data/app/models/panda/core/tag.rb +33 -0
- data/app/models/panda/core/tagging.rb +14 -0
- data/app/models/panda/core/user.rb +96 -18
- data/app/models/panda/core/user_activity.rb +42 -0
- data/app/models/panda/core/user_session.rb +35 -0
- data/app/services/panda/core/attach_avatar_service.rb +15 -3
- data/app/services/panda/core/csv_import_service.rb +65 -0
- data/app/services/panda/core/file_categorizer.rb +52 -0
- data/app/services/panda/core/file_parser.rb +76 -0
- data/app/services/panda/core/invite_user_service.rb +44 -0
- data/app/services/panda/core/presence_service.rb +49 -0
- data/app/views/layouts/panda/core/admin.html.erb +6 -67
- data/app/views/layouts/panda/core/admin_simple.html.erb +2 -2
- data/app/views/panda/core/admin/dashboard/_default_content.html.erb +1 -73
- data/app/views/panda/core/admin/dashboard/show.html.erb +12 -15
- data/app/views/panda/core/admin/feature_flags/index.html.erb +56 -0
- data/app/views/panda/core/admin/file_categories/_form.html.erb +20 -0
- data/app/views/panda/core/admin/file_categories/edit.html.erb +5 -0
- data/app/views/panda/core/admin/file_categories/index.html.erb +55 -0
- data/app/views/panda/core/admin/file_categories/new.html.erb +5 -0
- data/app/views/panda/core/admin/files/_file_details.html.erb +156 -0
- data/app/views/panda/core/admin/files/index.html.erb +61 -0
- data/app/views/panda/core/admin/import_sessions/column_map.html.erb +43 -0
- data/app/views/panda/core/admin/import_sessions/index.html.erb +48 -0
- data/app/views/panda/core/admin/import_sessions/new.html.erb +37 -0
- data/app/views/panda/core/admin/import_sessions/preview.html.erb +51 -0
- data/app/views/panda/core/admin/import_sessions/show.html.erb +87 -0
- data/app/views/panda/core/admin/my_profile/edit.html.erb +51 -39
- data/app/views/panda/core/admin/my_profile/logins/show.html.erb +72 -0
- data/app/views/panda/core/admin/sessions/new.html.erb +1 -38
- data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +1 -18
- data/app/views/panda/core/admin/shared/_sidebar.html.erb +1 -99
- data/app/views/panda/core/admin/shared/_slideover.html.erb +3 -3
- data/app/views/panda/core/admin/tags/_form.html.erb +21 -0
- data/app/views/panda/core/admin/tags/edit.html.erb +4 -0
- data/app/views/panda/core/admin/tags/index.html.erb +41 -0
- data/app/views/panda/core/admin/tags/new.html.erb +4 -0
- data/app/views/panda/core/admin/users/activity.html.erb +90 -0
- data/app/views/panda/core/admin/users/edit.html.erb +48 -0
- data/app/views/panda/core/admin/users/index.html.erb +161 -0
- data/app/views/panda/core/admin/users/sessions.html.erb +103 -0
- data/app/views/panda/core/admin/users/show.html.erb +224 -0
- data/app/views/panda/core/shared/_footer.html.erb +1 -2
- data/app/views/panda/core/shared/_header.html.erb +4 -22
- data/config/brakeman.ignore +68 -31
- data/config/importmap.rb +13 -11
- data/config/routes.rb +49 -2
- data/db/migrate/20260202171614_create_panda_core_file_categories.rb +30 -0
- data/db/migrate/20260203000001_add_user_management_fields_to_panda_core_users.rb +21 -0
- data/db/migrate/20260203000002_create_panda_core_user_activities.rb +24 -0
- data/db/migrate/20260203000003_create_panda_core_user_sessions.rb +25 -0
- data/db/migrate/20260207100000_create_panda_core_presences.rb +21 -0
- data/db/migrate/20260213000001_fix_active_storage_attachments_record_id_type.rb +44 -0
- data/db/migrate/20260217000001_create_panda_core_feature_flags.rb +14 -0
- data/db/migrate/20260303000001_add_metadata_to_panda_core_users.rb +11 -0
- data/db/migrate/20260310000001_create_panda_core_tags.rb +27 -0
- data/db/migrate/20260310000002_create_panda_core_import_sessions.rb +29 -0
- data/lib/generators/panda/core/install_generator.rb +32 -0
- data/lib/generators/panda/core/templates/panda.rb +63 -0
- data/lib/panda/core/asset_loader.rb +6 -3
- data/lib/panda/core/authentication.rb +3 -3
- data/lib/panda/core/configuration.rb +195 -1
- data/lib/panda/core/engine/admin_controller_config.rb +4 -3
- data/lib/panda/core/engine/autoload_config.rb +21 -9
- data/lib/panda/core/engine/omniauth_config.rb +114 -32
- data/lib/panda/core/engine/pending_migrations_check.rb +46 -0
- data/lib/panda/core/engine/route_config.rb +1 -1
- data/lib/panda/core/engine/sqlite_schema_compatibility.rb +36 -0
- data/lib/panda/core/engine/{phlex_config.rb → view_component_config.rb} +5 -7
- data/lib/panda/core/engine.rb +90 -25
- data/lib/panda/core/middleware.rb +35 -0
- data/lib/panda/core/module_registry.rb +21 -13
- data/lib/panda/core/navigation_registry.rb +455 -0
- data/lib/panda/core/oauth_providers.rb +27 -0
- data/lib/panda/core/permission_registry.rb +89 -0
- data/lib/panda/core/search_registry.rb +69 -0
- data/lib/panda/core/seeds/file_categories.rb +30 -0
- data/lib/panda/core/shared/inflections_config.rb +1 -5
- data/lib/panda/core/testing/rails_helper.rb +87 -0
- data/lib/panda/core/testing/support/authentication_test_helpers.rb +22 -18
- data/lib/panda/core/testing/support/system/cuprite_helpers.rb +4 -4
- data/lib/panda/core/testing/view_component_test_controller.rb +19 -0
- data/lib/panda/core/version.rb +1 -1
- data/lib/panda/core/widget_registry.rb +50 -0
- data/lib/panda/core.rb +20 -0
- data/lib/tasks/panda/core/file_categories.rake +41 -0
- data/public/panda-core-assets/panda-core-0.12.2.css +2 -0
- data/public/panda-core-assets/panda-core-0.12.3.css +2 -0
- data/public/panda-core-assets/panda-core-0.12.5.css +2 -0
- data/public/panda-core-assets/panda-core-0.13.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.0.css +2 -0
- data/public/panda-core-assets/panda-core-0.14.4.css +2 -0
- data/public/panda-core-assets/panda-core.css +2 -2
- data/public/panda-core-assets/vanilla-calendar.css +47 -0
- metadata +205 -26
- data/lib/panda/core/engine/inflections_config.rb +0 -23
|
@@ -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
|
|
@@ -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
|