panda-core 0.1.15 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. checksums.yaml +4 -4
  2. data/README.md +9 -16
  3. data/Rakefile +3 -0
  4. data/app/builders/panda/core/form_builder.rb +225 -0
  5. data/app/components/panda/core/admin/button_component.rb +70 -0
  6. data/app/components/panda/core/admin/container_component.html.erb +12 -0
  7. data/app/components/panda/core/admin/container_component.rb +13 -0
  8. data/app/components/panda/core/admin/flash_message_component.html.erb +31 -0
  9. data/app/components/panda/core/admin/flash_message_component.rb +47 -0
  10. data/app/components/panda/core/admin/heading_component.rb +46 -0
  11. data/app/components/panda/core/admin/panel_component.html.erb +7 -0
  12. data/app/components/panda/core/admin/panel_component.rb +13 -0
  13. data/app/components/panda/core/admin/slideover_component.html.erb +9 -0
  14. data/app/components/panda/core/admin/slideover_component.rb +15 -0
  15. data/app/components/panda/core/admin/table_component.html.erb +29 -0
  16. data/app/components/panda/core/admin/table_component.rb +46 -0
  17. data/app/components/panda/core/admin/tag_component.rb +35 -0
  18. data/app/constraints/panda/core/admin_constraint.rb +14 -0
  19. data/app/controllers/panda/core/admin/dashboard_controller.rb +22 -0
  20. data/app/controllers/panda/core/admin/my_profile_controller.rb +49 -0
  21. data/app/controllers/panda/core/admin/sessions_controller.rb +69 -0
  22. data/app/controllers/panda/core/admin_controller.rb +28 -0
  23. data/app/controllers/panda/core/application_controller.rb +59 -0
  24. data/app/helpers/panda/core/asset_helper.rb +32 -0
  25. data/app/javascript/panda/core/application.js +9 -0
  26. data/app/javascript/panda/core/controllers/index.js +20 -0
  27. data/app/javascript/panda/core/controllers/theme_form_controller.js +25 -0
  28. data/app/javascript/panda/core/tailwindcss-stimulus-components.js +3 -0
  29. data/app/models/panda/core/application_record.rb +9 -0
  30. data/app/models/panda/core/breadcrumb.rb +17 -0
  31. data/app/models/panda/core/current.rb +16 -0
  32. data/app/models/panda/core/user.rb +51 -0
  33. data/app/views/layouts/panda/core/admin.html.erb +59 -0
  34. data/app/views/panda/core/admin/dashboard/show.html.erb +27 -0
  35. data/app/views/panda/core/admin/my_profile/edit.html.erb +49 -0
  36. data/app/views/panda/core/admin/sessions/new.html.erb +38 -0
  37. data/app/views/panda/core/admin/shared/_breadcrumbs.html.erb +35 -0
  38. data/app/views/panda/core/admin/shared/_flash.html.erb +31 -0
  39. data/app/views/panda/core/admin/shared/_sidebar.html.erb +27 -0
  40. data/app/views/panda/core/admin/shared/_slideover.html.erb +33 -0
  41. data/config/routes.rb +22 -0
  42. data/db/migrate/20241210000003_add_current_theme_to_panda_core_users.rb +7 -0
  43. data/db/migrate/20250809000001_create_panda_core_users.rb +16 -0
  44. data/lib/generators/panda/core/dev_tools/USAGE +24 -0
  45. data/lib/generators/panda/core/dev_tools/templates/lefthook.yml +13 -0
  46. data/lib/generators/panda/core/dev_tools/templates/spec_support_panda_core_helpers.rb +18 -0
  47. data/lib/generators/panda/core/dev_tools_generator.rb +143 -0
  48. data/lib/panda/core/asset_loader.rb +221 -0
  49. data/lib/panda/core/authentication.rb +36 -0
  50. data/lib/panda/core/component_registry.rb +37 -0
  51. data/lib/panda/core/configuration.rb +31 -1
  52. data/lib/panda/core/engine.rb +43 -7
  53. data/lib/panda/core/notifications.rb +40 -0
  54. data/lib/panda/core/rake_tasks.rb +16 -0
  55. data/lib/panda/core/subscribers/authentication_subscriber.rb +61 -0
  56. data/lib/panda/core/testing/capybara_config.rb +70 -0
  57. data/lib/panda/core/testing/omniauth_helpers.rb +52 -0
  58. data/lib/panda/core/testing/rspec_config.rb +72 -0
  59. data/lib/panda/core/version.rb +1 -1
  60. data/lib/panda/core.rb +2 -8
  61. data/lib/tasks/assets.rake +423 -0
  62. data/lib/tasks/panda/core/migrations.rake +13 -0
  63. data/lib/tasks/panda_core.rake +52 -0
  64. metadata +375 -10
  65. data/db/migrate/20250121012333_logidze_install.rb +0 -577
  66. data/db/migrate/20250121012334_enable_hstore.rb +0 -5
@@ -9,7 +9,20 @@ module Panda
9
9
  :parent_mailer,
10
10
  :mailer_sender,
11
11
  :mailer_default_url_options,
12
- :session_token_cookie
12
+ :session_token_cookie,
13
+ :authentication_providers,
14
+ :admin_path,
15
+ :admin_navigation_items,
16
+ :admin_dashboard_widgets,
17
+ :user_attributes,
18
+ :user_associations,
19
+ :authorization_policy,
20
+ :additional_user_params,
21
+ :available_themes,
22
+ :login_logo_path,
23
+ :login_page_title,
24
+ :initial_admin_breadcrumb,
25
+ :dashboard_redirect_path
13
26
 
14
27
  def initialize
15
28
  @user_class = "Panda::Core::User"
@@ -21,6 +34,23 @@ module Panda
21
34
  @mailer_sender = "support@example.com"
22
35
  @mailer_default_url_options = {host: "localhost:3000"}
23
36
  @session_token_cookie = :panda_session
37
+ @authentication_providers = {}
38
+ @admin_path = "/admin"
39
+
40
+ # Hook system for extending admin UI
41
+ @admin_navigation_items = ->(user) { [] }
42
+ @admin_dashboard_widgets = ->(user) { [] }
43
+ @user_attributes = []
44
+ @user_associations = []
45
+ @authorization_policy = ->(user, action, resource) { user.admin? }
46
+
47
+ # Profile and UI customization
48
+ @additional_user_params = []
49
+ @available_themes = [["Default", "default"], ["Sky", "sky"]]
50
+ @login_logo_path = nil
51
+ @login_page_title = "Sign in to your account"
52
+ @initial_admin_breadcrumb = nil # Proc that returns [label, path]
53
+ @dashboard_redirect_path = nil # Path to redirect to after login (defaults to admin_root_path)
24
54
  end
25
55
  end
26
56
 
@@ -1,26 +1,62 @@
1
1
  require "rubygems"
2
2
 
3
3
  require "rails/engine"
4
+ require "omniauth"
5
+ require "omniauth/rails_csrf_protection"
6
+ require "view_component"
4
7
 
5
8
  module Panda
6
9
  module Core
7
10
  class Engine < ::Rails::Engine
8
11
  isolate_namespace Panda::Core
9
12
 
13
+ config.eager_load_namespaces << Panda::Core::Engine
14
+
15
+ # Add engine's app directories to autoload paths
16
+ config.autoload_paths += Dir[root.join("app", "models")]
17
+ config.autoload_paths += Dir[root.join("app", "controllers")]
18
+ config.autoload_paths += Dir[root.join("app", "builders")]
19
+ config.autoload_paths += Dir[root.join("app", "components")]
20
+
10
21
  config.generators do |g|
11
22
  g.test_framework :rspec
12
23
  g.fixture_replacement :factory_bot
13
24
  g.factory_bot dir: "spec/factories"
14
25
  end
15
26
 
27
+ initializer "panda_core.append_migrations" do |app|
28
+ unless app.root.to_s.match?(root.to_s)
29
+ config.paths["db/migrate"].expanded.each do |expanded_path|
30
+ app.config.paths["db/migrate"] << expanded_path
31
+ end
32
+ end
33
+ end
34
+
16
35
  initializer "panda_core.configuration" do |app|
17
- config.after_initialize do
18
- Panda::Core.configure do |config|
19
- config.parent_controller ||= "ActionController::API"
20
- config.parent_mailer ||= "ActionMailer::Base"
21
- config.mailer_sender ||= "support@example.com"
22
- config.mailer_default_url_options ||= {host: "localhost:3000"}
23
- config.session_token_cookie ||= :session_token
36
+ # Configuration is already initialized with defaults in Configuration class
37
+ end
38
+
39
+ initializer "panda_core.omniauth" do |app|
40
+ # Mount OmniAuth at configurable admin path
41
+ app.middleware.use OmniAuth::Builder do
42
+ # Configure OmniAuth to use the configured admin path
43
+ configure do |config|
44
+ config.path_prefix = "#{Panda::Core.configuration.admin_path}/auth"
45
+ # Allow POST requests for request phase (required for CSRF protection)
46
+ config.allowed_request_methods = [:get, :post]
47
+ end
48
+
49
+ Panda::Core.configuration.authentication_providers.each do |provider_name, settings|
50
+ case provider_name.to_s
51
+ when "microsoft_graph"
52
+ provider :microsoft_graph, settings[:client_id], settings[:client_secret], settings[:options] || {}
53
+ when "google_oauth2"
54
+ provider :google_oauth2, settings[:client_id], settings[:client_secret], settings[:options] || {}
55
+ when "github"
56
+ provider :github, settings[:client_id], settings[:client_secret], settings[:options] || {}
57
+ when "developer"
58
+ provider :developer if Rails.env.development?
59
+ end
24
60
  end
25
61
  end
26
62
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Notifications
6
+ # Event names following Rails convention: namespace.event
7
+ USER_CREATED = "panda.core.user_created"
8
+ USER_LOGIN = "panda.core.user_login"
9
+ USER_LOGOUT = "panda.core.user_logout"
10
+ ADMIN_ACTION = "panda.core.admin_action"
11
+
12
+ class << self
13
+ # Subscribe to a Panda Core event
14
+ #
15
+ # @example
16
+ # Panda::Core::Notifications.subscribe(:user_created) do |event|
17
+ # UserMailer.welcome(event.payload[:user]).deliver_later
18
+ # end
19
+ def subscribe(event_name, &block)
20
+ event_key = const_get(event_name.to_s.upcase)
21
+ ActiveSupport::Notifications.subscribe(event_key, &block)
22
+ end
23
+
24
+ # Instrument a Panda Core event
25
+ #
26
+ # @example
27
+ # Panda::Core::Notifications.instrument(:user_created, user: user, provider: provider)
28
+ def instrument(event_name, payload = {})
29
+ event_key = const_get(event_name.to_s.upcase)
30
+ ActiveSupport::Notifications.instrument(event_key, payload)
31
+ end
32
+
33
+ # Unsubscribe from a Panda Core event
34
+ def unsubscribe(subscriber)
35
+ ActiveSupport::Notifications.unsubscribe(subscriber)
36
+ end
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module RakeTasks
6
+ def self.install_tasks
7
+ Dir[File.expand_path("../tasks/*.rake", __dir__)].each { |f| load f }
8
+ end
9
+ end
10
+ end
11
+ end
12
+
13
+ # Load tasks if Rake is available
14
+ if defined?(Rake)
15
+ Panda::Core::RakeTasks.install_tasks
16
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Subscribers
6
+ # Example subscriber for authentication events
7
+ #
8
+ # To enable in your application, add to an initializer:
9
+ # Panda::Core::Subscribers::AuthenticationSubscriber.attach
10
+ class AuthenticationSubscriber
11
+ class << self
12
+ def attach
13
+ # Subscribe to user creation
14
+ ActiveSupport::Notifications.subscribe("panda.core.user_created") do |event|
15
+ user = event.payload[:user]
16
+ provider = event.payload[:provider]
17
+
18
+ Rails.logger.info "[AuthSubscriber] New user created: #{user.email} via #{provider}"
19
+
20
+ # Example: Send welcome email
21
+ # UserMailer.welcome(user).deliver_later if defined?(UserMailer)
22
+
23
+ # Example: Track analytics
24
+ # Analytics.track("User Signup", user_id: user.id, provider: provider) if defined?(Analytics)
25
+ end
26
+
27
+ # Subscribe to user login
28
+ ActiveSupport::Notifications.subscribe("panda.core.user_login") do |event|
29
+ user = event.payload[:user]
30
+ provider = event.payload[:provider]
31
+
32
+ Rails.logger.info "[AuthSubscriber] User logged in: #{user.email} via #{provider}"
33
+
34
+ # Example: Update last login time
35
+ # user.touch(:last_login_at) if user.respond_to?(:last_login_at)
36
+
37
+ # Example: Track analytics
38
+ # Analytics.track("User Login", user_id: user.id, provider: provider) if defined?(Analytics)
39
+ end
40
+
41
+ # Subscribe to user logout
42
+ ActiveSupport::Notifications.subscribe("panda.core.user_logout") do |event|
43
+ user = event.payload[:user]
44
+
45
+ Rails.logger.info "[AuthSubscriber] User logged out: #{user.email}"
46
+
47
+ # Example: Clean up session data
48
+ # SessionCleanupJob.perform_later(user.id) if defined?(SessionCleanupJob)
49
+ end
50
+ end
51
+
52
+ def detach
53
+ ActiveSupport::Notifications.unsubscribe("panda.core.user_created")
54
+ ActiveSupport::Notifications.unsubscribe("panda.core.user_login")
55
+ ActiveSupport::Notifications.unsubscribe("panda.core.user_logout")
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,70 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "capybara/rspec"
4
+
5
+ module Panda
6
+ module Core
7
+ module Testing
8
+ module CapybaraConfig
9
+ def self.configure
10
+ Capybara.server = :puma, {Silent: true}
11
+ Capybara.default_max_wait_time = 5
12
+ Capybara.disable_animation = true
13
+
14
+ # Register Chrome driver with sensible defaults
15
+ if defined?(Cuprite)
16
+ Capybara.register_driver :panda_chrome do |app|
17
+ Cuprite::Driver.new(
18
+ app,
19
+ window_size: [1400, 1400],
20
+ browser_options: {
21
+ "no-sandbox": nil,
22
+ "disable-gpu": nil,
23
+ "disable-dev-shm-usage": nil
24
+ },
25
+ inspector: ENV["INSPECTOR"] == "true",
26
+ headless: ENV["HEADLESS"] != "false"
27
+ )
28
+ end
29
+
30
+ Capybara.javascript_driver = :panda_chrome
31
+ Capybara.default_driver = :rack_test
32
+ end
33
+ end
34
+
35
+ # Helper methods for system tests
36
+ module Helpers
37
+ def wait_for_ajax
38
+ Timeout.timeout(Capybara.default_max_wait_time) do
39
+ loop until page.evaluate_script("jQuery.active").zero?
40
+ end
41
+ rescue Timeout::Error
42
+ # Ajax didn't finish, but continue anyway
43
+ end
44
+
45
+ def wait_for_turbo
46
+ has_css?("html", wait: 0.1)
47
+ return unless page.evaluate_script("typeof Turbo !== 'undefined'")
48
+
49
+ page.evaluate_script("Turbo.session.drive = false")
50
+ yield if block_given?
51
+ page.evaluate_script("Turbo.session.drive = true")
52
+ end
53
+
54
+ def take_screenshot_on_failure
55
+ return unless page.driver.respond_to?(:save_screenshot)
56
+
57
+ timestamp = Time.now.strftime("%Y%m%d_%H%M%S")
58
+ filename = "screenshot_#{timestamp}_#{example.description.parameterize}.png"
59
+ path = Rails.root.join("tmp", "screenshots", filename)
60
+
61
+ FileUtils.mkdir_p(File.dirname(path))
62
+ page.driver.save_screenshot(path)
63
+
64
+ puts "\nScreenshot saved: #{path}"
65
+ end
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
@@ -0,0 +1,52 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Testing
6
+ module OmniAuthHelpers
7
+ def mock_omniauth_login(email: "admin@example.com", name: "Test User", provider: :google_oauth2, uid: "123456789", is_admin: true)
8
+ OmniAuth.config.test_mode = true
9
+ OmniAuth.config.mock_auth[provider] = OmniAuth::AuthHash.new(
10
+ provider: provider.to_s,
11
+ uid: uid,
12
+ info: {
13
+ email: email,
14
+ name: name,
15
+ image: "https://example.com/avatar.jpg"
16
+ }
17
+ )
18
+
19
+ # Create or update the user in the test database
20
+ Panda::Core::User.find_or_create_by(email: email) do |u|
21
+ # Split name into firstname and lastname
22
+ parts = name.split(' ', 2)
23
+ u.firstname = parts[0] || name
24
+ u.lastname = parts[1] || ''
25
+ u.admin = is_admin
26
+ end
27
+ end
28
+
29
+ def login_as_admin(email: "admin@example.com", name: "Admin User")
30
+ user = mock_omniauth_login(email: email, name: name, is_admin: true)
31
+ visit "/admin/auth/google_oauth2"
32
+ user
33
+ end
34
+
35
+ def login_as_user(email: "user@example.com", name: "Regular User")
36
+ user = mock_omniauth_login(email: email, name: name, is_admin: false)
37
+ visit "/admin/auth/google_oauth2"
38
+ user
39
+ end
40
+
41
+ def logout
42
+ visit "/admin/logout"
43
+ end
44
+
45
+ def mock_omniauth_failure(message = "Authentication failed")
46
+ OmniAuth.config.test_mode = true
47
+ OmniAuth.config.mock_auth[:google_oauth2] = :invalid_credentials
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Panda
4
+ module Core
5
+ module Testing
6
+ module RSpecConfig
7
+ def self.configure(config)
8
+ # Add common RSpec configurations
9
+ config.expect_with :rspec do |expectations|
10
+ expectations.include_chain_clauses_in_custom_matcher_descriptions = true
11
+ end
12
+
13
+ config.mock_with :rspec do |mocks|
14
+ mocks.verify_partial_doubles = true
15
+ end
16
+
17
+ config.shared_context_metadata_behavior = :apply_to_host_groups
18
+ config.filter_run_when_matching :focus
19
+ config.example_status_persistence_file_path = "spec/examples.txt"
20
+ config.disable_monkey_patching!
21
+
22
+ if config.files_to_run.one?
23
+ config.default_formatter = "doc"
24
+ end
25
+
26
+ config.order = :random
27
+ Kernel.srand config.seed
28
+
29
+ # Database cleaner setup
30
+ if defined?(DatabaseCleaner)
31
+ config.before(:suite) do
32
+ DatabaseCleaner.strategy = :transaction
33
+ DatabaseCleaner.clean_with(:truncation)
34
+ end
35
+
36
+ config.around(:each) do |example|
37
+ DatabaseCleaner.cleaning do
38
+ example.run
39
+ end
40
+ end
41
+ end
42
+
43
+ # OmniAuth test mode
44
+ if defined?(OmniAuth)
45
+ config.before(:each) do
46
+ OmniAuth.config.test_mode = true
47
+ end
48
+
49
+ config.after(:each) do
50
+ OmniAuth.config.mock_auth.clear
51
+ end
52
+ end
53
+ end
54
+
55
+ # Common matchers for Panda gems
56
+ def self.setup_matchers
57
+ RSpec::Matchers.define :have_breadcrumb do |expected|
58
+ match do |page|
59
+ page.has_css?(".breadcrumb", text: expected)
60
+ end
61
+ end
62
+
63
+ RSpec::Matchers.define :have_flash_message do |type, message|
64
+ match do |page|
65
+ page.has_css?(".flash-message.flash-#{type}", text: message)
66
+ end
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Panda
4
4
  module Core
5
- VERSION = "0.1.15"
5
+ VERSION = "0.2.1"
6
6
  end
7
7
  end
data/lib/panda/core.rb CHANGED
@@ -1,22 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "rails"
4
- require "dry-configurable"
5
4
 
6
5
  module Panda
7
6
  module Core
8
- extend Dry::Configurable
9
-
10
- setting :user_class
11
- setting :authentication_providers, default: []
12
- setting :storage_provider, default: :active_storage
13
- setting :cache_store, default: :memory_store
14
-
15
7
  def self.root
16
8
  File.expand_path("../..", __FILE__)
17
9
  end
18
10
  end
19
11
  end
20
12
 
13
+ require_relative "core/version"
21
14
  require_relative "core/configuration"
15
+ require_relative "core/asset_loader"
22
16
  require_relative "core/engine" if defined?(Rails)