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