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,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class TagsController < BaseController
|
|
7
|
+
before_action :set_initial_breadcrumb
|
|
8
|
+
before_action :set_tag, only: %i[edit update destroy]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
tenant = resolve_tenant
|
|
12
|
+
@tags = Tag.for_tenant(tenant).ordered
|
|
13
|
+
|
|
14
|
+
if params[:q].present?
|
|
15
|
+
@tags = @tags.search_by_name(params[:q])
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
respond_to do |format|
|
|
19
|
+
format.html
|
|
20
|
+
format.json { render json: @tags.limit(10).map { |t| {id: t.id, name: t.name, colour: t.display_colour} } }
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def new
|
|
25
|
+
@tag = Tag.new
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def create
|
|
29
|
+
tenant = resolve_tenant
|
|
30
|
+
@tag = Tag.new(tag_params.merge(tenant: tenant))
|
|
31
|
+
|
|
32
|
+
if @tag.save
|
|
33
|
+
redirect_to admin_tags_path, success: "Tag created."
|
|
34
|
+
else
|
|
35
|
+
render :new, status: :unprocessable_entity
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def edit
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def update
|
|
43
|
+
if @tag.update(tag_params)
|
|
44
|
+
redirect_to admin_tags_path, success: "Tag updated."
|
|
45
|
+
else
|
|
46
|
+
render :edit, status: :unprocessable_entity
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def destroy
|
|
51
|
+
@tag.destroy
|
|
52
|
+
redirect_to admin_tags_path, success: "Tag deleted."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def set_tag
|
|
58
|
+
@tag = Tag.for_tenant(resolve_tenant).find(params[:id])
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def tag_params
|
|
62
|
+
params.require(:tag).permit(:name, :colour)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def set_initial_breadcrumb
|
|
66
|
+
add_breadcrumb "Settings", admin_settings_path
|
|
67
|
+
add_breadcrumb "Tags", admin_tags_path
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def resolve_tenant
|
|
71
|
+
return ActsAsTenant.current_tenant if defined?(ActsAsTenant) && ActsAsTenant.current_tenant
|
|
72
|
+
nil
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
|
@@ -18,19 +18,18 @@ module Panda
|
|
|
18
18
|
def create
|
|
19
19
|
user = Panda::Core::User.find(params[:user_id])
|
|
20
20
|
|
|
21
|
-
#
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
# Mirror real SessionsController: admin users always pass,
|
|
22
|
+
# non-admin users checked via authorization_policy
|
|
23
|
+
policy = Panda::Core.config.authorization_policy
|
|
24
|
+
unless user.admin? || policy.call(user, :access_admin, nil)
|
|
24
25
|
flash[:alert] = "You do not have permission to access the admin area."
|
|
25
|
-
# Keep flash for one more request to survive redirect in tests
|
|
26
26
|
flash.keep(:alert) if Rails.env.test?
|
|
27
|
-
# Use string path since route helpers aren't available in ActionController::Base
|
|
28
27
|
redirect_to "#{Panda::Core.config.admin_path || "/admin"}/login", allow_other_host: false, status: :found
|
|
29
28
|
return
|
|
30
29
|
end
|
|
31
30
|
|
|
32
31
|
# Set session (mimics real OAuth callback)
|
|
33
|
-
session[
|
|
32
|
+
session[Panda::Core::ADMIN_SESSION_KEY] = user.id
|
|
34
33
|
Panda::Core::Current.user = user
|
|
35
34
|
|
|
36
35
|
# Support custom redirect path for test flexibility
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module Admin
|
|
6
|
+
class UsersController < BaseController
|
|
7
|
+
before_action :set_initial_breadcrumb
|
|
8
|
+
before_action :set_user, only: %i[show edit update enable disable activity sessions revoke_session]
|
|
9
|
+
|
|
10
|
+
def index
|
|
11
|
+
@users = User.with_attached_avatar
|
|
12
|
+
@users = @users.search(params[:search]) if params[:search].present?
|
|
13
|
+
|
|
14
|
+
case params[:status]
|
|
15
|
+
when "enabled" then @users = @users.enabled
|
|
16
|
+
when "disabled" then @users = @users.disabled
|
|
17
|
+
when "invited" then @users = @users.invited
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
case params[:role]
|
|
21
|
+
when "admin" then @users = @users.admins
|
|
22
|
+
when "user" then @users = @users.where(User.admin_column => false)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
@users = User.apply_metadata_filters(@users, params)
|
|
26
|
+
|
|
27
|
+
@users = case params[:sort]
|
|
28
|
+
when "name" then @users.order(name: sort_direction)
|
|
29
|
+
when "email" then @users.order(email: sort_direction)
|
|
30
|
+
when "last_login" then @users.order(last_login_at: sort_direction)
|
|
31
|
+
when "created" then @users.order(created_at: sort_direction)
|
|
32
|
+
else @users.order(name: :asc)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
@page = [params.fetch(:page, 1).to_i, 1].max
|
|
36
|
+
@per_page = 25
|
|
37
|
+
@total_count = @users.count
|
|
38
|
+
@total_pages = (@total_count.to_f / @per_page).ceil
|
|
39
|
+
@users = @users.offset((@page - 1) * @per_page).limit(@per_page)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def show
|
|
43
|
+
add_breadcrumb @user.name, admin_user_path(@user)
|
|
44
|
+
@recent_activity = @user.user_activities.recent.limit(5)
|
|
45
|
+
@active_sessions = @user.user_sessions.active_sessions.recent
|
|
46
|
+
@active_sessions_count = @active_sessions.size
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def edit
|
|
50
|
+
add_breadcrumb @user.name, admin_user_path(@user)
|
|
51
|
+
add_breadcrumb "Edit", edit_admin_user_path(@user)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def update
|
|
55
|
+
if @user.update(user_params)
|
|
56
|
+
if (hook = Panda::Core.config.admin_user_after_update)
|
|
57
|
+
hook.call(@user, params, current_user)
|
|
58
|
+
end
|
|
59
|
+
UserActivity.log!(user: current_user, action: "updated_user", resource: @user, request: request)
|
|
60
|
+
flash[:success] = "User has been updated successfully."
|
|
61
|
+
redirect_to admin_users_path
|
|
62
|
+
else
|
|
63
|
+
add_breadcrumb @user.name, admin_user_path(@user)
|
|
64
|
+
add_breadcrumb "Edit", edit_admin_user_path(@user)
|
|
65
|
+
render :edit, status: :unprocessable_content
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def invite
|
|
70
|
+
result = InviteUserService.call(
|
|
71
|
+
email: params[:email],
|
|
72
|
+
name: params[:name],
|
|
73
|
+
invited_by: current_user,
|
|
74
|
+
admin: params[:admin] == "true"
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
if result.success?
|
|
78
|
+
if (hook = Panda::Core.config.after_user_invited)
|
|
79
|
+
hook.call(result.payload[:user], params, current_user)
|
|
80
|
+
end
|
|
81
|
+
flash[:success] = "Invitation sent to #{params[:email]}."
|
|
82
|
+
else
|
|
83
|
+
flash[:error] = result.errors.join(", ")
|
|
84
|
+
end
|
|
85
|
+
redirect_to admin_users_path
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def enable
|
|
89
|
+
@user.enable!
|
|
90
|
+
UserActivity.log!(user: current_user, action: "enabled_user", resource: @user, request: request)
|
|
91
|
+
flash[:success] = "#{@user.name} has been enabled."
|
|
92
|
+
redirect_to admin_user_path(@user)
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def disable
|
|
96
|
+
if @user == current_user
|
|
97
|
+
flash[:error] = "You cannot disable your own account."
|
|
98
|
+
redirect_to admin_user_path(@user)
|
|
99
|
+
return
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
@user.disable!
|
|
103
|
+
UserActivity.log!(user: current_user, action: "disabled_user", resource: @user, request: request)
|
|
104
|
+
flash[:success] = "#{@user.name} has been disabled."
|
|
105
|
+
redirect_to admin_user_path(@user)
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def bulk_action
|
|
109
|
+
user_ids = Array(params[:user_ids]).map(&:to_s)
|
|
110
|
+
user_ids = user_ids.select { |id| id.match?(/\A[0-9a-f-]{36}\z/i) }
|
|
111
|
+
|
|
112
|
+
return redirect_to admin_users_path, alert: "No valid users selected." if user_ids.blank?
|
|
113
|
+
|
|
114
|
+
case params[:bulk_action]
|
|
115
|
+
when "enable"
|
|
116
|
+
enabled_count = User.where(id: user_ids).update_all(enabled: true)
|
|
117
|
+
flash[:success] = "#{enabled_count} user(s) enabled."
|
|
118
|
+
when "disable"
|
|
119
|
+
disabled_count = User.where(id: user_ids).where.not(id: current_user.id).update_all(enabled: false)
|
|
120
|
+
flash[:success] = "#{disabled_count} user(s) disabled (excluding yourself)."
|
|
121
|
+
else
|
|
122
|
+
flash[:error] = "Unknown action."
|
|
123
|
+
end
|
|
124
|
+
redirect_to admin_users_path
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def activity
|
|
128
|
+
add_breadcrumb @user.name, admin_user_path(@user)
|
|
129
|
+
add_breadcrumb "Activity", activity_admin_user_path(@user)
|
|
130
|
+
@activities = @user.user_activities.recent
|
|
131
|
+
@page = [params.fetch(:page, 1).to_i, 1].max
|
|
132
|
+
@per_page = 25
|
|
133
|
+
@total_count = Rails.cache.fetch(["user_activities_count", @user.id], expires_in: 5.minutes) do
|
|
134
|
+
@activities.count
|
|
135
|
+
end
|
|
136
|
+
@total_pages = (@total_count.to_f / @per_page).ceil
|
|
137
|
+
@activities = @activities.offset((@page - 1) * @per_page).limit(@per_page)
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def sessions
|
|
141
|
+
add_breadcrumb @user.name, admin_user_path(@user)
|
|
142
|
+
add_breadcrumb "Sessions", sessions_admin_user_path(@user)
|
|
143
|
+
@sessions = @user.user_sessions.recent
|
|
144
|
+
@page = [params.fetch(:page, 1).to_i, 1].max
|
|
145
|
+
@per_page = 25
|
|
146
|
+
@total_count = @sessions.count
|
|
147
|
+
@total_pages = (@total_count.to_f / @per_page).ceil
|
|
148
|
+
@sessions = @sessions.offset((@page - 1) * @per_page).limit(@per_page)
|
|
149
|
+
@active_sessions = @user.user_sessions.active_sessions.recent
|
|
150
|
+
@revoked_sessions = @user.user_sessions.revoked_sessions.recent
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def revoke_session
|
|
154
|
+
session_record = @user.user_sessions.find(params[:session_id])
|
|
155
|
+
session_record.revoke!(admin: current_user)
|
|
156
|
+
UserActivity.log!(
|
|
157
|
+
user: current_user, action: "revoked_session", resource: @user,
|
|
158
|
+
request: request, metadata: {session_id: session_record.id}
|
|
159
|
+
)
|
|
160
|
+
flash[:success] = "Session revoked."
|
|
161
|
+
redirect_back fallback_location: admin_user_path(@user)
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
private
|
|
165
|
+
|
|
166
|
+
def set_initial_breadcrumb
|
|
167
|
+
add_breadcrumb "Users", admin_users_path
|
|
168
|
+
end
|
|
169
|
+
|
|
170
|
+
def set_user
|
|
171
|
+
@user = User.find(params[:id])
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def user_params
|
|
175
|
+
params.require(:user).permit(:name, :email, :admin, :internal)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def sort_direction
|
|
179
|
+
%w[asc desc].include?(params[:direction]) ? params[:direction].to_sym : :asc
|
|
180
|
+
end
|
|
181
|
+
end
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
end
|
|
@@ -8,18 +8,30 @@ module Panda
|
|
|
8
8
|
before_action :set_current_request_details
|
|
9
9
|
before_action :initialize_breadcrumbs
|
|
10
10
|
|
|
11
|
+
# Prioritize markdown format for AI tools (Rails 8.1+)
|
|
12
|
+
# When Claude Code sends "Accept: text/markdown, text/html, */*", Rails would serve HTML by default.
|
|
13
|
+
# This reorders formats to prioritize markdown when it's the first accepted format.
|
|
14
|
+
before_action :prioritize_markdown_format
|
|
15
|
+
|
|
11
16
|
helper_method :current_user, :user_signed_in?, :breadcrumbs
|
|
12
17
|
|
|
13
18
|
add_flash_types :success, :error, :warning, :info
|
|
14
19
|
|
|
15
20
|
private
|
|
16
21
|
|
|
22
|
+
# Prioritize markdown format when it's the first accepted format
|
|
23
|
+
# This ensures AI tools like Claude Code receive markdown responses instead of HTML
|
|
24
|
+
def prioritize_markdown_format
|
|
25
|
+
return unless request.accepts.first&.to_s == "text/markdown"
|
|
26
|
+
request.formats = [:md, :html]
|
|
27
|
+
end
|
|
28
|
+
|
|
17
29
|
def set_current_request_details
|
|
18
30
|
Current.request_id = request.uuid
|
|
19
31
|
Current.user_agent = request.user_agent
|
|
20
32
|
Current.ip_address = request.ip
|
|
21
33
|
Current.root = "#{request.protocol}#{request.host_with_port}"
|
|
22
|
-
Current.user = User.find_by(id: session[
|
|
34
|
+
Current.user = User.find_by(id: session[Panda::Core::ADMIN_SESSION_KEY]) if session[Panda::Core::ADMIN_SESSION_KEY]
|
|
23
35
|
end
|
|
24
36
|
|
|
25
37
|
def authenticate_user!
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module ActiveStorageHelper
|
|
6
|
+
# Generates a URL for an ActiveStorage variant, compatible with both Rails 7.x and 8.1+.
|
|
7
|
+
#
|
|
8
|
+
# In Rails 8.1+, both url_for(variant) and image_tag(variant) break because
|
|
9
|
+
# VariantWithRecord no longer implements to_model. This helper constructs the
|
|
10
|
+
# representation proxy path directly from the blob and variation, avoiding
|
|
11
|
+
# all polymorphic routing that relies on to_model.
|
|
12
|
+
#
|
|
13
|
+
# @param variant [ActiveStorage::VariantWithRecord] The variant to generate a URL for
|
|
14
|
+
# @return [String, nil] The URL path, or nil if generation fails
|
|
15
|
+
def variant_representation_url(variant)
|
|
16
|
+
blob = variant.blob
|
|
17
|
+
"/rails/active_storage/representations/proxy/#{blob.signed_id}/#{variant.variation.key}/#{blob.filename}"
|
|
18
|
+
rescue => e
|
|
19
|
+
Rails.logger.warn "[Panda] Failed to generate variant URL (#{e.class}): #{e.message}"
|
|
20
|
+
nil
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Panda
|
|
4
|
+
module Core
|
|
5
|
+
module FormHelper
|
|
6
|
+
# Wraps form_with to apply consistent admin form styling across all Panda engines.
|
|
7
|
+
# Sets the custom FormBuilder and applies default CSS classes for padding/layout.
|
|
8
|
+
def panda_form_with(**options, &)
|
|
9
|
+
options[:builder] = Panda::Core::FormBuilder
|
|
10
|
+
options[:class] = ["block visible", options[:class]].compact.join(" ")
|
|
11
|
+
form_with(**options, &)
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Connects to data-controller="avatar-upload"
|
|
4
|
+
export default class extends Controller {
|
|
5
|
+
static targets = ["input", "preview", "placeholder", "fileInfo", "fileName", "fileSize"]
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.originalPreviewSrc = this.hasPreviewTarget ? this.previewTarget.src : null
|
|
9
|
+
this.hadOriginalAvatar = this.hasPreviewTarget && !this.previewTarget.classList.contains("hidden")
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
handleFileSelect() {
|
|
13
|
+
const file = this.inputTarget.files[0]
|
|
14
|
+
if (!file || !file.type.startsWith("image/")) return
|
|
15
|
+
|
|
16
|
+
const reader = new FileReader()
|
|
17
|
+
reader.onload = (e) => {
|
|
18
|
+
// Show preview image, hide placeholder
|
|
19
|
+
if (this.hasPreviewTarget) {
|
|
20
|
+
this.previewTarget.src = e.target.result
|
|
21
|
+
this.previewTarget.classList.remove("hidden")
|
|
22
|
+
}
|
|
23
|
+
if (this.hasPlaceholderTarget) {
|
|
24
|
+
this.placeholderTarget.classList.add("hidden")
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Show file info
|
|
28
|
+
if (this.hasFileInfoTarget) {
|
|
29
|
+
this.fileInfoTarget.classList.remove("hidden")
|
|
30
|
+
}
|
|
31
|
+
if (this.hasFileNameTarget) {
|
|
32
|
+
this.fileNameTarget.textContent = file.name
|
|
33
|
+
}
|
|
34
|
+
if (this.hasFileSizeTarget) {
|
|
35
|
+
this.fileSizeTarget.textContent = this.formatFileSize(file.size)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
reader.readAsDataURL(file)
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
remove() {
|
|
42
|
+
// Clear file input
|
|
43
|
+
this.inputTarget.value = ""
|
|
44
|
+
|
|
45
|
+
// Restore original state
|
|
46
|
+
if (this.hadOriginalAvatar && this.hasPreviewTarget) {
|
|
47
|
+
this.previewTarget.src = this.originalPreviewSrc
|
|
48
|
+
this.previewTarget.classList.remove("hidden")
|
|
49
|
+
if (this.hasPlaceholderTarget) {
|
|
50
|
+
this.placeholderTarget.classList.add("hidden")
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
// No original avatar — show placeholder
|
|
54
|
+
if (this.hasPreviewTarget) {
|
|
55
|
+
this.previewTarget.classList.add("hidden")
|
|
56
|
+
}
|
|
57
|
+
if (this.hasPlaceholderTarget) {
|
|
58
|
+
this.placeholderTarget.classList.remove("hidden")
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// Hide file info
|
|
63
|
+
if (this.hasFileInfoTarget) {
|
|
64
|
+
this.fileInfoTarget.classList.add("hidden")
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
formatFileSize(bytes) {
|
|
69
|
+
if (bytes < 1024) return `${bytes} B`
|
|
70
|
+
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
|
|
71
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
// Handles copy-to-clipboard and reveal/mask for secret values like API tokens.
|
|
4
|
+
//
|
|
5
|
+
// Usage:
|
|
6
|
+
// <div data-controller="clipboard"
|
|
7
|
+
// data-clipboard-secret-value="the-full-token"
|
|
8
|
+
// data-clipboard-masked-value="true">
|
|
9
|
+
// <span data-clipboard-target="display">...</span>
|
|
10
|
+
// <button data-action="click->clipboard#toggleReveal" data-clipboard-target="revealButton">
|
|
11
|
+
// <i data-clipboard-target="revealIcon" class="fa-solid fa-eye"></i>
|
|
12
|
+
// <span data-clipboard-target="revealText">Reveal</span>
|
|
13
|
+
// </button>
|
|
14
|
+
// <button data-action="click->clipboard#copy" data-clipboard-target="copyButton">
|
|
15
|
+
// <i data-clipboard-target="copyIcon" class="fa-solid fa-copy"></i>
|
|
16
|
+
// <span data-clipboard-target="copyText">Copy</span>
|
|
17
|
+
// </button>
|
|
18
|
+
// </div>
|
|
19
|
+
export default class extends Controller {
|
|
20
|
+
static values = {
|
|
21
|
+
secret: String,
|
|
22
|
+
masked: { type: Boolean, default: true }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
static targets = [
|
|
26
|
+
"display",
|
|
27
|
+
"revealButton",
|
|
28
|
+
"revealIcon",
|
|
29
|
+
"revealText",
|
|
30
|
+
"copyButton",
|
|
31
|
+
"copyIcon",
|
|
32
|
+
"copyText"
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
connect() {
|
|
36
|
+
this._feedbackTimeout = null
|
|
37
|
+
this.updateDisplay()
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
disconnect() {
|
|
41
|
+
if (this._feedbackTimeout) {
|
|
42
|
+
clearTimeout(this._feedbackTimeout)
|
|
43
|
+
this._feedbackTimeout = null
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
toggleReveal() {
|
|
48
|
+
this.maskedValue = !this.maskedValue
|
|
49
|
+
this.updateDisplay()
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
updateDisplay() {
|
|
53
|
+
if (this.hasDisplayTarget) {
|
|
54
|
+
this.displayTarget.textContent = this.maskedValue ? this.maskedSecret : this.secretValue
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (this.hasRevealIconTarget) {
|
|
58
|
+
this.revealIconTarget.className = this.maskedValue
|
|
59
|
+
? "fa-solid fa-eye"
|
|
60
|
+
: "fa-solid fa-eye-slash"
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (this.hasRevealTextTarget) {
|
|
64
|
+
this.revealTextTarget.textContent = this.maskedValue ? "Reveal" : "Hide"
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
copy() {
|
|
69
|
+
navigator.clipboard.writeText(this.secretValue).then(() => {
|
|
70
|
+
this.showCopiedFeedback()
|
|
71
|
+
}).catch((err) => {
|
|
72
|
+
console.error("Failed to copy:", err)
|
|
73
|
+
})
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
showCopiedFeedback() {
|
|
77
|
+
if (!this.hasCopyButtonTarget) return
|
|
78
|
+
|
|
79
|
+
// Clear any pending feedback timeout from a previous copy
|
|
80
|
+
if (this._feedbackTimeout) {
|
|
81
|
+
clearTimeout(this._feedbackTimeout)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const button = this.copyButtonTarget
|
|
85
|
+
if (!this._originalClasses) {
|
|
86
|
+
this._originalClasses = button.className
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Update icon and text
|
|
90
|
+
if (this.hasCopyIconTarget) {
|
|
91
|
+
this.copyIconTarget.className = "fa-solid fa-check"
|
|
92
|
+
}
|
|
93
|
+
if (this.hasCopyTextTarget) {
|
|
94
|
+
this.copyTextTarget.textContent = "Copied!"
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Swap to success style
|
|
98
|
+
button.className = "shrink-0 btn btn-success transition"
|
|
99
|
+
|
|
100
|
+
// Restore after 2 seconds
|
|
101
|
+
this._feedbackTimeout = setTimeout(() => {
|
|
102
|
+
this._feedbackTimeout = null
|
|
103
|
+
button.className = this._originalClasses
|
|
104
|
+
if (this.hasCopyIconTarget) {
|
|
105
|
+
this.copyIconTarget.className = "fa-solid fa-copy"
|
|
106
|
+
}
|
|
107
|
+
if (this.hasCopyTextTarget) {
|
|
108
|
+
this.copyTextTarget.textContent = "Copy"
|
|
109
|
+
}
|
|
110
|
+
}, 2000)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
get maskedSecret() {
|
|
114
|
+
const secret = this.secretValue
|
|
115
|
+
if (!secret || secret.length === 0) return ""
|
|
116
|
+
if (secret.length <= 4) return "\u2022".repeat(secret.length)
|
|
117
|
+
return "\u2022".repeat(12) + secret.slice(-4)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Controller } from "@hotwired/stimulus"
|
|
2
|
+
|
|
3
|
+
export default class extends Controller {
|
|
4
|
+
static targets = ["body", "icon", "summary"]
|
|
5
|
+
static values = { expanded: { type: Boolean, default: false } }
|
|
6
|
+
|
|
7
|
+
connect() {
|
|
8
|
+
this.render()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
toggle(event) {
|
|
12
|
+
// Don't toggle if clicking a button, link, input, select, or drag handle inside the header
|
|
13
|
+
if (event.target.closest("button, a, input, select, textarea, [data-sortable-handle]")) return
|
|
14
|
+
|
|
15
|
+
this.expandedValue = !this.expandedValue
|
|
16
|
+
this.render()
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
// No-op action to stop event propagation from nested buttons
|
|
20
|
+
noop() {}
|
|
21
|
+
|
|
22
|
+
render() {
|
|
23
|
+
if (this.hasBodyTarget) {
|
|
24
|
+
if (this.expandedValue) {
|
|
25
|
+
this.bodyTarget.style.maxHeight = this.bodyTarget.scrollHeight + "px"
|
|
26
|
+
this.bodyTarget.style.opacity = "1"
|
|
27
|
+
} else {
|
|
28
|
+
this.bodyTarget.style.maxHeight = "0px"
|
|
29
|
+
this.bodyTarget.style.opacity = "0"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (this.hasIconTarget) {
|
|
34
|
+
this.iconTarget.style.transform = this.expandedValue ? "rotate(180deg)" : "rotate(0deg)"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Update summary text from a form input event
|
|
39
|
+
updateSummary(event) {
|
|
40
|
+
if (this.hasSummaryTarget) {
|
|
41
|
+
const value = event.target.value.trim()
|
|
42
|
+
this.summaryTarget.textContent = value || this.summaryTarget.dataset.placeholder || "New item"
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|