Compare commits

..
Author SHA1 Message Date
Shivam Mishra 8196936a62 fix: validate bot tokens 2026-06-11 21:35:37 +05:30
Shivam MishraandGitHub b26dea7879 Merge branch 'develop' into feat/read-only-token 2026-06-11 19:38:26 +05:30
Shivam Mishra 85776ec65c fix: inbox jbuilder 2026-06-11 19:37:53 +05:30
Shivam MishraandGitHub e408b5dbbd Merge branch 'develop' into feat/read-only-token 2026-06-11 14:15:39 +05:30
Shivam Mishra c633c35712 feat: hide integration app config when using read only token 2026-06-11 14:14:34 +05:30
Shivam MishraandGitHub 40432499c8 Merge branch 'develop' into feat/read-only-token 2026-06-10 16:39:19 +05:30
Shivam MishraandGitHub 72d0e91d3c Merge branch 'develop' into feat/read-only-token 2026-06-08 18:08:12 +05:30
Shivam MishraandGitHub ca5ef95db7 Merge branch 'develop' into feat/read-only-token 2026-06-03 12:05:54 +05:30
Shivam Mishra eedffccf6d feat: withold pubsub token for readonly token 2026-06-02 17:38:43 +05:30
Shivam MishraandGitHub 134eb157cc Merge branch 'develop' into feat/read-only-token 2026-06-02 15:07:53 +05:30
Shivam MishraandGitHub c7c4da35f6 Merge branch 'develop' into feat/read-only-token 2026-06-02 14:33:28 +05:30
Shivam MishraandGitHub 57a5d7e64e Merge branch 'develop' into feat/read-only-token 2026-06-01 13:36:27 +05:30
Shivam Mishra eef3a6fefc feat: block voice conference token from read only token 2026-06-01 13:36:14 +05:30
Shivam MishraandGitHub cb2061f260 Merge branch 'develop' into feat/read-only-token 2026-05-29 19:04:31 +05:30
Shivam Mishra 2e30a69a18 feat: block portal ssl status from read only token 2026-05-29 16:54:20 +05:30
Shivam Mishra 473d1a6c22 feat: hide captain custom tool auth config when using read only token 2026-05-29 16:22:14 +05:30
Shivam Mishra 4efb421800 feat: hide integration hook settings when using read only token 2026-05-29 16:22:13 +05:30
Shivam Mishra 54b86939d2 feat: hide webhook secret when using read only token 2026-05-29 16:18:55 +05:30
Shivam Mishra 49ac1557c1 feat: hide inbox channel provider credentials when using read only token 2026-05-29 16:18:54 +05:30
Shivam Mishra 45bd3f0fc7 feat: hide inbox channel credentials when using read only token 2026-05-29 16:10:18 +05:30
Shivam MishraandGitHub 5db4bb963b Merge branch 'develop' into feat/read-only-token 2026-05-29 14:45:23 +05:30
Shivam Mishra bd780b4cc6 feat: hide agent bot access_token when using read only token 2026-05-27 17:21:53 +05:30
Shivam Mishra 2f607f67c4 refactor: align conversation direct uploads with api token auth chain 2026-05-27 17:18:33 +05:30
Shivam Mishra ae676077ed test: readonly token can access context inbox filter 2026-05-27 14:53:18 +05:30
Shivam MishraandGitHub a31fb0f906 Merge branch 'develop' into feat/read-only-token 2026-05-27 14:52:49 +05:30
Shivam Mishra c0c12f2fbd chore: allow contact_inboxes filter 2026-05-27 14:52:27 +05:30
Shivam Mishra 7333cdd79f fix: scope user access token serialization 2026-05-27 13:50:41 +05:30
Shivam MishraandGitHub b9e83ec2d4 Merge branch 'develop' into feat/read-only-token 2026-05-26 18:55:25 +05:30
Shivam Mishra f3b990997e refactor: make access token generation race-safe 2026-05-26 12:34:34 +05:30
Shivam Mishra f778057b18 refactor: reuse access token scope helper for direct uploads 2026-05-26 12:32:27 +05:30
Shivam Mishra 1987abf9e2 chore: reset access token changes 2026-05-25 16:41:44 +05:30
Shivam Mishra a1c79f90b1 fix: width 2026-05-25 16:31:34 +05:30
Shivam Mishra 8f8cd7b929 fix: width 2026-05-25 16:28:33 +05:30
Shivam Mishra 48d8960539 feat: allow creating fresh token 2026-05-25 16:25:30 +05:30
Shivam Mishra c816e6b330 feat: add token list 2026-05-25 16:24:58 +05:30
Shivam Mishra b0aa51e811 chore: update migration 2026-05-20 17:47:26 +05:30
Shivam Mishra 7c73981ddd test: profile token serialization across auth modes 2026-05-20 13:44:51 +05:30
Shivam Mishra fe944720a8 refactor: tighten read-only access token scope enforcement 2026-05-20 13:38:33 +05:30
Shivam Mishra d2de7bed6c test: token creation 2026-05-20 13:22:08 +05:30
Shivam Mishra 5d8fce92f7 feat: expose read-only access token in profile settings 2026-05-20 13:19:58 +05:30
Shivam Mishra e195f9842f feat: gate write operations behind read-only access token scope 2026-05-20 13:19:49 +05:30
Shivam Mishra b96c3df19f feat: add scoped access token model for read-only API access 2026-05-20 13:19:37 +05:30
288 changed files with 8143 additions and 9854 deletions
+13 -13
View File
@@ -170,7 +170,7 @@ GEM
base64 (0.3.0)
bcrypt (3.1.22)
benchmark (0.4.1)
bigdecimal (4.1.2)
bigdecimal (3.3.1)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
@@ -193,7 +193,7 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.7)
concurrent-ruby (1.3.5)
connection_pool (2.5.5)
crack (1.0.0)
bigdecimal
@@ -274,8 +274,8 @@ GEM
dry-logic (~> 1.5)
dry-types (~> 1.8)
zeitwerk (~> 2.6)
dry-types (1.9.1)
bigdecimal (>= 3.0)
dry-types (1.8.3)
bigdecimal (~> 3.0)
concurrent-ruby (~> 1.0)
dry-core (~> 1.0)
dry-inflector (~> 1.0)
@@ -304,7 +304,7 @@ GEM
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
faraday (2.14.3)
faraday (2.14.2)
faraday-net_http (>= 2.0, < 3.5)
json
logger
@@ -474,7 +474,7 @@ GEM
rails-dom-testing (>= 1, < 3)
railties (>= 4.2.0)
thor (>= 0.14, < 2.0)
json (2.19.9)
json (2.19.8)
json_refs (0.1.8)
hana
json_schemer (0.2.24)
@@ -582,7 +582,7 @@ GEM
uri (>= 0.11.1)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.6.4.1)
net-imap (0.4.24)
date
net-protocol
net-pop (0.1.2)
@@ -598,14 +598,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.5)
nokogiri (1.19.4)
nokogiri (1.19.3)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.19.4-arm64-darwin)
nokogiri (1.19.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-darwin)
nokogiri (1.19.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.19.4-x86_64-linux-gnu)
nokogiri (1.19.3-x86_64-linux-gnu)
racc (~> 1.4)
oauth (1.1.6)
auth-sanitizer (~> 0.2, >= 0.2.1)
@@ -627,7 +627,7 @@ GEM
rack (>= 1.2, < 4)
snaky_hash (~> 2.0, >= 2.0.5)
version_gem (~> 1.1, >= 1.1.11)
oj (3.17.3)
oj (3.16.10)
bigdecimal (>= 3.0)
ostruct (>= 0.2)
omniauth (2.1.4)
@@ -674,7 +674,7 @@ GEM
opentelemetry-api (~> 1.0)
orm_adapter (0.5.0)
os (1.1.4)
ostruct (0.6.3)
ostruct (0.6.1)
parallel (1.27.0)
parser (3.3.8.0)
ast (~> 2.4.1)
+1 -1
View File
@@ -1 +1 @@
4.15.1
4.14.2
@@ -6,11 +6,6 @@ class Messages::Messenger::MessageBuilder
return if unsupported_file_type?(attachment['type'])
params = attachment_params(attachment)
# During Meta's sticker webhook transition, a sticker message carries both an `image`
# and a `sticker` attachment pointing to the same URL. Skip the redundant sticker so it
# isn't attached twice, while still storing legitimate duplicate attachments of other types.
return if duplicate_sticker?(attachment, params[:external_url])
attachment_obj = @message.attachments.new(params.except(:remote_file_url))
attachment_obj.save!
if facebook_reel?(attachment)
@@ -18,14 +13,10 @@ class Messages::Messenger::MessageBuilder
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url])
end
fetch_attachment_links(attachment_obj)
update_attachment_file_type(attachment_obj)
end
def fetch_attachment_links(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
update_attachment_file_type(attachment_obj)
end
def attach_file(attachment, file_url)
@@ -120,20 +111,13 @@ class Messages::Messenger::MessageBuilder
# Facebook may send attachment types that don't directly match our file_type enum.
# Map known aliases to their canonical enum values.
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel, sticker: :image }.freeze
FACEBOOK_FILE_TYPE_MAP = { reel: :ig_reel }.freeze
def normalize_file_type(type)
sym = type.to_sym
FACEBOOK_FILE_TYPE_MAP.fetch(sym, sym)
end
def duplicate_sticker?(attachment, url)
return false unless attachment['type'].to_sym == :sticker
return false if url.blank?
@message.attachments.any? { |existing| existing.external_url == url }
end
# Facebook sends reel URLs as webpage links (facebook.com/reel/...) rather than
# direct video URLs. Downloading these yields HTML, not video content.
def facebook_reel?(attachment)
+38
View File
@@ -1,9 +1,28 @@
class Api::BaseController < ApplicationController
include AccessTokenAuthHelper
# POST-shaped endpoints that are semantically reads and should be reachable
# with a read-only token. Keep this list minimal; expand only when a clear
# read-with-complex-body use case appears.
READ_ONLY_POST_ALLOWLIST = %w[
api/v1/accounts/conversations#filter
api/v1/accounts/contacts#filter
api/v1/accounts/contact_inboxes#filter
].freeze
# GET-shaped endpoints that mutate state and must be blocked for read-only
# tokens despite the verb being "safe". Add any new write-on-GET endpoint
# here when it ships.
READ_ONLY_BLOCKED_GET_ACTIONS = %w[
api/v1/accounts/callbacks#register_facebook_page
api/v1/accounts/portals#ssl_status
api/v1/accounts/conference#token
].freeze
respond_to :json
before_action :authenticate_access_token!, if: :authenticate_by_access_token?
before_action :validate_bot_access_token!, if: :authenticate_by_access_token?
before_action :authenticate_user!, unless: :authenticate_by_access_token?
before_action :enforce_read_only_token_scope
private
@@ -11,6 +30,25 @@ class Api::BaseController < ApplicationController
request.headers[:api_access_token].present? || request.headers[:HTTP_API_ACCESS_TOKEN].present?
end
def enforce_read_only_token_scope
return unless @access_token&.scope == 'read_only'
return if read_only_token_action_allowed?
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
def read_only_token_action_allowed?
action_key = "#{params[:controller]}##{params[:action]}"
return READ_ONLY_POST_ALLOWLIST.include?(action_key) unless safe_http_method?
READ_ONLY_BLOCKED_GET_ACTIONS.exclude?(action_key)
end
def safe_http_method?
request.get? || request.head? || request.options?
end
def check_authorization(model = nil)
model ||= controller_name.classify.constantize
@@ -53,7 +53,7 @@ class Api::V1::Accounts::CategoriesController < Api::V1::Accounts::BaseControlle
def category_params
params.require(:category).permit(
:name, :description, :position, :slug, :locale, :icon, :icon_color, :parent_category_id, :associated_category_id
:name, :description, :position, :slug, :locale, :icon, :parent_category_id, :associated_category_id
)
end
@@ -214,5 +214,3 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
render json: error, status: error_status
end
end
Api::V1::Accounts::ContactsController.prepend_mod_with('Api::V1::Accounts::ContactsController')
@@ -1,5 +1,10 @@
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
include EnsureCurrentAccountHelper
include AccessTokenAuthHelper
include RequestExceptionHandler
before_action :authenticate_access_token!
before_action :validate_bot_access_token!
before_action :prevent_read_only_access_token!
before_action :current_account
before_action :conversation
@@ -140,7 +140,7 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
def destroy
authorize @conversation, :destroy?
::Conversations::DeleteService.new(conversation: @conversation, user: Current.user, ip: request.ip).perform
::DeleteObjectJob.perform_later(@conversation, Current.user, request.ip)
head :ok
end
@@ -21,11 +21,6 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
@assignable_agents = @inbox.assignable_agents
end
def assignable_owners
@assignable_agents = @inbox.assignable_agents.select(&:confirmed?)
@agent_bots = AgentBot.accessible_to(Current.account)
end
def campaigns
@campaigns = @inbox.campaigns
end
@@ -1,36 +0,0 @@
class Api::V1::Profile::SessionsController < Api::BaseController
before_action :set_session, only: [:destroy]
def index
@sessions = current_user.user_sessions.where(client_id: active_token_client_ids).order(last_activity_at: :desc)
@current_client_id = request.headers['client']
end
def destroy
if @session.current?(request.headers['client'])
render json: { error: I18n.t('profile_settings.sessions.cannot_revoke_current') }, status: :unprocessable_entity
return
end
revoke_token!(@session.client_id)
@session.destroy!
head :ok
end
private
def set_session
@session = current_user.user_sessions.find(params[:id])
end
def revoke_token!(client_id)
tokens = current_user.tokens
tokens.delete(client_id)
current_user.update!(tokens: tokens)
end
def active_token_client_ids
now = Time.current.to_i
(current_user.tokens || {}).select { |_, v| v['expiry'].to_i > now }.keys
end
end
@@ -39,7 +39,14 @@ class Api::V1::ProfilesController < Api::BaseController
end
def reset_access_token
@user.access_token.regenerate_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'full')
token.regenerate_token
@user.reload
end
def reset_read_only_access_token
token = AccessToken.create_or_find_by!(owner: @user, scope: 'read_only')
token.regenerate_token
@user.reload
end
@@ -3,7 +3,6 @@ class ApplicationController < ActionController::Base
include RequestExceptionHandler
include Pundit::Authorization
include SwitchLocale
include TrackSessionActivity
skip_before_action :verify_authenticity_token
@@ -37,4 +37,15 @@ module AccessTokenAuthHelper
def agent_bot_accessible?
BOT_ACCESSIBLE_ENDPOINTS.fetch(params[:controller], []).include?(params[:action])
end
# Blocks read-only access tokens from reaching write endpoints that live outside
# Api::BaseController (e.g. controllers inheriting from ActiveStorage). Resolves
# the token directly since these controllers skip the Api::BaseController chain.
def prevent_read_only_access_token!
ensure_access_token
return unless @access_token&.scope == 'read_only'
render json: { error: 'This access token is read-only and cannot perform write operations.' },
status: :forbidden
end
end
@@ -1,22 +0,0 @@
module TrackSessionActivity
extend ActiveSupport::Concern
included do
after_action :update_session_activity
end
private
def update_session_activity
return unless current_user
return if request.headers['client'].blank?
UserSessionTrackingService.new(
user: current_user,
request: request,
client_id: request.headers['client']
).update_activity!
rescue StandardError => e
Rails.logger.warn "Session activity update failed: #{e.message}"
end
end
@@ -1,6 +1,4 @@
class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
MAX_SESSIONS = ENV.fetch('MAX_USER_SESSIONS', 25).to_i
# Prevent session parameter from being passed
# Unpermitted parameter: session
wrap_parameters format: []
@@ -16,14 +14,12 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
user = find_user_for_authentication
return handle_mfa_required(user) if user&.mfa_enabled?
return if user && enforce_session_limit_for_password_login(user)
# Only proceed with standard authentication if no MFA is required
super
end
def render_create_success
track_user_session unless @impersonation
render partial: 'devise/auth', formats: [:json], locals: { resource: @resource }
end
@@ -57,8 +53,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def handle_sso_authentication
return if !@impersonation && enforce_session_limit_for_password_login(@resource)
authenticate_resource_with_sso_token
yield @resource if block_given?
render_create_success
@@ -71,10 +65,7 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def authenticate_resource_with_sso_token
# DTA evicts the earliest-expiring token after save when at max_number_of_devices.
# The short-lived impersonation token would always be that one, so pre-evict to make room.
make_room_for_impersonation_token if @impersonation
@token = @resource.create_token(lifespan: @impersonation ? 2.days.to_i : nil)
@token = @resource.create_token
@resource.save!
sign_in(:user, @resource, store: false, bypass: false)
@@ -82,21 +73,11 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
@resource.invalidate_sso_auth_token(params[:sso_auth_token])
end
def make_room_for_impersonation_token
return if @resource.tokens.size < DeviseTokenAuth.max_number_of_devices
oldest_client_id = @resource.tokens.min_by { |_, v| v['expiry'].to_i }&.first
@resource.tokens.delete(oldest_client_id) if oldest_client_id
end
def process_sso_auth_token
return if params[:email].blank?
user = User.from_email(params[:email])
return unless user&.valid_sso_auth_token?(params[:sso_auth_token])
@resource = user
@impersonation = user.sso_auth_token_impersonation?(params[:sso_auth_token])
@resource = user if user&.valid_sso_auth_token?(params[:sso_auth_token])
end
def handle_mfa_required(user)
@@ -122,7 +103,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
end
def sign_in_mfa_user(user)
evict_oldest_session(user) if sessions_limit_reached?(user)
@resource = user
@token = @resource.create_token
@resource.save!
@@ -134,103 +114,6 @@ class DeviseOverrides::SessionsController < DeviseTokenAuth::SessionsController
def render_mfa_error(message_key, status = :bad_request)
render json: { error: I18n.t(message_key) }, status: status
end
def sessions_limit_reached?(user)
active_token_count(user) >= MAX_SESSIONS
end
def active_token_count(user)
now = Time.current.to_i
(user.tokens || {}).count { |_, v| v['expiry'].to_i > now }
end
# Returns true when a response has been rendered (e.g., 409 picker). Non-browser clients
# auto-evict instead of getting stuck on a UI they can't render.
def enforce_session_limit_for_password_login(user)
if revoking_sessions?
revoke_sessions_for_login(user)
return false
end
return false unless sessions_limit_reached?(user)
# Picker only when every token has a tracked session; partial tracking would
# show a misleading count, so fall through to silent eviction instead.
if browser_request? && user.user_sessions.count >= user.tokens.size
handle_sessions_limit_for_login(user)
true
else
evict_oldest_session(user)
false
end
end
def browser_request?
request.user_agent.to_s.include?('Mozilla')
end
def revoking_sessions?
params[:revoke_session_id].present? || params[:revoke_all_sessions].present?
end
def revoke_sessions_for_login(user)
if params[:revoke_all_sessions].present?
user.tokens = {}
user.save!
user.user_sessions.destroy_all
elsif params[:revoke_session_id].present?
session = user.user_sessions.find_by(id: params[:revoke_session_id])
return unless session
user.tokens.delete(session.client_id)
user.save!
session.destroy!
end
end
def evict_oldest_session(user)
# Drop pre-rollout untracked tokens first so freshly tracked logins aren't evicted.
return evict_oldest_token(user) if user.user_sessions.count < user.tokens.size
oldest_session = user.user_sessions.order(Arel.sql('COALESCE(last_activity_at, created_at) ASC')).first
return evict_oldest_token(user) unless oldest_session
user.tokens.delete(oldest_session.client_id)
user.save!
oldest_session.destroy!
end
# Fallback if a token exists without a UserSession row (e.g., legacy data before tracking shipped).
def evict_oldest_token(user)
return if user.tokens.blank?
oldest_client_id = user.tokens.min_by { |_, v| v['expiry'].to_i }&.first
return unless oldest_client_id
user.tokens.delete(oldest_client_id)
user.save!
end
PICKER_SESSION_FIELDS = %i[id browser_name browser_version device_name platform_name platform_version
ip_address city country last_activity_at created_at].freeze
def handle_sessions_limit_for_login(user)
sessions = user.user_sessions.order(last_activity_at: :desc).map { |s| s.slice(*PICKER_SESSION_FIELDS) }
render json: { sessions_limit_reached: true, sessions: sessions }, status: :conflict
end
def track_user_session
client_id = @token&.try(:client) || response.headers['client']
return unless client_id.present? && @resource.present?
UserSessionTrackingService.new(
user: @resource,
request: request,
client_id: client_id
).create_or_update!
rescue StandardError => e
Rails.logger.warn "Session tracking failed: #{e.message}"
end
end
DeviseOverrides::SessionsController.prepend_mod_with('DeviseOverrides::SessionsController')
+1 -11
View File
@@ -35,17 +35,7 @@ class Twilio::CallbackController < ApplicationController
:ExternalUserId,
:ParentExternalUserId,
:ProfileUsername,
:Username,
:ReferralBody,
:ReferralHeadline,
:ReferralSourceId,
:ReferralSourceType,
:ReferralSourceUrl,
:ReferralMediaId,
:ReferralMediaContentType,
:ReferralMediaUrl,
:ReferralNumMedia,
:ReferralCtwaClid
:Username
)
end
end
-1
View File
@@ -12,7 +12,6 @@ class ConversationFinder
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
'priority_desc_created_at_asc' => %w[sort_on_priority_created_at desc],
'unread' => %w[sort_on_unread desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
+5 -17
View File
@@ -17,11 +17,15 @@ module PortalHelper
uri.to_s
end
def generate_portal_bg(portal_color, theme)
def generate_portal_bg_color(portal_color, theme)
base_color = theme == 'dark' ? 'black' : 'white'
"color-mix(in srgb, #{portal_color} 20%, #{base_color})"
end
def generate_portal_bg(portal_color, theme)
generate_portal_bg_color(portal_color, theme)
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
@@ -37,10 +41,6 @@ module PortalHelper
language_map[locale] || locale
end
def html_lang_attribute(locale)
locale.to_s.tr('_', '-')
end
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
@@ -97,18 +97,6 @@ module PortalHelper
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
# Renders a stored category icon: a bare ri icon name (e.g. `vip-crown-2-fill/line`) saved color, or a plain emoji character.
def render_emoji_or_icon(value, color = nil)
return '' if value.blank?
# Emojis are non-ascii; bare icon names match this safe charset.
return ERB::Util.html_escape(value) unless value.match?(/\A[a-z][a-z0-9-]*\z/)
icon_class = value.start_with?('i-') ? value : "i-ri-#{value}"
style = "color: #{color};" if color.to_s.match?(/\A#\h{3,8}\z/)
tag.span(class: icon_class, style: style, 'aria-hidden': true)
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
+3 -5
View File
@@ -106,10 +106,8 @@ export default {
const urlData = endPoints('resetAccessToken');
return axios.post(urlData.url);
},
getSessions() {
return axios.get('/api/v1/profile/sessions');
},
revokeSession(id) {
return axios.delete(`/api/v1/profile/sessions/${id}`);
resetReadOnlyAccessToken() {
const urlData = endPoints('resetReadOnlyAccessToken');
return axios.post(urlData.url);
},
};
@@ -1,9 +0,0 @@
import ApiClient from '../ApiClient';
class MessageReports extends ApiClient {
constructor() {
super('captain/message_reports', { accountScoped: true });
}
}
export default new MessageReports();
-6
View File
@@ -40,12 +40,6 @@ class ContactAPI extends ApiClient {
return axios.get(`${this.url}/${contactId}/conversations`, { params });
}
getAttachments(contactId, page = 1) {
return axios.get(`${this.url}/${contactId}/attachments`, {
params: { page },
});
}
getContactableInboxes(contactId) {
return axios.get(`${this.url}/${contactId}/contactable_inboxes`);
}
@@ -55,6 +55,10 @@ const endPoints = {
resetAccessToken: {
url: '/api/v1/profile/reset_access_token',
},
resetReadOnlyAccessToken: {
url: '/api/v1/profile/reset_read_only_access_token',
},
};
export default page => {
@@ -16,7 +16,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
}) {
const url = getArticleSearchURL({
pageNumber,
@@ -26,7 +25,6 @@ class ArticlesAPI extends PortalsAPI {
authorId,
categorySlug,
sort,
query,
host: this.url,
});
@@ -62,10 +62,9 @@ class ConversationApi extends ApiClient {
});
}
assignAgent({ conversationId, agentId, assigneeType }) {
assignAgent({ conversationId, agentId }) {
return axios.post(`${this.url}/${conversationId}/assignments`, {
assignee_id: agentId,
assignee_type: assigneeType,
});
}
-4
View File
@@ -15,10 +15,6 @@ class Inboxes extends CacheEnabledApiClient {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}
getAssignableOwners(inboxId) {
return axios.get(`${this.url}/${inboxId}/assignable_owners`);
}
deleteInboxAvatar(inboxId) {
return axios.delete(`${this.url}/${inboxId}/avatar`);
}
@@ -90,16 +90,11 @@ describe('#ConversationAPI', () => {
});
it('#assignAgent', () => {
conversationAPI.assignAgent({
conversationId: 12,
agentId: 34,
assigneeType: 'AgentBot',
});
conversationAPI.assignAgent({ conversationId: 12, agentId: 34 });
expect(axiosMock.post).toHaveBeenCalledWith(
`/api/v1/conversations/12/assignments`,
{
assignee_id: 34,
assignee_type: 'AgentBot',
}
);
});
@@ -9,7 +9,6 @@ describe('#InboxesAPI', () => {
expect(inboxesAPI).toHaveProperty('create');
expect(inboxesAPI).toHaveProperty('update');
expect(inboxesAPI).toHaveProperty('delete');
expect(inboxesAPI).toHaveProperty('getAssignableOwners');
expect(inboxesAPI).toHaveProperty('getCampaigns');
expect(inboxesAPI).toHaveProperty('getAgentBot');
expect(inboxesAPI).toHaveProperty('setAgentBot');
@@ -38,13 +37,6 @@ describe('#InboxesAPI', () => {
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/inboxes/2/campaigns');
});
it('#getAssignableOwners', () => {
inboxesAPI.getAssignableOwners(2);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/inboxes/2/assignable_owners'
);
});
it('#deleteInboxAvatar', () => {
inboxesAPI.deleteInboxAvatar(2);
expect(axiosMock.delete).toHaveBeenCalledWith('/api/v1/inboxes/2/avatar');
@@ -145,12 +145,6 @@
--black-alpha-2: 0, 0, 0, 0.04;
--border-blue: 39, 129, 246, 0.5;
--white-alpha: 255, 255, 255, 0.8;
// Voice call widget - light mode
--call-widget: 33, 34, 38, 0.95;
--call-widget-border: 255, 255, 255, 0.1;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
.dark {
@@ -297,12 +291,6 @@
--border-blue: 39, 129, 246, 0.5;
--border-container: 255, 255, 255, 0;
--white-alpha: 255, 255, 255, 0.1;
// Voice call widget - dark mode
--call-widget: 50, 53, 61, 1;
--call-widget-border: 255, 255, 255, 0.07;
--call-widget-text: 237, 238, 240, 1;
--call-widget-sub-text: 173, 177, 184, 1;
}
}
// NEXT COLORS END
@@ -26,13 +26,6 @@ const resetForm = () => {
form.description = '';
};
const open = (company = {}) => {
form.name = company.name || '';
form.domain = company.domain || '';
form.description = company.description || '';
dialogRef.value?.open();
};
const handleConfirm = () => {
if (isFormInvalid.value) return;
@@ -52,7 +45,7 @@ const onSuccess = () => {
closeDialog();
};
defineExpose({ dialogRef, onSuccess, open });
defineExpose({ dialogRef, onSuccess });
</script>
<template>
@@ -1,153 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useDebounceFn } from '@vueuse/core';
import CompanyAPI from 'dashboard/api/companies';
import { useAlert } from 'dashboard/composables';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanyCreateDialog from 'dashboard/components-next/Companies/CompanyCreateDialog.vue';
const props = defineProps({
modelValue: {
type: [String, Number],
default: '',
},
// Name of the linked company, so the label shows before the list is loaded.
selectedName: {
type: String,
default: '',
},
isDetailsView: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select']);
const { t } = useI18n();
const CREATE_PREFIX = 'create:';
const options = ref([]);
const searchQuery = ref('');
const createDialogRef = ref(null);
const isCreatingCompany = ref(false);
const toOption = company => ({ label: company.name, value: company.id });
const createOption = computed(() => {
const name = searchQuery.value.trim();
if (!name) return null;
const exists = options.value.some(
option => option.label.toLowerCase() === name.toLowerCase()
);
if (exists) return null;
return {
label: t('COMPANIES.SELECTOR.CREATE_OPTION', { name }),
value: `${CREATE_PREFIX}${name}`,
};
});
const comboboxOptions = computed(() => {
const list = [...options.value];
// Keep the linked company visible even when it is not in the loaded results.
if (
props.modelValue &&
props.selectedName &&
!list.some(option => option.value === Number(props.modelValue))
) {
list.unshift({
label: props.selectedName,
value: Number(props.modelValue),
});
}
if (createOption.value) list.push(createOption.value);
return list;
});
const fetchCompanies = async query => {
try {
const {
data: { payload },
} = query
? await CompanyAPI.search(query)
: await CompanyAPI.get({ page: 1 });
options.value = (payload || []).map(toOption);
} catch {
options.value = [];
}
};
// Fetch lazily, only when the dropdown opens, instead of on mount.
const handleOpen = () => {
searchQuery.value = '';
fetchCompanies('');
};
const handleSearch = useDebounceFn(query => {
searchQuery.value = query?.trim() || '';
fetchCompanies(searchQuery.value);
}, 300);
// Open the create dialog (prefilled with the typed name) so the user can add
// domain/description before saving, instead of creating with just a name.
const createCompany = async company => {
isCreatingCompany.value = true;
try {
const {
data: { payload },
} = await CompanyAPI.create({ company });
createDialogRef.value?.onSuccess();
emit('select', { id: payload.id, name: payload.name });
useAlert(t('COMPANIES.CREATE.MESSAGES.SUCCESS'));
} catch {
useAlert(t('COMPANIES.CREATE.MESSAGES.ERROR'));
} finally {
isCreatingCompany.value = false;
}
};
const handleSelect = value => {
if (typeof value === 'string' && value.startsWith(CREATE_PREFIX)) {
createDialogRef.value?.open({ name: value.slice(CREATE_PREFIX.length) });
// Drop the transient "Add " option so the button label doesn't stick to
// it if the dialog is dismissed without creating.
searchQuery.value = '';
return;
}
const id = value ? Number(value) : '';
const selected = comboboxOptions.value.find(option => option.value === id);
emit('select', { id, name: selected?.label || '' });
};
</script>
<template>
<ComboBox
:model-value="modelValue"
:options="comboboxOptions"
:display-label="selectedName"
:placeholder="t('COMPANIES.SELECTOR.PLACEHOLDER')"
:search-placeholder="t('COMPANIES.SEARCH_PLACEHOLDER')"
use-api-results
class="[&>div>button]:h-8 [&>div>div_ul]:max-h-56"
:class="{
'[&>div>button]:bg-n-alpha-black2 [&>div>button:not(.focused)]:!outline-transparent':
!isDetailsView,
'[&>div>button]:!bg-n-alpha-black2': isDetailsView,
}"
@open="handleOpen"
@search="handleSearch"
@update:model-value="handleSelect"
/>
<CompanyCreateDialog
ref="createDialogRef"
:is-loading="isCreatingCompany"
@create="createCompany"
/>
</template>
@@ -15,7 +15,6 @@ const props = defineProps({
id: { type: Number, required: true },
name: { type: String, default: '' },
email: { type: String, default: '' },
companyId: { type: [Number, String], default: '' },
additionalAttributes: { type: Object, default: () => ({}) },
phoneNumber: { type: String, default: '' },
thumbnail: { type: String, default: '' },
@@ -42,7 +41,6 @@ const getInitialContactData = () => ({
id: props.id,
name: props.name,
email: props.email,
companyId: props.companyId,
phoneNumber: props.phoneNumber,
additionalAttributes: props.additionalAttributes,
});
@@ -128,14 +128,9 @@ const closeMobileSidebar = () => {
<!-- Desktop sidebar -->
<div
v-if="slots.sidebar"
class="hidden lg:flex flex-col min-w-52 w-full max-w-md border-l border-n-weak bg-n-solid-2"
class="hidden lg:block overflow-y-auto justify-end min-w-52 w-full py-6 max-w-md border-l border-n-weak bg-n-solid-2"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
<!-- Mobile sidebar container -->
@@ -184,14 +179,9 @@ const closeMobileSidebar = () => {
<div
v-if="isContactSidebarOpen"
id="contact-sidebar-content"
class="order-2 w-[85%] sm:w-[50%] flex flex-col bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak shadow-lg"
class="order-2 w-[85%] sm:w-[50%] bg-n-solid-2 ltr:border-l rtl:border-r border-n-weak overflow-y-auto py-6 shadow-lg"
>
<div class="shrink-0">
<slot name="sidebarHeader" />
</div>
<div class="flex-1 min-h-0 overflow-y-auto pb-6 pt-3">
<slot name="sidebar" />
</div>
<slot name="sidebar" />
</div>
</Transition>
</div>
@@ -5,11 +5,8 @@ import { required, email } from '@vuelidate/validators';
import { useVuelidate } from '@vuelidate/core';
import { splitName } from '@chatwoot/utils';
import countries from 'shared/constants/countries.js';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import Input from 'dashboard/components-next/input/Input.vue';
import ComboBox from 'dashboard/components-next/combobox/ComboBox.vue';
import CompanySelector from 'dashboard/components-next/Companies/CompanySelector.vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import PhoneNumberInput from 'dashboard/components-next/phonenumberinput/PhoneNumberInput.vue';
@@ -31,7 +28,6 @@ const props = defineProps({
const emit = defineEmits(['update']);
const { t } = useI18n();
const { currentAccount, isCloudFeatureEnabled } = useAccount();
const FORM_CONFIG = {
FIRST_NAME: { field: 'firstName' },
@@ -58,7 +54,6 @@ const defaultState = {
id: 0,
name: '',
email: '',
companyId: '',
firstName: '',
lastName: '',
phoneNumber: '',
@@ -90,23 +85,6 @@ const validationRules = {
const v$ = useVuelidate(validationRules, state);
const isFormInvalid = computed(() => v$.value.$invalid);
const hasCompaniesFeature = computed(
() =>
currentAccount.value?.id && isCloudFeatureEnabled(FEATURE_FLAGS.COMPANIES)
);
const showCompanySelector = computed(
() =>
hasCompaniesFeature.value &&
(Boolean(state.companyId) || !state.additionalAttributes.companyName)
);
const emitContactUpdate = async () => {
const isFormValid = await v$.value.$validate();
if (!isFormValid) return;
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
};
const prepareStateBasedOnProps = () => {
if (props.isNewContact) {
@@ -118,7 +96,6 @@ const prepareStateBasedOnProps = () => {
name = '',
email: emailAddress,
phoneNumber,
companyId = '',
additionalAttributes = {},
} = props.contactData || {};
const { firstName, lastName } = splitName(name || '');
@@ -138,7 +115,6 @@ const prepareStateBasedOnProps = () => {
Object.assign(state, {
id,
name,
companyId: companyId || '',
firstName,
lastName,
email: emailAddress,
@@ -224,7 +200,11 @@ const getFormBinding = key => {
}
}
await emitContactUpdate();
const isFormValid = await v$.value.$validate();
if (isFormValid) {
const { firstName, lastName, ...stateWithoutNames } = state;
emit('update', stateWithoutNames);
}
},
});
};
@@ -241,12 +221,6 @@ const handleCountrySelection = value => {
emit('update', state);
};
const handleCompanySelection = async ({ id, name }) => {
state.companyId = id || '';
state.additionalAttributes.companyName = name || '';
await emitContactUpdate();
};
const resetValidation = () => {
v$.value.$reset();
};
@@ -299,13 +273,6 @@ defineExpose({
:placeholder="item.placeholder"
:show-border="isDetailsView"
/>
<CompanySelector
v-else-if="item.key === 'COMPANY_NAME' && showCompanySelector"
:model-value="state.companyId"
:selected-name="state.additionalAttributes.companyName"
:is-details-view="isDetailsView"
@select="handleCompanySelection"
/>
<Input
v-else
v-model="getFormBinding(item.key).value"
@@ -108,7 +108,7 @@ const hasNoUsedAttributes = computed(() => usedAttributes.value.length === 0);
</script>
<template>
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6">
<div v-if="hasContactAttributes" class="flex flex-col gap-6 px-6 py-6">
<div v-if="!hasNoUsedAttributes" class="flex flex-col gap-2">
<ContactCustomAttributeItem
v-for="attribute in usedAttributes"
@@ -36,7 +36,7 @@ const contactConversations = computed(() =>
</div>
<div
v-else-if="contactConversations.length > 0"
class="px-6 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
class="px-6 py-4 divide-y divide-n-strong [&>*:hover]:!border-y-transparent [&>*:hover+*]:!border-t-transparent"
>
<ConversationCard
v-for="conversation in contactConversations"
@@ -1,108 +0,0 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import GalleryView from 'dashboard/components/widgets/conversation/components/GalleryView.vue';
import Media from 'dashboard/components-next/SharedAttachments/Media.vue';
import Files from 'dashboard/components-next/SharedAttachments/Files.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
const MEDIA_PEEK_LIMIT = 12;
const FILES_PEEK_LIMIT = 6;
const route = useRoute();
const router = useRouter();
const store = useStore();
const { t } = useI18n();
const attachmentsByContact = useMapGetter('contacts/getContactAttachments');
const uiFlags = useMapGetter('contacts/getUIFlags');
const attachments = computed(() =>
attachmentsByContact.value(route.params.contactId)
);
const isFetching = computed(() => uiFlags.value.isFetchingAttachments);
const hasContent = computed(() =>
attachments.value.some(
a => a.data_url && !NON_FILE_TYPES.includes(a.file_type)
)
);
const mediaAttachments = computed(() =>
attachments.value
.filter(a => MEDIA_TYPES.includes(a.file_type) && a.data_url)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showGallery = ref(false);
const selectedAttachment = ref(null);
const onMediaSelect = attachment => {
selectedAttachment.value = attachment;
showGallery.value = true;
};
const onFileSelect = attachment => {
if (attachment.data_url) {
window.open(attachment.data_url, '_blank', 'noopener,noreferrer');
}
};
const onJumpToMessage = attachment => {
if (!attachment.conversation_id || !attachment.message_id) return;
router.push({
name: 'inbox_conversation',
params: {
accountId: route.params.accountId,
conversation_id: attachment.conversation_id,
},
query: { messageId: attachment.message_id },
});
};
onMounted(() => {
store.dispatch('contacts/fetchAttachments', route.params.contactId);
});
</script>
<template>
<div class="px-6">
<div v-if="isFetching" class="flex justify-center p-3">
<Spinner class="size-5" />
</div>
<p v-else-if="!hasContent" class="p-3 text-sm text-center text-n-slate-11">
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.EMPTY') }}
</p>
<div v-else class="flex flex-col gap-5">
<Media
:attachments="attachments"
:peek-limit="MEDIA_PEEK_LIMIT"
show-jump-to-message
@select="onMediaSelect"
@jump-to-message="onJumpToMessage"
/>
<Files
:attachments="attachments"
:peek-limit="FILES_PEEK_LIMIT"
show-jump-to-message
@select="onFileSelect"
@jump-to-message="onJumpToMessage"
/>
</div>
<GalleryView
v-if="showGallery && selectedAttachment"
v-model:show="showGallery"
:attachment="selectedAttachment"
:all-attachments="mediaAttachments"
auto-play
@close="showGallery = false"
/>
</div>
</template>
@@ -103,7 +103,7 @@ const onMergeContacts = async () => {
</script>
<template>
<div class="flex flex-col gap-8 px-6">
<div class="flex flex-col gap-8 px-6 py-6">
<div class="flex flex-col gap-2">
<h4 class="text-base text-n-slate-12">
{{ t('CONTACTS_LAYOUT.SIDEBAR.MERGE.TITLE') }}
@@ -55,7 +55,7 @@ useKeyboardEvents(keyboardEvents);
</script>
<template>
<div class="flex flex-col gap-6">
<div class="flex flex-col gap-6 py-6">
<Editor
v-model="state.message"
:placeholder="t('CONTACTS_LAYOUT.SIDEBAR.NOTES.PLACEHOLDER')"
@@ -92,7 +92,6 @@ const handleAvatarHover = (id, isHovered) => {
:id="contact.id"
:name="contact.name"
:email="contact.email"
:company-id="contact.companyId"
:thumbnail="contact.thumbnail"
:phone-number="contact.phoneNumber"
:additional-attributes="contact.additionalAttributes"
@@ -13,7 +13,6 @@ import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
@@ -132,7 +131,7 @@ const statusText = computed(() => {
const categoryName = computed(() => {
if (props.category?.slug) {
return props.category.name;
return `${props.category.icon} ${props.category.name}`;
}
return t(
'HELP_CENTER.ARTICLES_PAGE.ARTICLE_CARD.CARD.CATEGORY.UNCATEGORISED'
@@ -210,9 +209,9 @@ const handleClick = id => {
</div>
</div>
</div>
<div class="flex items-center justify-between w-full gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-2 sm:gap-4">
<div class="flex items-center min-w-0 gap-1">
<div class="flex items-center justify-between w-full gap-4">
<div class="flex items-center gap-4">
<div class="flex items-center gap-1">
<Avatar
:name="authorName"
:src="authorThumbnailSrc"
@@ -223,17 +222,11 @@ const handleClick = id => {
{{ authorName || '-' }}
</span>
</div>
<span class="flex items-center min-w-0 gap-1 text-sm text-n-slate-11">
<EmojiIcon
v-if="category?.icon"
:value="category.icon"
:color="category.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ categoryName }}</span>
<span class="block text-sm whitespace-nowrap text-n-slate-11">
{{ categoryName }}
</span>
<div
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap shrink-0"
class="inline-flex items-center gap-1 text-n-slate-11 whitespace-nowrap"
>
<Icon icon="i-lucide-eye" class="size-4" />
<span class="text-sm">
@@ -245,7 +238,7 @@ const handleClick = id => {
</span>
</div>
</div>
<span class="text-sm text-n-slate-11 line-clamp-1 shrink-0">
<span class="text-sm text-n-slate-11 line-clamp-1">
{{ lastUpdatedAt }}
</span>
</div>
@@ -6,7 +6,6 @@ import { useToggle } from '@vueuse/core';
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
id: {
@@ -21,10 +20,6 @@ const props = defineProps({
type: String,
required: true,
},
iconColor: {
type: String,
default: '',
},
description: {
type: String,
required: true,
@@ -60,6 +55,10 @@ const categoryMenuItems = [
},
];
const categoryTitleWithIcon = computed(() => {
return `${props.icon} ${props.title}`;
});
const description = computed(() => {
return props.description ? props.description : 'No description added';
});
@@ -84,16 +83,10 @@ const handleAction = ({ action, value }) => {
<div class="flex justify-between w-full gap-2">
<div class="flex items-center justify-start w-full min-w-0 gap-2">
<span
class="flex items-center gap-1.5 text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
class="text-base truncate cursor-pointer hover:underline underline-offset-2 hover:text-n-blue-11 text-n-slate-12"
@click="handleClick(slug)"
>
<EmojiIcon
v-if="icon"
:value="icon"
:color="iconColor"
class="shrink-0 size-4"
/>
<span class="truncate">{{ title }}</span>
{{ categoryTitleWithIcon }}
</span>
<span
class="inline-flex items-center justify-center h-6 px-2 py-1 text-xs text-center border rounded-lg bg-n-slate-1 whitespace-nowrap shrink-0 text-n-slate-11 border-n-slate-4"
@@ -67,11 +67,11 @@ const togglePortalSwitcher = () => {
>
<span
v-if="activePortalName"
class="min-w-0 text-xl font-medium truncate text-n-slate-12"
class="text-xl font-medium text-n-slate-12"
>
{{ activePortalName }}
</span>
<div v-if="activePortalName" class="relative shrink-0 group">
<div v-if="activePortalName" class="relative group">
<OnClickOutside @trigger="showPortalSwitcher = false">
<Button
icon="i-lucide-chevron-down"
@@ -91,9 +91,6 @@ const togglePortalSwitcher = () => {
</OnClickOutside>
<CreatePortalDialog ref="createPortalDialogRef" />
</div>
<div class="flex justify-end min-w-0 grow">
<slot name="title-actions" />
</div>
</div>
<slot name="header-actions" />
</div>
@@ -8,7 +8,6 @@ import { useMapGetter } from 'dashboard/composables/store';
import Button from 'dashboard/components-next/button/Button.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import ArticleEditorProperties from 'dashboard/components-next/HelpCenter/Pages/ArticleEditorPage/ArticleEditorProperties.vue';
const props = defineProps({
@@ -108,11 +107,10 @@ const selectedCategory = computed(() => {
const categoryList = computed(() => {
return (
categories.value
.map(({ name, id, icon, icon_color: iconColor }) => ({
.map(({ name, id, icon }) => ({
label: name,
value: id,
emoji: icon,
iconColor,
isSelected: isNewArticle.value
? id === (selectedCategoryId.value || selectedCategory.value?.id)
: id === props.article?.category?.id,
@@ -204,6 +202,10 @@ onMounted(() => {
<div class="relative">
<OnClickOutside @trigger="openCategoryList = false">
<Button
:label="
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
"
:icon="!selectedCategory?.icon ? 'i-lucide-shapes' : ''"
variant="ghost"
color="slate"
@@ -211,20 +213,12 @@ onMounted(() => {
@click="openCategoryList = !openCategoryList"
>
<span
class="flex items-center gap-1.5 min-w-0 text-sm text-n-slate-12 hover:text-n-slate-11"
v-if="selectedCategory"
class="text-sm text-n-slate-12 hover:text-n-slate-11"
>
<EmojiIcon
v-if="selectedCategory?.icon"
:value="selectedCategory.icon"
:color="selectedCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">
{{
selectedCategory?.name ||
t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')
}}
</span>
{{
`${selectedCategory.icon || ''} ${selectedCategory.name || t('HELP_CENTER.EDIT_ARTICLE_PAGE.EDIT_ARTICLE.UNCATEGORIZED')}`
}}
</span>
</Button>
<DropdownMenu
@@ -13,7 +13,6 @@ import {
import TabBar from 'dashboard/components-next/tabbar/TabBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
categories: {
@@ -64,15 +63,18 @@ const activeTabIndex = computed(() => {
return tabs.value.findIndex(tab => tab.value === tabParam);
});
const activeCategory = computed(() =>
props.categories.find(category => category.slug === route.params.categorySlug)
);
const activeCategoryName = computed(() => {
const activeCategory = props.categories.find(
category => category.slug === route.params.categorySlug
);
const activeCategoryName = computed(
() =>
activeCategory.value?.name ||
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL')
);
if (activeCategory) {
const { icon, name } = activeCategory;
return `${icon} ${name}`;
}
return t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.CATEGORY.ALL');
});
const activeLocaleName = computed(() => {
return props.allowedLocales.find(
@@ -92,7 +94,6 @@ const categoryMenuItems = computed(() => {
value: category.slug,
action: 'filter',
emoji: category.icon,
iconColor: category.icon_color,
}));
const hasCategorySlug = !!route.params.categorySlug;
@@ -166,23 +167,14 @@ const handleTabChange = value => {
<div v-if="hasCategoryMenuItems" class="relative group">
<OnClickOutside @trigger="isCategoryMenuOpen = false">
<Button
:label="activeCategoryName"
icon="i-lucide-chevron-down"
size="sm"
color="slate"
trailing-icon
class="max-w-48"
@click="isCategoryMenuOpen = !isCategoryMenuOpen"
>
<span class="flex items-center gap-1.5 min-w-0">
<EmojiIcon
v-if="activeCategory?.icon"
:value="activeCategory.icon"
:color="activeCategory.icon_color"
class="flex-shrink-0 size-4"
/>
<span class="truncate">{{ activeCategoryName }}</span>
</span>
</Button>
/>
<DropdownMenu
v-if="isCategoryMenuOpen"
@@ -24,10 +24,6 @@ const props = defineProps({
type: Set,
default: () => new Set(),
},
isSearching: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['translateArticle', 'toggleSelect']);
@@ -45,7 +41,6 @@ const hoveredArticleId = ref(null);
const dragEnabled = computed(() => {
return (
props.isCategoryArticles &&
!props.isSearching &&
localArticles.value?.length > 1 &&
props.selectedArticleIds.size === 0
);
@@ -5,7 +5,6 @@ import { useI18n } from 'vue-i18n';
import { OnClickOutside } from '@vueuse/components';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useConfig } from 'dashboard/composables/useConfig';
import { debounce } from '@chatwoot/utils';
import { ARTICLE_TABS, CATEGORY_ALL } from 'dashboard/helper/portalHelper';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAlert } from 'dashboard/composables';
@@ -19,7 +18,6 @@ import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ArticleEmptyState from 'dashboard/components-next/HelpCenter/EmptyState/Article/ArticleEmptyState.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import BulkTranslateDialog from './BulkTranslateDialog.vue';
@@ -51,12 +49,7 @@ const props = defineProps({
},
});
const emit = defineEmits([
'pageChange',
'fetchPortal',
'refreshArticles',
'search',
]);
const emit = defineEmits(['pageChange', 'fetchPortal', 'refreshArticles']);
const router = useRouter();
const route = useRoute();
@@ -72,9 +65,6 @@ const isFeatureEnabledonAccount = useMapGetter(
const selectedArticleIds = ref(new Set());
const deleteConfirmDialogRef = ref(null);
const isCategoryMenuOpen = ref(false);
const searchQuery = ref(route.query.search || '');
const debouncedSearch = debounce(() => emit('search', searchQuery.value), 500);
const { isEnterprise } = useConfig();
@@ -132,7 +122,6 @@ const updateRoute = newParams => {
categorySlug: newParams.categorySlug ?? categorySlug,
...newParams,
},
query: route.query,
});
};
@@ -156,12 +145,7 @@ const showCategoryHeaderControls = computed(
() => props.isCategoryArticles && !isSwitchingPortal.value
);
const isSearching = computed(() => Boolean(searchQuery.value?.trim()));
const getEmptyStateText = type => {
if (isSearching.value) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.SEARCH.${type}`);
}
if (props.isCategoryArticles) {
return t(`HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.CATEGORY.${type}`);
}
@@ -246,7 +230,6 @@ const categoryMenuItems = computed(() =>
value: category.id,
action: 'move',
emoji: category.icon,
iconColor: category.icon_color,
}))
);
@@ -307,19 +290,6 @@ watch(
:show-pagination-footer="shouldShowPaginationFooter"
@update:current-page="handlePageChange"
>
<template #title-actions>
<Input
v-if="!isSwitchingPortal"
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.ARTICLES_PAGE.ARTICLES_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-full max-w-[16rem] min-w-0"
@input="debouncedSearch"
/>
</template>
<template #header-actions>
<div class="flex items-end justify-between">
<ArticleHeaderControls
@@ -451,7 +421,6 @@ watch(
<ArticleList
:articles="articles"
:is-category-articles="isCategoryArticles"
:is-searching="isSearching"
:selected-article-ids="selectedArticleIds"
class="relative z-0"
@translate-article="handleTranslateArticle"
@@ -463,7 +432,7 @@ watch(
class="pt-14"
:title="getEmptyStateTitle"
:subtitle="getEmptyStateSubtitle"
:show-button="hasNoArticlesInPortal && !isSearching"
:show-button="hasNoArticlesInPortal"
:button-label="
t('HELP_CENTER.ARTICLES_PAGE.EMPTY_STATE.ALL.BUTTON_LABEL')
"
@@ -37,21 +37,10 @@ const { t } = useI18n();
const editCategoryDialog = ref(null);
const selectedCategory = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
const isLoading = computed(() => props.isFetching || isSwitchingPortal.value);
const filteredCategories = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.categories;
return props.categories.filter(category =>
category.name?.toLowerCase().includes(query)
);
});
const hasCategories = computed(() => filteredCategories.value?.length > 0);
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasCategories = computed(() => props.categories?.length > 0);
const updateRoute = (newParams, routeName) => {
const { accountId, portalSlug, locale } = route.params;
@@ -126,7 +115,6 @@ const reorderCategories = async reorderedGroup => {
<HelpCenterLayout :show-pagination-footer="false">
<template #header-actions>
<CategoryHeaderControls
v-model:search-query="searchQuery"
:categories="categories"
:is-category-articles="false"
:allowed-locales="allowedLocales"
@@ -142,18 +130,11 @@ const reorderCategories = async reorderedGroup => {
</div>
<CategoryList
v-else-if="hasCategories"
:categories="filteredCategories"
:disable-drag="isSearching"
:categories="categories"
@click="openCategoryArticles"
@action="handleAction"
@reorder="reorderCategories"
/>
<CategoryEmptyState
v-else-if="isSearching"
class="pt-14"
:title="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.CATEGORY_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
/>
<CategoryEmptyState
v-else
class="pt-14"
@@ -38,8 +38,8 @@ const { t } = useI18n();
const route = useRoute();
const handleCategory = async formData => {
const { id, name, slug, icon, iconColor, description, locale } = formData;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
const { id, name, slug, icon, description, locale } = formData;
const categoryData = { name, icon, slug, description };
if (props.mode === 'create') {
categoryData.locale = locale;
@@ -18,7 +18,6 @@ import { convertToCategorySlug } from 'dashboard/helper/commons.js';
import Input from 'dashboard/components-next/input/Input.vue';
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
const props = defineProps({
mode: {
@@ -50,9 +49,8 @@ const props = defineProps({
const emit = defineEmits(['submit', 'cancel']);
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const { t } = useI18n();
@@ -74,7 +72,6 @@ const state = reactive({
id: '',
name: '',
icon: '',
iconColor: '',
slug: '',
description: '',
locale: '',
@@ -112,19 +109,8 @@ const slugHelpText = computed(() => {
});
});
const onSelectIcon = ({ type, value, color }) => {
state.icon = value;
state.iconColor = type === 'icon' ? color : '';
isEmojiPickerOpen.value = false;
};
const onColorChange = color => {
state.iconColor = color;
};
const onRemoveIcon = () => {
state.icon = '';
state.iconColor = '';
const onClickInsertEmoji = emoji => {
state.icon = emoji;
isEmojiPickerOpen.value = false;
};
@@ -153,14 +139,7 @@ watch(
newCategory => {
if (props.mode === 'edit' && newCategory) {
const { id, name, icon, slug, description } = newCategory;
Object.assign(state, {
id,
name,
icon,
iconColor: newCategory.icon_color || '',
slug,
description,
});
Object.assign(state, { id, name, icon, slug, description });
}
},
{ immediate: true }
@@ -218,29 +197,19 @@ defineExpose({ state, isSubmitDisabled });
<template #prefix>
<OnClickOutside @trigger="isEmojiPickerOpen = false">
<Button
type="button"
variant="ghost"
:label="state.icon"
color="slate"
class="!h-[2.38rem] !w-[2.375rem] !p-0 absolute top-[2rem] start-px ltr:!rounded-r-none rtl:!rounded-l-none"
size="sm"
type="button"
:icon="!state.icon ? 'i-lucide-smile-plus' : ''"
class="!h-[2.38rem] !w-[2.375rem] absolute top-[2rem] !outline-none !rounded-[0.438rem] border-0 ltr:left-px rtl:right-px ltr:!rounded-r-none rtl:!rounded-l-none"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
>
<EmojiIcon
v-if="state.icon"
:value="state.icon"
:color="state.iconColor"
class="size-5 text-xl !leading-5"
/>
<span v-else class="i-lucide-smile-plus size-4" />
</Button>
<EmojiIconPicker
/>
<EmojiInput
v-if="isEmojiPickerOpen"
class="left-0 top-16"
:value="state.icon"
:color="state.iconColor"
show-remove-button
@select="onSelectIcon"
@color-change="onColorChange"
@remove="onRemoveIcon"
:on-click="onClickInsertEmoji"
/>
</OnClickOutside>
</template>
@@ -6,7 +6,6 @@ import { OnClickOutside } from '@vueuse/components';
import { useStoreGetters } from 'dashboard/composables/store.js';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import Breadcrumb from 'dashboard/components-next/breadcrumb/Breadcrumb.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import CategoryDialog from 'dashboard/components-next/HelpCenter/Pages/CategoryPage/CategoryDialog.vue';
@@ -28,11 +27,6 @@ const props = defineProps({
const emit = defineEmits(['localeChange', 'newArticle']);
const searchQuery = defineModel('searchQuery', {
type: String,
default: '',
});
const route = useRoute();
const router = useRouter();
const getters = useStoreGetters();
@@ -112,7 +106,6 @@ const breadcrumbItems = computed(() => {
}
),
emoji: selectedCategoryEmoji.value,
iconColor: selectedCategory.value?.icon_color,
});
}
return items;
@@ -168,34 +161,23 @@ const handleBreadcrumbClick = () => {
:items="breadcrumbItems"
@click="handleBreadcrumbClick"
/>
<div v-if="!hasSelectedCategory" class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="
t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.SEARCH_PLACEHOLDER')
"
type="search"
size="sm"
class="w-48"
/>
<div class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-if="!hasSelectedCategory" class="relative">
<OnClickOutside @trigger="isCreateCategoryDialogOpen = false">
<Button
:label="t('HELP_CENTER.CATEGORY_PAGE.CATEGORY_HEADER.NEW_CATEGORY')"
icon="i-lucide-plus"
size="sm"
@click="isCreateCategoryDialogOpen = !isCreateCategoryDialogOpen"
/>
<CategoryDialog
v-if="isCreateCategoryDialogOpen"
mode="create"
:portal-name="currentPortalName"
:active-locale-name="activeLocaleName"
:active-locale-code="activeLocaleCode"
@close="isCreateCategoryDialogOpen = false"
/>
</OnClickOutside>
</div>
<div v-else class="relative flex items-center gap-2">
<OnClickOutside @trigger="isEditCategoryDialogOpen = false">
@@ -8,10 +8,6 @@ const props = defineProps({
type: Array,
required: true,
},
disableDrag: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['click', 'action', 'reorder']);
@@ -19,7 +15,7 @@ const emit = defineEmits(['click', 'action', 'reorder']);
const localCategories = ref(props.categories);
const dragEnabled = computed(() => {
return !props.disableDrag && localCategories.value?.length > 1;
return localCategories.value?.length > 1;
});
const handleClick = slug => {
@@ -72,7 +68,6 @@ watch(
:id="element.id"
:title="element.name"
:icon="element.icon"
:icon-color="element.icon_color"
:description="element.description"
:articles-count="element.meta?.articles_count || 0"
:slug="element.slug"
@@ -52,8 +52,8 @@ const activeLocaleCode = computed(() => activeLocale.value?.code ?? '');
const onUpdateCategory = async () => {
if (!categoryFormRef.value) return;
const { state } = categoryFormRef.value;
const { id, name, slug, icon, iconColor, description } = state;
const categoryData = { name, icon, icon_color: iconColor, slug, description };
const { id, name, slug, icon, description } = state;
const categoryData = { name, icon, slug, description };
categoryData.id = id;
try {
@@ -2,12 +2,8 @@
import { computed, ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store.js';
import { useI18n } from 'vue-i18n';
import HelpCenterLayout from 'dashboard/components-next/HelpCenter/HelpCenterLayout.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import LocaleList from 'dashboard/components-next/HelpCenter/Pages/LocalePage/LocaleList.vue';
import AddLocaleDialog from 'dashboard/components-next/HelpCenter/Pages/LocalePage/AddLocaleDialog.vue';
@@ -23,10 +19,7 @@ const props = defineProps({
},
});
const { t } = useI18n();
const addLocaleDialogRef = ref(null);
const searchQuery = ref('');
const isSwitchingPortal = useMapGetter('portals/isSwitchingPortal');
@@ -35,19 +28,6 @@ const openAddLocaleDialog = () => {
};
const localeCount = computed(() => props.locales?.length);
const filteredLocales = computed(() => {
const query = searchQuery.value.trim().toLowerCase();
if (!query) return props.locales;
return props.locales.filter(
locale =>
locale.name?.toLowerCase().includes(query) ||
locale.code?.toLowerCase().includes(query)
);
});
const isSearching = computed(() => searchQuery.value.trim().length > 0);
const hasResults = computed(() => filteredLocales.value?.length > 0);
</script>
<template>
@@ -59,21 +39,12 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
{{ $t('HELP_CENTER.LOCALES_PAGE.LOCALES_COUNT', localeCount) }}
</span>
</div>
<div class="flex items-center gap-2">
<Input
v-model="searchQuery"
:placeholder="$t('HELP_CENTER.LOCALES_PAGE.SEARCH_PLACEHOLDER')"
type="search"
size="sm"
class="w-48"
/>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
<Button
:label="$t('HELP_CENTER.LOCALES_PAGE.NEW_LOCALE_BUTTON_TEXT')"
icon="i-lucide-plus"
size="sm"
@click="openAddLocaleDialog"
/>
</div>
</template>
<template #content>
@@ -83,13 +54,7 @@ const hasResults = computed(() => filteredLocales.value?.length > 0);
>
<Spinner />
</div>
<EmptyStateLayout
v-else-if="isSearching && !hasResults"
:title="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.TITLE')"
:subtitle="t('HELP_CENTER.LOCALES_PAGE.SEARCH_EMPTY_STATE.SUBTITLE')"
:show-backdrop="false"
/>
<LocaleList v-else :locales="filteredLocales" :portal="portal" />
<LocaleList v-else :locales="locales" :portal="portal" />
</template>
<AddLocaleDialog ref="addLocaleDialogRef" :portal="portal" />
</HelpCenterLayout>
@@ -53,9 +53,8 @@ const generateUid = () => {
const uploadAttachment = ref(null);
const isEmojiPickerOpen = ref(false);
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
const {
@@ -216,11 +215,10 @@ useEventListener(document, 'paste', onPaste);
class="!w-10"
@click="isEmojiPickerOpen = !isEmojiPickerOpen"
/>
<EmojiIconPicker
<EmojiInput
v-if="isEmojiPickerOpen"
mode="emoji"
class="!top-auto !bottom-full mb-1.5 ltr:left-0 rtl:right-0"
@select="onClickInsertEmoji($event.value)"
:on-click="onClickInsertEmoji"
/>
</div>
<FileUpload
@@ -1,175 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import { formatBytes } from 'shared/helpers/FileHelper';
import { dynamicTime, shortTimestamp } from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
MEDIA_TYPES,
NON_FILE_TYPES,
} from 'dashboard/components-next/message/constants';
import FileIcon from 'next/icon/FileIcon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const fileAttachments = computed(() =>
[...props.attachments]
.filter(
a =>
a.data_url &&
!MEDIA_TYPES.includes(a.file_type) &&
!NON_FILE_TYPES.includes(a.file_type)
)
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleFiles = computed(() => {
if (!isPeekable.value || showAll.value) return fileAttachments.value;
return fileAttachments.value.slice(0, props.peekLimit);
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const displayName = attachment =>
fileNameFromUrl(attachment.data_url) ||
t('CONVERSATION_SIDEBAR.SHARED_FILES.UNTITLED_FILE');
const displaySize = attachment => {
if (attachment.file_size) return formatBytes(attachment.file_size);
if (attachment.extension) return attachment.extension.toUpperCase();
return '—';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const onActivate = attachment => emit('select', attachment);
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="fileAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.FILES_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ fileAttachments.length }}
</span>
</h4>
<NextButton
v-if="isPeekable && fileAttachments.length > peekLimit"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<ul class="flex flex-col gap-0.5">
<li
v-for="attachment in visibleFiles"
:key="attachment.id"
role="button"
tabindex="0"
class="flex items-center gap-3 px-2 py-2 transition-colors rounded-lg cursor-pointer hover:bg-n-slate-3 group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onActivate(attachment)"
@keydown.enter="onActivate(attachment)"
@keydown.space.prevent="onActivate(attachment)"
>
<div
class="flex items-center justify-center rounded-lg size-9 shrink-0 bg-gradient-to-br from-n-slate-3 to-n-slate-4 ring-1 ring-inset ring-n-slate-4/40"
>
<FileIcon
:file-type="attachment.extension?.toLowerCase() || ''"
class="size-4 text-n-slate-11"
/>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm font-medium truncate text-n-slate-12 mb-1">
{{ displayName(attachment) }}
</p>
<p class="text-xs text-n-slate-11">
{{ displaySize(attachment) }}
<template v-if="displayTime(attachment)">
· {{ displayTime(attachment) }}
</template>
</p>
</div>
<div class="flex items-center gap-1">
<NextButton
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
ghost
slate
sm
icon="i-lucide-external-link"
class="opacity-0 group-hover:opacity-100"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
<NextButton
ghost
slate
sm
icon="i-lucide-download"
class="opacity-0 group-hover:opacity-100"
:is-loading="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
/>
</div>
</li>
</ul>
</section>
<template v-else />
</template>
@@ -1,305 +0,0 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { useAlert } from 'dashboard/composables';
import {
dynamicTime,
formatDuration,
shortTimestamp,
} from 'shared/helpers/timeHelper';
import { downloadFile } from '@chatwoot/utils';
import {
ATTACHMENT_TYPES,
MEDIA_TYPES,
} from 'dashboard/components-next/message/constants';
import Icon from 'next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
attachments: { type: Array, default: () => [] },
peekLimit: { type: Number, default: 0 },
showJumpToMessage: { type: Boolean, default: false },
});
const emit = defineEmits(['select', 'jumpToMessage']);
const { t } = useI18n();
const mediaAttachments = computed(() =>
[...props.attachments]
.filter(a => a.data_url && MEDIA_TYPES.includes(a.file_type))
.sort((a, b) => (b.created_at || 0) - (a.created_at || 0))
);
const showAll = ref(false);
const failedThumbs = ref(new Set());
const failedPreviews = ref(new Set());
const durations = ref({});
const downloadingId = ref(null);
const isPeekable = computed(() => props.peekLimit > 0);
const visibleMedia = computed(() => {
if (!isPeekable.value || showAll.value) return mediaAttachments.value;
return mediaAttachments.value.slice(0, props.peekLimit);
});
const overflow = computed(() => {
if (!isPeekable.value) return 0;
const total = mediaAttachments.value.length;
return total > props.peekLimit ? total - (props.peekLimit - 1) : 0;
});
const fileNameFromUrl = url => {
if (!url) return '';
const name = url.split('/').pop();
return name ? decodeURIComponent(name) : '';
};
const isVideoType = type =>
[ATTACHMENT_TYPES.VIDEO, ATTACHMENT_TYPES.IG_REEL].includes(type);
const isAudioType = type => type === ATTACHMENT_TYPES.AUDIO;
const isPlayableType = type => isVideoType(type) || isAudioType(type);
const imagePreviewSrc = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canUseThumb = thumbUrl && !failedThumbs.value.has(id);
if (type === ATTACHMENT_TYPES.IMAGE) return canUseThumb ? thumbUrl : dataUrl;
if (isVideoType(type)) return canUseThumb ? thumbUrl : null;
return null;
};
const onPreviewError = ({
id,
file_type: type,
thumb_url: thumbUrl,
data_url: dataUrl,
}) => {
const canRetryWithFull = thumbUrl && !failedThumbs.value.has(id) && dataUrl;
if (
canRetryWithFull &&
(type === ATTACHMENT_TYPES.IMAGE || isVideoType(type))
) {
failedThumbs.value.add(id);
return;
}
failedPreviews.value.add(id);
};
const hasPreview = attachment =>
!!imagePreviewSrc(attachment) && !failedPreviews.value.has(attachment.id);
const hasVideoPreview = attachment =>
isVideoType(attachment.file_type) &&
attachment.data_url &&
!failedPreviews.value.has(attachment.id);
const fallbackIcon = type => {
if (isAudioType(type)) return 'i-lucide-music';
if (isVideoType(type)) return 'i-lucide-video';
return 'i-lucide-image';
};
const onLoadedMetadata = (attachment, event) => {
const seconds = event.target?.duration;
if (Number.isFinite(seconds) && seconds > 0) {
durations.value[attachment.id] = seconds;
}
};
const displayDuration = attachment => {
const seconds = durations.value[attachment.id];
return seconds ? formatDuration(Math.round(seconds)) : '';
};
const displayTime = attachment => {
if (!attachment.created_at) return '';
return shortTimestamp(dynamicTime(attachment.created_at), true);
};
const isOverflowTile = index =>
isPeekable.value &&
!showAll.value &&
overflow.value > 0 &&
index === props.peekLimit - 1;
const onTileActivate = (attachment, index) => {
if (isOverflowTile(index)) {
showAll.value = true;
return;
}
emit('select', attachment);
};
const onDownloadFile = async attachment => {
const { id, file_type: type, data_url: url, extension } = attachment;
try {
downloadingId.value = id;
await downloadFile({ url, type, extension });
} catch (error) {
useAlert(t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD_ERROR'));
} finally {
downloadingId.value = null;
}
};
</script>
<template>
<section v-if="mediaAttachments.length" class="flex flex-col gap-2.5">
<header class="flex items-center justify-between px-0.5">
<h4
class="text-xs font-semibold tracking-wider uppercase text-n-slate-11"
>
{{ t('CONVERSATION_SIDEBAR.SHARED_FILES.MEDIA_HEADING') }}
<span
class="ms-1 font-medium tracking-normal normal-case text-n-slate-10"
>
{{ mediaAttachments.length }}
</span>
</h4>
<NextButton
v-if="overflow > 0"
ghost
slate
xs
trailing-icon
:icon="showAll ? 'i-lucide-chevron-up' : 'i-lucide-chevron-right'"
:label="
showAll
? t('CONVERSATION_SIDEBAR.SHARED_FILES.SHOW_LESS')
: t('CONVERSATION_SIDEBAR.SHARED_FILES.VIEW_ALL')
"
@click="showAll = !showAll"
/>
</header>
<div class="grid grid-cols-3 gap-2">
<div
v-for="(attachment, index) in visibleMedia"
:key="attachment.id"
role="button"
tabindex="0"
class="relative w-full overflow-hidden transition-all duration-200 rounded-lg cursor-pointer aspect-square bg-n-slate-3 shadow-sm hover:shadow-md hover:-translate-y-px group focus:outline-none focus-visible:ring-2 focus-visible:ring-n-blue-9"
@click="onTileActivate(attachment, index)"
@keydown.enter="onTileActivate(attachment, index)"
@keydown.space.prevent="onTileActivate(attachment, index)"
>
<template v-if="!isOverflowTile(index)">
<img
v-if="hasPreview(attachment)"
:src="imagePreviewSrc(attachment)"
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110"
loading="lazy"
:alt="fileNameFromUrl(attachment.data_url)"
@error="onPreviewError(attachment)"
/>
<video
v-else-if="hasVideoPreview(attachment)"
:src="`${attachment.data_url}#t=0.1`"
preload="metadata"
muted
playsinline
class="object-cover w-full h-full transition-transform duration-300 group-hover:scale-110 pointer-events-none"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
@error="onPreviewError(attachment)"
/>
<div
v-else
class="flex items-center justify-center w-full h-full bg-gradient-to-br from-n-slate-3 to-n-slate-4"
>
<Icon
:icon="fallbackIcon(attachment.file_type)"
class="size-6 text-n-slate-11"
/>
</div>
<audio
v-if="isAudioType(attachment.file_type) && attachment.data_url"
:src="attachment.data_url"
preload="metadata"
class="hidden"
@loadedmetadata="onLoadedMetadata(attachment, $event)"
/>
<div
class="absolute inset-0 transition-opacity duration-200 opacity-0 pointer-events-none group-hover:opacity-100 bg-gradient-to-t from-black/40 via-transparent to-transparent"
/>
<div
v-if="hasVideoPreview(attachment)"
class="absolute inset-0 flex items-center justify-center pointer-events-none bg-gradient-to-t from-black/30 via-transparent to-transparent"
>
<div
class="flex items-center justify-center rounded-full size-7 bg-white/95 shadow-md"
>
<Icon icon="i-lucide-play" class="ms-0.5 size-3.5 text-n-black" />
</div>
</div>
<span
v-if="
isPlayableType(attachment.file_type) &&
displayDuration(attachment)
"
class="absolute text-xxs font-medium tabular-nums transition-opacity bottom-1.5 ltr:right-1.5 rtl:left-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-0"
>
{{ displayDuration(attachment) }}
</span>
<span
v-if="displayTime(attachment)"
class="absolute text-xxs font-medium transition-opacity opacity-0 bottom-1.5 ltr:left-1.5 rtl:right-1.5 text-white [text-shadow:_0_1px_3px_rgba(0,0,0,0.95),_0_0_10px_rgba(0,0,0,0.7)] group-hover:opacity-100"
>
{{ displayTime(attachment) }}
</span>
<button
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 bottom-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white disabled:opacity-50"
:disabled="downloadingId === attachment.id"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.DOWNLOAD')"
@click.stop="onDownloadFile(attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-download" class="size-3 text-n-black" />
</button>
<button
v-if="showJumpToMessage && attachment.message_id"
v-tooltip.top="{
content: t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE'),
delay: { show: 500, hide: 0 },
}"
type="button"
class="absolute flex items-center justify-center !p-px transition-all rounded-full opacity-0 top-1.5 ltr:right-1.5 rtl:left-1.5 size-6 bg-white/95 shadow-md group-hover:opacity-100 hover:bg-white"
:aria-label="t('CONVERSATION_SIDEBAR.SHARED_FILES.JUMP_TO_MESSAGE')"
@click.stop="emit('jumpToMessage', attachment)"
@keydown.enter.stop
@keydown.space.stop
>
<Icon icon="i-lucide-external-link" class="size-3 text-n-black" />
</button>
</template>
<div
v-if="isOverflowTile(index)"
class="absolute inset-0 flex items-center justify-center bg-n-slate-5"
>
<span class="text-base font-semibold text-n-slate-12">
{{
t('CONVERSATION_SIDEBAR.SHARED_FILES.MORE_COUNT', {
count: overflow,
})
}}
</span>
</div>
</div>
</div>
</section>
<template v-else />
</template>
@@ -2,7 +2,6 @@
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
defineProps({
items: {
@@ -48,17 +47,8 @@ const onClick = (item, index) => {
</button>
<!-- The last breadcrumb item is plain text -->
<span
v-else
class="inline-flex items-center gap-1 text-sm truncate min-w-0"
>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span class="truncate text-n-slate-12">{{ item.label }}</span>
<span v-else class="text-sm truncate text-n-slate-12 min-w-0 block">
{{ item.emoji ? item.emoji : '' }} {{ item.label }}
</span>
</li>
</ol>
@@ -75,11 +75,11 @@ const channelIcon = computed(() => {
<template>
<div
class="flex flex-col gap-1 pt-4 bg-n-call-widget rounded-2xl shadow-xl outline outline-1 outline-n-call-widget-border backdrop-blur-md"
class="flex flex-col gap-2 pt-4 bg-n-solid-2/95 rounded-2xl shadow-xl outline outline-1 outline-n-strong backdrop-blur-md"
:class="call?.conversationId ? 'pb-2' : 'pb-4'"
>
<!-- Top section: status badge + location/inbox + duration -->
<div class="flex flex-col gap-3 pb-3 border-b border-n-call-widget-border">
<div class="flex flex-col gap-3">
<div class="flex items-center gap-2 px-4">
<!-- Ongoing: status badge on left -->
<div v-if="isOngoing" class="flex items-center gap-1.5 shrink-0">
@@ -100,10 +100,10 @@ const channelIcon = computed(() => {
<Icon
v-else-if="!isOngoing"
:icon="channelIcon"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-10 shrink-0"
/>
<span
class="text-xs font-medium text-n-call-widget-sub-text tracking-tight truncate"
class="text-xs font-medium text-n-slate-11 tracking-tight truncate"
>
{{ callInfo.location }}
</span>
@@ -112,7 +112,7 @@ const channelIcon = computed(() => {
<!-- Ongoing: duration on right -->
<p
v-if="isOngoing"
class="font-display text-base font-medium text-n-call-widget-sub-text shrink-0 mb-0 tabular-nums tracking-tight"
class="font-display text-base font-medium text-n-slate-11 shrink-0 mb-0 tabular-nums tracking-tight"
>
{{ duration }}
</p>
@@ -132,7 +132,7 @@ const channelIcon = computed(() => {
slate
ghost
xs
class="!rounded-full -my-1 -me-1 !text-n-call-widget-sub-text"
class="!rounded-full -my-1"
@click="$emit('dismiss')"
/>
</div>
@@ -149,13 +149,13 @@ const channelIcon = computed(() => {
</div>
<div class="flex-1 min-w-0">
<p
class="font-display text-sm font-medium text-n-call-widget-text truncate mb-0.5 tracking-tight leading-tight"
class="font-display text-sm font-medium text-n-slate-12 truncate mb-0.5 tracking-tight leading-tight"
>
{{ callInfo.contactName }}
</p>
<p
v-if="callInfo.phoneNumber"
class="text-sm text-n-call-widget-sub-text truncate mb-0 tracking-tight leading-tight"
class="text-sm text-n-slate-11 truncate mb-0 tracking-tight leading-tight"
>
{{ callInfo.phoneNumber }}
</p>
@@ -217,23 +217,23 @@ const channelIcon = computed(() => {
>
<template #icon>
<span
class="flex items-center gap-1 text-n-call-widget-sub-text group-hover:text-n-call-widget-text"
class="flex items-center gap-1 text-n-slate-11 group-hover:text-n-slate-12"
>
<Icon
icon="i-ph-chat-circle-text-bold"
class="size-3.5 text-n-call-widget-sub-text shrink-0"
class="size-3.5 text-n-slate-11 shrink-0"
/>
<span class="text-sm tracking-tight tabular-nums">
#{{ call.conversationId }}
</span>
<Icon
icon="i-ph-caret-right-bold"
class="size-3 text-n-call-widget-sub-text shrink-0"
class="size-3 text-n-slate-11 shrink-0"
/>
</span>
</template>
<span
class="text-sm text-n-call-widget-sub-text tracking-tight group-hover:text-n-call-widget-text"
class="text-sm text-n-slate-11 tracking-tight group-hover:text-n-slate-12"
>
{{ $t('CONVERSATION.VOICE_WIDGET.GO_TO_CONVERSATION') }}
</span>
@@ -14,9 +14,6 @@ const props = defineProps({
value.every(option => 'value' in option && 'label' in option),
},
placeholder: { type: String, default: '' },
// Fallback label shown when the selected value is not in `options` yet
// (e.g. API-backed lists that load lazily on open).
displayLabel: { type: String, default: '' },
modelValue: { type: [String, Number], default: '' },
disabled: { type: Boolean, default: false },
searchPlaceholder: { type: String, default: '' },
@@ -26,7 +23,7 @@ const props = defineProps({
useApiResults: { type: Boolean, default: false }, // useApiResults prop to determine if search is handled by API
});
const emit = defineEmits(['update:modelValue', 'search', 'open']);
const emit = defineEmits(['update:modelValue', 'search']);
const { t } = useI18n();
@@ -55,7 +52,7 @@ const selectedLabel = computed(() => {
const selected = props.options.find(
option => option.value === selectedValue.value
);
return selected?.label ?? (props.displayLabel || selectPlaceholder.value);
return selected?.label ?? selectPlaceholder.value;
});
const selectOption = option => {
@@ -75,7 +72,6 @@ const toggleDropdown = () => {
open.value = !open.value;
if (open.value) {
search.value = '';
emit('open');
nextTick(() => dropdownRef.value?.focus());
}
};
@@ -87,7 +87,7 @@ defineExpose({
}"
role="option"
:aria-selected="isSelected(option)"
@click.stop="emit('select', option)"
@click="emit('select', option)"
>
<span
:class="{
@@ -3,7 +3,6 @@ import { ref, computed, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import EmojiIcon from 'dashboard/components-next/emoji-icon-picker/EmojiIcon.vue';
import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
@@ -163,7 +162,7 @@ onMounted(() => {
>
<p
v-if="section.title"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm truncate min-w-0"
class="px-2 py-2 text-xs mb-0 font-medium text-n-slate-11 uppercase tracking-wide sticky top-0 z-10 bg-n-alpha-3 backdrop-blur-sm"
>
{{ section.title }}
</p>
@@ -208,12 +207,9 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{
item.emoji
}}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -264,12 +260,7 @@ onMounted(() => {
class="flex-shrink-0 size-3.5"
/>
</slot>
<EmojiIcon
v-if="item.emoji"
:value="item.emoji"
:color="item.iconColor"
class="flex-shrink-0 size-4"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<slot name="label" :item="item">
<span
v-if="item.label"
@@ -1,27 +0,0 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { ICON_COLORS } from './constants';
const { t } = useI18n();
const selectedColor = defineModel({ type: String, default: '' });
</script>
<template>
<div class="flex items-center justify-between gap-1 px-0.5 mx-2 py-1 mb-px">
<button
v-for="color in ICON_COLORS"
:key="color.value"
v-tooltip.top="t(`EMOJI_ICON_PICKER.COLORS.${color.name}`)"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-full size-5 ring-offset-2 ring-offset-n-surface-2 hover:ring-2"
:class="{ 'ring-2': selectedColor === color.value }"
:style="{ backgroundColor: color.value, '--tw-ring-color': color.value }"
@click="selectedColor = color.value"
>
<span
v-if="selectedColor === color.value"
class="text-white i-lucide-check size-3"
/>
</button>
</div>
</template>
@@ -1,35 +0,0 @@
<script setup>
import { computed } from 'vue';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import { isIconValue, iconClassFor } from './constants';
// Renders a saved picker value: a color-tinted icon or an emoji.
const props = defineProps({
value: {
type: String,
default: '',
},
color: {
type: String,
default: '',
},
});
const isIcon = computed(() => isIconValue(props.value));
const iconClass = computed(() => iconClassFor(props.value));
const iconStyle = computed(() =>
props.color ? { color: props.color } : undefined
);
</script>
<template>
<span class="inline-flex items-center justify-center leading-none">
<Icon
v-if="isIcon"
:icon="iconClass"
class="size-full"
:style="iconStyle"
/>
<span v-else v-dompurify-html="value" />
</span>
</template>
@@ -1,310 +0,0 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { Virtualizer } from 'virtua/vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Input from 'dashboard/components-next/input/Input.vue';
import ColorPalette from './ColorPalette.vue';
import { CURATED_ICONS } from './icons';
import {
DEFAULT_ICON_COLOR,
ICON_STYLE,
PICKER_MODE,
PICKER_TAB,
isIconValue,
} from './constants';
import {
buildEmojiSections,
getEmojiTint,
getRecentEmojis,
addRecentEmoji,
} from 'shared/components/emoji/pickerHelper';
const props = defineProps({
// 'both' shows Icons + Emojis tabs; 'emoji' shows only the emoji panel.
mode: {
type: String,
default: PICKER_MODE.BOTH,
validator: value => Object.values(PICKER_MODE).includes(value),
},
// Saved value: an emoji char or an icon name (e.g. "rocket-line").
value: {
type: String,
default: '',
},
// Current stored icon color (hex), used to preselect a swatch.
color: {
type: String,
default: '',
},
showRemoveButton: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['select', 'remove', 'colorChange']);
const { t } = useI18n();
const showTabs = computed(() => props.mode === PICKER_MODE.BOTH);
const showHeader = computed(
() => showTabs.value || (props.showRemoveButton && props.value)
);
const activeTab = ref(
props.value && !isIconValue(props.value)
? PICKER_TAB.EMOJIS
: PICKER_TAB.ICONS
);
const tabs = computed(() => [
{
id: PICKER_TAB.ICONS,
label: t('EMOJI_ICON_PICKER.TABS.ICONS'),
icon: 'i-lucide-shapes',
},
{
id: PICKER_TAB.EMOJIS,
label: t('EMOJI_ICON_PICKER.TABS.EMOJIS'),
icon: 'i-lucide-smile',
},
]);
// Icons
const iconSearch = ref('');
const selectedColor = ref(props.color || DEFAULT_ICON_COLOR);
// Preselect the outline/filled toggle from the saved icon's style suffix.
const iconStyle = ref(
props.value.endsWith(`-${ICON_STYLE.FILL}`)
? ICON_STYLE.FILL
: ICON_STYLE.LINE
);
const filteredIcons = computed(() => {
const term = iconSearch.value.trim().toLowerCase();
if (!term) return CURATED_ICONS;
return CURATED_ICONS.filter(
icon => icon.name.includes(term) || icon.keywords.includes(term)
);
});
const toggleIconStyle = () => {
iconStyle.value =
iconStyle.value === ICON_STYLE.LINE ? ICON_STYLE.FILL : ICON_STYLE.LINE;
};
// Each icon button tints its hover with the selected color (low opacity).
const iconHoverStyle = computed(() => ({
'--ep-tint': `${selectedColor.value}24`,
}));
const selectIcon = icon => {
// Store the name with its style suffix, e.g. "rocket-fill".
const value = `${icon.name}-${iconStyle.value}`;
emit('select', {
type: 'icon',
value,
icon: value,
color: selectedColor.value,
});
};
watch(selectedColor, color => {
if (isIconValue(props.value)) emit('colorChange', color);
});
// Emojis
const emojiSearch = ref('');
const recentEmojis = ref([]);
onMounted(() => {
recentEmojis.value = getRecentEmojis();
});
const emojiSections = computed(() =>
buildEmojiSections(
emojiSearch.value,
recentEmojis.value,
t('EMOJI_ICON_PICKER.FREQUENTLY_USED')
)
);
// Tints an emoji button with a light shade of the emoji's own color on hover.
const applyEmojiTint = (event, emoji) => {
event.currentTarget.style.setProperty('--ep-tint', getEmojiTint(emoji));
};
const selectEmoji = emoji => {
recentEmojis.value = addRecentEmoji(emoji);
emit('select', { type: 'emoji', value: emoji.emoji, emoji: emoji.emoji });
};
</script>
<template>
<div
role="dialog"
class="absolute z-20 flex flex-col overflow-hidden shadow-xl w-[22rem] bg-n-surface-2 backdrop-blur-[100px] rounded-2xl outline outline-1 outline-n-weak dark:outline-n-strong/50"
>
<div v-if="showHeader" class="flex items-center justify-between gap-2 p-2">
<div v-if="showTabs" class="flex gap-0.5 p-0.5 rounded-lg bg-n-alpha-1">
<button
v-for="tab in tabs"
:key="tab.id"
v-tooltip.top="tab.label"
type="button"
class="flex items-center justify-center !p-0 transition-all rounded-md size-7 active:scale-[0.92]"
:class="
activeTab === tab.id
? 'bg-n-surface-1 shadow-sm text-n-slate-12'
: 'text-n-slate-10 hover:text-n-slate-12'
"
@click="activeTab = tab.id"
>
<span :class="tab.icon" class="size-4" />
</button>
</div>
<Button
v-if="showRemoveButton && value"
v-tooltip.top="t('EMOJI_ICON_PICKER.REMOVE')"
variant="ghost"
color="slate"
size="sm"
icon="i-lucide-eraser"
class="flex-shrink-0"
@click="emit('remove')"
/>
</div>
<!-- Icons panel -->
<div
v-if="showTabs && activeTab === PICKER_TAB.ICONS"
class="flex flex-col gap-1.5"
:style="iconHoverStyle"
>
<ColorPalette v-model="selectedColor" />
<div class="flex items-center gap-1 px-2">
<Input
v-model="iconSearch"
size="md"
class="flex-1"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_ICON')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
<button
v-tooltip.top="
iconStyle === ICON_STYLE.FILL
? t('EMOJI_ICON_PICKER.STYLE.FILLED')
: t('EMOJI_ICON_PICKER.STYLE.OUTLINE')
"
type="button"
class="flex items-center justify-center flex-shrink-0 transition-all rounded-lg !p-2 size-9 text-n-slate-11 hover:bg-[var(--ep-tint)] active:scale-[0.92]"
@click="toggleIconStyle"
>
<span
:class="
iconStyle === ICON_STYLE.FILL
? 'i-ri-contrast-2-fill'
: 'i-ri-contrast-2-line'
"
class="size-[1.2rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-if="filteredIcons.length"
class="grid grid-cols-10 h-52 gap-0.5 overflow-y-auto no-scrollbar content-start px-2 pb-2"
>
<button
v-for="icon in filteredIcons"
:key="icon.name"
type="button"
:title="icon.name"
class="flex items-center justify-center !p-0 size-8 transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@click="selectIcon(icon)"
>
<span
:class="icon[iconStyle]"
class="size-[1.125rem]"
:style="{ color: selectedColor }"
/>
</button>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-52 text-n-slate-10"
>
<span class="i-lucide-search-x size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_ICON') }}
</span>
</div>
</div>
<!-- Emojis panel -->
<div
v-if="!showTabs || activeTab === PICKER_TAB.EMOJIS"
class="flex flex-col gap-1.5"
:class="{ 'pt-2': !showHeader }"
>
<div class="px-2">
<Input
v-model="emojiSearch"
size="md"
:placeholder="t('EMOJI_ICON_PICKER.SEARCH_EMOJI')"
custom-input-class="!ps-9 !bg-transparent"
autofocus
>
<template #prefix>
<span
class="absolute z-10 -translate-y-1/2 i-lucide-search size-4 text-n-slate-10 top-1/2 start-3"
/>
</template>
</Input>
</div>
<div
v-if="emojiSections.length"
class="h-60 overflow-y-auto px-2 no-scrollbar pb-2"
>
<Virtualizer v-slot="{ item: section }" :data="emojiSections">
<h5
class="px-1 pt-2 pb-1 m-0 text-xs font-medium tracking-wide uppercase text-n-slate-10"
>
{{ section.name }}
</h5>
<div class="grid grid-cols-10 gap-0.5">
<button
v-for="emoji in section.emojis"
:key="`${section.name}-${emoji.slug}`"
type="button"
:title="emoji.name"
class="flex items-center justify-center !p-0 size-8 w-full max-w-[2rem] text-xl transition-colors rounded-lg aspect-square hover:bg-[var(--ep-tint)] active:enabled:scale-[0.97]"
@mouseenter="applyEmojiTint($event, emoji.emoji)"
@click="selectEmoji(emoji)"
>
{{ emoji.emoji }}
</button>
</div>
</Virtualizer>
</div>
<div
v-else
class="flex flex-col items-center justify-center gap-2 h-60 text-n-slate-10"
>
<span class="i-lucide-smile-plus size-7" />
<span class="text-sm font-medium">
{{ t('EMOJI_ICON_PICKER.NO_EMOJI') }}
</span>
</div>
</div>
</div>
</template>
@@ -1,40 +0,0 @@
// Prefix that turns a stored icon name (e.g. "rocket-line") into a class.
// Swapping icon libraries means changing this and the curated set only.
export const ICON_PREFIX = 'i-ri-';
export const ICON_STYLE = {
LINE: 'line',
FILL: 'fill',
};
// Icon values are ascii names (e.g. "rocket-line"); emoji are non-ascii.
export const isIconValue = value =>
typeof value === 'string' && /^[a-z][a-z0-9-]*$/.test(value);
export const iconClassFor = value =>
value.startsWith(ICON_PREFIX) ? value : `${ICON_PREFIX}${value}`;
export const ICON_COLORS = [
{ name: 'SLATE', value: '#64748B' },
{ name: 'RED', value: '#EF4444' },
{ name: 'ORANGE', value: '#F97316' },
{ name: 'AMBER', value: '#F59E0B' },
{ name: 'GREEN', value: '#22C55E' },
{ name: 'TEAL', value: '#14B8A6' },
{ name: 'BLUE', value: '#3B82F6' },
{ name: 'INDIGO', value: '#6366F1' },
{ name: 'VIOLET', value: '#8B5CF6' },
{ name: 'PINK', value: '#EC4899' },
];
export const DEFAULT_ICON_COLOR = '#3B82F6';
export const PICKER_MODE = {
BOTH: 'both',
EMOJI: 'emoji',
};
export const PICKER_TAB = {
ICONS: 'icons',
EMOJIS: 'emojis',
};
@@ -1,983 +0,0 @@
// Curated Remix (ri) icons, each with a `line` and `fill` class. The class
// literals are kept here so Tailwind generates them. { name, keywords, line, fill }
export const CURATED_ICONS = [
{
name: 'home',
keywords: 'home house main',
line: 'i-ri-home-line',
fill: 'i-ri-home-fill',
},
{
name: 'star',
keywords: 'favorite star',
line: 'i-ri-star-line',
fill: 'i-ri-star-fill',
},
{
name: 'heart',
keywords: 'like love',
line: 'i-ri-heart-line',
fill: 'i-ri-heart-fill',
},
{
name: 'bookmark',
keywords: 'save',
line: 'i-ri-bookmark-line',
fill: 'i-ri-bookmark-fill',
},
{
name: 'flag-2',
keywords: 'flag report',
line: 'i-ri-flag-2-line',
fill: 'i-ri-flag-2-fill',
},
{
name: 'price-tag-3',
keywords: 'tag label',
line: 'i-ri-price-tag-3-line',
fill: 'i-ri-price-tag-3-fill',
},
{
name: 'map-pin',
keywords: 'location place',
line: 'i-ri-map-pin-line',
fill: 'i-ri-map-pin-fill',
},
{
name: 'sparkling-2',
keywords: 'magic ai shine sparkle',
line: 'i-ri-sparkling-2-line',
fill: 'i-ri-sparkling-2-fill',
},
{
name: 'fire',
keywords: 'flame hot trending',
line: 'i-ri-fire-line',
fill: 'i-ri-fire-fill',
},
{
name: 'lightbulb',
keywords: 'idea tip',
line: 'i-ri-lightbulb-line',
fill: 'i-ri-lightbulb-fill',
},
{
name: 'rocket',
keywords: 'launch startup ship',
line: 'i-ri-rocket-line',
fill: 'i-ri-rocket-fill',
},
{
name: 'focus-3',
keywords: 'target goal aim',
line: 'i-ri-focus-3-line',
fill: 'i-ri-focus-3-fill',
},
{
name: 'award',
keywords: 'prize badge medal',
line: 'i-ri-award-line',
fill: 'i-ri-award-fill',
},
{
name: 'trophy',
keywords: 'win achievement',
line: 'i-ri-trophy-line',
fill: 'i-ri-trophy-fill',
},
{
name: 'vip-crown-2',
keywords: 'crown premium vip',
line: 'i-ri-vip-crown-2-line',
fill: 'i-ri-vip-crown-2-fill',
},
{
name: 'vip-diamond',
keywords: 'gem diamond premium',
line: 'i-ri-vip-diamond-line',
fill: 'i-ri-vip-diamond-fill',
},
{
name: 'gift',
keywords: 'present reward',
line: 'i-ri-gift-line',
fill: 'i-ri-gift-fill',
},
{
name: 'notification-3',
keywords: 'bell alert',
line: 'i-ri-notification-3-line',
fill: 'i-ri-notification-3-fill',
},
{
name: 'global',
keywords: 'globe world web',
line: 'i-ri-global-line',
fill: 'i-ri-global-fill',
},
{
name: 'compass-3',
keywords: 'explore discover',
line: 'i-ri-compass-3-line',
fill: 'i-ri-compass-3-fill',
},
{
name: 'puzzle-2',
keywords: 'extension integration',
line: 'i-ri-puzzle-2-line',
fill: 'i-ri-puzzle-2-fill',
},
{
name: 'ticket-2',
keywords: 'support coupon',
line: 'i-ri-ticket-2-line',
fill: 'i-ri-ticket-2-fill',
},
{
name: 'megaphone',
keywords: 'announce broadcast marketing',
line: 'i-ri-megaphone-line',
fill: 'i-ri-megaphone-fill',
},
{
name: 'briefcase-4',
keywords: 'work job business',
line: 'i-ri-briefcase-4-line',
fill: 'i-ri-briefcase-4-fill',
},
{
name: 'building-2',
keywords: 'company office org',
line: 'i-ri-building-2-line',
fill: 'i-ri-building-2-fill',
},
{
name: 'store-2',
keywords: 'shop commerce',
line: 'i-ri-store-2-line',
fill: 'i-ri-store-2-fill',
},
{
name: 'community',
keywords: 'group team',
line: 'i-ri-community-line',
fill: 'i-ri-community-fill',
},
{
name: 'folder',
keywords: 'directory',
line: 'i-ri-folder-line',
fill: 'i-ri-folder-fill',
},
{
name: 'folder-open',
keywords: 'directory open',
line: 'i-ri-folder-open-line',
fill: 'i-ri-folder-open-fill',
},
{
name: 'file',
keywords: 'document',
line: 'i-ri-file-line',
fill: 'i-ri-file-fill',
},
{
name: 'file-text',
keywords: 'document doc',
line: 'i-ri-file-text-line',
fill: 'i-ri-file-text-fill',
},
{
name: 'file-copy',
keywords: 'documents files',
line: 'i-ri-file-copy-line',
fill: 'i-ri-file-copy-fill',
},
{
name: 'clipboard',
keywords: 'copy tasks',
line: 'i-ri-clipboard-line',
fill: 'i-ri-clipboard-fill',
},
{
name: 'book-2',
keywords: 'read guide book',
line: 'i-ri-book-2-line',
fill: 'i-ri-book-2-fill',
},
{
name: 'book-open',
keywords: 'read guide docs',
line: 'i-ri-book-open-line',
fill: 'i-ri-book-open-fill',
},
{
name: 'graduation-cap',
keywords: 'learn course education',
line: 'i-ri-graduation-cap-line',
fill: 'i-ri-graduation-cap-fill',
},
{
name: 'newspaper',
keywords: 'news article blog',
line: 'i-ri-newspaper-line',
fill: 'i-ri-newspaper-fill',
},
{
name: 'sticky-note',
keywords: 'note memo',
line: 'i-ri-sticky-note-line',
fill: 'i-ri-sticky-note-fill',
},
{
name: 'pencil',
keywords: 'write edit',
line: 'i-ri-pencil-line',
fill: 'i-ri-pencil-fill',
},
{
name: 'quill-pen',
keywords: 'write author',
line: 'i-ri-quill-pen-line',
fill: 'i-ri-quill-pen-fill',
},
{
name: 'mark-pen',
keywords: 'highlight',
line: 'i-ri-mark-pen-line',
fill: 'i-ri-mark-pen-fill',
},
{
name: 'calendar-2',
keywords: 'date schedule calendar',
line: 'i-ri-calendar-2-line',
fill: 'i-ri-calendar-2-fill',
},
{
name: 'calendar-event',
keywords: 'schedule event',
line: 'i-ri-calendar-event-line',
fill: 'i-ri-calendar-event-fill',
},
{
name: 'time',
keywords: 'clock time',
line: 'i-ri-time-line',
fill: 'i-ri-time-fill',
},
{
name: 'timer',
keywords: 'countdown',
line: 'i-ri-timer-line',
fill: 'i-ri-timer-fill',
},
{
name: 'history',
keywords: 'recent past',
line: 'i-ri-history-line',
fill: 'i-ri-history-fill',
},
{
name: 'hourglass',
keywords: 'wait time',
line: 'i-ri-hourglass-line',
fill: 'i-ri-hourglass-fill',
},
{
name: 'shopping-cart-2',
keywords: 'buy ecommerce cart',
line: 'i-ri-shopping-cart-2-line',
fill: 'i-ri-shopping-cart-2-fill',
},
{
name: 'shopping-bag-3',
keywords: 'buy purchase bag',
line: 'i-ri-shopping-bag-3-line',
fill: 'i-ri-shopping-bag-3-fill',
},
{
name: 'box-3',
keywords: 'package box shipping',
line: 'i-ri-box-3-line',
fill: 'i-ri-box-3-fill',
},
{
name: 'archive',
keywords: 'store box archive',
line: 'i-ri-archive-line',
fill: 'i-ri-archive-fill',
},
{
name: 'bank-card',
keywords: 'pay billing card credit',
line: 'i-ri-bank-card-line',
fill: 'i-ri-bank-card-fill',
},
{
name: 'wallet-3',
keywords: 'money pay wallet',
line: 'i-ri-wallet-3-line',
fill: 'i-ri-wallet-3-fill',
},
{
name: 'money-dollar-circle',
keywords: 'money price usd',
line: 'i-ri-money-dollar-circle-line',
fill: 'i-ri-money-dollar-circle-fill',
},
{
name: 'copper-coin',
keywords: 'coins money',
line: 'i-ri-copper-coin-line',
fill: 'i-ri-copper-coin-fill',
},
{
name: 'currency',
keywords: 'money exchange',
line: 'i-ri-currency-line',
fill: 'i-ri-currency-fill',
},
{
name: 'receipt',
keywords: 'invoice bill',
line: 'i-ri-receipt-line',
fill: 'i-ri-receipt-fill',
},
{
name: 'percent',
keywords: 'discount sale',
line: 'i-ri-percent-line',
fill: 'i-ri-percent-fill',
},
{
name: 'calculator',
keywords: 'math',
line: 'i-ri-calculator-line',
fill: 'i-ri-calculator-fill',
},
{
name: 'mail',
keywords: 'email message',
line: 'i-ri-mail-line',
fill: 'i-ri-mail-fill',
},
{
name: 'send-plane',
keywords: 'send submit',
line: 'i-ri-send-plane-line',
fill: 'i-ri-send-plane-fill',
},
{
name: 'chat-3',
keywords: 'chat talk message',
line: 'i-ri-chat-3-line',
fill: 'i-ri-chat-3-fill',
},
{
name: 'message-2',
keywords: 'chat comment',
line: 'i-ri-message-2-line',
fill: 'i-ri-message-2-fill',
},
{
name: 'question-answer',
keywords: 'chat conversation faq',
line: 'i-ri-question-answer-line',
fill: 'i-ri-question-answer-fill',
},
{
name: 'phone',
keywords: 'call',
line: 'i-ri-phone-line',
fill: 'i-ri-phone-fill',
},
{
name: 'vidicon',
keywords: 'video call meeting',
line: 'i-ri-vidicon-line',
fill: 'i-ri-vidicon-fill',
},
{
name: 'mic',
keywords: 'record voice audio',
line: 'i-ri-mic-line',
fill: 'i-ri-mic-fill',
},
{
name: 'group',
keywords: 'team people users',
line: 'i-ri-group-line',
fill: 'i-ri-group-fill',
},
{
name: 'user',
keywords: 'person account',
line: 'i-ri-user-line',
fill: 'i-ri-user-fill',
},
{
name: 'user-add',
keywords: 'add member',
line: 'i-ri-user-add-line',
fill: 'i-ri-user-add-fill',
},
{
name: 'contacts',
keywords: 'person card contact',
line: 'i-ri-contacts-line',
fill: 'i-ri-contacts-fill',
},
{
name: 'emotion-happy',
keywords: 'happy smile emoji',
line: 'i-ri-emotion-happy-line',
fill: 'i-ri-emotion-happy-fill',
},
{
name: 'thumb-up',
keywords: 'like approve',
line: 'i-ri-thumb-up-line',
fill: 'i-ri-thumb-up-fill',
},
{
name: 'hand-heart',
keywords: 'care support',
line: 'i-ri-hand-heart-line',
fill: 'i-ri-hand-heart-fill',
},
{
name: 'at',
keywords: 'mention email at',
line: 'i-ri-at-line',
fill: 'i-ri-at-fill',
},
{
name: 'image-2',
keywords: 'picture photo image',
line: 'i-ri-image-2-line',
fill: 'i-ri-image-2-fill',
},
{
name: 'camera-3',
keywords: 'photo camera',
line: 'i-ri-camera-3-line',
fill: 'i-ri-camera-3-fill',
},
{
name: 'film',
keywords: 'movie video',
line: 'i-ri-film-line',
fill: 'i-ri-film-fill',
},
{
name: 'music-2',
keywords: 'audio song music',
line: 'i-ri-music-2-line',
fill: 'i-ri-music-2-fill',
},
{
name: 'headphone',
keywords: 'audio support',
line: 'i-ri-headphone-line',
fill: 'i-ri-headphone-fill',
},
{
name: 'palette',
keywords: 'color design art',
line: 'i-ri-palette-line',
fill: 'i-ri-palette-fill',
},
{
name: 'brush-3',
keywords: 'paint design brush',
line: 'i-ri-brush-3-line',
fill: 'i-ri-brush-3-fill',
},
{
name: 'scissors-2',
keywords: 'cut',
line: 'i-ri-scissors-2-line',
fill: 'i-ri-scissors-2-fill',
},
{
name: 'code-s-slash',
keywords: 'develop code programming',
line: 'i-ri-code-s-slash-line',
fill: 'i-ri-code-s-slash-fill',
},
{
name: 'terminal-box',
keywords: 'console cli terminal',
line: 'i-ri-terminal-box-line',
fill: 'i-ri-terminal-box-fill',
},
{
name: 'git-branch',
keywords: 'version code git',
line: 'i-ri-git-branch-line',
fill: 'i-ri-git-branch-fill',
},
{
name: 'github',
keywords: 'git code',
line: 'i-ri-github-line',
fill: 'i-ri-github-fill',
},
{
name: 'cpu',
keywords: 'chip processor',
line: 'i-ri-cpu-line',
fill: 'i-ri-cpu-fill',
},
{
name: 'database-2',
keywords: 'storage data',
line: 'i-ri-database-2-line',
fill: 'i-ri-database-2-fill',
},
{
name: 'server',
keywords: 'host backend',
line: 'i-ri-server-line',
fill: 'i-ri-server-fill',
},
{
name: 'cloud',
keywords: 'storage cloud',
line: 'i-ri-cloud-line',
fill: 'i-ri-cloud-fill',
},
{
name: 'wifi',
keywords: 'network internet',
line: 'i-ri-wifi-line',
fill: 'i-ri-wifi-fill',
},
{
name: 'computer',
keywords: 'screen desktop monitor',
line: 'i-ri-computer-line',
fill: 'i-ri-computer-fill',
},
{
name: 'smartphone',
keywords: 'mobile phone',
line: 'i-ri-smartphone-line',
fill: 'i-ri-smartphone-fill',
},
{
name: 'macbook',
keywords: 'laptop computer',
line: 'i-ri-macbook-line',
fill: 'i-ri-macbook-fill',
},
{
name: 'bug',
keywords: 'issue error bug',
line: 'i-ri-bug-line',
fill: 'i-ri-bug-fill',
},
{
name: 'command',
keywords: 'keyboard shortcut',
line: 'i-ri-command-line',
fill: 'i-ri-command-fill',
},
{
name: 'settings-3',
keywords: 'config gear options settings',
line: 'i-ri-settings-3-line',
fill: 'i-ri-settings-3-fill',
},
{
name: 'equalizer',
keywords: 'controls adjust sliders filter',
line: 'i-ri-equalizer-line',
fill: 'i-ri-equalizer-fill',
},
{
name: 'tools',
keywords: 'fix tools config',
line: 'i-ri-tools-line',
fill: 'i-ri-tools-fill',
},
{
name: 'hammer',
keywords: 'build fix hammer',
line: 'i-ri-hammer-line',
fill: 'i-ri-hammer-fill',
},
{
name: 'key-2',
keywords: 'password access key',
line: 'i-ri-key-2-line',
fill: 'i-ri-key-2-fill',
},
{
name: 'lock',
keywords: 'secure private lock',
line: 'i-ri-lock-line',
fill: 'i-ri-lock-fill',
},
{
name: 'lock-unlock',
keywords: 'open access',
line: 'i-ri-lock-unlock-line',
fill: 'i-ri-lock-unlock-fill',
},
{
name: 'shield',
keywords: 'security protect',
line: 'i-ri-shield-line',
fill: 'i-ri-shield-fill',
},
{
name: 'shield-check',
keywords: 'secure verified',
line: 'i-ri-shield-check-line',
fill: 'i-ri-shield-check-fill',
},
{
name: 'eye',
keywords: 'view visible preview',
line: 'i-ri-eye-line',
fill: 'i-ri-eye-fill',
},
{
name: 'search',
keywords: 'find lookup search',
line: 'i-ri-search-line',
fill: 'i-ri-search-fill',
},
{
name: 'filter-3',
keywords: 'sort refine filter',
line: 'i-ri-filter-3-line',
fill: 'i-ri-filter-3-fill',
},
{
name: 'links',
keywords: 'url chain link',
line: 'i-ri-links-line',
fill: 'i-ri-links-fill',
},
{
name: 'share-forward',
keywords: 'share send distribute',
line: 'i-ri-share-forward-line',
fill: 'i-ri-share-forward-fill',
},
{
name: 'plug',
keywords: 'connect integration',
line: 'i-ri-plug-line',
fill: 'i-ri-plug-fill',
},
{
name: 'shut-down',
keywords: 'power on off',
line: 'i-ri-shut-down-line',
fill: 'i-ri-shut-down-fill',
},
{
name: 'qr-scan-2',
keywords: 'qr scan',
line: 'i-ri-qr-scan-2-line',
fill: 'i-ri-qr-scan-2-fill',
},
{
name: 'fingerprint',
keywords: 'identity auth fingerprint',
line: 'i-ri-fingerprint-line',
fill: 'i-ri-fingerprint-fill',
},
{
name: 'bar-chart',
keywords: 'analytics stats graph bar',
line: 'i-ri-bar-chart-line',
fill: 'i-ri-bar-chart-fill',
},
{
name: 'pie-chart',
keywords: 'analytics stats pie',
line: 'i-ri-pie-chart-line',
fill: 'i-ri-pie-chart-fill',
},
{
name: 'line-chart',
keywords: 'analytics trend graph line',
line: 'i-ri-line-chart-line',
fill: 'i-ri-line-chart-fill',
},
{
name: 'stock',
keywords: 'growth increase trending',
line: 'i-ri-stock-line',
fill: 'i-ri-stock-fill',
},
{
name: 'pulse',
keywords: 'activity health monitor',
line: 'i-ri-pulse-line',
fill: 'i-ri-pulse-fill',
},
{
name: 'dashboard-3',
keywords: 'speed performance dashboard gauge',
line: 'i-ri-dashboard-3-line',
fill: 'i-ri-dashboard-3-fill',
},
{
name: 'check',
keywords: 'done complete ok check',
line: 'i-ri-check-line',
fill: 'i-ri-check-fill',
},
{
name: 'checkbox-circle',
keywords: 'done success check',
line: 'i-ri-checkbox-circle-line',
fill: 'i-ri-checkbox-circle-fill',
},
{
name: 'close-circle',
keywords: 'error fail close',
line: 'i-ri-close-circle-line',
fill: 'i-ri-close-circle-fill',
},
{
name: 'information',
keywords: 'info information',
line: 'i-ri-information-line',
fill: 'i-ri-information-fill',
},
{
name: 'error-warning',
keywords: 'warning alert',
line: 'i-ri-error-warning-line',
fill: 'i-ri-error-warning-fill',
},
{
name: 'alert',
keywords: 'warning caution',
line: 'i-ri-alert-line',
fill: 'i-ri-alert-fill',
},
{
name: 'question',
keywords: 'support faq help',
line: 'i-ri-question-line',
fill: 'i-ri-question-fill',
},
{
name: 'layout-grid',
keywords: 'dashboard apps grid',
line: 'i-ri-layout-grid-line',
fill: 'i-ri-layout-grid-fill',
},
{
name: 'table',
keywords: 'grid data table',
line: 'i-ri-table-line',
fill: 'i-ri-table-fill',
},
{
name: 'function',
keywords: 'kanban board',
line: 'i-ri-function-line',
fill: 'i-ri-function-fill',
},
{
name: 'map-2',
keywords: 'location navigate map',
line: 'i-ri-map-2-line',
fill: 'i-ri-map-2-fill',
},
{
name: 'navigation',
keywords: 'direction gps',
line: 'i-ri-navigation-line',
fill: 'i-ri-navigation-fill',
},
{
name: 'car',
keywords: 'drive vehicle car',
line: 'i-ri-car-line',
fill: 'i-ri-car-fill',
},
{
name: 'truck',
keywords: 'delivery shipping',
line: 'i-ri-truck-line',
fill: 'i-ri-truck-fill',
},
{
name: 'plane',
keywords: 'travel flight plane',
line: 'i-ri-plane-line',
fill: 'i-ri-plane-fill',
},
{
name: 'bike',
keywords: 'cycle bike',
line: 'i-ri-bike-line',
fill: 'i-ri-bike-fill',
},
{
name: 'anchor',
keywords: 'ship marine anchor',
line: 'i-ri-anchor-line',
fill: 'i-ri-anchor-fill',
},
{
name: 'leaf',
keywords: 'eco nature green leaf',
line: 'i-ri-leaf-line',
fill: 'i-ri-leaf-fill',
},
{
name: 'plant',
keywords: 'nature plant grow',
line: 'i-ri-plant-line',
fill: 'i-ri-plant-fill',
},
{
name: 'sun',
keywords: 'day light weather sun',
line: 'i-ri-sun-line',
fill: 'i-ri-sun-fill',
},
{
name: 'moon',
keywords: 'night dark moon',
line: 'i-ri-moon-line',
fill: 'i-ri-moon-fill',
},
{
name: 'rainy',
keywords: 'weather rain',
line: 'i-ri-rainy-line',
fill: 'i-ri-rainy-fill',
},
{
name: 'umbrella',
keywords: 'rain protect umbrella',
line: 'i-ri-umbrella-line',
fill: 'i-ri-umbrella-fill',
},
{
name: 'landscape',
keywords: 'outdoor hike mountain',
line: 'i-ri-landscape-line',
fill: 'i-ri-landscape-fill',
},
{
name: 'drop',
keywords: 'water drop',
line: 'i-ri-drop-line',
fill: 'i-ri-drop-fill',
},
{
name: 'cup',
keywords: 'coffee drink cup',
line: 'i-ri-cup-line',
fill: 'i-ri-cup-fill',
},
{
name: 'restaurant',
keywords: 'food eat restaurant',
line: 'i-ri-restaurant-line',
fill: 'i-ri-restaurant-fill',
},
{
name: 'cake-3',
keywords: 'birthday celebrate cake',
line: 'i-ri-cake-3-line',
fill: 'i-ri-cake-3-fill',
},
{
name: 'goblet',
keywords: 'drink wine bar',
line: 'i-ri-goblet-line',
fill: 'i-ri-goblet-fill',
},
{
name: 'heart-pulse',
keywords: 'health medical',
line: 'i-ri-heart-pulse-line',
fill: 'i-ri-heart-pulse-fill',
},
{
name: 'stethoscope',
keywords: 'health doctor medical',
line: 'i-ri-stethoscope-line',
fill: 'i-ri-stethoscope-fill',
},
{
name: 'capsule',
keywords: 'medicine pill health',
line: 'i-ri-capsule-line',
fill: 'i-ri-capsule-fill',
},
{
name: 'add',
keywords: 'add new create plus',
line: 'i-ri-add-line',
fill: 'i-ri-add-fill',
},
{
name: 'delete-bin',
keywords: 'delete remove trash',
line: 'i-ri-delete-bin-line',
fill: 'i-ri-delete-bin-fill',
},
{
name: 'download',
keywords: 'save export download',
line: 'i-ri-download-line',
fill: 'i-ri-download-fill',
},
{
name: 'upload',
keywords: 'import upload',
line: 'i-ri-upload-line',
fill: 'i-ri-upload-fill',
},
{
name: 'refresh',
keywords: 'reload sync update refresh',
line: 'i-ri-refresh-line',
fill: 'i-ri-refresh-fill',
},
{
name: 'external-link',
keywords: 'open new external',
line: 'i-ri-external-link-line',
fill: 'i-ri-external-link-fill',
},
{
name: 'logout-box',
keywords: 'exit signout logout',
line: 'i-ri-logout-box-line',
fill: 'i-ri-logout-box-fill',
},
{
name: 'fullscreen',
keywords: 'expand maximize fullscreen',
line: 'i-ri-fullscreen-line',
fill: 'i-ri-fullscreen-fill',
},
{
name: 'drag-move',
keywords: 'drag reorder move',
line: 'i-ri-drag-move-line',
fill: 'i-ri-drag-move-fill',
},
{
name: 'paint-brush',
keywords: 'brush paint',
line: 'i-ri-paint-brush-line',
fill: 'i-ri-paint-brush-fill',
},
];
@@ -147,14 +147,8 @@ const { t } = useI18n();
const route = useRoute();
const inboxGetter = useMapGetter('inboxes/getInbox');
const inbox = computed(() => inboxGetter.value(props.inboxId) || {});
const isOnChatwootCloud = useMapGetter('globalConfig/isOnChatwootCloud');
const { replaceInstallationName } = useBranding();
const isCaptainMessage = computed(() => {
const senderType = props.sender?.type ?? props.senderType;
return senderType === SENDER_TYPES.CAPTAIN_ASSISTANT;
});
/**
* Computes the message variant based on props
* @type {import('vue').ComputedRef<'user'|'agent'|'activity'|'private'|'bot'|'template'>}
@@ -396,10 +390,6 @@ const contextMenuEnabledOptions = computed(() => {
!props.private &&
props.inboxSupportsReplyTo.outgoing &&
!isFailedOrProcessing,
report:
isOnChatwootCloud.value &&
isCaptainMessage.value &&
!isMessageDeleted.value,
};
});
@@ -78,12 +78,6 @@ export const MEDIA_TYPES = [
ATTACHMENT_TYPES.IG_REEL,
];
export const NON_FILE_TYPES = [
ATTACHMENT_TYPES.LOCATION,
ATTACHMENT_TYPES.FALLBACK,
ATTACHMENT_TYPES.CONTACT,
];
export const VOICE_CALL_STATUS = {
IN_PROGRESS: 'in-progress',
RINGING: 'ringing',
@@ -21,12 +21,6 @@ import ChannelIcon from 'next/icon/ChannelIcon.vue';
import SidebarAccountSwitcher from './SidebarAccountSwitcher.vue';
import Logo from 'next/icon/Logo.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import {
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
resolveSidebarSort,
sortSidebarItems,
} from 'dashboard/helper/sidebarSort';
const props = defineProps({
isMobileSidebarOpen: {
@@ -56,7 +50,6 @@ const { width: windowWidth } = useWindowSize();
const isMobile = computed(() => windowWidth.value < 768);
const accountId = useMapGetter('getCurrentAccountId');
const currentUserId = useMapGetter('getCurrentUserID');
const isFeatureEnabledonAccount = useMapGetter(
'accounts/isFeatureEnabledonAccount'
);
@@ -86,11 +79,6 @@ const fetchConversationUnreadCounts = ([currentAccountId, isEnabled]) => {
store.dispatch('conversationUnreadCounts/get');
};
const fetchSidebarSortPreferences = ([currentAccountId, userId]) => {
if (!currentAccountId || !userId) return;
store.dispatch('sidebarSortPreferences/initialize');
};
const toggleShortcutModalFn = show => {
if (show) {
emit('openKeyShortcutModal');
@@ -204,9 +192,6 @@ const contactCustomViews = useMapGetter('customViews/getContactCustomViews');
const conversationCustomViews = useMapGetter(
'customViews/getConversationCustomViews'
);
const getSidebarSectionSort = useMapGetter(
'sidebarSortPreferences/getSectionSort'
);
onMounted(() => {
store.dispatch('labels/get');
@@ -222,62 +207,44 @@ watch([accountId, hasConversationUnreadCounts], fetchConversationUnreadCounts, {
immediate: true,
});
watch([accountId, currentUserId], fetchSidebarSortPreferences, {
immediate: true,
});
const getSortOptionsForSection = section =>
getSidebarSortOptions(section, {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const getSortForSection = section =>
resolveSidebarSort(section, getSidebarSectionSort.value(section), {
hasUnreadCounts: hasConversationUnreadCounts.value,
});
const updateSortPreference = (section, sortBy) => {
store.dispatch('sidebarSortPreferences/setSectionSort', {
section,
sortBy,
});
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const buildSortConfig = section => ({
sortOptions: getSortOptionsForSection(section),
activeSort: getSortForSection(section),
onSortChange: sortBy => updateSortPreference(section, sortBy),
});
const sortByUnreadCount = (items, labelKey, unreadCountKey) =>
items.slice().sort((a, b) => {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
const sortedFolders = computed(() =>
sortSidebarItems(conversationCustomViews.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.FOLDERS),
labelKey: view => view.name,
})
);
if (unreadCountDiff !== 0) return unreadCountDiff;
return labelKey(a).localeCompare(labelKey(b));
});
const sortedTeams = computed(() =>
sortSidebarItems(teams.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.TEAMS),
labelKey: team => team.name,
unreadCountKey: team => getTeamUnreadCount.value(team.id),
})
sortByUnreadCount(
teams.value,
team => team.name,
team => getTeamUnreadCount.value(team.id)
)
);
const sortedInboxes = computed(() =>
sortSidebarItems(inboxes.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.CHANNELS),
labelKey: inbox => inbox.name,
unreadCountKey: inbox => getInboxUnreadCount.value(inbox.id),
})
sortByUnreadCount(
inboxes.value,
inbox => inbox.name,
inbox => getInboxUnreadCount.value(inbox.id)
)
);
const sortedLabels = computed(() =>
sortSidebarItems(labels.value, {
sortBy: getSortForSection(SIDEBAR_SORT_SECTIONS.LABELS),
labelKey: label => label.title,
unreadCountKey: label => getLabelUnreadCount.value(label.id),
})
sortByUnreadCount(
labels.value,
label => label.title,
label => getLabelUnreadCount.value(label.id)
)
);
const closeMobileSidebar = () => {
@@ -333,7 +300,6 @@ const menuItems = computed(() => {
{
name: 'All',
label: t('SIDEBAR.ALL_CONVERSATIONS'),
icon: 'i-lucide-inbox',
badgeCount: allUnreadCount.value,
activeOn: ['inbox_conversation'],
to: accountScopedRoute('home'),
@@ -341,14 +307,12 @@ const menuItems = computed(() => {
{
name: 'Mentions',
label: t('SIDEBAR.MENTIONED_CONVERSATIONS'),
icon: 'i-lucide-at-sign',
activeOn: ['conversation_through_mentions'],
to: accountScopedRoute('conversation_mentions'),
},
{
name: 'Participating',
label: t('SIDEBAR.PARTICIPATING_CONVERSATIONS'),
icon: 'i-lucide-user-round-check',
activeOn: ['conversation_through_participating'],
to: accountScopedRoute('conversation_participating'),
},
@@ -356,7 +320,6 @@ const menuItems = computed(() => {
name: 'Unattended',
activeOn: ['conversation_through_unattended'],
label: t('SIDEBAR.UNATTENDED_CONVERSATIONS'),
icon: 'i-lucide-clock-alert',
to: accountScopedRoute('conversation_unattended'),
},
{
@@ -364,10 +327,7 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CUSTOM_VIEWS_FOLDER'),
icon: 'i-lucide-folder',
activeOn: ['conversations_through_folders'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.FOLDERS),
collapsible: true,
showTreeLine: true,
children: sortedFolders.value.map(view => ({
children: conversationCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
to: accountScopedRoute('folder_conversations', { id: view.id }),
@@ -378,9 +338,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.TEAMS'),
icon: 'i-lucide-users',
activeOn: ['conversations_through_team'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.TEAMS),
collapsible: true,
showTreeLine: true,
children: sortedTeams.value.map(team => ({
name: `${team.name}-${team.id}`,
label: team.name,
@@ -393,9 +350,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.CHANNELS'),
icon: 'i-lucide-mailbox',
activeOn: ['conversation_through_inbox'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.CHANNELS),
collapsible: true,
showTreeLine: true,
children: sortedInboxes.value.map(inbox => ({
name: `${inbox.name}-${inbox.id}`,
label: inbox.name,
@@ -416,9 +370,6 @@ const menuItems = computed(() => {
label: t('SIDEBAR.LABELS'),
icon: 'i-lucide-tag',
activeOn: ['conversations_through_label'],
...buildSortConfig(SIDEBAR_SORT_SECTIONS.LABELS),
collapsible: true,
showTreeLine: true,
children: sortedLabels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
@@ -530,8 +481,6 @@ const menuItems = computed(() => {
name: 'Segments',
icon: 'i-lucide-group',
label: t('SIDEBAR.CUSTOM_VIEWS_SEGMENTS'),
collapsible: true,
showTreeLine: true,
children: contactCustomViews.value.map(view => ({
name: `${view.name}-${view.id}`,
label: view.name,
@@ -550,8 +499,6 @@ const menuItems = computed(() => {
name: 'Tagged With',
icon: 'i-lucide-tag',
label: t('SIDEBAR.TAGGED_WITH'),
collapsible: true,
showTreeLine: true,
children: labels.value.map(label => ({
name: `${label.title}-${label.id}`,
label: label.title,
@@ -1,13 +1,11 @@
<script setup>
import { computed, ref, onMounted, nextTick } from 'vue';
import { onClickOutside } from '@vueuse/core';
import { useRouter } from 'vue-router';
import { useSidebarContext } from './provider';
import { useMapGetter } from 'dashboard/composables/store';
import Icon from 'next/icon/Icon.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import SidebarUnreadBadge from './SidebarUnreadBadge.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
const props = defineProps({
label: { type: String, required: true },
@@ -16,7 +14,7 @@ const props = defineProps({
triggerRect: { type: Object, default: () => ({ top: 0, left: 0 }) },
});
const emit = defineEmits(['close', 'mouseenter', 'mouseleave', 'sortToggle']);
const emit = defineEmits(['close', 'mouseenter', 'mouseleave']);
const router = useRouter();
const { isAllowed, sidebarWidth } = useSidebarContext();
@@ -31,14 +29,6 @@ const toggleSubGroup = name => {
expandedSubGroup.value = expandedSubGroup.value === name ? null : name;
};
const handleSortToggle = isSortOpen => {
emit('sortToggle', isSortOpen);
};
onClickOutside(popoverRef, () => emit('close'), {
ignore: ['[data-popover-content]'],
});
const navigateAndClose = to => {
router.push(to);
emit('close');
@@ -133,44 +123,24 @@ onMounted(async () => {
>
<template v-for="child in accessibleChildren" :key="child.name">
<!-- SubGroup with children -->
<li v-if="child.children" class="group/sidebar-section py-0.5">
<div
class="flex items-center rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out"
<li v-if="child.children" class="py-0.5">
<button
class="flex items-center gap-2 px-2 py-1.5 w-full rounded-lg text-n-slate-11 hover:bg-n-alpha-2 transition-colors duration-150 ease-out text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<button
class="flex flex-1 min-w-0 items-center gap-2 ps-2 py-1.5 text-left rtl:text-right"
@click="toggleSubGroup(child.name)"
>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
</button>
<div class="flex flex-shrink-0 items-center gap-1 pe-2">
<SidebarSortMenu
v-if="child.sortOptions?.length"
:active-sort="child.activeSort"
:options="child.sortOptions"
:open-on-hover="false"
@sort="child.onSortChange"
@toggle="handleSortToggle"
/>
<button
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-11 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
@click.stop="toggleSubGroup(child.name)"
>
<span
class="size-4 flex-shrink-0 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
</div>
</div>
<Icon
v-if="child.icon"
:icon="child.icon"
class="size-4 flex-shrink-0"
/>
<span class="flex-1 truncate text-sm">{{ child.label }}</span>
<span
class="size-3 transition-transform i-lucide-chevron-down"
:class="{
'rotate-180': expandedSubGroup === child.name,
}"
/>
</button>
<Transition v-bind="transition">
<ul
v-if="expandedSubGroup === child.name"
@@ -55,9 +55,6 @@ const hasChildren = computed(
const isPopoverOpen = computed(() => activePopover.value === props.name);
const triggerRef = ref(null);
const triggerRect = ref({ top: 0, left: 0, bottom: 0, right: 0 });
// The sort dropdown teleports outside the popover; keep the popover open while
// it is showing so moving the cursor onto it does not close everything.
const isSortMenuOpen = ref(false);
const openPopover = () => {
if (triggerRef.value) {
@@ -85,7 +82,7 @@ const handleMouseEnter = () => {
};
const handleMouseLeave = () => {
if (!hasChildren.value || isSortMenuOpen.value) return;
if (!hasChildren.value) return;
scheduleClose(200);
};
@@ -94,53 +91,28 @@ const handlePopoverMouseEnter = () => {
};
const handlePopoverMouseLeave = () => {
if (isSortMenuOpen.value) return;
scheduleClose(100);
};
const handleSortToggle = isOpen => {
isSortMenuOpen.value = isOpen;
cancelClose();
};
// Close popover when mouse leaves the window
const handleWindowBlur = () => {
closeActivePopover();
};
const hasAccessibleSubChildren = child => {
return child.children?.some(
subChild => subChild.to && isAllowed(subChild.to)
);
};
const visibleChildren = computed(() => {
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return props.children.filter(child => {
if (child.children) return hasAccessibleSubChildren(child);
// If a item has no link, it means it's just a subgroup header
// So we don't need to check for permissions here, because there's nothing to
// access here anyway
return child.to && isAllowed(child.to);
});
});
const accessibleItems = computed(() => {
if (!hasChildren.value) return [];
return visibleChildren.value
.flatMap(child => child.children || child)
.filter(child => child.to && isAllowed(child.to));
});
const hasAccessibleChildren = computed(() => {
return visibleChildren.value.length > 0;
return accessibleItems.value.length > 0;
});
const isLastVisibleChild = child => {
const lastChild = visibleChildren.value[visibleChildren.value.length - 1];
return lastChild === child;
};
const isActive = computed(() => {
if (props.to) {
if (route.path === resolvePath(props.to)) return true;
@@ -282,7 +254,6 @@ watch(
@close="closePopover"
@mouseenter="handlePopoverMouseEnter"
@mouseleave="handlePopoverMouseLeave"
@sort-toggle="handleSortToggle"
/>
</div>
</template>
@@ -303,23 +274,16 @@ watch(
<ul
v-if="hasChildren"
v-show="isExpanded || hasActiveChild"
class="grid m-0 list-none min-w-0"
class="grid m-0 list-none sidebar-group-children min-w-0"
>
<template v-for="child in visibleChildren" :key="child.name">
<template v-for="child in children" :key="child.name">
<SidebarSubGroup
v-if="child.children"
:name="`${name}:${child.name}`"
:label="child.label"
:icon="child.icon"
:children="child.children"
:collapsible="child.collapsible"
:show-tree-line="child.showTreeLine"
:end-tree-line="child.showTreeLine && isLastVisibleChild(child)"
:is-expanded="isExpanded"
:active-child="activeChild"
:sort-options="child.sortOptions"
:active-sort="child.activeSort"
@update-sort="child.onSortChange"
/>
<SidebarGroupLeaf
v-else-if="isAllowed(child.to)"
@@ -335,3 +299,59 @@ watch(
</template>
</Policy>
</template>
<style>
.sidebar-group-children .child-item::before {
content: '';
position: absolute;
width: 0.125rem;
/* 0.5px */
height: 100%;
}
.sidebar-group-children .child-item:first-child::before {
border-radius: 4px 4px 0 0;
}
/* This selects the last child in a group */
/* https://codepen.io/scmmishra/pen/yLmKNLW */
.sidebar-group-children > .child-item:last-child::before,
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::before {
height: 20%;
}
.sidebar-group-children > .child-item:last-child::after,
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::after {
content: '';
position: absolute;
width: 10px;
height: 12px;
bottom: calc(50% - 2px);
border-bottom-width: 0.125rem;
border-left-width: 0.125rem;
border-right-width: 0px;
border-top-width: 0px;
border-radius: 0 0 0 4px;
left: 0;
}
#app[dir='rtl'] .sidebar-group-children > .child-item:last-child::after,
#app[dir='rtl']
.sidebar-group-children
> *:last-child
> *:last-child
> .child-item:last-child::after {
right: 0;
border-bottom-width: 0.125rem;
border-right-width: 0.125rem;
border-left-width: 0px;
border-top-width: 0px;
border-radius: 0 0 4px 0px;
}
</style>
@@ -45,9 +45,7 @@ const count = computed(() =>
class="size-2 -top-px ltr:-right-px rtl:-left-px bg-n-brand absolute rounded-full border border-n-solid-2"
/>
</div>
<div
class="flex items-center gap-1.5 flex-grow justify-between min-w-0 flex-1"
>
<div class="flex items-center gap-1.5 flex-grow min-w-0 flex-1">
<span
class="truncate"
:class="{
@@ -59,7 +57,11 @@ const count = computed(() =>
</span>
<span
v-if="dynamicCount && !expandable"
class="inline-grid h-5 min-w-5 place-items-center rounded-full bg-n-slate-4 px-1 text-xxs font-medium leading-3 text-n-slate-12 dark:bg-n-slate-5 flex-shrink-0"
class="rounded-md capitalize text-xs leading-5 font-medium text-center outline outline-1 px-1 flex-shrink-0"
:class="{
'text-n-slate-12 outline-n-slate-6': isActive,
'text-n-slate-11 outline-n-strong': !isActive,
}"
>
{{ count }}
</span>
@@ -12,8 +12,6 @@ const props = defineProps({
active: { type: Boolean, default: false },
component: { type: Function, default: null },
badgeCount: { type: [Number, String], default: 0 },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
});
const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
@@ -21,30 +19,21 @@ const { resolvePermissions, resolveFeatureFlag } = useSidebarContext();
const shouldRenderComponent = computed(() => {
return typeof props.component === 'function' || isVNode(props.component);
});
// Tree-line connector per leaf: vertical line (::before) + rounded elbow on the
// last child (::after). Logical props (start / border-s / rounded-es)
const TREE_CONNECTOR =
"child-item before:content-[''] before:absolute before:start-0 before:w-0.5 before:h-full before:bg-n-slate-4 first:before:rounded-t last:before:h-1/5 last:after:content-[''] last:after:absolute last:after:start-0 last:after:bottom-[calc(50%_-_2px)] last:after:h-3 last:after:w-2.5 last:after:border-b-2 last:after:border-s-2 last:after:rounded-es last:after:border-n-slate-4";
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<Policy
:permissions="resolvePermissions(to)"
:feature-flag="resolveFeatureFlag(to)"
as="li"
class="py-0.5 ps-2 ms-3 relative text-n-slate-11 min-w-0"
:class="{
[TREE_CONNECTOR]: !hideTreeLine,
'before:!w-px last:after:!border-b last:after:!border-s':
!hideTreeLine && thinTreeLine,
}"
class="py-0.5 ltr:pl-2 rtl:pr-2 rtl:mr-3 ltr:ml-3 relative text-n-slate-11 child-item before:bg-n-slate-4 after:bg-transparent after:border-n-slate-4 before:left-0 rtl:before:right-0 min-w-0"
>
<component
:is="to ? 'router-link' : 'div'"
:to="to"
:title="label"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg ltr:hover:bg-gradient-to-r rtl:hover:bg-gradient-to-l from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
class="flex h-8 items-center gap-2 px-2 py-1 rounded-lg hover:bg-gradient-to-r from-transparent via-n-slate-3/70 to-n-slate-3/70 group min-w-0"
:class="{
'text-n-slate-12 bg-n-alpha-2 active': active,
}"
@@ -1,104 +1,25 @@
<script setup>
import Icon from 'next/icon/Icon.vue';
import SidebarSortMenu from './SidebarSortMenu.vue';
defineProps({
collapsible: {
type: Boolean,
default: false,
},
isExpanded: {
type: Boolean,
default: true,
},
label: {
type: String,
default: '',
},
icon: {
type: [Object, String],
default: '',
},
sortOptions: {
type: Array,
default: () => [],
},
activeSort: {
type: String,
default: '',
},
showTreeLine: {
type: Boolean,
default: false,
},
endTreeLine: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['toggle', 'update-sort']);
const TREE_VERTICAL_LINE =
"before:content-[''] before:absolute before:-top-1 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
const TREE_ELBOW =
"after:content-[''] after:absolute after:w-2.5 after:h-3 after:bottom-1/2 after:start-[-0.5rem] after:border-b-2 after:border-s-2 after:rounded-es after:border-n-slate-4";
</script>
<template>
<div class="relative min-w-0" :class="{ 'ms-5': collapsible }">
<component
:is="collapsible ? 'button' : 'div'"
:type="collapsible ? 'button' : undefined"
:aria-expanded="collapsible ? isExpanded : undefined"
:title="label"
class="relative flex h-8 w-full min-w-0 items-center justify-between gap-2 rounded-lg px-2 py-1.5 text-n-slate-10 select-none"
:class="[
showTreeLine && TREE_VERTICAL_LINE,
showTreeLine &&
(endTreeLine ? `before:h-3 ${TREE_ELBOW}` : 'before:-bottom-1'),
{
'pointer-events-none': !collapsible,
'cursor-pointer hover:bg-n-alpha-2': collapsible,
'pe-14': collapsible && sortOptions.length,
'pe-8': collapsible && !sortOptions.length,
'pe-10': !collapsible && sortOptions.length,
},
]"
@click.stop="collapsible ? emit('toggle') : undefined"
>
<div class="inline-flex min-w-0 items-center gap-2">
<Icon v-if="icon" :icon="icon" class="size-4 flex-shrink-0" />
<span
class="flex-grow truncate text-start text-sm font-medium leading-5"
>
{{ label }}
</span>
</div>
</component>
<div
v-if="collapsible || sortOptions.length"
class="absolute end-2 top-1/2 flex -translate-y-1/2 items-center gap-1"
>
<SidebarSortMenu
v-if="sortOptions.length"
:active-sort="activeSort"
:options="sortOptions"
@sort="sortBy => emit('update-sort', sortBy)"
/>
<button
v-if="collapsible"
type="button"
class="flex size-6 flex-shrink-0 items-center justify-center rounded-md text-n-slate-10 hover:bg-n-alpha-2 focus-visible:bg-n-alpha-2 focus-visible:outline-none"
:aria-expanded="isExpanded"
:aria-label="label"
@click.stop="emit('toggle')"
>
<span
class="size-3 flex-shrink-0"
:class="isExpanded ? 'i-lucide-chevron-up' : 'i-lucide-chevron-down'"
/>
</button>
</div>
<div
class="flex items-center gap-2 px-2 py-1.5 rounded-lg h-8 text-n-slate-10 select-none pointer-events-none"
>
<Icon v-if="icon" :icon="icon" class="size-4" />
<span class="text-sm font-medium leading-5 flex-grow">
{{ label }}
</span>
</div>
</template>
@@ -1,209 +0,0 @@
<script setup>
import { computed, nextTick, onBeforeUnmount, ref } from 'vue';
import { vOnClickOutside } from '@vueuse/components';
import { useI18n } from 'vue-i18n';
import { useDropdownPosition } from 'dashboard/composables/useDropdownPosition';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import TeleportWithDirection from 'dashboard/components-next/TeleportWithDirection.vue';
import { SIDEBAR_SORT_KEYS } from 'dashboard/helper/sidebarSort';
const props = defineProps({
activeSort: {
type: String,
default: '',
},
options: {
type: Array,
default: () => [],
},
openOnHover: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['sort', 'toggle']);
const SORT_OPTION_GROUPS = [
{
key: 'created',
options: [SIDEBAR_SORT_KEYS.CREATED_DESC, SIDEBAR_SORT_KEYS.CREATED_ASC],
},
{
key: 'alphabetical',
options: [
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
},
{
key: 'unread_count',
options: [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
},
];
const { t } = useI18n();
const isOpen = ref(false);
const triggerRef = ref(null);
const popoverRef = ref(null);
let closeTimer;
const { fixedPosition, updatePosition } = useDropdownPosition(
triggerRef,
popoverRef,
isOpen,
{ align: 'start' }
);
const getSortOptionLabel = option => {
if (option === SIDEBAR_SORT_KEYS.CREATED_DESC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_DESC');
}
if (option === SIDEBAR_SORT_KEYS.CREATED_ASC) {
return t('SIDEBAR.SORT_OPTIONS.CREATED_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_ASC');
}
if (option === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return t('SIDEBAR.SORT_OPTIONS.ALPHABETICAL_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_DESC');
}
if (option === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
return t('SIDEBAR.SORT_OPTIONS.UNREAD_COUNT_ASC');
}
return '';
};
const getSortGroupLabel = groupKey => {
if (groupKey === 'created') {
return t('SIDEBAR.SORT_GROUPS.CREATED');
}
if (groupKey === 'alphabetical') {
return t('SIDEBAR.SORT_GROUPS.ALPHABETICAL');
}
if (groupKey === 'unread_count') {
return t('SIDEBAR.SORT_GROUPS.UNREAD_COUNT');
}
return '';
};
const sortMenuSections = computed(() =>
SORT_OPTION_GROUPS.map(group => ({
title: getSortGroupLabel(group.key),
items: group.options
.filter(option => props.options.includes(option))
.map(option => ({
label: getSortOptionLabel(option),
value: option,
action: 'sort',
isActive: option === props.activeSort,
})),
})).filter(section => section.items.length)
);
const clearCloseTimer = () => {
if (closeTimer) {
clearTimeout(closeTimer);
closeTimer = null;
}
};
const openMenu = async () => {
clearCloseTimer();
isOpen.value = true;
emit('toggle', true);
await nextTick();
updatePosition();
};
const closeMenu = () => {
clearCloseTimer();
isOpen.value = false;
emit('toggle', false);
};
const scheduleClose = () => {
clearCloseTimer();
closeTimer = setTimeout(closeMenu, 150);
};
const handleTriggerEnter = () => {
if (props.openOnHover) openMenu();
};
const handleTriggerLeave = () => {
if (props.openOnHover) scheduleClose();
};
const handleClickOutside = event => {
if (triggerRef.value?.contains(event.target)) return;
closeMenu();
};
const handleSortChange = ({ value }) => {
emit('sort', value);
closeMenu();
};
onBeforeUnmount(clearCloseTimer);
</script>
<template>
<div
ref="triggerRef"
class="relative invisible flex-shrink-0 opacity-0 pointer-events-none transition-opacity duration-150 group-hover/sidebar-section:visible group-hover/sidebar-section:opacity-100 group-hover/sidebar-section:pointer-events-auto"
:class="{ '!visible !opacity-100 !pointer-events-auto': isOpen }"
@mouseenter="handleTriggerEnter"
@mouseleave="handleTriggerLeave"
>
<Button
:title="t('SIDEBAR.SORT_TOOLTIP')"
icon="i-lucide-arrow-up-down"
ghost
slate
xs
class="!size-6 !text-n-slate-11 hover:!text-n-slate-12"
:class="{ '!bg-n-alpha-2': isOpen }"
@click.stop="openMenu"
/>
<TeleportWithDirection>
<DropdownMenu
v-if="isOpen"
ref="popoverRef"
v-on-click-outside="handleClickOutside"
data-popover-content
:menu-sections="sortMenuSections"
:class="fixedPosition.class"
:style="fixedPosition.style"
class="w-60 !fixed"
@action="handleSortChange"
@mouseenter="clearCloseTimer"
@mouseleave="handleTriggerLeave"
>
<template #trailing-icon="{ item }">
<span
v-if="item.isActive"
class="i-lucide-check ms-auto size-4 flex-shrink-0 text-n-slate-11"
/>
</template>
</DropdownMenu>
</TeleportWithDirection>
</div>
</template>
@@ -1,56 +1,21 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useEventListener } from '@vueuse/core';
import { useMapGetter } from 'dashboard/composables/store';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import Icon from 'next/icon/Icon.vue';
import { computed, ref } from 'vue';
import SidebarGroupLeaf from './SidebarGroupLeaf.vue';
import SidebarGroupSeparator from './SidebarGroupSeparator.vue';
import { useSidebarContext } from './provider';
import { useEventListener } from '@vueuse/core';
const props = defineProps({
name: { type: String, required: true },
isExpanded: { type: Boolean, default: false },
label: { type: String, required: true },
icon: { type: [Object, String], required: true },
children: { type: Array, default: undefined },
activeChild: { type: Object, default: undefined },
sortOptions: { type: Array, default: () => [] },
activeSort: { type: String, default: '' },
collapsible: { type: Boolean, default: false },
showTreeLine: { type: Boolean, default: false },
endTreeLine: { type: Boolean, default: false },
});
const emit = defineEmits(['update-sort']);
const { isAllowed } = useSidebarContext();
const scrollableContainer = ref(null);
const accountId = useMapGetter('getCurrentAccountId');
const minimizedSectionsKey = LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS;
const getMinimizedSections = () => {
const minimizedSections = LocalStorage.get(minimizedSectionsKey);
return minimizedSections &&
typeof minimizedSections === 'object' &&
!Array.isArray(minimizedSections)
? minimizedSections
: {};
};
const minimizedSections = ref(getMinimizedSections());
const storageKey = computed(() =>
accountId.value ? `${accountId.value}:${props.name}` : props.name
);
const isSubGroupExpanded = computed(
() => !props.collapsible || !minimizedSections.value[storageKey.value]
);
const hasActiveChild = computed(() =>
props.children.some(child => child.name === props.activeChild?.name)
);
const accessibleItems = computed(() =>
props.children.filter(child => {
@@ -63,120 +28,84 @@ const hasAccessibleItems = computed(() => {
});
const isScrollable = computed(() => {
return (
props.isExpanded &&
isSubGroupExpanded.value &&
accessibleItems.value.length > 7
);
return accessibleItems.value.length > 7;
});
const scrollEnd = ref(false);
const CHILDREN_TRUNK =
"before:content-[''] before:absolute before:top-0 before:bottom-0 before:w-0.5 before:bg-n-slate-4 before:start-[-0.5rem]";
const hideLeafTreeLine = computed(
() => props.showTreeLine && !props.isExpanded
);
const toggleSubGroup = () => {
if (!props.collapsible) return;
if (isSubGroupExpanded.value) {
LocalStorage.updateJsonStore(minimizedSectionsKey, storageKey.value, true);
} else {
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
}
minimizedSections.value = getMinimizedSections();
};
const expandSubGroupOnActiveChild = () => {
if (!props.collapsible || !hasActiveChild.value || isSubGroupExpanded.value) {
return;
}
LocalStorage.deleteFromJsonStore(minimizedSectionsKey, storageKey.value);
minimizedSections.value = getMinimizedSections();
};
const shouldShowItem = child => {
return (
isSubGroupExpanded.value &&
(props.isExpanded || props.activeChild?.name === child.name)
);
};
// set scrollEnd to true when the scroll reaches the end
useEventListener(scrollableContainer, 'scroll', () => {
const { scrollHeight, scrollTop, clientHeight } = scrollableContainer.value;
scrollEnd.value = scrollHeight - scrollTop === clientHeight;
});
useEventListener(window, 'storage', event => {
if (event.key === minimizedSectionsKey) {
minimizedSections.value = getMinimizedSections();
}
});
watch([hasActiveChild, storageKey], expandSubGroupOnActiveChild, {
immediate: true,
});
</script>
<template>
<li class="group/sidebar-section relative flex flex-col list-none min-w-0">
<template v-if="hasAccessibleItems">
<SidebarGroupSeparator
v-show="isExpanded"
:label
:icon
:collapsible
:is-expanded="isSubGroupExpanded"
:show-tree-line="showTreeLine"
:end-tree-line="endTreeLine"
:sort-options="sortOptions"
:active-sort="activeSort"
class="my-1"
@toggle="toggleSubGroup"
@update-sort="sortBy => emit('update-sort', sortBy)"
<SidebarGroupSeparator
v-if="hasAccessibleItems"
v-show="isExpanded"
:label
:icon
class="my-1"
/>
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
>
<!-- Each element has h-8, which is 32px, we will show 7 items with one hidden at the end,
which is 14rem. Then we add 16px so that we have some text visible from the next item -->
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-[calc(14rem+16px)] overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<SidebarGroupLeaf
v-for="child in children"
v-show="isExpanded || activeChild?.name === child.name"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
/>
<ul
v-if="children.length"
class="m-0 list-none reset-base relative group min-w-0"
:class="[
{ 'ms-5': collapsible },
showTreeLine && !endTreeLine && CHILDREN_TRUNK,
]"
</div>
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<svg
width="16"
height="24"
viewBox="0 0 16 24"
fill="none"
class="text-n-slate-9 opacity-50 group-hover:opacity-100"
xmlns="http://www.w3.org/2000/svg"
>
<div
ref="scrollableContainer"
class="min-w-0"
:class="{
'max-h-60 overflow-y-scroll no-scrollbar': isScrollable,
}"
>
<SidebarGroupLeaf
v-for="child in children"
v-show="shouldShowItem(child)"
v-bind="child"
:key="child.name"
:active="activeChild?.name === child.name"
:hide-tree-line="hideLeafTreeLine"
thin-tree-line
/>
</div>
<div
v-if="isScrollable && isExpanded"
v-show="!scrollEnd"
class="absolute bg-gradient-to-t from-n-background w-full h-12 to-transparent -bottom-1 pointer-events-none flex items-end justify-end px-2 animate-fade-in-up"
>
<Icon
icon="i-woot-chevrons-down"
class="w-4 h-6 text-n-slate-9 opacity-50 group-hover:opacity-100"
/>
</div>
</ul>
</template>
</li>
<path
d="M4 4L8 8L12 4"
stroke="currentColor"
opacity="0.5"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 10L8 14L12 10"
stroke="currentColor"
opacity="0.75"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
<path
d="M4 16L8 20L12 16"
stroke="currentColor"
stroke-width="1.33333"
stroke-linecap="round"
stroke-linejoin="round"
/>
</svg>
</div>
</ul>
</template>
@@ -1,172 +0,0 @@
import { mount } from '@vue/test-utils';
import { ref } from 'vue';
import SidebarSubGroup from '../SidebarSubGroup.vue';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { provideSidebarContext } from '../provider';
vi.mock('dashboard/composables/store', () => ({
useMapGetter: () => ref(1),
}));
vi.mock('dashboard/composables/usePolicy', () => ({
usePolicy: () => ({
shouldShow: () => true,
}),
}));
vi.mock('vue-router', () => ({
useRouter: () => ({
resolve: () => ({ path: '/' }),
getRoutes: () => [],
}),
}));
const children = [
{
name: 'Sales-1',
label: 'Sales',
to: { name: 'team_conversations' },
},
];
const mountSubGroup = props => {
return mount(
{
components: { SidebarSubGroup },
setup() {
provideSidebarContext({});
},
template: '<SidebarSubGroup v-bind="$attrs" />',
},
{
attrs: {
name: 'Conversation:Teams',
label: 'Teams',
icon: 'i-lucide-users',
children,
isExpanded: true,
collapsible: true,
...props,
},
global: {
stubs: {
SidebarGroupLeaf: {
props: {
label: { type: String, required: true },
hideTreeLine: { type: Boolean, default: false },
thinTreeLine: { type: Boolean, default: false },
},
template:
'<li class="sidebar-leaf" :data-hide-tree-line="String(hideTreeLine)" :data-thin-tree-line="String(thinTreeLine)">{{ label }}</li>',
},
},
},
}
);
};
describe('SidebarSubGroup', () => {
let localStorageStore;
beforeEach(() => {
localStorageStore = {};
Object.defineProperty(window, 'localStorage', {
value: {
getItem: key => localStorageStore[key] || null,
setItem: (key, value) => {
localStorageStore[key] = String(value);
},
removeItem: key => {
delete localStorageStore[key];
},
clear: () => {
localStorageStore = {};
},
},
configurable: true,
});
window.localStorage.clear();
});
it('keeps collapsible sections expanded by default', () => {
const wrapper = mountSubGroup();
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
});
it('renders the tree line on the separator, positioned relative to it', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
const button = wrapper.find('button');
expect(button.classes()).toContain('relative');
expect(button.classes()).toContain('before:bg-n-slate-4');
expect(button.classes()).toContain('before:-bottom-1');
});
it('renders the end curve on the last separator', () => {
const wrapper = mountSubGroup({ showTreeLine: true, endTreeLine: true });
const button = wrapper.find('button');
expect(button.classes()).toContain('before:h-3');
expect(button.classes()).toContain('after:border-b-2');
});
it('draws nested item tree lines via the leaf connectors', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
expect(
wrapper.find('.sidebar-leaf').attributes('data-hide-tree-line')
).toBe('false');
});
it('marks nested item connectors as thin', () => {
const wrapper = mountSubGroup({ showTreeLine: true });
expect(
wrapper.find('.sidebar-leaf').attributes('data-thin-tree-line')
).toBe('true');
});
it('minimizes the section and stores it by account and section name', async () => {
const wrapper = mountSubGroup();
await wrapper.find('button').trigger('click');
const storedSections = JSON.parse(
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
);
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
expect(storedSections).toEqual({ '1:Conversation:Teams': true });
});
it('uses the stored minimized state when mounted again', () => {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
JSON.stringify({ '1:Conversation:Teams': true })
);
const wrapper = mountSubGroup();
expect(wrapper.find('button').attributes('aria-expanded')).toBe('false');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(false);
});
it('expands a stored minimized section when one of its children is active', () => {
window.localStorage.setItem(
LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS,
JSON.stringify({ '1:Conversation:Teams': true })
);
const wrapper = mountSubGroup({ activeChild: children[0] });
const storedSections = JSON.parse(
window.localStorage.getItem(LOCAL_STORAGE_KEYS.SIDEBAR_MINIMIZED_SECTIONS)
);
expect(wrapper.find('button').attributes('aria-expanded')).toBe('true');
expect(wrapper.find('.sidebar-leaf').isVisible()).toBe(true);
expect(storedSections).toEqual({});
});
});
@@ -81,7 +81,6 @@ const showDivider = index => {
<template v-for="(tab, index) in tabs" :key="index">
<button
:ref="el => (tabRefs[index] = el)"
type="button"
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
:class="[
activeTab === index
@@ -308,15 +308,6 @@ function filterByAssigneeTab(conversations) {
return [...conversations];
}
function sortByUnreadStatus(conversations) {
return [...conversations].sort((a, b) => {
const unreadCountDiff = (b.unread_count || 0) - (a.unread_count || 0);
if (unreadCountDiff !== 0) return unreadCountDiff;
return (b.last_activity_at || 0) - (a.last_activity_at || 0);
});
}
const conversationList = computed(() => {
let localConversationList = [];
@@ -346,13 +337,6 @@ const conversationList = computed(() => {
});
}
if (
!hasAppliedFiltersOrActiveFolders.value &&
activeSortBy.value === wootConstants.SORT_BY_TYPE.UNREAD
) {
localConversationList = sortByUnreadStatus(localConversationList);
}
return localConversationList;
});
@@ -1,141 +0,0 @@
<script setup>
import { ref, computed, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { format, parseISO } from 'date-fns';
import Icon from 'dashboard/components-next/icon/Icon.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
const props = defineProps({
sessions: {
type: Array,
required: true,
},
});
const emit = defineEmits(['revoke', 'revokeAll', 'cancel']);
const { t } = useI18n();
const revokingId = ref(null);
const revokingAll = ref(false);
const sortedSessions = computed(() =>
[...props.sessions].sort(
(a, b) => new Date(b.created_at) - new Date(a.created_at)
)
);
const formatDate = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'MMMM d, yyyy');
};
const formatTime = dateStr => {
if (!dateStr) return '';
return format(parseISO(dateStr), 'hh:mma');
};
const isUnknown = val => !val || val === 'Unknown' || val === 'Unknown Browser';
const sessionLabel = session => {
const parts = [];
if (!isUnknown(session.browser_name)) parts.push(session.browser_name);
if (!isUnknown(session.platform_name)) parts.push(session.platform_name);
return parts.join(' on ') || t('SESSION_LIMIT.UNKNOWN_DEVICE');
};
watch(
() => props.sessions,
() => {
revokingId.value = null;
revokingAll.value = false;
}
);
const handleRevoke = session => {
revokingId.value = session.id;
emit('revoke', session.id);
};
const handleRevokeAll = () => {
revokingAll.value = true;
emit('revokeAll');
};
</script>
<template>
<div class="w-full max-w-lg mx-auto">
<div
class="bg-white shadow dark:bg-n-solid-2 p-11 sm:shadow-lg sm:rounded-lg"
>
<!-- Header -->
<div class="flex items-start justify-between gap-4 mb-6">
<div>
<h2 class="text-2xl font-semibold text-n-slate-12">
{{ $t('SESSION_LIMIT.TITLE') }}
</h2>
<p class="text-sm text-n-slate-11 mt-2">
{{ $t('SESSION_LIMIT.DESCRIPTION') }}
</p>
</div>
<NextButton
type="button"
faded
sm
class="flex-shrink-0 whitespace-nowrap"
:label="$t('SESSION_LIMIT.END_ALL')"
:is-loading="revokingAll"
:disabled="revokingId !== null"
@click="handleRevokeAll"
/>
</div>
<!-- Session List -->
<div class="flex flex-col gap-3 max-h-80 overflow-y-auto">
<div
v-for="session in sortedSessions"
:key="session.id"
class="flex items-center justify-between gap-4 rounded-xl border border-n-slate-4 bg-n-background px-4 py-3"
>
<div class="flex items-center gap-3 min-w-0">
<Icon
icon="i-lucide-monitor"
class="size-4 text-n-slate-10 flex-shrink-0"
/>
<div class="flex flex-col gap-0.5 min-w-0">
<span class="text-sm font-medium text-n-slate-12">
{{ sessionLabel(session) }}
</span>
<span class="text-xs text-n-slate-10">
{{
`${$t('SESSION_LIMIT.STARTED')} ${formatDate(session.created_at)}, ${formatTime(session.created_at)}`
}}
</span>
</div>
</div>
<button
type="button"
class="text-sm font-medium text-n-slate-11 hover:text-n-slate-12 flex-shrink-0 disabled:opacity-50"
:disabled="revokingId !== null || revokingAll"
@click="handleRevoke(session)"
>
{{ $t('SESSION_LIMIT.END') }}
</button>
</div>
</div>
<!-- Cancel -->
<div class="text-center pt-4">
<NextButton
sm
slate
link
type="button"
class="w-full hover:!no-underline"
:label="$t('SESSION_LIMIT.CANCEL')"
@click="() => emit('cancel')"
/>
</div>
</div>
</div>
</template>
@@ -78,10 +78,6 @@ const chatSortOptions = computed(() => [
label: t('CHAT_LIST.SORT_ORDER_ITEMS.created_at_asc.TEXT'),
value: 'created_at_asc',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.unread.TEXT'),
value: 'unread',
},
{
label: t('CHAT_LIST.SORT_ORDER_ITEMS.priority_desc.TEXT'),
value: 'priority_desc',
@@ -56,9 +56,8 @@ import { isFileTypeAllowedForChannel } from 'shared/helpers/FileHelper';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { emitter } from 'shared/helpers/mitt';
const EmojiIconPicker = defineAsyncComponent(
() =>
import('dashboard/components-next/emoji-icon-picker/EmojiIconPicker.vue')
const EmojiInput = defineAsyncComponent(
() => import('shared/components/emoji/EmojiInput.vue')
);
export default {
@@ -67,7 +66,7 @@ export default {
AttachmentPreview,
AudioRecorder,
ReplyBoxBanner,
EmojiIconPicker,
EmojiInput,
MessageSignatureMissingAlert,
ReplyBottomPanel,
ReplyEmailHead,
@@ -1285,15 +1284,13 @@ export default {
:message="inReplyTo"
@dismiss="resetReplyToMessage"
/>
<EmojiIconPicker
<EmojiInput
v-if="showEmojiPicker"
v-on-clickaway="hideEmojiPicker"
mode="emoji"
class="emoji-dialog"
:class="{
'emoji-dialog--expanded': isOnExpandedLayout,
}"
@select="addIntoEditor($event.value)"
:on-click="addIntoEditor"
/>
<ReplyEmailHead
v-if="showReplyHead && isDefaultEditorMode"
@@ -31,7 +31,6 @@ const mockUseMapGetter = (overrides = {}) => {
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
getCurrentAccountId: ref(1),
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
'inboxAssignableAgents/getAssignableOwners': ref(() => []),
};
const mergedGetters = { ...defaultGetters, ...overrides };
@@ -76,32 +75,6 @@ describe('useAgentsList', () => {
expect(agentsList.value.length).toBe(formattedAgentsData.slice(1).length);
});
it('includes agent bots when includeAgentBots is true', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableOwners': ref(() => [
{ id: 2, name: 'Agent', assignee_type: 'User' },
{
id: 1,
name: 'Bot',
thumbnail: '',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
},
]),
});
const { agentsList } = useAgentsList(true, true);
expect(agentsList.value).toContainEqual(
expect.objectContaining({
id: 1,
name: 'Bot',
assignee_type: 'AgentBot',
icon: 'i-lucide-bot',
})
);
});
it('handles empty assignable agents', () => {
mockUseMapGetter({
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
@@ -70,8 +70,7 @@ describe('useFacebookPageConnect', () => {
ACCOUNT_ID
);
expect(window.FB.login).toHaveBeenCalledWith(expect.any(Function), {
scope:
'pages_manage_metadata,business_management,pages_messaging,pages_show_list,pages_read_engagement',
scope: expect.stringContaining('pages_show_list'),
});
});
@@ -10,21 +10,14 @@ import {
* A composable function that provides a list of agents for assignment.
*
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
* @param {boolean} [includeAgentBots=false] - Whether to include agent bots as assignment options.
* @returns {Object} An object containing the agents list and assignable agents.
*/
export function useAgentsList(
includeNoneAgent = true,
includeAgentBots = false
) {
export function useAgentsList(includeNoneAgent = true) {
const { t } = useI18n();
const currentUser = useMapGetter('getCurrentUser');
const currentChat = useMapGetter('getSelectedChat');
const currentAccountId = useMapGetter('getCurrentAccountId');
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
const assignableOwners = useMapGetter(
'inboxAssignableAgents/getAssignableOwners'
);
const inboxId = computed(() => currentChat.value?.inbox_id);
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
@@ -49,22 +42,11 @@ export function useAgentsList(
return inboxId.value ? assignable.value(inboxId.value) : [];
});
const owners = computed(() => {
return includeAgentBots && inboxId.value
? assignableOwners.value(inboxId.value)
: [];
});
/**
* @type {import('vue').ComputedRef<Array>}
*/
const agentsList = computed(() => {
const agents = includeAgentBots
? owners.value.filter(owner => owner.assignee_type === 'User')
: assignableAgents.value || [];
const bots = owners.value.filter(
owner => owner.assignee_type === 'AgentBot'
);
const agents = assignableAgents.value || [];
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
agents,
currentUser.value,
@@ -78,7 +60,6 @@ export function useAgentsList(
return [
...(includeNoneAgent && isAgentSelected.value ? [createNoneAgent()] : []),
...filteredAgentsByAvailability,
...bots,
];
});
@@ -1,9 +1,13 @@
import { ref } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
import ChannelApi from 'dashboard/api/channels';
import { buildFacebookLoginScopes } from 'dashboard/helper/facebookScopes';
import { setupFacebookSdk } from 'dashboard/routes/dashboard/settings/inbox/channels/whatsapp/utils';
// Page-management + messaging scopes required to list pages and create a
// Channel::FacebookPage inbox (mirrors the standalone settings flow).
const FB_PAGE_SCOPES =
'pages_manage_metadata,business_management,pages_messaging,instagram_basic,pages_show_list,pages_read_engagement,instagram_manage_messages';
// Headless half of the Facebook Page connect flow: load the Meta SDK, run
// FB.login for page scopes, and fetch the user's pages. The caller owns the
// page-picker UI and the channel creation, because choosing a page is an
@@ -47,7 +51,7 @@ export function useFacebookPageConnect() {
: null
);
},
{ scope: buildFacebookLoginScopes() }
{ scope: FB_PAGE_SCOPES }
);
});
@@ -27,7 +27,6 @@ export default {
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
PRIORITY_DESC_CREATED_AT_ASC: 'priority_desc_created_at_asc',
UNREAD: 'unread',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -7,5 +7,4 @@ export const LOCAL_STORAGE_KEYS = {
DISMISSED_LABEL_SUGGESTIONS: 'labelSuggestionsDismissed',
MESSAGE_REPLY_TO: 'messageReplyTo',
RECENT_SEARCHES: 'recentSearches',
SIDEBAR_MINIMIZED_SECTIONS: 'sidebarMinimizedSections',
};
@@ -154,11 +154,6 @@ export const YEAR_IN_REVIEW_EVENTS = Object.freeze({
SHARE_CLICKED: 'Year in Review: Share clicked',
});
export const SESSION_EVENTS = Object.freeze({
LIMIT_HIT: 'Session limit reached at login',
REVOKED_FROM_PROFILE: 'Revoked an active session',
});
export const ONBOARDING_EVENTS = Object.freeze({
ACCOUNT_DETAILS_VISITED: 'Onboarding: Account details visited',
ACCOUNT_DETAILS_COMPLETED: 'Onboarding: Account details completed',
@@ -1,22 +0,0 @@
export const FACEBOOK_PAGE_SCOPES = [
'pages_manage_metadata',
'business_management',
'pages_messaging',
'pages_show_list',
'pages_read_engagement',
];
export const INSTAGRAM_SCOPES = [
'instagram_basic',
'instagram_manage_messages',
];
export const buildFacebookLoginScopes = ({
includeInstagramScopes = false,
} = {}) => {
const scopes = [...FACEBOOK_PAGE_SCOPES];
if (includeInstagramScopes) {
scopes.push(...INSTAGRAM_SCOPES);
}
return scopes.join(',');
};
@@ -20,6 +20,9 @@ const FEATURE_HELP_URLS = {
billing: 'https://chwt.app/pricing',
saml: 'https://chwt.app/hc/saml',
captain_billing: 'https://chwt.app/hc/captain_billing',
access_token_api:
'https://developers.chatwoot.com/api-reference/introduction',
chatwoot_cli: 'https://developers.chatwoot.com/cli',
};
export function getHelpUrlForFeature(featureName) {
@@ -1,181 +0,0 @@
export const SIDEBAR_SORT_KEYS = Object.freeze({
CREATED_DESC: 'created_at_desc',
CREATED_ASC: 'created_at_asc',
ALPHABETICAL_ASC: 'alphabetical_asc',
ALPHABETICAL_DESC: 'alphabetical_desc',
UNREAD_COUNT_DESC: 'unread_count_desc',
UNREAD_COUNT_ASC: 'unread_count_asc',
});
export const SIDEBAR_SORT_SECTIONS = Object.freeze({
FOLDERS: 'folders',
TEAMS: 'teams',
CHANNELS: 'channels',
LABELS: 'labels',
});
export const SIDEBAR_SORT_OPTIONS_BY_SECTION = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
],
[SIDEBAR_SORT_SECTIONS.TEAMS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.CHANNELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
[SIDEBAR_SORT_SECTIONS.LABELS]: [
SIDEBAR_SORT_KEYS.CREATED_DESC,
SIDEBAR_SORT_KEYS.CREATED_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
],
});
const UNREAD_COUNT_SORT_OPTIONS = [
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
];
export const DEFAULT_SIDEBAR_SORT_PREFERENCES = Object.freeze({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.CREATED_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.CHANNELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
[SIDEBAR_SORT_SECTIONS.LABELS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
export const isValidSidebarSort = (section, sortBy) => {
return SIDEBAR_SORT_OPTIONS_BY_SECTION[section]?.includes(sortBy);
};
const isUnreadCountSort = sortBy => UNREAD_COUNT_SORT_OPTIONS.includes(sortBy);
export const getSidebarSortOptions = (
section,
{ hasUnreadCounts = true } = {}
) => {
const options = SIDEBAR_SORT_OPTIONS_BY_SECTION[section] || [];
if (hasUnreadCounts) return options;
return options.filter(option => !isUnreadCountSort(option));
};
export const resolveSidebarSort = (
section,
sortBy,
{ hasUnreadCounts = true } = {}
) => {
const options = getSidebarSortOptions(section, { hasUnreadCounts });
if (options.includes(sortBy)) return sortBy;
if (!hasUnreadCounts && isUnreadCountSort(sortBy)) {
return SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
}
return options[0] || SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC;
};
export const normalizeSidebarSortPreferences = (preferences = {}) => {
const savedPreferences = preferences || {};
return Object.keys(DEFAULT_SIDEBAR_SORT_PREFERENCES).reduce(
(result, section) => {
const sortBy = savedPreferences[section];
result[section] = isValidSidebarSort(section, sortBy)
? sortBy
: DEFAULT_SIDEBAR_SORT_PREFERENCES[section];
return result;
},
{}
);
};
const normalizeUnreadCount = count => {
const unreadCount = Number(count);
return Number.isFinite(unreadCount) && unreadCount > 0 ? unreadCount : 0;
};
const getCreatedValue = item => {
const createdAt = item.created_at || item.createdAt;
if (typeof createdAt === 'number') return createdAt;
if (createdAt) {
const timestamp = Date.parse(createdAt);
if (Number.isFinite(timestamp)) return timestamp;
}
const id = Number(item.id);
return Number.isFinite(id) ? id : 0;
};
const getLabelValue = (item, labelKey) => {
return String(labelKey(item) || '');
};
const compareAlphabetically = (a, b, labelKey) => {
return getLabelValue(a, labelKey).localeCompare(
getLabelValue(b, labelKey),
undefined,
{
sensitivity: 'base',
}
);
};
export const sortSidebarItems = (
items,
{ sortBy, labelKey, unreadCountKey = () => 0 }
) => {
return (items || []).slice().sort((a, b) => {
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_DESC) {
const createdDiff = getCreatedValue(b) - getCreatedValue(a);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.CREATED_ASC) {
const createdDiff = getCreatedValue(a) - getCreatedValue(b);
if (createdDiff !== 0) return createdDiff;
return compareAlphabetically(a, b, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC) {
return compareAlphabetically(b, a, labelKey);
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(b)) -
normalizeUnreadCount(unreadCountKey(a));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
if (sortBy === SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC) {
const unreadCountDiff =
normalizeUnreadCount(unreadCountKey(a)) -
normalizeUnreadCount(unreadCountKey(b));
if (unreadCountDiff !== 0) return unreadCountDiff;
}
return compareAlphabetically(a, b, labelKey);
});
};
@@ -1,205 +0,0 @@
import {
DEFAULT_SIDEBAR_SORT_PREFERENCES,
SIDEBAR_SORT_KEYS,
SIDEBAR_SORT_SECTIONS,
getSidebarSortOptions,
normalizeSidebarSortPreferences,
resolveSidebarSort,
sortSidebarItems,
} from '../sidebarSort';
const items = [
{
id: 1,
name: 'Billing',
created_at: '2024-01-01T00:00:00.000Z',
},
{
id: 3,
name: 'Accounts',
created_at: '2024-03-01T00:00:00.000Z',
},
{
id: 2,
name: 'Support',
created_at: '2024-02-01T00:00:00.000Z',
},
];
describe('#sortSidebarItems', () => {
it('sorts by created date descending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Support',
'Billing',
]);
});
it('sorts by created date ascending', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.CREATED_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
it('falls back to id when created date is not present', () => {
const sortedItems = sortSidebarItems(
items.map(({ created_at: _createdAt, ...item }) => item),
{
sortBy: SIDEBAR_SORT_KEYS.CREATED_DESC,
labelKey: item => item.name,
}
);
expect(sortedItems.map(item => item.id)).toEqual([3, 2, 1]);
});
it('sorts alphabetically from A to Z', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts alphabetically from Z to A', () => {
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
labelKey: item => item.name,
});
expect(sortedItems.map(item => item.name)).toEqual([
'Support',
'Billing',
'Accounts',
]);
});
it('sorts by unread count descending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Accounts',
'Billing',
'Support',
]);
});
it('sorts by unread count ascending and falls back to alphabetical order', () => {
const unreadCounts = {
1: 3,
2: 3,
3: 7,
};
const sortedItems = sortSidebarItems(items, {
sortBy: SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC,
labelKey: item => item.name,
unreadCountKey: item => unreadCounts[item.id],
});
expect(sortedItems.map(item => item.name)).toEqual([
'Billing',
'Support',
'Accounts',
]);
});
});
describe('#normalizeSidebarSortPreferences', () => {
it('keeps valid preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
expect(preferences).toEqual({
...DEFAULT_SIDEBAR_SORT_PREFERENCES,
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.ALPHABETICAL_DESC,
[SIDEBAR_SORT_SECTIONS.TEAMS]: SIDEBAR_SORT_KEYS.CREATED_ASC,
});
});
it('falls back to defaults for unsupported preferences', () => {
const preferences = normalizeSidebarSortPreferences({
[SIDEBAR_SORT_SECTIONS.FOLDERS]: SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
});
expect(preferences).toEqual(DEFAULT_SIDEBAR_SORT_PREFERENCES);
});
it('falls back to defaults when stored preferences are null', () => {
expect(normalizeSidebarSortPreferences(null)).toEqual(
DEFAULT_SIDEBAR_SORT_PREFERENCES
);
});
});
describe('#getSidebarSortOptions', () => {
it('keeps unread count options when unread counts are enabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: true,
});
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
});
it('removes unread count options when unread counts are disabled', () => {
const options = getSidebarSortOptions(SIDEBAR_SORT_SECTIONS.TEAMS, {
hasUnreadCounts: false,
});
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
expect(options).not.toContain(SIDEBAR_SORT_KEYS.UNREAD_COUNT_ASC);
expect(options).toContain(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
describe('#resolveSidebarSort', () => {
it('keeps unread count sort when unread counts are enabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: true }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC);
});
it('falls back to alphabetical sort when unread counts are disabled', () => {
const sortBy = resolveSidebarSort(
SIDEBAR_SORT_SECTIONS.TEAMS,
SIDEBAR_SORT_KEYS.UNREAD_COUNT_DESC,
{ hasUnreadCounts: false }
);
expect(sortBy).toBe(SIDEBAR_SORT_KEYS.ALPHABETICAL_ASC);
});
});
@@ -79,9 +79,6 @@
},
"priority_desc_created_at_asc": {
"TEXT": "Priority: Highest first, Created: Oldest first"
},
"unread": {
"TEXT": "Unread Count: Highest first"
}
},
"ATTACHMENTS": {
@@ -25,10 +25,6 @@
"ACTIONS": {
"CREATE": "Add company"
},
"SELECTOR": {
"PLACEHOLDER": "Select company",
"CREATE_OPTION": "Add \"{name}\""
},
"CREATE": {
"TITLE": "Add company details",
"ACTIONS": {
@@ -511,7 +511,6 @@
"ATTRIBUTES": "Attributes",
"HISTORY": "History",
"NOTES": "Notes",
"MEDIA": "Media",
"MERGE": "Merge"
},
"HISTORY": {
@@ -304,25 +304,6 @@
"MESSAGE": "You cannot undo this action",
"DELETE": "Delete",
"CANCEL": "Cancel"
},
"REPORT_MESSAGE": {
"LABEL": "Report message",
"TITLE": "Report Captain message",
"DESCRIPTION": "Found an issue with this AI response? Let us know what went wrong and our team will review it to help improve Captain's accuracy.",
"PROBLEM_TYPE": "Problem type",
"PROBLEM_TYPE_PLACEHOLDER": "Select a problem type",
"DESCRIPTION_LABEL": "Description",
"DESCRIPTION_PLACEHOLDER": "Describe the problem in detail",
"SUBMIT": "Report",
"SUCCESS": "Thanks for reporting. Our team will take a look.",
"ERROR": "Could not report this message. Please try again.",
"REASONS": {
"incorrect_information": "Incorrect information",
"inappropriate_response": "Inappropriate response",
"incomplete_response": "Incomplete response",
"outdated_information": "Outdated information",
"other": "Other"
}
}
},
"SIDEBAR": {
@@ -420,8 +401,7 @@
"VIEW_ALL": "View all",
"SHOW_LESS": "Show less",
"MORE_COUNT": "+{count}",
"UNTITLED_FILE": "Untitled file",
"JUMP_TO_MESSAGE": "Jump to message"
"UNTITLED_FILE": "Untitled file"
},
"SHOPIFY": {
"ORDER_ID": "Order #{id}",
@@ -3,33 +3,5 @@
"PLACEHOLDER": "Search emojis",
"NOT_FOUND": "No emoji match your search",
"REMOVE": "Remove"
},
"EMOJI_ICON_PICKER": {
"TABS": {
"ICONS": "Icons",
"EMOJIS": "Emojis"
},
"SEARCH_EMOJI": "Search emoji…",
"SEARCH_ICON": "Search icons…",
"FREQUENTLY_USED": "Frequently used",
"NO_EMOJI": "No emoji match your search",
"NO_ICON": "No icons match your search",
"REMOVE": "Remove",
"STYLE": {
"OUTLINE": "Outline icons",
"FILLED": "Filled icons"
},
"COLORS": {
"SLATE": "Slate",
"RED": "Red",
"ORANGE": "Orange",
"AMBER": "Amber",
"GREEN": "Green",
"TEAL": "Teal",
"BLUE": "Blue",
"INDIGO": "Indigo",
"VIOLET": "Violet",
"PINK": "Pink"
}
}
}
@@ -552,7 +552,6 @@
"LOCALE": {
"ALL": "All locales"
},
"SEARCH_PLACEHOLDER": "Search articles...",
"NEW_ARTICLE": "New article"
},
"EMPTY_STATE": {
@@ -580,10 +579,6 @@
"CATEGORY": {
"TITLE": "There are no articles in this category",
"SUBTITLE": "Articles in this category will appear here"
},
"SEARCH": {
"TITLE": "No matching articles",
"SUBTITLE": "We couldn't find any articles matching your search. Try a different term."
}
},
"BULK_TRANSLATE": {
@@ -629,7 +624,6 @@
"CATEGORY_HEADER": {
"NEW_CATEGORY": "New category",
"EDIT_CATEGORY": "Edit category",
"SEARCH_PLACEHOLDER": "Search categories...",
"CATEGORIES_COUNT": "{n} category | {n} categories",
"BREADCRUMB": {
"CATEGORY_LOCALE": "Categories ({localeCode})",
@@ -640,10 +634,6 @@
"TITLE": "No categories found",
"SUBTITLE": "Categories will appear here. You can add a category by clicking the 'New Category' button."
},
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching categories",
"SUBTITLE": "We couldn't find any categories matching your search. Try a different term."
},
"CATEGORY_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles"
},
@@ -701,11 +691,6 @@
"LOCALES_PAGE": {
"LOCALES_COUNT": "No locales available | {n} locale | {n} locales",
"NEW_LOCALE_BUTTON_TEXT": "New locale",
"SEARCH_PLACEHOLDER": "Search locales...",
"SEARCH_EMPTY_STATE": {
"TITLE": "No matching locales",
"SUBTITLE": "We couldn't find any locales matching your search. Try a different term."
},
"LOCALE_CARD": {
"ARTICLES_COUNT": "{count} article | {count} articles",
"CATEGORIES_COUNT": "{count} category | {count} categories",
@@ -65,8 +65,7 @@
"CONNECT_YOUR_TIKTOK_PROFILE": "Connect your TikTok Profile",
"HELP": "To add your TikTok profile as a channel, you need to authenticate your TikTok Profile by clicking on 'Continue with TikTok' ",
"ERROR_MESSAGE": "There was an error connecting to TikTok, please try again",
"ERROR_AUTH": "There was an error connecting to TikTok, please try again",
"NORTH_AMERICA_WARNING": "TikTok connections for North American accounts are temporarily unavailable while we wait for an update from the TikTok team."
"ERROR_AUTH": "There was an error connecting to TikTok, please try again"
},
"TWITTER": {
"HELP": "To add your Twitter profile as a channel, you need to authenticate your Twitter Profile by clicking on 'Sign in with Twitter' ",

Some files were not shown because too many files have changed in this diff Show More