maquina 0.5.2 → 0.7.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (93) hide show
  1. checksums.yaml +4 -4
  2. data/.ruby-version +1 -1
  3. data/Gemfile +2 -0
  4. data/Gemfile.lock +3 -35
  5. data/app/assets/javascripts/controllers/alert_controller.js +22 -0
  6. data/app/assets/stylesheets/maquina/application.tailwind.css +4 -101
  7. data/app/assets/stylesheets/maquina.css +114 -0
  8. data/app/controllers/concerns/maquina/authenticate.rb +29 -1
  9. data/app/controllers/concerns/maquina/index.rb +81 -2
  10. data/app/controllers/concerns/maquina/resourceful.rb +95 -9
  11. data/app/controllers/maquina/application_controller.rb +1 -1
  12. data/app/controllers/maquina/dashboard_controller.rb +10 -5
  13. data/app/controllers/maquina/invitations_controller.rb +0 -2
  14. data/app/controllers/maquina/plans_controller.rb +13 -23
  15. data/app/controllers/maquina/sessions_controller.rb +1 -1
  16. data/app/controllers/maquina/users_controller.rb +0 -2
  17. data/app/helpers/maquina/application_helper.rb +0 -8
  18. data/app/helpers/maquina/navbar_menu_helper.rb +1 -1
  19. data/app/models/concerns/maquina/blockeable.rb +64 -0
  20. data/app/models/concerns/maquina/organization_scoped.rb +26 -0
  21. data/app/models/concerns/maquina/retain_passwords.rb +44 -0
  22. data/app/models/concerns/maquina/sqlite_search.rb +92 -0
  23. data/app/models/concerns/maquina/user_scoped.rb +26 -0
  24. data/app/models/maquina/active_session.rb +40 -0
  25. data/app/models/maquina/current.rb +39 -2
  26. data/app/models/maquina/invitation.rb +28 -0
  27. data/app/models/maquina/membership.rb +23 -1
  28. data/app/models/maquina/organization.rb +19 -2
  29. data/app/models/maquina/plan.rb +26 -6
  30. data/app/models/maquina/used_password.rb +30 -0
  31. data/app/models/maquina/user.rb +50 -8
  32. data/app/policies/maquina/application_policy.rb +1 -1
  33. data/app/policies/maquina/dashboard_policy.rb +7 -0
  34. data/app/views/layouts/maquina/application.html.erb +2 -2
  35. data/app/views/layouts/maquina/sessions.html.erb +1 -1
  36. data/app/views/maquina/application/_navbar.html.erb +8 -3
  37. data/app/views/maquina/application/components/checkbox_component.rb +3 -3
  38. data/app/views/maquina/application/components/component_base.rb +3 -2
  39. data/app/views/maquina/application/edit.html.erb +10 -7
  40. data/app/views/maquina/application/filters.rb +118 -0
  41. data/app/views/maquina/application/index.html.erb +6 -3
  42. data/app/views/maquina/application/index_header.rb +5 -2
  43. data/app/views/maquina/application/index_table.rb +71 -6
  44. data/app/views/maquina/application/index_tools.rb +17 -0
  45. data/app/views/maquina/application/new.html.erb +10 -7
  46. data/app/views/maquina/application/search.rb +42 -0
  47. data/app/views/maquina/application/sessions_header.rb +3 -9
  48. data/app/views/maquina/dashboard/index.html.erb +4 -0
  49. data/app/views/maquina/dashboard/stats.rb +35 -0
  50. data/app/views/maquina/dashboard/tasks.rb +124 -0
  51. data/app/views/maquina/first_runs/form.rb +0 -2
  52. data/app/views/maquina/first_runs/show.html.erb +4 -1
  53. data/app/views/maquina/navbar/title.rb +4 -2
  54. data/config/importmap.rb +1 -13
  55. data/config/locales/flash.en.yml +6 -0
  56. data/config/locales/flash.es.yml +6 -0
  57. data/config/locales/forms.en.yml +33 -4
  58. data/config/locales/forms.es.yml +22 -11
  59. data/config/locales/models.en.yml +10 -0
  60. data/config/locales/models.es.yml +10 -0
  61. data/config/locales/views.en.yml +33 -5
  62. data/config/locales/views.es.yml +28 -10
  63. data/config/routes.rb +1 -0
  64. data/db/migrate/20221109010726_create_maquina_plans.rb +1 -1
  65. data/db/migrate/20221113000409_create_maquina_users.rb +1 -1
  66. data/db/migrate/20221113020108_create_maquina_used_passwords.rb +1 -1
  67. data/db/migrate/20221115223414_create_maquina_active_sessions.rb +1 -3
  68. data/db/migrate/20230201203922_create_maquina_invitations.rb +1 -1
  69. data/db/migrate/20230829183530_create_maquina_organizations.rb +1 -1
  70. data/db/migrate/20230829192656_create_maquina_memberships.rb +2 -4
  71. data/db/migrate/20241109191405_add_counter_cache_to_plans.rb +5 -0
  72. data/lib/generators/maquina/install_generator.rb +67 -1
  73. data/lib/generators/maquina/tailwind_config/templates/lib/generators/tailwind_config/templates/config/tailwind.config.js.tt +9 -5
  74. data/lib/generators/maquina/tailwind_config/templates/lib/tasks/tailwind.rake.tt +2 -0
  75. data/lib/generators/maquina/templates/config/initializers/maquina.rb.tt +2 -1
  76. data/lib/maquina/engine.rb +6 -3
  77. data/lib/maquina/version.rb +1 -1
  78. data/lib/maquina.rb +2 -9
  79. metadata +20 -76
  80. data/app/assets/javascripts/maquina/application.js +0 -4
  81. data/app/assets/javascripts/maquina/controllers/alert_controller.js +0 -29
  82. data/app/assets/javascripts/maquina/controllers/application.js +0 -9
  83. data/app/assets/javascripts/maquina/controllers/index.js +0 -11
  84. data/app/models/concerns/maquina/authenticate_by.rb +0 -33
  85. data/app/views/maquina/navbar/search.rb +0 -40
  86. data/lib/generators/maquina/install_templates/install_templates_generator.rb +0 -31
  87. /data/app/assets/javascripts/{maquina/controllers → controllers}/backdrop_controller.js +0 -0
  88. /data/app/assets/javascripts/{maquina/controllers → controllers}/file_controller.js +0 -0
  89. /data/app/assets/javascripts/{maquina/controllers → controllers}/mobile_menu_controller.js +0 -0
  90. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_controller.js +0 -0
  91. /data/app/assets/javascripts/{maquina/controllers → controllers}/modal_open_controller.js +0 -0
  92. /data/app/assets/javascripts/{maquina/controllers → controllers}/popup_menu_controller.js +0 -0
  93. /data/app/assets/javascripts/{maquina/controllers → controllers}/submit_form_controller.js +0 -0
@@ -1,6 +1,35 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the ActiveSession model in the Maquina module.
6
+ # Manages active user sessions and their expiration.
7
+ #
8
+ # == Attributes
9
+ #
10
+ # - +maquina_user_id+:: Foreign key reference to the associated User.
11
+ # - +expires_at+:: Timestamp when the session expires.
12
+ # - +user_agent+:: The user agent string from the client browser.
13
+ # - +remote_addr+:: The remote IP address of the client.
14
+ # - +return_url+:: URL to return to after session activities.
15
+ #
16
+ # == Associations
17
+ #
18
+ # - +user+:: Belongs to a User.
19
+ #
20
+ # == Validations
21
+ #
22
+ # - +expires_at+:: Must be present and in the future if session expiration is configured.
23
+ # - +user+:: Must not be blocked.
24
+ #
25
+ # == Callbacks
26
+ #
27
+ # - After initialize: Sets initial expiration time for new sessions based on configuration.
28
+ #
29
+ # == Delegations
30
+ #
31
+ # - +blocked?+:: Delegated to the associated user.
32
+ #
4
33
  class ActiveSession < ApplicationRecord
5
34
  belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
6
35
  delegate :blocked?, to: :user
@@ -10,6 +39,11 @@ module Maquina
10
39
  validates :expires_at, presence: true, comparison: {greater_than: Time.zone.now}, if: ->(session) { (session.new_record? || session.changed.includes?("expires_at")) && Maquina.configuration.session_expiration.present? }
11
40
  validate :non_blocked_user
12
41
 
42
+ # Checks if the session has expired
43
+ #
44
+ # Returns:
45
+ # - true -> if expires_at is set and in the past
46
+ # - false -> if expires_at is not set or is in the future
13
47
  def expired?
14
48
  return false if expires_at.blank?
15
49
 
@@ -18,12 +52,18 @@ module Maquina
18
52
 
19
53
  private
20
54
 
55
+ # Validates that the associated user is not blocked
56
+ #
57
+ # Adds an error if the user is blocked
21
58
  def non_blocked_user
22
59
  return if user.blank? || !blocked?
23
60
 
24
61
  errors.add(:user, :blocked)
25
62
  end
26
63
 
64
+ # Sets the initial expiration time for new sessions
65
+ #
66
+ # Uses the configured session expiration duration if available
27
67
  def configure_expiration
28
68
  if new_record? && expires_at.blank? && Maquina.configuration.session_expiration.present?
29
69
  self.expires_at = Time.zone.now.since(Maquina.configuration.session_expiration)
@@ -1,20 +1,57 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the Current context in the Maquina module.
6
+ # Manages per-request global state for the current user session.
7
+ #
8
+ # == Attributes
9
+ #
10
+ # - +active_session+:: The current active session for the request.
11
+ # - +user+:: The current authenticated user.
12
+ # - +membership+:: The current user's default membership.
13
+ #
14
+ # == Usage
15
+ #
16
+ # Current attributes are cleared between requests and provide a thread-safe way to
17
+ # handle request-specific data. They are automatically reset after each request.
18
+ #
4
19
  class Current < ActiveSupport::CurrentAttributes
5
20
  attribute :active_session, :user, :membership
6
21
 
22
+ # Checks if there is a valid signed-in session
23
+ #
24
+ # Returns:
25
+ # - true -> if there is an active, non-expired, non-blocked session
26
+ # - false -> if there is no active session or the session is invalid
7
27
  def signed_in?
8
- return false if active_session.blank?
9
- !active_session.expired? && !active_session.blocked?
28
+ if active_session.blank? || active_session.expired? || active_session.blocked?
29
+ self.user = nil
30
+ self.membership = nil
31
+
32
+ false
33
+ end
34
+ true
10
35
  end
11
36
 
37
+ # Sets the active session and updates related attributes
38
+ #
39
+ # When setting a new active session, automatically updates the current user
40
+ # and their default membership.
41
+ #
42
+ # Args:
43
+ # - value -> Maquina::ActiveSession: The active session to set
12
44
  def active_session=(value)
13
45
  super
14
46
  self.user = value&.user
15
47
  self.membership = user.default_membership
16
48
  end
17
49
 
50
+ # Checks if the current user has management privileges
51
+ #
52
+ # Returns:
53
+ # - true -> if the current user has management access
54
+ # - false -> if the current user does not have management access
18
55
  def management?
19
56
  user.management?
20
57
  end
@@ -1,13 +1,41 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the Invitation model in the Maquina module.
6
+ # Manages invitations sent to users to join the system.
7
+ #
8
+ # == Attributes
9
+ #
10
+ # - +email+:: The email address of the invited user (required, must be valid format).
11
+ # - +accepted_at+:: Timestamp indicating when the invitation was accepted, nil if pending.
12
+ #
13
+ # == Validations
14
+ #
15
+ # - +email+:: Must be present and in valid email format.
16
+ #
4
17
  class Invitation < ApplicationRecord
5
18
  validates :email, presence: true, format: {with: URI::MailTo::EMAIL_REGEXP}
6
19
 
20
+ # Checks if the invitation has been accepted
21
+ #
22
+ # Returns:
23
+ # - true -> if the invitation has been accepted
24
+ # - false -> if the invitation is still pending
7
25
  def accepted?
8
26
  accepted_at.present?
9
27
  end
10
28
 
29
+ # Marks the invitation as accepted with the current timestamp
30
+ #
31
+ # Updates the accepted_at timestamp with the current time.
32
+ #
33
+ # Returns:
34
+ # - true -> if the update was successful
35
+ # - false -> if the update failed
36
+ #
37
+ # Raises:
38
+ # - ActiveRecord::RecordInvalid -> if validation fails
11
39
  def accept!
12
40
  update!(accepted_at: Time.zone.now)
13
41
  end
@@ -1,10 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the Membership model in the Maquina module.
6
+ # Links users to organizations and defines their roles and permissions within them.
7
+ #
8
+ # == Attributes
9
+ #
10
+ # - +maquina_organization_id+:: Foreign key reference to the associated Organization.
11
+ # - +maquina_user_id+:: Foreign key reference to the associated User.
12
+ # - +owner+:: Boolean flag indicating if the member is an owner of the organization.
13
+ # - +role+:: Enum defining the member's role within the organization (configured via Maquina.configuration.membership_roles).
14
+ # - +blocked_at+:: Timestamp indicating when the membership was blocked, nil if active.
15
+ #
16
+ # == Associations
17
+ #
18
+ # - +organization+:: Belongs to an Organization.
19
+ # - +user+:: Belongs to a User.
20
+ #
21
+ # == Enums
22
+ #
23
+ # - +role+:: Available roles are defined in Maquina.configuration.membership_roles.
24
+ # These define the member's permissions and access level within their organization.
25
+ #
4
26
  class Membership < ApplicationRecord
5
27
  belongs_to :organization, class_name: "Maquina::Organization", foreign_key: :maquina_organization_id
6
28
  belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
7
29
 
8
- enum role: Maquina.configuration.membership_roles
30
+ enum :role, Maquina.configuration.membership_roles
9
31
  end
10
32
  end
@@ -1,8 +1,25 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the Organization model in the Maquina module.
6
+ #
7
+ # == Attributes
8
+ #
9
+ # - +name+:: The name of the Organization.
10
+ # - +active+:: A boolean flag indicating whether the Organization is active.
11
+ # - +maquina_plan_id+:: The foreign key reference to the associated Plan.
12
+ #
13
+ # == Associations
14
+ #
15
+ # - +plan+:: Belongs to a Plan (optional). When the associated Plan is deleted, the reference is set to null.
16
+ #
17
+ # == Usage
18
+ #
19
+ # The Organization model represents an organizational entity within the Maquina module.
20
+ # Organizations can be associated with a pricing plan and their active status can be tracked.
21
+ #
4
22
  class Organization < ApplicationRecord
5
-
6
- belongs_to :plan, class_name: "Maquina::Plan", foreign_key: :maquina_plan_id, optional: true
23
+ belongs_to :plan, class_name: "Maquina::Plan", foreign_key: :maquina_plan_id, optional: true, counter_cache: true
7
24
  end
8
25
  end
@@ -8,6 +8,11 @@ module Maquina
8
8
  #
9
9
  # - +price+:: The price of the Plan, represented as a monetary value with currency.
10
10
  # - +name+:: The name of the Plan.
11
+ # - +organizations_count+:: Counter cache for the number of associated organizations.
12
+ #
13
+ # == Associations
14
+ #
15
+ # - +organizations+:: Has many organizations. When plan is deleted, organization references are set to null.
11
16
  #
12
17
  # == Validations
13
18
  #
@@ -15,22 +20,37 @@ module Maquina
15
20
  # - +price+:: Must be greater than 0 if the Plan is not marked as free.
16
21
  # - +name+:: Must be present and unique.
17
22
  #
18
- # == Scopes
23
+ # == Monetized Attributes
19
24
  #
20
- # - +search_full+:: Provides a search scope for Plan model, allowing searching by name with options for prefix matching and matching any word.
25
+ # - +price+:: Uses the Money gem to handle currency and monetary calculations.
21
26
  #
22
27
  # == Usage
23
28
  #
24
29
  # The Plan model represents a pricing plan in the Maquina module. To use the Plan model, create instances with valid
25
30
  # attributes and use the provided methods and scopes for querying and manipulating plan data.
26
-
31
+ #
27
32
  class Plan < ApplicationRecord
28
- has_many :organizations, class_name: "Maquina::Organization", foreign_key: :maquina_plan_id, dependent: :nullify
33
+ has_many :organizations, class_name: "Maquina::Organization", foreign_key: :maquina_plan_id, dependent: :nullify,
34
+ counter_cache: true
29
35
 
30
36
  monetize :price_cents
31
37
 
32
- validates :price, numericality: {greater_than_or_equal_to: 0}, if: ->(plan) { plan.free? }
33
- validates :price, numericality: {greater_than: 0}, if: ->(plan) { !plan.free? }
38
+ validates :price, numericality: {greater_than_or_equal_to: 0.00}, if: ->(plan) { plan.free? }
39
+ validates :price, numericality: {greater_than: 0.00}, if: ->(plan) { !plan.free? }
34
40
  validates :name, presence: true, uniqueness: true
41
+
42
+ def self.searchable?
43
+ true
44
+ end
45
+
46
+ if Maquina.configuration.search_adapter == :sqlite
47
+ include Maquina::SqliteSearch
48
+
49
+ search_scope :name
50
+ elsif Maquina.configuration.search_adapter == :pg_search
51
+ include PgSearch::Model
52
+
53
+ pg_search_scope :full_search, against: [:name], using: {tsearch: {prefix: true}}
54
+ end
35
55
  end
36
56
  end
@@ -1,12 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the UsedPassword model in the Maquina module.
6
+ # Keeps track of previously used passwords for users to prevent password reuse.
7
+ #
8
+ # == Attributes
9
+ #
10
+ # - +password_digest+:: Encrypted password digest (required).
11
+ # - +maquina_user_id+:: Foreign key reference to the associated User.
12
+ #
13
+ # == Associations
14
+ #
15
+ # - +user+:: Belongs to a User.
16
+ #
17
+ # == Encryption
18
+ #
19
+ # - +password_digest+:: Value is encrypted at rest in the database.
20
+ #
4
21
  class UsedPassword < ApplicationRecord
5
22
  belongs_to :user, class_name: "Maquina::User", foreign_key: :maquina_user_id
6
23
 
7
24
  encrypts :password_digest
8
25
  validates :password_digest, presence: true
9
26
 
27
+ # Stores a new password digest for a user while maintaining password history limit.
28
+ # If password retention is disabled via configuration, no storage occurs.
29
+ #
30
+ # Args:
31
+ # - +user_id+:: Integer representing the ID of the user
32
+ # - +password_digest+:: String of encrypted password digest to store
33
+ #
34
+ # Returns:
35
+ # - +nil+:: If password retention is disabled (count is nil or 0)
36
+ # - +Maquina::UsedPassword+:: The newly created used password record
37
+ #
38
+ # Example:
39
+ # Maquina::UsedPassword.store_password_digest(1, "digest123")
10
40
  def self.store_password_digest(user_id, password_digest)
11
41
  return if Maquina.configuration.password_retain_count.blank? || Maquina.configuration.password_retain_count.zero?
12
42
 
@@ -1,9 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Maquina
4
+ ##
5
+ # A class representing the User model in the Maquina module.
6
+ #
7
+ # == Attributes
8
+ #
9
+ # - +email+:: The user's email address (required, unique, automatically normalized).
10
+ # - +password+:: Encrypted password that must meet complexity requirements.
11
+ # - +password_expires_at+:: Timestamp indicating when the password expires.
12
+ #
13
+ # == Associations
14
+ #
15
+ # - +memberships+:: Has many memberships linking users to organizations.
16
+ # - +active_sessions+:: Has many active sessions that are destroyed when the user is deleted.
17
+ #
18
+ # == Included Modules
19
+ #
20
+ # - +RetainPasswords+:: Handles password retention functionality.
21
+ # - +Blockeable+:: Provides blocking/unblocking capabilities.
22
+ # - +Multifactor+:: Implements multi-factor authentication features.
23
+ #
24
+ # == Validations
25
+ #
26
+ # - +email+:: Must be present, unique, and in valid email format.
27
+ # - +password+:: Must contain at least:
28
+ # * 8 characters
29
+ # * 1 uppercase letter
30
+ # * 1 lowercase letter
31
+ # * 1 number
32
+ # * 1 special character (@$!%*?&#-=+)
33
+ #
34
+ # == Normalizations
35
+ #
36
+ # - +email+:: Automatically stripped of whitespace and converted to lowercase.
37
+ #
38
+ # == Security
39
+ #
40
+ # Uses has_secure_password for password encryption and authentication.
41
+ #
4
42
  class User < ApplicationRecord
5
43
  include Maquina::RetainPasswords
6
- include Maquina::AuthenticateBy
7
44
  include Maquina::Blockeable
8
45
  include Maquina::Multifactor
9
46
 
@@ -16,24 +53,29 @@ module Maquina
16
53
  validates :email, presence: true, uniqueness: true, format: {with: URI::MailTo::EMAIL_REGEXP}
17
54
  validates :password, format: {with: PASSWORD_COMPLEXITY_REGEX}, unless: ->(user) { user.password.blank? }
18
55
 
19
- before_save :downcase_email
56
+ normalizes :email, with: ->(email) { email.strip.downcase }
20
57
 
58
+ # Checks if the user's password has expired
59
+ #
60
+ # Returns true if password_expires_at is set and in the past, false otherwise
21
61
  def expired_password?
22
62
  return false if password_expires_at.blank?
23
63
 
24
64
  password_expires_at < Time.zone.now
25
65
  end
26
66
 
67
+ # Returns the user's default active membership
68
+ #
69
+ # A default membership is the first unblocked membership associated with an active organization.
70
+ # Returns nil if user is management or no valid membership exists.
71
+ #
72
+ # Returns:
73
+ # - Maquina::Membership: the default membership
74
+ # - nil: if user is management or no valid membership exists
27
75
  def default_membership
28
76
  return nil if management?
29
77
 
30
78
  memberships.detect { |membership| membership.blocked_at.blank? && membership.organization.present? && membership.organization.active? }
31
79
  end
32
-
33
- private
34
-
35
- def downcase_email
36
- self.email = email.downcase
37
- end
38
80
  end
39
81
  end
@@ -41,7 +41,7 @@ module Maquina
41
41
  end
42
42
 
43
43
  def admin?
44
- (Maquina::Current.membership.present? && Maquina::Current.membership.admin?)
44
+ Maquina::Current.membership.present? && Maquina::Current.membership.admin?
45
45
  end
46
46
 
47
47
  relation_scope do |scope|
@@ -0,0 +1,7 @@
1
+ module Maquina
2
+ class DashboardPolicy < ApplicationPolicy
3
+ def management_stats?
4
+ management?
5
+ end
6
+ end
7
+ end
@@ -8,11 +8,11 @@
8
8
 
9
9
  <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
10
10
 
11
- <%= maquina_importmap_tags %>
11
+ <%= javascript_importmap_tags %>
12
12
  </head>
13
13
 
14
14
  <body class="h-full">
15
- <%= render partial: "navbar" %>
15
+ <%= render partial: "maquina/application/navbar" %>
16
16
 
17
17
  <%= turbo_frame_tag :alert do %>
18
18
  <%= render Maquina::Application::Alert.new(flash) %>
@@ -10,7 +10,7 @@
10
10
  <%= yield :head %>
11
11
 
12
12
  <%= stylesheet_link_tag "tailwind", "inter-font", "data-turbo-track": "reload" %>
13
- <%= maquina_importmap_tags %>
13
+ <%= javascript_importmap_tags %>
14
14
 
15
15
  <%= turbo_refreshes_with method: :morph, scroll: :preserve %>
16
16
  </head>
@@ -1,4 +1,11 @@
1
- <nav class="transition-all ease-in-out delay-300 backdrop-blur-sm bg-white shadow-sm fixed top-0 left-0 right-0 z-20" data-controller="mobile-menu" data-navbar-target="navbar">
1
+ <nav
2
+ class="
3
+ transition-all ease-in-out delay-300 backdrop-blur-sm bg-white shadow-sm fixed
4
+ top-0 left-0 right-0 z-20
5
+ "
6
+ data-controller="mobile-menu"
7
+ data-navbar-target="navbar"
8
+ >
2
9
  <div class="max-w-7xl mx-auto px-2 sm:px-4 lg:px-8">
3
10
  <div class="flex justify-between h-16">
4
11
  <div class="flex px-2 lg:px-0">
@@ -6,8 +13,6 @@
6
13
  <%= render Maquina::Navbar::Menu.new %>
7
14
  </div>
8
15
 
9
- <%= render Maquina::Navbar::Search.new(query: params[:q], url: collection_path) if action_name == "index" && respond_to?(:collection_path) && collection_path.present? && respond_to?(:resource_class) && resource_class.searchable? %>
10
-
11
16
  <%= render Maquina::Navbar::MobileButton.new %>
12
17
 
13
18
  <div class="hidden sm:ml-6 lg:flex sm:items-center">
@@ -6,12 +6,12 @@ module Maquina
6
6
  class CheckboxComponent < ComponentBase
7
7
  def view_template
8
8
  div(**control_html) do
9
- div(class: "flex h-5 items-center") do
9
+ div(class: "flex h-6 items-center") do
10
10
  @form.check_box attribute_name, **input_html
11
11
  end
12
- div(class: "text-sm leading-6") do
12
+ div(class: "ml-3 text-sm leading-6") do
13
13
  @form.label attribute_name, class: "label #{label_css_class}"
14
- help_template
14
+ help_template(margin: false)
15
15
  end
16
16
  end
17
17
  end
@@ -5,6 +5,7 @@ module Maquina
5
5
  module Components
6
6
  class ComponentBase < Phlex::HTML
7
7
  include ApplicationView
8
+ include Phlex::Rails::Helpers::TokenList
8
9
 
9
10
  def initialize(resource:, form:, options: {})
10
11
  @resource = resource
@@ -41,11 +42,11 @@ module Maquina
41
42
  @options.dig(:control_html, :label_class) || ""
42
43
  end
43
44
 
44
- def help_template
45
+ def help_template(margin: true)
45
46
  help = t("helpers.help.#{@resource.model_name.i18n_key}.#{attribute_name}", default: nil)
46
47
  return if help.blank?
47
48
 
48
- p(class: "help") { unsafe_raw help }
49
+ p(class: token_list("help", "mt-0": !margin)) { unsafe_raw help }
49
50
  end
50
51
 
51
52
  def error_template
@@ -1,9 +1,12 @@
1
- <div class="bg-white">
2
- <div class="mx-auto max-w-7xl py-12 px-4 sm:px-6 lg:px-8">
3
- <div class="mx-auto max-w-3xl">
4
- <%= turbo_frame_tag class_to_form_frame(resource_class) do %>
5
- <%= render Maquina::Application::Edit.new(resource: resource) %>
6
- <% end %>
7
- </div>
1
+ <div
2
+ class="
3
+ bg-white mx-auto max-w-4xl py-12 px-4 sm:px-6 lg:px-8 border border-gray-200
4
+ rounded-lg shadow-sm
5
+ "
6
+ >
7
+ <div class="mx-auto max-w-3xl">
8
+ <%= turbo_frame_tag class_to_form_frame(resource_class) do %>
9
+ <%= render Maquina::Application::Edit.new(resource: resource) %>
10
+ <% end %>
8
11
  </div>
9
12
  </div>
@@ -0,0 +1,118 @@
1
+ module Maquina
2
+ module Application
3
+ class Filters < Phlex::HTML
4
+ include ApplicationView
5
+ include Phlex::Rails::Helpers::LinkTo
6
+ include Phlex::Rails::Helpers::TokenList
7
+
8
+ delegate :request, :filters, :resource_class, :collection_path, :params, to: :helpers
9
+
10
+ def view_template
11
+ div(class: "flex flex-1 items-center space-x-2") do
12
+ filters.each_pair do |field, options|
13
+ selected_option = calculate_selected(field, options)
14
+ display_filter(field, selected_option, options)
15
+ end
16
+
17
+ active_filters = filters.keys.select { |field| params[field].present? }
18
+ link_to(collection_path(**clear_filters), class: token_list("text-skin-dimmed text-xs border border-gray-200 inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-white hover:text-skin-base py-2 h-9 px-2 lg:px-3", hidden: active_filters.empty?)) do
19
+ span { t("filters.clear") }
20
+ svg(width: "15", height: "15", viewbox: "0 0 15 15", fill: "none", xmlns: "http://www.w3.org/2000/svg", class: "ml-2 h-4 w-4") do |s|
21
+ s.path(d: "M11.7816 4.03157C12.0062 3.80702 12.0062 3.44295 11.7816 3.2184C11.5571 2.99385 11.193 2.99385 10.9685 3.2184L7.50005 6.68682L4.03164 3.2184C3.80708 2.99385 3.44301 2.99385 3.21846 3.2184C2.99391 3.44295 2.99391 3.80702 3.21846 4.03157L6.68688 7.49999L3.21846 10.9684C2.99391 11.193 2.99391 11.557 3.21846 11.7816C3.44301 12.0061 3.80708 12.0061 4.03164 11.7816L7.50005 8.31316L10.9685 11.7816C11.193 12.0061 11.5571 12.0061 11.7816 11.7816C12.0062 11.557 12.0062 11.193 11.7816 10.9684L8.31322 7.49999L11.7816 4.03157Z",
22
+ fill: "currentColor",
23
+ fill_rule: "evenodd",
24
+ clip_rule: "evenodd")
25
+ end
26
+ end
27
+ end
28
+ end
29
+
30
+ private
31
+
32
+ def calculate_selected(field, options)
33
+ param_value = params[field] || nil
34
+ value = []
35
+
36
+ value = options.detect { |option| option[1].to_s == param_value } || [] if param_value.present?
37
+
38
+ value
39
+ end
40
+
41
+ def display_filter(field, selected_option, options)
42
+ div(class: "relative inline-block text-left", data: {controller: "popup-menu"}) do
43
+ button(class: "text-skin-dimmed inline-flex items-center justify-center whitespace-nowrap font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 border bg-gray-50 shadow-sm hover:bg-white hover:text-skin-base rounded-md px-3 text-xs h-9",
44
+ type: "button", aria_haspopup: "dialog", aria_expanded: "false", data_action: "popup-menu#toggleTransition") do
45
+ unsafe_raw filter_icon
46
+ plain resource_class.human_attribute_name(field)
47
+
48
+ if selected_option.any?
49
+ div(data_orientation: "vertical", role: "none", class: "hiddens shrink-0 border-l border-gray-300 w-[1px] mx-2 h-4")
50
+ div(class: "hiddens space-x-1 flex") do
51
+ div(class: "inline-flex items-center border py-0.5 text-xs transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 border-transparent bg-skin-accented/80 text-white hover:bg-skin-accented/90 rounded-sm px-1 font-normal") { selected_option.first }
52
+ end
53
+ end
54
+ end
55
+
56
+ div(class: "hidden absolute left-0 z-10 mt-2 w-56 origin-top-right rounded-md bg-white shadow-lg ring-1 ring-black ring-opacity-5 focus:outline-none",
57
+ role: "menu", "aria-orientation": "vertical", "aria-labelledby": "menu-button", tabindex: "-1", data: data_attributes) do
58
+ div(class: "py-1", role: "none") do
59
+ options.each_pair do |label, value|
60
+ current_value = value.to_s == selected_option.last.to_s
61
+
62
+ link_to(current_params(field, value), class: "text-skin-muted block px-4 py-2 text-sm relative flex cursor-default select-none items-center hover:bg-gray-100", role: "menuitem", tabindex: "-1", "data-value": value) do
63
+ div(class: "filter-option", selected: current_value) do
64
+ unsafe_raw selected_icon
65
+ end
66
+ span { label }
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
72
+ end
73
+
74
+ def current_params(filter, option)
75
+ current_params = request.query_parameters.except("page")
76
+ current_params.reject! { |key, value| value.blank? }
77
+
78
+ if current_params[filter] == option.to_s
79
+ current_params.except(filter)
80
+ else
81
+ current_params.merge(filter => option)
82
+ end
83
+ end
84
+
85
+ def clear_filters
86
+ request.query_parameters.except("page", *filters.keys.map(&:to_s))
87
+ end
88
+
89
+ def selected_icon
90
+ <<~SVG
91
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewbox="0 0 15 15" fill="none" class="h-4 w-4">
92
+ <path d="M11.4669 3.72684C11.7558 3.91574 11.8369 4.30308 11.648 4.59198L7.39799 11.092C7.29783 11.2452 7.13556 11.3467 6.95402 11.3699C6.77247 11.3931 6.58989 11.3355 6.45446 11.2124L3.70446 8.71241C3.44905 8.48022 3.43023 8.08494 3.66242 7.82953C3.89461 7.57412 4.28989 7.55529 4.5453 7.78749L6.75292 9.79441L10.6018 3.90792C10.7907 3.61902 11.178 3.53795 11.4669 3.72684Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"/>
93
+ </svg>
94
+ SVG
95
+ end
96
+
97
+ def filter_icon
98
+ <<~SVG
99
+ <svg xmlns="http://www.w3.org/2000/svg" width="15" height="15" viewbox="0 0 15 15" fill="none" class="mr-2 h-4 w-4">
100
+ <path d="M7.49991 0.876892C3.84222 0.876892 0.877075 3.84204 0.877075 7.49972C0.877075 11.1574 3.84222 14.1226 7.49991 14.1226C11.1576 14.1226 14.1227 11.1574 14.1227 7.49972C14.1227 3.84204 11.1576 0.876892 7.49991 0.876892ZM1.82707 7.49972C1.82707 4.36671 4.36689 1.82689 7.49991 1.82689C10.6329 1.82689 13.1727 4.36671 13.1727 7.49972C13.1727 10.6327 10.6329 13.1726 7.49991 13.1726C4.36689 13.1726 1.82707 10.6327 1.82707 7.49972ZM7.50003 4C7.77617 4 8.00003 4.22386 8.00003 4.5V7H10.5C10.7762 7 11 7.22386 11 7.5C11 7.77614 10.7762 8 10.5 8H8.00003V10.5C8.00003 10.7761 7.77617 11 7.50003 11C7.22389 11 7.00003 10.7761 7.00003 10.5V8H4.50003C4.22389 8 4.00003 7.77614 4.00003 7.5C4.00003 7.22386 4.22389 7 4.50003 7H7.00003V4.5C7.00003 4.22386 7.22389 4 7.50003 4Z" fill="currentColor" fill-rule="evenodd" clip-rule="evenodd"/>
101
+ </svg>
102
+ SVG
103
+ end
104
+
105
+ def data_attributes
106
+ {
107
+ "popup-menu-target": "menu",
108
+ "transition-enter": "transition ease-out duration-100",
109
+ "transition-enter-active": "transform opacity-0 scale-95",
110
+ "transition-enter-to": "transform opacity-100 scale-100",
111
+ "transition-leave": "transition ease-in duration-75",
112
+ "transition-leave-active": "transform opacity-100 scale-100",
113
+ "transition-leave-to": "transform opacity-0 scale-95"
114
+ }
115
+ end
116
+ end
117
+ end
118
+ end