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