mirror of
https://github.com/lingble/chatwoot.git
synced 2025-12-03 11:33:55 +00:00
Merge branch 'release/3.13.0'
This commit is contained in:
8
.github/workflows/lock.yml
vendored
8
.github/workflows/lock.yml
vendored
@@ -25,13 +25,5 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
issue-inactive-days: '30'
|
issue-inactive-days: '30'
|
||||||
issue-lock-reason: 'resolved'
|
issue-lock-reason: 'resolved'
|
||||||
issue-comment: >
|
|
||||||
This issue has been automatically locked since there
|
|
||||||
has not been any recent activity after it was closed.
|
|
||||||
Please open a new issue for related bugs.
|
|
||||||
pr-inactive-days: '30'
|
pr-inactive-days: '30'
|
||||||
pr-lock-reason: 'resolved'
|
pr-lock-reason: 'resolved'
|
||||||
pr-comment: >
|
|
||||||
This pull request has been automatically locked since there
|
|
||||||
has not been any recent activity after it was closed.
|
|
||||||
Please open a new issue for related bugs.
|
|
||||||
|
|||||||
12
Gemfile
12
Gemfile
@@ -96,12 +96,12 @@ gem 'koala'
|
|||||||
# slack client
|
# slack client
|
||||||
gem 'slack-ruby-client', '~> 2.2.0'
|
gem 'slack-ruby-client', '~> 2.2.0'
|
||||||
# for dialogflow integrations
|
# for dialogflow integrations
|
||||||
gem 'google-cloud-dialogflow-v2'
|
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||||
gem 'grpc'
|
gem 'grpc'
|
||||||
# Translate integrations
|
# Translate integrations
|
||||||
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||||
# this dependency breaks the slack-ruby-client gem
|
# this dependency breaks the slack-ruby-client gem
|
||||||
gem 'google-cloud-translate-v3'
|
gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||||
|
|
||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
# loaded only when environment variables are set.
|
# loaded only when environment variables are set.
|
||||||
@@ -116,7 +116,7 @@ gem 'sentry-ruby', require: false
|
|||||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||||
|
|
||||||
##-- background job processing --##
|
##-- background job processing --##
|
||||||
gem 'sidekiq', '>= 7.3.0'
|
gem 'sidekiq', '>= 7.3.1'
|
||||||
# We want cron jobs
|
# We want cron jobs
|
||||||
gem 'sidekiq-cron', '>= 1.12.0'
|
gem 'sidekiq-cron', '>= 1.12.0'
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
|||||||
|
|
||||||
# need for google auth
|
# need for google auth
|
||||||
gem 'omniauth', '>= 2.1.2'
|
gem 'omniauth', '>= 2.1.2'
|
||||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||||
|
|
||||||
## Gems for reponse bot
|
## Gems for reponse bot
|
||||||
@@ -200,7 +200,7 @@ group :development do
|
|||||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||||
gem 'stackprof'
|
gem 'stackprof'
|
||||||
# Should install the associated chrome extension to view query logs
|
# Should install the associated chrome extension to view query logs
|
||||||
gem 'meta_request', '>= 0.8.0'
|
gem 'meta_request', '>= 0.8.3'
|
||||||
end
|
end
|
||||||
|
|
||||||
group :test do
|
group :test do
|
||||||
@@ -228,7 +228,7 @@ group :development, :test do
|
|||||||
gem 'mock_redis'
|
gem 'mock_redis'
|
||||||
gem 'pry-rails'
|
gem 'pry-rails'
|
||||||
gem 'rspec_junit_formatter'
|
gem 'rspec_junit_formatter'
|
||||||
gem 'rspec-rails', '>= 6.1.3'
|
gem 'rspec-rails', '>= 6.1.5'
|
||||||
gem 'rubocop', require: false
|
gem 'rubocop', require: false
|
||||||
gem 'rubocop-performance', require: false
|
gem 'rubocop-performance', require: false
|
||||||
gem 'rubocop-rails', require: false
|
gem 'rubocop-rails', require: false
|
||||||
|
|||||||
68
Gemfile.lock
68
Gemfile.lock
@@ -169,7 +169,7 @@ GEM
|
|||||||
climate_control (1.2.0)
|
climate_control (1.2.0)
|
||||||
coderay (1.1.3)
|
coderay (1.1.3)
|
||||||
commonmarker (0.23.10)
|
commonmarker (0.23.10)
|
||||||
concurrent-ruby (1.3.3)
|
concurrent-ruby (1.3.4)
|
||||||
connection_pool (2.4.1)
|
connection_pool (2.4.1)
|
||||||
crack (1.0.0)
|
crack (1.0.0)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
@@ -230,7 +230,7 @@ GEM
|
|||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
email_reply_trimmer (0.1.13)
|
email_reply_trimmer (0.1.13)
|
||||||
erubi (1.13.0)
|
erubi (1.13.0)
|
||||||
et-orbi (1.2.7)
|
et-orbi (1.2.11)
|
||||||
tzinfo
|
tzinfo
|
||||||
execjs (2.8.1)
|
execjs (2.8.1)
|
||||||
facebook-messenger (2.0.1)
|
facebook-messenger (2.0.1)
|
||||||
@@ -257,7 +257,7 @@ GEM
|
|||||||
faraday-net_http_persistent (2.1.0)
|
faraday-net_http_persistent (2.1.0)
|
||||||
faraday (~> 2.5)
|
faraday (~> 2.5)
|
||||||
net-http-persistent (~> 4.0)
|
net-http-persistent (~> 4.0)
|
||||||
faraday-retry (2.1.0)
|
faraday-retry (2.2.1)
|
||||||
faraday (~> 2.0)
|
faraday (~> 2.0)
|
||||||
fcm (1.0.8)
|
fcm (1.0.8)
|
||||||
faraday (>= 1.0.0, < 3.0)
|
faraday (>= 1.0.0, < 3.0)
|
||||||
@@ -268,10 +268,10 @@ GEM
|
|||||||
rake
|
rake
|
||||||
flag_shih_tzu (0.3.23)
|
flag_shih_tzu (0.3.23)
|
||||||
foreman (0.87.2)
|
foreman (0.87.2)
|
||||||
fugit (1.9.0)
|
fugit (1.11.1)
|
||||||
et-orbi (~> 1, >= 1.2.7)
|
et-orbi (~> 1, >= 1.2.11)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
gapic-common (0.18.0)
|
gapic-common (0.20.0)
|
||||||
faraday (>= 1.9, < 3.a)
|
faraday (>= 1.9, < 3.a)
|
||||||
faraday-retry (>= 1.0, < 3.a)
|
faraday-retry (>= 1.0, < 3.a)
|
||||||
google-protobuf (~> 3.14)
|
google-protobuf (~> 3.14)
|
||||||
@@ -301,15 +301,15 @@ GEM
|
|||||||
google-cloud-core (1.6.0)
|
google-cloud-core (1.6.0)
|
||||||
google-cloud-env (~> 1.0)
|
google-cloud-env (~> 1.0)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-dialogflow-v2 (0.23.0)
|
google-cloud-dialogflow-v2 (0.31.0)
|
||||||
gapic-common (>= 0.18.0, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-location (>= 0.4, < 2.a)
|
google-cloud-location (>= 0.4, < 2.a)
|
||||||
google-cloud-env (1.6.0)
|
google-cloud-env (1.6.0)
|
||||||
faraday (>= 0.17.3, < 3.0)
|
faraday (>= 0.17.3, < 3.0)
|
||||||
google-cloud-errors (1.3.1)
|
google-cloud-errors (1.3.1)
|
||||||
google-cloud-location (0.4.0)
|
google-cloud-location (0.6.0)
|
||||||
gapic-common (>= 0.17.1, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-cloud-storage (1.44.0)
|
google-cloud-storage (1.44.0)
|
||||||
addressable (~> 2.8)
|
addressable (~> 2.8)
|
||||||
@@ -319,17 +319,17 @@ GEM
|
|||||||
google-cloud-core (~> 1.6)
|
google-cloud-core (~> 1.6)
|
||||||
googleauth (>= 0.16.2, < 2.a)
|
googleauth (>= 0.16.2, < 2.a)
|
||||||
mini_mime (~> 1.0)
|
mini_mime (~> 1.0)
|
||||||
google-cloud-translate-v3 (0.6.0)
|
google-cloud-translate-v3 (0.10.0)
|
||||||
gapic-common (>= 0.17.1, < 2.a)
|
gapic-common (>= 0.20.0, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-protobuf (3.25.3)
|
google-protobuf (3.25.3)
|
||||||
google-protobuf (3.25.3-arm64-darwin)
|
google-protobuf (3.25.3-arm64-darwin)
|
||||||
google-protobuf (3.25.3-x86_64-darwin)
|
google-protobuf (3.25.3-x86_64-darwin)
|
||||||
google-protobuf (3.25.3-x86_64-linux)
|
google-protobuf (3.25.3-x86_64-linux)
|
||||||
googleapis-common-protos (1.4.0)
|
googleapis-common-protos (1.6.0)
|
||||||
google-protobuf (~> 3.14)
|
google-protobuf (>= 3.18, < 5.a)
|
||||||
googleapis-common-protos-types (~> 1.2)
|
googleapis-common-protos-types (~> 1.7)
|
||||||
grpc (~> 1.27)
|
grpc (~> 1.41)
|
||||||
googleapis-common-protos-types (1.14.0)
|
googleapis-common-protos-types (1.14.0)
|
||||||
google-protobuf (~> 3.18)
|
google-protobuf (~> 3.18)
|
||||||
googleauth (1.5.2)
|
googleauth (1.5.2)
|
||||||
@@ -457,7 +457,7 @@ GEM
|
|||||||
marcel (1.0.4)
|
marcel (1.0.4)
|
||||||
maxminddb (0.1.22)
|
maxminddb (0.1.22)
|
||||||
memoist (0.16.2)
|
memoist (0.16.2)
|
||||||
meta_request (0.8.2)
|
meta_request (0.8.3)
|
||||||
rack-contrib (>= 1.1, < 3)
|
rack-contrib (>= 1.1, < 3)
|
||||||
railties (>= 3.0.0, < 8)
|
railties (>= 3.0.0, < 8)
|
||||||
method_source (1.1.0)
|
method_source (1.1.0)
|
||||||
@@ -467,7 +467,7 @@ GEM
|
|||||||
mini_magick (4.12.0)
|
mini_magick (4.12.0)
|
||||||
mini_mime (1.1.5)
|
mini_mime (1.1.5)
|
||||||
mini_portile2 (2.8.7)
|
mini_portile2 (2.8.7)
|
||||||
minitest (5.24.1)
|
minitest (5.25.1)
|
||||||
mock_redis (0.36.0)
|
mock_redis (0.36.0)
|
||||||
ruby2_keywords
|
ruby2_keywords
|
||||||
msgpack (1.7.0)
|
msgpack (1.7.0)
|
||||||
@@ -480,7 +480,7 @@ GEM
|
|||||||
uri
|
uri
|
||||||
net-http-persistent (4.0.2)
|
net-http-persistent (4.0.2)
|
||||||
connection_pool (~> 2.2)
|
connection_pool (~> 2.2)
|
||||||
net-imap (0.4.12)
|
net-imap (0.4.14)
|
||||||
date
|
date
|
||||||
net-protocol
|
net-protocol
|
||||||
net-pop (0.1.2)
|
net-pop (0.1.2)
|
||||||
@@ -522,7 +522,7 @@ GEM
|
|||||||
hashie (>= 3.4.6)
|
hashie (>= 3.4.6)
|
||||||
rack (>= 2.2.3)
|
rack (>= 2.2.3)
|
||||||
rack-protection
|
rack-protection
|
||||||
omniauth-google-oauth2 (1.1.2)
|
omniauth-google-oauth2 (1.1.3)
|
||||||
jwt (>= 2.0)
|
jwt (>= 2.0)
|
||||||
oauth2 (~> 2.0)
|
oauth2 (~> 2.0)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
@@ -634,20 +634,20 @@ GEM
|
|||||||
retriable (3.1.2)
|
retriable (3.1.2)
|
||||||
reverse_markdown (2.1.1)
|
reverse_markdown (2.1.1)
|
||||||
nokogiri
|
nokogiri
|
||||||
rexml (3.3.4)
|
rexml (3.3.6)
|
||||||
strscan
|
strscan
|
||||||
rspec-core (3.13.0)
|
rspec-core (3.13.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-expectations (3.13.1)
|
rspec-expectations (3.13.2)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-mocks (3.13.1)
|
rspec-mocks (3.13.1)
|
||||||
diff-lcs (>= 1.2.0, < 2.0)
|
diff-lcs (>= 1.2.0, < 2.0)
|
||||||
rspec-support (~> 3.13.0)
|
rspec-support (~> 3.13.0)
|
||||||
rspec-rails (6.1.3)
|
rspec-rails (7.0.1)
|
||||||
actionpack (>= 6.1)
|
actionpack (>= 7.0)
|
||||||
activesupport (>= 6.1)
|
activesupport (>= 7.0)
|
||||||
railties (>= 6.1)
|
railties (>= 7.0)
|
||||||
rspec-core (~> 3.13)
|
rspec-core (~> 3.13)
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
@@ -722,7 +722,7 @@ GEM
|
|||||||
sexp_processor (4.17.0)
|
sexp_processor (4.17.0)
|
||||||
shoulda-matchers (5.3.0)
|
shoulda-matchers (5.3.0)
|
||||||
activesupport (>= 5.2.0)
|
activesupport (>= 5.2.0)
|
||||||
sidekiq (7.3.0)
|
sidekiq (7.3.1)
|
||||||
concurrent-ruby (< 2)
|
concurrent-ruby (< 2)
|
||||||
connection_pool (>= 2.3.0)
|
connection_pool (>= 2.3.0)
|
||||||
logger
|
logger
|
||||||
@@ -796,7 +796,7 @@ GEM
|
|||||||
uniform_notifier (1.16.0)
|
uniform_notifier (1.16.0)
|
||||||
uri (0.13.0)
|
uri (0.13.0)
|
||||||
uri_template (0.7.0)
|
uri_template (0.7.0)
|
||||||
valid_email2 (4.0.6)
|
valid_email2 (5.2.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
mail (~> 2.5)
|
mail (~> 2.5)
|
||||||
version_gem (1.1.4)
|
version_gem (1.1.4)
|
||||||
@@ -881,9 +881,9 @@ DEPENDENCIES
|
|||||||
foreman
|
foreman
|
||||||
geocoder
|
geocoder
|
||||||
gmail_xoauth
|
gmail_xoauth
|
||||||
google-cloud-dialogflow-v2
|
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
google-cloud-translate-v3
|
google-cloud-translate-v3 (>= 0.7.0)
|
||||||
groupdate
|
groupdate
|
||||||
grpc
|
grpc
|
||||||
haikunator
|
haikunator
|
||||||
@@ -903,14 +903,14 @@ DEPENDENCIES
|
|||||||
listen
|
listen
|
||||||
lograge (~> 0.14.0)
|
lograge (~> 0.14.0)
|
||||||
maxminddb
|
maxminddb
|
||||||
meta_request (>= 0.8.0)
|
meta_request (>= 0.8.3)
|
||||||
mock_redis
|
mock_redis
|
||||||
neighbor
|
neighbor
|
||||||
net-smtp (~> 0.3.4)
|
net-smtp (~> 0.3.4)
|
||||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||||
newrelic_rpm
|
newrelic_rpm
|
||||||
omniauth (>= 2.1.2)
|
omniauth (>= 2.1.2)
|
||||||
omniauth-google-oauth2 (>= 1.1.2)
|
omniauth-google-oauth2 (>= 1.1.3)
|
||||||
omniauth-oauth2
|
omniauth-oauth2
|
||||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||||
pg
|
pg
|
||||||
@@ -930,7 +930,7 @@ DEPENDENCIES
|
|||||||
responders (>= 3.1.1)
|
responders (>= 3.1.1)
|
||||||
rest-client
|
rest-client
|
||||||
reverse_markdown
|
reverse_markdown
|
||||||
rspec-rails (>= 6.1.3)
|
rspec-rails (>= 6.1.5)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
rubocop
|
rubocop
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
@@ -943,7 +943,7 @@ DEPENDENCIES
|
|||||||
sentry-ruby
|
sentry-ruby
|
||||||
sentry-sidekiq (>= 5.19.0)
|
sentry-sidekiq (>= 5.19.0)
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (>= 7.3.0)
|
sidekiq (>= 7.3.1)
|
||||||
sidekiq-cron (>= 1.12.0)
|
sidekiq-cron (>= 1.12.0)
|
||||||
simplecov (= 0.17.1)
|
simplecov (= 0.17.1)
|
||||||
slack-ruby-client (~> 2.2.0)
|
slack-ruby-client (~> 2.2.0)
|
||||||
|
|||||||
@@ -32,11 +32,13 @@ class AccountBuilder
|
|||||||
end
|
end
|
||||||
|
|
||||||
def validate_email
|
def validate_email
|
||||||
|
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
|
||||||
|
|
||||||
address = ValidEmail2::Address.new(@email)
|
address = ValidEmail2::Address.new(@email)
|
||||||
if address.valid? # && !address.disposable?
|
if address.valid? && !address.disposable?
|
||||||
true
|
true
|
||||||
else
|
else
|
||||||
raise InvalidEmail.new(valid: address.valid?)
|
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -79,4 +81,21 @@ class AccountBuilder
|
|||||||
@user.confirm if @confirmed
|
@user.confirm if @confirmed
|
||||||
@user.save!
|
@user.save!
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def domain_blocked?
|
||||||
|
domain = @email.split('@').last
|
||||||
|
|
||||||
|
blocked_domains.each do |blocked_domain|
|
||||||
|
return true if domain.match?(blocked_domain)
|
||||||
|
end
|
||||||
|
|
||||||
|
false
|
||||||
|
end
|
||||||
|
|
||||||
|
def blocked_domains
|
||||||
|
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
||||||
|
return [] if domains.blank?
|
||||||
|
|
||||||
|
domains.split("\n").map(&:strip)
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
|
|
||||||
def update
|
def update
|
||||||
@agent.update!(agent_params.slice(:name).compact)
|
@agent.update!(agent_params.slice(:name).compact)
|
||||||
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
|
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -67,8 +67,16 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
@agent = agents.find(params[:id])
|
@agent = agents.find(params[:id])
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def account_user_attributes
|
||||||
|
[:role, :availability, :auto_offline]
|
||||||
|
end
|
||||||
|
|
||||||
|
def allowed_agent_params
|
||||||
|
[:name, :email, :name, :role, :availability, :auto_offline]
|
||||||
|
end
|
||||||
|
|
||||||
def agent_params
|
def agent_params
|
||||||
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
params.require(:agent).permit(allowed_agent_params)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_agent_params
|
def new_agent_params
|
||||||
@@ -101,3 +109,5 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
|||||||
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
|||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
ActiveRecord::Base.transaction do
|
ActiveRecord::Base.transaction do
|
||||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
|
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||||
message.attachments.destroy_all
|
message.attachments.destroy_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,7 +11,17 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_event
|
def process_event
|
||||||
render json: { message: @hook.process_event(params[:event]) }
|
response = @hook.process_event(params[:event])
|
||||||
|
|
||||||
|
# for cases like an invalid event, or when conversation does not have enough messages
|
||||||
|
# for a label suggestion, the response is nil
|
||||||
|
if response.nil?
|
||||||
|
render json: { message: nil }
|
||||||
|
elsif response[:error]
|
||||||
|
render json: { error: response[:error] }, status: :unprocessable_entity
|
||||||
|
else
|
||||||
|
render json: { message: response[:message] }
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
|
|||||||
@@ -1,13 +1,68 @@
|
|||||||
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||||
def create
|
def create
|
||||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
result = if params[:attachment].present?
|
||||||
key: nil,
|
create_from_file
|
||||||
io: params[:attachment].tempfile,
|
elsif params[:external_url].present?
|
||||||
filename: params[:attachment].original_filename,
|
create_from_url
|
||||||
content_type: params[:attachment].content_type
|
else
|
||||||
)
|
render_error('No file or URL provided', :unprocessable_entity)
|
||||||
file_blob.save!
|
end
|
||||||
|
|
||||||
|
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def create_from_file
|
||||||
|
attachment = params[:attachment]
|
||||||
|
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_from_url
|
||||||
|
uri = parse_uri(params[:external_url])
|
||||||
|
return if performed?
|
||||||
|
|
||||||
|
fetch_and_process_file_from_uri(uri)
|
||||||
|
end
|
||||||
|
|
||||||
|
def parse_uri(url)
|
||||||
|
uri = URI.parse(url)
|
||||||
|
validate_uri(uri)
|
||||||
|
uri
|
||||||
|
rescue URI::InvalidURIError, SocketError
|
||||||
|
render_error('Invalid URL provided', :unprocessable_entity)
|
||||||
|
nil
|
||||||
|
end
|
||||||
|
|
||||||
|
def validate_uri(uri)
|
||||||
|
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||||
|
end
|
||||||
|
|
||||||
|
def fetch_and_process_file_from_uri(uri)
|
||||||
|
uri.open do |file|
|
||||||
|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||||
|
end
|
||||||
|
rescue OpenURI::HTTPError => e
|
||||||
|
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||||
|
rescue SocketError
|
||||||
|
render_error('Invalid URL provided', :unprocessable_entity)
|
||||||
|
rescue StandardError
|
||||||
|
render_error('An unexpected error occurred', :internal_server_error)
|
||||||
|
end
|
||||||
|
|
||||||
|
def create_and_save_blob(io, filename, content_type)
|
||||||
|
ActiveStorage::Blob.create_and_upload!(
|
||||||
|
io: io,
|
||||||
|
filename: filename,
|
||||||
|
content_type: content_type
|
||||||
|
)
|
||||||
|
end
|
||||||
|
|
||||||
|
def render_success(file_blob)
|
||||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def render_error(message, status)
|
||||||
|
render json: { error: message }, status: status
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
|||||||
process_update_contact
|
process_update_contact
|
||||||
@conversation = create_conversation
|
@conversation = create_conversation
|
||||||
conversation.messages.create!(message_params)
|
conversation.messages.create!(message_params)
|
||||||
|
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
|
||||||
|
conversation.reload
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -66,7 +66,9 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
return if Current.account_user.administrator?
|
||||||
|
|
||||||
|
raise Pundit::NotAuthorizedError
|
||||||
end
|
end
|
||||||
|
|
||||||
def common_params
|
def common_params
|
||||||
@@ -135,3 +137,5 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
|||||||
layout 'portal'
|
layout 'portal'
|
||||||
|
|
||||||
def index
|
def index
|
||||||
@articles = @portal.articles
|
@articles = @portal.articles.published
|
||||||
search_articles
|
search_articles
|
||||||
order_by_sort_param
|
order_by_sort_param
|
||||||
@articles.page(list_params[:page]) if list_params[:page].present?
|
@articles.page(list_params[:page]) if list_params[:page].present?
|
||||||
|
|||||||
9
app/javascript/dashboard/api/customRole.js
Normal file
9
app/javascript/dashboard/api/customRole.js
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import ApiClient from './ApiClient';
|
||||||
|
|
||||||
|
class CustomRole extends ApiClient {
|
||||||
|
constructor() {
|
||||||
|
super('custom_roles', { accountScoped: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export default new CustomRole();
|
||||||
@@ -4,6 +4,7 @@ import { mapGetters } from 'vuex';
|
|||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useFilter } from 'shared/composables/useFilter';
|
||||||
import VirtualList from 'vue-virtual-scroll-list';
|
import VirtualList from 'vue-virtual-scroll-list';
|
||||||
|
|
||||||
import ChatListHeader from './ChatListHeader.vue';
|
import ChatListHeader from './ChatListHeader.vue';
|
||||||
@@ -16,7 +17,6 @@ import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
|||||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
|
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
|
||||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||||
import filterMixin from 'shared/mixins/filterMixin';
|
|
||||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||||
import countries from 'shared/constants/countries';
|
import countries from 'shared/constants/countries';
|
||||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||||
@@ -27,6 +27,11 @@ import {
|
|||||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||||
import IntersectionObserver from './IntersectionObserver.vue';
|
import IntersectionObserver from './IntersectionObserver.vue';
|
||||||
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
filterItemsByPermission,
|
||||||
|
} from 'dashboard/helper/permissionsHelper.js';
|
||||||
|
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -41,7 +46,6 @@ export default {
|
|||||||
IntersectionObserver,
|
IntersectionObserver,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
},
|
},
|
||||||
mixins: [filterMixin],
|
|
||||||
provide() {
|
provide() {
|
||||||
return {
|
return {
|
||||||
// Actions to be performed on virtual list item and context menu.
|
// Actions to be performed on virtual list item and context menu.
|
||||||
@@ -91,6 +95,15 @@ export default {
|
|||||||
|
|
||||||
const conversationListRef = ref(null);
|
const conversationListRef = ref(null);
|
||||||
|
|
||||||
|
const {
|
||||||
|
setFilterAttributes,
|
||||||
|
initializeStatusAndAssigneeFilterToModal,
|
||||||
|
initializeInboxTeamAndLabelFilterToModal,
|
||||||
|
} = useFilter({
|
||||||
|
filteri18nKey: 'FILTER',
|
||||||
|
attributeModel: 'conversation_attribute',
|
||||||
|
});
|
||||||
|
|
||||||
const getKeyboardListenerParams = () => {
|
const getKeyboardListenerParams = () => {
|
||||||
const allConversations = conversationListRef.value.querySelectorAll(
|
const allConversations = conversationListRef.value.querySelectorAll(
|
||||||
'div.conversations-list div.conversation'
|
'div.conversations-list div.conversation'
|
||||||
@@ -109,43 +122,52 @@ export default {
|
|||||||
lastConversationIndex,
|
lastConversationIndex,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const handlePreviousConversation = () => {
|
const handleConversationNavigation = direction => {
|
||||||
const { allConversations, activeConversationIndex } =
|
|
||||||
getKeyboardListenerParams();
|
|
||||||
if (activeConversationIndex === -1) {
|
|
||||||
allConversations[0].click();
|
|
||||||
}
|
|
||||||
if (activeConversationIndex >= 1) {
|
|
||||||
allConversations[activeConversationIndex - 1].click();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const handleNextConversation = () => {
|
|
||||||
const {
|
const {
|
||||||
allConversations,
|
allConversations,
|
||||||
activeConversationIndex,
|
activeConversationIndex,
|
||||||
lastConversationIndex,
|
lastConversationIndex,
|
||||||
} = getKeyboardListenerParams();
|
} = getKeyboardListenerParams();
|
||||||
if (activeConversationIndex === -1) {
|
|
||||||
allConversations[lastConversationIndex].click();
|
// Determine the new index based on the direction
|
||||||
} else if (activeConversationIndex < lastConversationIndex) {
|
const newIndex =
|
||||||
allConversations[activeConversationIndex + 1].click();
|
direction === 'previous'
|
||||||
|
? activeConversationIndex - 1
|
||||||
|
: activeConversationIndex + 1;
|
||||||
|
|
||||||
|
// Check if the new index is within the valid range
|
||||||
|
if (
|
||||||
|
allConversations.length > 0 &&
|
||||||
|
newIndex >= 0 &&
|
||||||
|
newIndex <= lastConversationIndex
|
||||||
|
) {
|
||||||
|
// Click the conversation at the new index
|
||||||
|
allConversations[newIndex].click();
|
||||||
|
} else if (allConversations.length > 0) {
|
||||||
|
// If the new index is out of range, click the first or last conversation based on the direction
|
||||||
|
const fallbackIndex =
|
||||||
|
direction === 'previous' ? 0 : lastConversationIndex;
|
||||||
|
allConversations[fallbackIndex].click();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'Alt+KeyJ': {
|
'Alt+KeyJ': {
|
||||||
action: () => handlePreviousConversation(),
|
action: () => handleConversationNavigation('previous'),
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
'Alt+KeyK': {
|
'Alt+KeyK': {
|
||||||
action: () => handleNextConversation(),
|
action: () => handleConversationNavigation('next'),
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, conversationListRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
conversationListRef,
|
conversationListRef,
|
||||||
|
setFilterAttributes,
|
||||||
|
initializeStatusAndAssigneeFilterToModal,
|
||||||
|
initializeInboxTeamAndLabelFilterToModal,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -187,6 +209,7 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
|
currentAccountId: 'getCurrentAccountId',
|
||||||
chatLists: 'getAllConversations',
|
chatLists: 'getAllConversations',
|
||||||
mineChatsList: 'getMineChats',
|
mineChatsList: 'getMineChats',
|
||||||
allChatList: 'getAllStatusChats',
|
allChatList: 'getAllStatusChats',
|
||||||
@@ -226,20 +249,19 @@ export default {
|
|||||||
name,
|
name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
userPermissions() {
|
||||||
|
return getUserPermissions(this.currentUser, this.currentAccountId);
|
||||||
|
},
|
||||||
assigneeTabItems() {
|
assigneeTabItems() {
|
||||||
const ASSIGNEE_TYPE_TAB_KEYS = {
|
return filterItemsByPermission(
|
||||||
me: 'mineCount',
|
ASSIGNEE_TYPE_TAB_PERMISSIONS,
|
||||||
unassigned: 'unAssignedCount',
|
this.userPermissions,
|
||||||
all: 'allCount',
|
item => item.permissions
|
||||||
};
|
).map(({ key, count: countKey }) => ({
|
||||||
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
|
key,
|
||||||
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
|
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
||||||
return {
|
count: this.conversationStats[countKey] || 0,
|
||||||
key,
|
}));
|
||||||
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
|
||||||
count,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
},
|
},
|
||||||
showAssigneeInConversationCard() {
|
showAssigneeInConversationCard() {
|
||||||
return (
|
return (
|
||||||
@@ -860,6 +882,25 @@ export default {
|
|||||||
onContextMenuToggle(state) {
|
onContextMenuToggle(state) {
|
||||||
this.isContextMenuOpen = state;
|
this.isContextMenuOpen = state;
|
||||||
},
|
},
|
||||||
|
initializeExistingFilterToModal() {
|
||||||
|
const statusFilter = this.initializeStatusAndAssigneeFilterToModal(
|
||||||
|
this.activeStatus,
|
||||||
|
this.currentUserDetails,
|
||||||
|
this.activeAssigneeTab
|
||||||
|
);
|
||||||
|
if (statusFilter) {
|
||||||
|
this.appliedFilter.push(statusFilter);
|
||||||
|
}
|
||||||
|
|
||||||
|
const otherFilters = this.initializeInboxTeamAndLabelFilterToModal(
|
||||||
|
this.conversationInbox,
|
||||||
|
this.inbox,
|
||||||
|
this.teamId,
|
||||||
|
this.activeTeam,
|
||||||
|
this.label
|
||||||
|
);
|
||||||
|
this.appliedFilter.push(...otherFilters);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -331,10 +331,12 @@ export default {
|
|||||||
::v-deep {
|
::v-deep {
|
||||||
.selector-wrap {
|
.selector-wrap {
|
||||||
@apply m-0 top-1;
|
@apply m-0 top-1;
|
||||||
|
|
||||||
.selector-name {
|
.selector-name {
|
||||||
@apply ml-0;
|
@apply ml-0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.name {
|
.name {
|
||||||
@apply ml-0;
|
@apply ml-0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,13 +13,12 @@ import wootConstants from 'dashboard/constants/globals';
|
|||||||
import {
|
import {
|
||||||
CMD_REOPEN_CONVERSATION,
|
CMD_REOPEN_CONVERSATION,
|
||||||
CMD_RESOLVE_CONVERSATION,
|
CMD_RESOLVE_CONVERSATION,
|
||||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
const resolveActionsRef = ref(null);
|
|
||||||
const arrowDownButtonRef = ref(null);
|
const arrowDownButtonRef = ref(null);
|
||||||
const isLoading = ref(false);
|
const isLoading = ref(false);
|
||||||
|
|
||||||
@@ -131,17 +130,14 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents, resolveActionsRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
|
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
|
||||||
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="relative flex items-center justify-end resolve-actions">
|
||||||
ref="resolveActionsRef"
|
|
||||||
class="relative flex items-center justify-end resolve-actions"
|
|
||||||
>
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<woot-button
|
<woot-button
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { getSidebarItems } from './config/default-sidebar';
|
import { getSidebarItems } from './config/default-sidebar';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
@@ -8,7 +7,10 @@ import { useRoute, useRouter } from 'dashboard/composables/route';
|
|||||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||||
import { routesWithPermissions } from '../../routes';
|
import { routesWithPermissions } from '../../routes';
|
||||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
hasPermissions,
|
||||||
|
} from '../../helper/permissionsHelper';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -26,7 +28,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const sidebarRef = ref(null);
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -64,11 +65,10 @@ export default {
|
|||||||
action: () => navigateToRoute('agent_list'),
|
action: () => navigateToRoute('agent_list'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, sidebarRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toggleKeyShortcutModal,
|
toggleKeyShortcutModal,
|
||||||
sidebarRef,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -113,7 +113,10 @@ export default {
|
|||||||
return getSidebarItems(this.accountId);
|
return getSidebarItems(this.accountId);
|
||||||
},
|
},
|
||||||
primaryMenuItems() {
|
primaryMenuItems() {
|
||||||
const userPermissions = this.currentUser.permissions;
|
const userPermissions = getUserPermissions(
|
||||||
|
this.currentUser,
|
||||||
|
this.accountId
|
||||||
|
);
|
||||||
const menuItems = this.sideMenuConfig.primaryMenu;
|
const menuItems = this.sideMenuConfig.primaryMenu;
|
||||||
return menuItems.filter(menuItem => {
|
return menuItems.filter(menuItem => {
|
||||||
const isAvailableForTheUser = hasPermissions(
|
const isAvailableForTheUser = hasPermissions(
|
||||||
@@ -195,7 +198,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside ref="sidebarRef" class="flex h-full">
|
<aside class="flex h-full">
|
||||||
<PrimarySidebar
|
<PrimarySidebar
|
||||||
:logo-source="globalConfig.logoThumbnail"
|
:logo-source="globalConfig.logoThumbnail"
|
||||||
:installation-name="globalConfig.installationName"
|
:installation-name="globalConfig.installationName"
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const settings = accountId => ({
|
|||||||
'settings_teams_list',
|
'settings_teams_list',
|
||||||
'settings_teams_new',
|
'settings_teams_new',
|
||||||
'sla_list',
|
'sla_list',
|
||||||
|
'custom_roles_list',
|
||||||
],
|
],
|
||||||
menuItems: [
|
menuItems: [
|
||||||
{
|
{
|
||||||
@@ -178,6 +179,18 @@ const settings = accountId => ({
|
|||||||
isEnterpriseOnly: true,
|
isEnterpriseOnly: true,
|
||||||
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: 'scan-person',
|
||||||
|
label: 'CUSTOM_ROLES',
|
||||||
|
hasSubMenu: false,
|
||||||
|
meta: {
|
||||||
|
permissions: ['administrator'],
|
||||||
|
},
|
||||||
|
toState: frontendURL(`accounts/${accountId}/settings/custom-roles/list`),
|
||||||
|
toStateName: 'custom_roles_list',
|
||||||
|
isEnterpriseOnly: true,
|
||||||
|
beta: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: 'document-list-clock',
|
icon: 'document-list-clock',
|
||||||
label: 'SLA',
|
label: 'SLA',
|
||||||
|
|||||||
@@ -52,9 +52,13 @@ export default {
|
|||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs font-medium text-slate-500 dark:text-slate-500 hover:underline-offset-4"
|
class="text-xs font-medium lowercase text-slate-500 dark:text-slate-500 hover:underline-offset-4"
|
||||||
>
|
>
|
||||||
{{ account.role }}
|
{{
|
||||||
|
account.custom_role_id
|
||||||
|
? account.custom_role.name
|
||||||
|
: account.role
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</label>
|
</label>
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -4,7 +4,10 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
|
|||||||
import AccountContext from './AccountContext.vue';
|
import AccountContext from './AccountContext.vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||||
import { hasPermissions } from '../../../helper/permissionsHelper';
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
hasPermissions,
|
||||||
|
} from '../../../helper/permissionsHelper';
|
||||||
import { routesWithPermissions } from '../../../routes';
|
import { routesWithPermissions } from '../../../routes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -59,7 +62,10 @@ export default {
|
|||||||
accessibleMenuItems() {
|
accessibleMenuItems() {
|
||||||
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
||||||
menuItem => {
|
menuItem => {
|
||||||
const { permissions: userPermissions = [] } = this.currentUser;
|
const userPermissions = getUserPermissions(
|
||||||
|
this.currentUser,
|
||||||
|
this.accountId
|
||||||
|
);
|
||||||
return hasPermissions(
|
return hasPermissions(
|
||||||
routesWithPermissions[menuItem.toStateName],
|
routesWithPermissions[menuItem.toStateName],
|
||||||
userPermissions
|
userPermissions
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { hasPermissions } from '../helper/permissionsHelper';
|
import {
|
||||||
|
getUserPermissions,
|
||||||
|
hasPermissions,
|
||||||
|
} from '../helper/permissionsHelper';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
permissions: {
|
permissions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -10,12 +14,17 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const user = getters.getCurrentUser.value;
|
const user = computed(() => getters.getCurrentUser.value);
|
||||||
const hasPermission = computed(() =>
|
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||||
hasPermissions(props.permissions, user.permissions)
|
const userPermissions = computed(() => {
|
||||||
);
|
return getUserPermissions(user.value, accountId.value);
|
||||||
|
});
|
||||||
|
const hasPermission = computed(() => {
|
||||||
|
return hasPermissions(props.permissions, userPermissions.value);
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<div v-if="hasPermission">
|
<div v-if="hasPermission">
|
||||||
<slot />
|
<slot />
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import { mapGetters } from 'vuex';
|
|||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import AICTAModal from './AICTAModal.vue';
|
import AICTAModal from './AICTAModal.vue';
|
||||||
import AIAssistanceModal from './AIAssistanceModal.vue';
|
import AIAssistanceModal from './AIAssistanceModal.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||||
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
|
||||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -16,17 +16,17 @@ export default {
|
|||||||
AICTAModal,
|
AICTAModal,
|
||||||
AIAssistanceCTAButton,
|
AIAssistanceCTAButton,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin],
|
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const { uiSettings, updateUISettings } = useUISettings();
|
const { uiSettings, updateUISettings } = useUISettings();
|
||||||
|
|
||||||
|
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
|
||||||
|
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
|
||||||
const aiAssistanceButtonRef = ref(null);
|
|
||||||
const initialMessage = ref('');
|
const initialMessage = ref('');
|
||||||
|
|
||||||
const initializeMessage = draftMessage => {
|
const initializeMessage = draftMsg => {
|
||||||
initialMessage.value = draftMessage;
|
initialMessage.value = draftMsg;
|
||||||
};
|
};
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'$mod+KeyZ': {
|
'$mod+KeyZ': {
|
||||||
@@ -39,15 +39,17 @@ export default {
|
|||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
updateUISettings,
|
updateUISettings,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
aiAssistanceButtonRef,
|
|
||||||
initialMessage,
|
initialMessage,
|
||||||
initializeMessage,
|
initializeMessage,
|
||||||
|
recordAnalytics,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
draftMessage,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
@@ -118,7 +120,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="aiAssistanceButtonRef">
|
<div>
|
||||||
<div v-if="isAIIntegrationEnabled" class="relative">
|
<div v-if="isAIIntegrationEnabled" class="relative">
|
||||||
<AIAssistanceCTAButton
|
<AIAssistanceCTAButton
|
||||||
v-if="shouldShowAIAssistCTAButton"
|
v-if="shouldShowAIAssistCTAButton"
|
||||||
|
|||||||
@@ -1,20 +1,23 @@
|
|||||||
<script>
|
<script>
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import AILoader from './AILoader.vue';
|
import AILoader from './AILoader.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
AILoader,
|
AILoader,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin, messageFormatterMixin],
|
|
||||||
props: {
|
props: {
|
||||||
aiOption: {
|
aiOption: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
const { draftMessage, processEvent, recordAnalytics } = useAI();
|
||||||
|
return { draftMessage, processEvent, recordAnalytics, formatMessage };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
generatedContent: '',
|
generatedContent: '',
|
||||||
|
|||||||
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
|
|||||||
import { required } from '@vuelidate/validators';
|
import { required } from '@vuelidate/validators';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
mixins: [aiMixin],
|
|
||||||
setup() {
|
setup() {
|
||||||
const { updateUISettings } = useUISettings();
|
const { updateUISettings } = useUISettings();
|
||||||
|
const { recordAnalytics } = useAI();
|
||||||
const v$ = useVuelidate();
|
const v$ = useVuelidate();
|
||||||
|
|
||||||
return { updateUISettings, v$ };
|
return { updateUISettings, v$, recordAnalytics };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
@@ -16,15 +16,16 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['chatTabChange']);
|
const emit = defineEmits(['chatTabChange']);
|
||||||
|
|
||||||
const chatTypeTabsRef = ref(null);
|
|
||||||
|
|
||||||
const activeTabIndex = computed(() => {
|
const activeTabIndex = computed(() => {
|
||||||
return props.items.findIndex(item => item.key === props.activeTab);
|
return props.items.findIndex(item => item.key === props.activeTab);
|
||||||
});
|
});
|
||||||
|
|
||||||
const onTabChange = selectedTabIndex => {
|
const onTabChange = selectedTabIndex => {
|
||||||
if (props.items[selectedTabIndex].key !== props.activeTab) {
|
if (selectedTabIndex >= 0 && selectedTabIndex < props.items.length) {
|
||||||
emit('chatTabChange', props.items[selectedTabIndex].key);
|
const selectedItem = props.items[selectedTabIndex];
|
||||||
|
if (selectedItem.key !== props.activeTab) {
|
||||||
|
emit('chatTabChange', selectedItem.key);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -34,29 +35,29 @@ const keyboardEvents = {
|
|||||||
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||||
onTabChange(0);
|
onTabChange(0);
|
||||||
} else {
|
} else {
|
||||||
onTabChange(activeTabIndex.value + 1);
|
const nextIndex = (activeTabIndex.value + 1) % props.items.length;
|
||||||
|
onTabChange(nextIndex);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
|
|
||||||
|
useKeyboardEvents(keyboardEvents);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div ref="chatTypeTabsRef">
|
<woot-tabs
|
||||||
<woot-tabs
|
:index="activeTabIndex"
|
||||||
:index="activeTabIndex"
|
class="w-full px-4 py-0 tab--chat-type"
|
||||||
class="tab--chat-type py-0 px-4 w-full"
|
@change="onTabChange"
|
||||||
@change="onTabChange"
|
>
|
||||||
>
|
<woot-tabs-item
|
||||||
<woot-tabs-item
|
v-for="item in items"
|
||||||
v-for="item in items"
|
:key="item.key"
|
||||||
:key="item.key"
|
:name="item.name"
|
||||||
:name="item.name"
|
:count="item.count"
|
||||||
:count="item.count"
|
/>
|
||||||
/>
|
</woot-tabs>
|
||||||
</woot-tabs>
|
|
||||||
</div>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
|
|||||||
@@ -18,8 +18,6 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['add', 'remove']);
|
const emit = defineEmits(['add', 'remove']);
|
||||||
|
|
||||||
const labelSelectorWrapRef = ref(null);
|
|
||||||
|
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
|
||||||
const showSearchDropdownLabel = ref(false);
|
const showSearchDropdownLabel = ref(false);
|
||||||
@@ -57,15 +55,11 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
||||||
ref="labelSelectorWrapRef"
|
|
||||||
v-on-clickaway="closeDropdownLabel"
|
|
||||||
class="relative leading-6"
|
|
||||||
>
|
|
||||||
<AddLabel @add="toggleLabels" />
|
<AddLabel @add="toggleLabels" />
|
||||||
<woot-label
|
<woot-label
|
||||||
v-for="label in savedLabels"
|
v-for="label in savedLabels"
|
||||||
|
|||||||
@@ -1,38 +1,34 @@
|
|||||||
<script>
|
<script setup>
|
||||||
export default {
|
import { computed, ref } from 'vue';
|
||||||
props: {
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
text: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
limit: {
|
|
||||||
type: Number,
|
|
||||||
default: 120,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
showMore: false,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
textToBeDisplayed() {
|
|
||||||
if (this.showMore || this.text.length <= this.limit) {
|
|
||||||
return this.text;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.text.slice(0, this.limit) + '...';
|
const props = defineProps({
|
||||||
},
|
text: {
|
||||||
buttonLabel() {
|
type: String,
|
||||||
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
|
default: '',
|
||||||
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
methods: {
|
limit: {
|
||||||
toggleShowMore() {
|
type: Number,
|
||||||
this.showMore = !this.showMore;
|
default: 120,
|
||||||
},
|
|
||||||
},
|
},
|
||||||
|
});
|
||||||
|
const { t } = useI18n();
|
||||||
|
const showMore = ref(false);
|
||||||
|
|
||||||
|
const textToBeDisplayed = computed(() => {
|
||||||
|
if (showMore.value || props.text.length <= props.limit) {
|
||||||
|
return props.text;
|
||||||
|
}
|
||||||
|
|
||||||
|
return props.text.slice(0, props.limit) + '...';
|
||||||
|
});
|
||||||
|
const buttonLabel = computed(() => {
|
||||||
|
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||||
|
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleShowMore = () => {
|
||||||
|
showMore.value = !showMore.value;
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -41,16 +37,10 @@ export default {
|
|||||||
{{ textToBeDisplayed }}
|
{{ textToBeDisplayed }}
|
||||||
<button
|
<button
|
||||||
v-if="text.length > limit"
|
v-if="text.length > limit"
|
||||||
class="show-more--button"
|
class="text-woot-500 !p-0 !border-0 align-top"
|
||||||
@click="toggleShowMore"
|
@click="toggleShowMore"
|
||||||
>
|
>
|
||||||
{{ buttonLabel }}
|
{{ buttonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.show-more--button {
|
|
||||||
color: var(--w-500);
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
|||||||
import TagAgents from '../conversation/TagAgents.vue';
|
import TagAgents from '../conversation/TagAgents.vue';
|
||||||
import CannedResponse from '../conversation/CannedResponse.vue';
|
import CannedResponse from '../conversation/CannedResponse.vue';
|
||||||
import VariableList from '../conversation/VariableList.vue';
|
import VariableList from '../conversation/VariableList.vue';
|
||||||
|
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
removeSignature,
|
removeSignature,
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
scrollCursorIntoView,
|
scrollCursorIntoView,
|
||||||
findNodeToInsertImage,
|
findNodeToInsertImage,
|
||||||
setURLWithQueryAndSize,
|
setURLWithQueryAndSize,
|
||||||
|
getContentNode,
|
||||||
} from 'dashboard/helper/editorHelper';
|
} from 'dashboard/helper/editorHelper';
|
||||||
|
|
||||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||||
@@ -35,10 +38,8 @@ import {
|
|||||||
} from 'shared/helpers/KeyboardHelpers';
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import {
|
|
||||||
replaceVariablesInMessage,
|
import { createTypingIndicator } from '@chatwoot/utils';
|
||||||
createTypingIndicator,
|
|
||||||
} from '@chatwoot/utils';
|
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||||
@@ -71,7 +72,12 @@ const createState = (
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'WootMessageEditor',
|
name: 'WootMessageEditor',
|
||||||
components: { TagAgents, CannedResponse, VariableList },
|
components: {
|
||||||
|
TagAgents,
|
||||||
|
CannedResponse,
|
||||||
|
VariableList,
|
||||||
|
KeyboardEmojiSelector,
|
||||||
|
},
|
||||||
mixins: [keyboardEventListenerMixins],
|
mixins: [keyboardEventListenerMixins],
|
||||||
props: {
|
props: {
|
||||||
value: { type: String, default: '' },
|
value: { type: String, default: '' },
|
||||||
@@ -91,6 +97,7 @@ export default {
|
|||||||
allowSignature: { type: Boolean, default: false },
|
allowSignature: { type: Boolean, default: false },
|
||||||
channelType: { type: String, default: '' },
|
channelType: { type: String, default: '' },
|
||||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||||
|
focusOnMount: { type: Boolean, default: true },
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const {
|
const {
|
||||||
@@ -119,9 +126,11 @@ export default {
|
|||||||
showUserMentions: false,
|
showUserMentions: false,
|
||||||
showCannedMenu: false,
|
showCannedMenu: false,
|
||||||
showVariables: false,
|
showVariables: false,
|
||||||
|
showEmojiMenu: false,
|
||||||
mentionSearchKey: '',
|
mentionSearchKey: '',
|
||||||
cannedSearchTerm: '',
|
cannedSearchTerm: '',
|
||||||
variableSearchTerm: '',
|
variableSearchTerm: '',
|
||||||
|
emojiSearchTerm: '',
|
||||||
editorView: null,
|
editorView: null,
|
||||||
range: null,
|
range: null,
|
||||||
state: undefined,
|
state: undefined,
|
||||||
@@ -169,7 +178,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.mentionSearchKey = args.text.replace('@', '');
|
this.mentionSearchKey = args.text;
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -198,7 +207,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.cannedSearchTerm = args.text.replace('/', '');
|
this.cannedSearchTerm = args.text;
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
@@ -226,7 +235,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.variableSearchTerm = args.text.replace('{{', '');
|
this.variableSearchTerm = args.text;
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
@@ -238,6 +247,31 @@ export default {
|
|||||||
return event.keyCode === 13 && this.showVariables;
|
return event.keyCode === 13 && this.showVariables;
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
suggestionsPlugin({
|
||||||
|
matcher: triggerCharacters(':', 2), // Trigger after ':' and at least 2 characters
|
||||||
|
suggestionClass: '',
|
||||||
|
onEnter: args => {
|
||||||
|
this.showEmojiMenu = true;
|
||||||
|
this.emojiSearchTerm = args.text || '';
|
||||||
|
this.range = args.range;
|
||||||
|
this.editorView = args.view;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onChange: args => {
|
||||||
|
this.editorView = args.view;
|
||||||
|
this.range = args.range;
|
||||||
|
this.emojiSearchTerm = args.text;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onExit: () => {
|
||||||
|
this.emojiSearchTerm = '';
|
||||||
|
this.showEmojiMenu = false;
|
||||||
|
return false;
|
||||||
|
},
|
||||||
|
onKeyDown: ({ event }) => {
|
||||||
|
return event.keyCode === 13 && this.showEmojiMenu;
|
||||||
|
},
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
},
|
},
|
||||||
sendWithSignature() {
|
sendWithSignature() {
|
||||||
@@ -267,6 +301,8 @@ export default {
|
|||||||
},
|
},
|
||||||
editorId() {
|
editorId() {
|
||||||
this.showCannedMenu = false;
|
this.showCannedMenu = false;
|
||||||
|
this.showEmojiMenu = false;
|
||||||
|
this.showVariables = false;
|
||||||
this.cannedSearchTerm = '';
|
this.cannedSearchTerm = '';
|
||||||
this.reloadState(this.value);
|
this.reloadState(this.value);
|
||||||
},
|
},
|
||||||
@@ -311,7 +347,9 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.createEditorView();
|
this.createEditorView();
|
||||||
this.editorView.updateState(this.state);
|
this.editorView.updateState(this.state);
|
||||||
this.focusEditorInputField();
|
if (this.focusOnMount) {
|
||||||
|
this.focusEditorInputField();
|
||||||
|
}
|
||||||
|
|
||||||
// BUS Event to insert text or markdown into the editor at the
|
// BUS Event to insert text or markdown into the editor at the
|
||||||
// current cursor position.
|
// current cursor position.
|
||||||
@@ -348,7 +386,7 @@ export default {
|
|||||||
// these drafts can also have a signature, so we need to check if the body is empty
|
// these drafts can also have a signature, so we need to check if the body is empty
|
||||||
// and handle things accordingly
|
// and handle things accordingly
|
||||||
this.handleEmptyBodyWithSignature();
|
this.handleEmptyBodyWithSignature();
|
||||||
} else {
|
} else if (this.focusOnMount) {
|
||||||
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
|
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
|
||||||
// the position is set to start, because the signature is added at the end of the body
|
// the position is set to start, because the signature is added at the end of the body
|
||||||
this.focusEditorInputField('end');
|
this.focusEditorInputField('end');
|
||||||
@@ -517,57 +555,36 @@ export default {
|
|||||||
this.editorView.dispatch(tr.setSelection(selection));
|
this.editorView.dispatch(tr.setSelection(selection));
|
||||||
this.editorView.focus();
|
this.editorView.focus();
|
||||||
},
|
},
|
||||||
insertMentionNode(mentionItem) {
|
/**
|
||||||
|
* Inserts special content (mention, canned response, variable, emoji) into the editor.
|
||||||
|
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
|
||||||
|
* @param {Object|string} content - The content to insert, depending on the type.
|
||||||
|
*/
|
||||||
|
insertSpecialContent(type, content) {
|
||||||
if (!this.editorView) {
|
if (!this.editorView) {
|
||||||
return null;
|
return;
|
||||||
}
|
|
||||||
const node = this.editorView.state.schema.nodes.mention.create({
|
|
||||||
userId: mentionItem.id,
|
|
||||||
userFullName: mentionItem.name,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
|
|
||||||
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
|
|
||||||
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
insertCannedResponse(cannedItem) {
|
|
||||||
const updatedMessage = replaceVariablesInMessage({
|
|
||||||
message: cannedItem,
|
|
||||||
variables: this.variables,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.editorView) {
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let node = new MessageMarkdownTransformer(messageSchema).parse(
|
let { node, from, to } = getContentNode(
|
||||||
updatedMessage
|
this.editorView,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
this.range,
|
||||||
|
this.variables
|
||||||
);
|
);
|
||||||
|
|
||||||
const from =
|
if (!node) return;
|
||||||
node.textContent === updatedMessage
|
|
||||||
? this.range.from
|
|
||||||
: this.range.from - 1;
|
|
||||||
|
|
||||||
this.insertNodeIntoEditor(node, from, this.range.to);
|
|
||||||
|
|
||||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
|
||||||
return false;
|
|
||||||
},
|
|
||||||
insertVariable(variable) {
|
|
||||||
if (!this.editorView) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const content = `{{${variable}}}`;
|
|
||||||
let node = this.editorView.state.schema.text(content);
|
|
||||||
const { from, to } = this.range;
|
|
||||||
|
|
||||||
this.insertNodeIntoEditor(node, from, to);
|
this.insertNodeIntoEditor(node, from, to);
|
||||||
this.showVariables = false;
|
|
||||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
|
const event_map = {
|
||||||
return false;
|
mention: CONVERSATION_EVENTS.USED_MENTIONS,
|
||||||
|
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
||||||
|
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
||||||
|
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.$track(event_map[type]);
|
||||||
},
|
},
|
||||||
openFileBrowser() {
|
openFileBrowser() {
|
||||||
this.$refs.imageUpload.click();
|
this.$refs.imageUpload.click();
|
||||||
@@ -687,17 +704,22 @@ export default {
|
|||||||
<TagAgents
|
<TagAgents
|
||||||
v-if="showUserMentions && isPrivate"
|
v-if="showUserMentions && isPrivate"
|
||||||
:search-key="mentionSearchKey"
|
:search-key="mentionSearchKey"
|
||||||
@click="insertMentionNode"
|
@click="content => insertSpecialContent('mention', content)"
|
||||||
/>
|
/>
|
||||||
<CannedResponse
|
<CannedResponse
|
||||||
v-if="shouldShowCannedResponses"
|
v-if="shouldShowCannedResponses"
|
||||||
:search-key="cannedSearchTerm"
|
:search-key="cannedSearchTerm"
|
||||||
@click="insertCannedResponse"
|
@click="content => insertSpecialContent('cannedResponse', content)"
|
||||||
/>
|
/>
|
||||||
<VariableList
|
<VariableList
|
||||||
v-if="shouldShowVariables"
|
v-if="shouldShowVariables"
|
||||||
:search-key="variableSearchTerm"
|
:search-key="variableSearchTerm"
|
||||||
@click="insertVariable"
|
@click="content => insertSpecialContent('variable', content)"
|
||||||
|
/>
|
||||||
|
<KeyboardEmojiSelector
|
||||||
|
v-if="showEmojiMenu"
|
||||||
|
:search-key="emojiSearchTerm"
|
||||||
|
@click="emoji => insertSpecialContent('emoji', emoji)"
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref="imageUpload"
|
ref="imageUpload"
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
EditorState,
|
EditorState,
|
||||||
Selection,
|
Selection,
|
||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
|
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
@@ -55,7 +56,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
editorView: null,
|
editorView: null,
|
||||||
state: undefined,
|
state: undefined,
|
||||||
plugins: [],
|
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -76,6 +77,7 @@ export default {
|
|||||||
this.reloadState();
|
this.reloadState();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.state = createState(
|
this.state = createState(
|
||||||
this.value,
|
this.value,
|
||||||
@@ -95,6 +97,24 @@ export default {
|
|||||||
openFileBrowser() {
|
openFileBrowser() {
|
||||||
this.$refs.imageUploadInput.click();
|
this.$refs.imageUploadInput.click();
|
||||||
},
|
},
|
||||||
|
async handleImageUpload(url) {
|
||||||
|
try {
|
||||||
|
const fileUrl = await this.$store.dispatch(
|
||||||
|
'articles/uploadExternalImage',
|
||||||
|
{
|
||||||
|
portalSlug: this.$route.params.portalSlug,
|
||||||
|
url,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return fileUrl;
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(
|
||||||
|
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
|
||||||
|
);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
},
|
||||||
onFileChange() {
|
onFileChange() {
|
||||||
const file = this.$refs.imageUploadInput.files[0];
|
const file = this.$refs.imageUploadInput.files[0];
|
||||||
|
|
||||||
@@ -120,7 +140,6 @@ export default {
|
|||||||
if (fileUrl) {
|
if (fileUrl) {
|
||||||
this.onImageUploadStart(fileUrl);
|
this.onImageUploadStart(fileUrl);
|
||||||
}
|
}
|
||||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS'));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
||||||
}
|
}
|
||||||
@@ -173,6 +192,18 @@ export default {
|
|||||||
blur: () => {
|
blur: () => {
|
||||||
this.onBlur();
|
this.onBlur();
|
||||||
},
|
},
|
||||||
|
paste: (view, event) => {
|
||||||
|
const data = event.clipboardData.files;
|
||||||
|
if (data.length > 0) {
|
||||||
|
data.forEach(file => {
|
||||||
|
// Check if the file is an image
|
||||||
|
if (file.type.includes('image')) {
|
||||||
|
this.uploadImageToStorage(file);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref, watchEffect, computed } from 'vue';
|
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import FileUpload from 'vue-upload-component';
|
import FileUpload from 'vue-upload-component';
|
||||||
@@ -117,15 +116,13 @@ export default {
|
|||||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||||
useUISettings();
|
useUISettings();
|
||||||
|
|
||||||
const uploadRef = ref(null);
|
|
||||||
// TODO: This is really hacky, we need to replace the file picker component with
|
|
||||||
// a custom one, where the logic and the component markup is isolated.
|
|
||||||
// Once we have the custom component, we can remove the hacky logic below.
|
|
||||||
const uploadRefElem = computed(() => uploadRef.value?.$el);
|
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'Alt+KeyA': {
|
'Alt+KeyA': {
|
||||||
action: () => {
|
action: () => {
|
||||||
|
// TODO: This is really hacky, we need to replace the file picker component with
|
||||||
|
// a custom one, where the logic and the component markup is isolated.
|
||||||
|
// Once we have the custom component, we can remove the hacky logic below.
|
||||||
|
|
||||||
const uploadTriggerButton = document.querySelector(
|
const uploadTriggerButton = document.querySelector(
|
||||||
'#conversationAttachment'
|
'#conversationAttachment'
|
||||||
);
|
);
|
||||||
@@ -135,14 +132,11 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
watchEffect(() => {
|
useKeyboardEvents(keyboardEvents);
|
||||||
useKeyboardEvents(keyboardEvents, uploadRefElem);
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
uploadRef,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -226,11 +220,7 @@ export default {
|
|||||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||||
},
|
},
|
||||||
enableInsertArticleInReply() {
|
enableInsertArticleInReply() {
|
||||||
const isFeatEnabled = this.isFeatureEnabledonAccount(
|
return this.portalSlug;
|
||||||
this.accountId,
|
|
||||||
FEATURE_FLAGS.INSERT_ARTICLE_IN_REPLY
|
|
||||||
);
|
|
||||||
return isFeatEnabled && this.portalSlug;
|
|
||||||
},
|
},
|
||||||
isFetchingAppIntegrations() {
|
isFetchingAppIntegrations() {
|
||||||
return this.uiFlags.isFetching;
|
return this.uiFlags.isFetching;
|
||||||
@@ -267,7 +257,6 @@ export default {
|
|||||||
@click="toggleEmojiPicker"
|
@click="toggleEmojiPicker"
|
||||||
/>
|
/>
|
||||||
<FileUpload
|
<FileUpload
|
||||||
ref="uploadRef"
|
|
||||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||||
input-id="conversationAttachment"
|
input-id="conversationAttachment"
|
||||||
:size="4096 * 4096"
|
:size="4096 * 4096"
|
||||||
@@ -410,6 +399,7 @@ export default {
|
|||||||
label {
|
label {
|
||||||
@apply cursor-pointer;
|
@apply cursor-pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover button {
|
&:hover button {
|
||||||
@apply dark:bg-slate-800 bg-slate-100;
|
@apply dark:bg-slate-800 bg-slate-100;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||||
export default {
|
export default {
|
||||||
@@ -23,8 +22,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const replyTopRef = ref(null);
|
|
||||||
|
|
||||||
const setReplyMode = mode => {
|
const setReplyMode = mode => {
|
||||||
emit('setReplyMode', mode);
|
emit('setReplyMode', mode);
|
||||||
};
|
};
|
||||||
@@ -44,12 +41,11 @@ export default {
|
|||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, replyTopRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleReplyClick,
|
handleReplyClick,
|
||||||
handleNoteClick,
|
handleNoteClick,
|
||||||
replyTopRef,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -76,10 +72,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
||||||
ref="replyTopRef"
|
|
||||||
class="flex justify-between bg-black-50 dark:bg-slate-800"
|
|
||||||
>
|
|
||||||
<div class="button-group">
|
<div class="button-group">
|
||||||
<woot-button
|
<woot-button
|
||||||
variant="clear"
|
variant="clear"
|
||||||
|
|||||||
@@ -0,0 +1,70 @@
|
|||||||
|
<script setup>
|
||||||
|
import { shallowRef, computed, onMounted } from 'vue';
|
||||||
|
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
|
||||||
|
import MentionBox from '../mentions/MentionBox.vue';
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
searchKey: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const emit = defineEmits(['click']);
|
||||||
|
|
||||||
|
const allEmojis = shallowRef([]);
|
||||||
|
|
||||||
|
const items = computed(() => {
|
||||||
|
if (!props.searchKey) return [];
|
||||||
|
const searchTerm = props.searchKey.toLowerCase();
|
||||||
|
return allEmojis.value.filter(emoji =>
|
||||||
|
emoji.searchString.includes(searchTerm)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
function loadEmojis() {
|
||||||
|
allEmojis.value = emojiGroups.flatMap(({ emojis }) =>
|
||||||
|
emojis.map(({ name, slug, ...rest }) => ({
|
||||||
|
...rest,
|
||||||
|
name,
|
||||||
|
slug,
|
||||||
|
searchString: `${name.replace(/\s+/g, '')} ${slug}`.toLowerCase(), // Remove all whitespace and convert to lowercase
|
||||||
|
}))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMentionClick(item = {}) {
|
||||||
|
emit('click', item.emoji);
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadEmojis();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
|
<template>
|
||||||
|
<MentionBox
|
||||||
|
v-if="items.length"
|
||||||
|
type="emoji"
|
||||||
|
:items="items"
|
||||||
|
@mentionSelect="handleMentionClick"
|
||||||
|
>
|
||||||
|
<template #default="{ item, selected }">
|
||||||
|
<span
|
||||||
|
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
|
||||||
|
>
|
||||||
|
{{ item.emoji }}
|
||||||
|
<p
|
||||||
|
class="relative mb-0 truncate bottom-px"
|
||||||
|
:class="{
|
||||||
|
'text-woot-500 dark:text-woot-500': selected,
|
||||||
|
'font-normal': !selected,
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
:{{ item.name }}
|
||||||
|
</p>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</MentionBox>
|
||||||
|
</template>
|
||||||
@@ -41,14 +41,11 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<MentionBox
|
<MentionBox
|
||||||
v-if="items.length"
|
v-if="items.length"
|
||||||
:items="items"
|
:items="items"
|
||||||
@mentionSelect="handleMentionClick"
|
@mentionSelect="handleMentionClick"
|
||||||
>
|
/>
|
||||||
<template slot-scope="{ item }">
|
|
||||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
|
||||||
</template>
|
|
||||||
</MentionBox>
|
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import languages from './advancedFilterItems/languages';
|
|||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { filterAttributeGroups } from './advancedFilterItems';
|
import { filterAttributeGroups } from './advancedFilterItems';
|
||||||
import filterMixin from 'shared/mixins/filterMixin';
|
import { useFilter } from 'shared/composables/useFilter';
|
||||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
|
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
|
||||||
@@ -14,7 +14,6 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FilterInputBox,
|
FilterInputBox,
|
||||||
},
|
},
|
||||||
mixins: [filterMixin],
|
|
||||||
props: {
|
props: {
|
||||||
onClose: {
|
onClose: {
|
||||||
type: Function,
|
type: Function,
|
||||||
@@ -37,6 +36,15 @@ export default {
|
|||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { setFilterAttributes } = useFilter({
|
||||||
|
filteri18nKey: 'FILTER',
|
||||||
|
attributeModel: 'conversation_attribute',
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
setFilterAttributes,
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
show: true,
|
show: true,
|
||||||
@@ -67,7 +75,11 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
this.setFilterAttributes();
|
const { filterGroups, filterTypes } = this.setFilterAttributes();
|
||||||
|
|
||||||
|
this.filterTypes = [...this.filterTypes, ...filterTypes];
|
||||||
|
this.filterGroups = filterGroups;
|
||||||
|
|
||||||
this.$store.dispatch('campaigns/get');
|
this.$store.dispatch('campaigns/get');
|
||||||
if (this.getAppliedConversationFilters.length) {
|
if (this.getAppliedConversationFilters.length) {
|
||||||
this.appliedFilters = [];
|
this.appliedFilters = [];
|
||||||
@@ -326,7 +338,11 @@ export default {
|
|||||||
:show-query-operator="i !== appliedFilters.length - 1"
|
:show-query-operator="i !== appliedFilters.length - 1"
|
||||||
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
|
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
|
||||||
grouped-filters
|
grouped-filters
|
||||||
:error-message="validationErrors[`filter_${i}`]"
|
:error-message="
|
||||||
|
validationErrors[`filter_${i}`]
|
||||||
|
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
|
||||||
|
: ''
|
||||||
|
"
|
||||||
@resetFilter="resetFilter(i, appliedFilters[i])"
|
@resetFilter="resetFilter(i, appliedFilters[i])"
|
||||||
@removeFilter="removeFilter(i)"
|
@removeFilter="removeFilter(i)"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,8 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
import agentMixin from '../../../mixins/agentMixin.js';
|
|
||||||
import BackButton from '../BackButton.vue';
|
import BackButton from '../BackButton.vue';
|
||||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
@@ -24,7 +22,7 @@ export default {
|
|||||||
SLACardLabel,
|
SLACardLabel,
|
||||||
Linear,
|
Linear,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, agentMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
chat: {
|
chat: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -44,18 +42,12 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
const conversationHeaderActionsRef = ref(null);
|
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'Alt+KeyO': {
|
'Alt+KeyO': {
|
||||||
action: () => emit('contactPanelToggle'),
|
action: () => emit('contactPanelToggle'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
|
||||||
conversationHeaderActionsRef,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
@@ -183,7 +175,6 @@ export default {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
ref="conversationHeaderActionsRef"
|
|
||||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import BubbleActions from './bubble/Actions.vue';
|
import BubbleActions from './bubble/Actions.vue';
|
||||||
import BubbleContact from './bubble/Contact.vue';
|
import BubbleContact from './bubble/Contact.vue';
|
||||||
import BubbleFile from './bubble/File.vue';
|
import BubbleFile from './bubble/File.vue';
|
||||||
@@ -39,7 +39,6 @@ export default {
|
|||||||
InstagramStoryReply,
|
InstagramStoryReply,
|
||||||
Spinner,
|
Spinner,
|
||||||
},
|
},
|
||||||
mixins: [messageFormatterMixin],
|
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -74,6 +73,12 @@ export default {
|
|||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { formatMessage } = useMessageFormatter();
|
||||||
|
return {
|
||||||
|
formatMessage,
|
||||||
|
};
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showContextMenu: false,
|
showContextMenu: false,
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
<script>
|
<script>
|
||||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||||
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
|
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'MessagePreview',
|
name: 'MessagePreview',
|
||||||
mixins: [messageFormatterMixin],
|
|
||||||
props: {
|
props: {
|
||||||
message: {
|
message: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -20,6 +19,12 @@ export default {
|
|||||||
default: '',
|
default: '',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { getPlainText } = useMessageFormatter();
|
||||||
|
return {
|
||||||
|
getPlainText,
|
||||||
|
};
|
||||||
|
},
|
||||||
computed: {
|
computed: {
|
||||||
messageByAgent() {
|
messageByAgent() {
|
||||||
const { message_type: messageType } = this.message;
|
const { message_type: messageType } = this.message;
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { ref } from 'vue';
|
|||||||
// composable
|
// composable
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import { useConfig } from 'dashboard/composables/useConfig';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
|
||||||
// components
|
// components
|
||||||
import ReplyBox from './ReplyBox.vue';
|
import ReplyBox from './ReplyBox.vue';
|
||||||
@@ -15,7 +16,6 @@ import { mapGetters } from 'vuex';
|
|||||||
|
|
||||||
// mixins
|
// mixins
|
||||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
|
||||||
// utils
|
// utils
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
@@ -40,7 +40,7 @@ export default {
|
|||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, aiMixin],
|
mixins: [inboxMixin],
|
||||||
props: {
|
props: {
|
||||||
isContactPanelOpen: {
|
isContactPanelOpen: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -52,7 +52,6 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const conversationFooterRef = ref(null);
|
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
const { isEnterprise } = useConfig();
|
const { isEnterprise } = useConfig();
|
||||||
|
|
||||||
@@ -70,14 +69,24 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
|
const {
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
fetchLabelSuggestions,
|
||||||
|
} = useAI();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
isEnterprise,
|
||||||
conversationFooterRef,
|
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
closePopOutReplyBox,
|
closePopOutReplyBox,
|
||||||
showPopOutReplyBox,
|
showPopOutReplyBox,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
fetchLabelSuggestions,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -518,7 +527,6 @@ export default {
|
|||||||
/>
|
/>
|
||||||
</ul>
|
</ul>
|
||||||
<div
|
<div
|
||||||
ref="conversationFooterRef"
|
|
||||||
class="conversation-footer"
|
class="conversation-footer"
|
||||||
:class="{ 'modal-mask': isPopOutReplyBox }"
|
:class="{ 'modal-mask': isPopOutReplyBox }"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
CMD_MUTE_CONVERSATION,
|
CMD_MUTE_CONVERSATION,
|
||||||
CMD_SEND_TRANSCRIPT,
|
CMD_SEND_TRANSCRIPT,
|
||||||
CMD_UNMUTE_CONVERSATION,
|
CMD_UNMUTE_CONVERSATION,
|
||||||
} from '../../../routes/dashboard/commands/commandBarBusEvents';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import Banner from 'dashboard/components/ui/Banner.vue';
|
|||||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||||
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
|
||||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
import {
|
import {
|
||||||
@@ -61,12 +60,7 @@ export default {
|
|||||||
MessageSignatureMissingAlert,
|
MessageSignatureMissingAlert,
|
||||||
ArticleSearchPopover,
|
ArticleSearchPopover,
|
||||||
},
|
},
|
||||||
mixins: [
|
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||||
inboxMixin,
|
|
||||||
messageFormatterMixin,
|
|
||||||
fileUploadMixin,
|
|
||||||
keyboardEventListenerMixins,
|
|
||||||
],
|
|
||||||
props: {
|
props: {
|
||||||
popoutReplyBox: {
|
popoutReplyBox: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -243,6 +237,9 @@ export default {
|
|||||||
if (this.isASmsInbox) {
|
if (this.isASmsInbox) {
|
||||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||||
}
|
}
|
||||||
|
if (this.isAnEmailChannel) {
|
||||||
|
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||||
|
}
|
||||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||||
},
|
},
|
||||||
showFileUpload() {
|
showFileUpload() {
|
||||||
|
|||||||
@@ -40,7 +40,6 @@ const onSelect = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef: tagAgentsRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
|
|||||||
@@ -56,20 +56,14 @@ export default {
|
|||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||||
<template>
|
<template>
|
||||||
<MentionBox
|
<MentionBox
|
||||||
v-if="items.length"
|
v-if="items.length"
|
||||||
type="variable"
|
type="variable"
|
||||||
:items="items"
|
:items="items"
|
||||||
@mentionSelect="handleVariableClick"
|
@mentionSelect="handleVariableClick"
|
||||||
>
|
/>
|
||||||
<template slot-scope="{ item }">
|
|
||||||
<span class="text-capitalize variable--list-label">
|
|
||||||
{{ item.description }}
|
|
||||||
</span>
|
|
||||||
({{ item.label }})
|
|
||||||
</template>
|
|
||||||
</MentionBox>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -35,7 +35,6 @@ const ALLOWED_FILE_TYPES = {
|
|||||||
const MAX_ZOOM_LEVEL = 2;
|
const MAX_ZOOM_LEVEL = 2;
|
||||||
const MIN_ZOOM_LEVEL = 1;
|
const MIN_ZOOM_LEVEL = 1;
|
||||||
|
|
||||||
const galleryViewRef = ref(null);
|
|
||||||
const zoomScale = ref(1);
|
const zoomScale = ref(1);
|
||||||
const activeAttachment = ref({});
|
const activeAttachment = ref({});
|
||||||
const activeFileType = ref('');
|
const activeFileType = ref('');
|
||||||
@@ -202,7 +201,7 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents, galleryViewRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setImageAndVideoSrc(props.attachment);
|
setImageAndVideoSrc(props.attachment);
|
||||||
@@ -218,7 +217,6 @@ onMounted(() => {
|
|||||||
:on-close="onClose"
|
:on-close="onClose"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
ref="galleryViewRef"
|
|
||||||
v-on-clickaway="onClose"
|
v-on-clickaway="onClose"
|
||||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||||
@click="onClose"
|
@click="onClose"
|
||||||
|
|||||||
@@ -1,9 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
|
import { mapGetters } from 'vuex';
|
||||||
|
import {
|
||||||
|
getSortedAgentsByAvailability,
|
||||||
|
getAgentsByUpdatedPresence,
|
||||||
|
} from 'dashboard/helper/agentHelper.js';
|
||||||
import MenuItem from './menuItem.vue';
|
import MenuItem from './menuItem.vue';
|
||||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
|
||||||
import { mapGetters } from 'vuex';
|
|
||||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -11,7 +14,6 @@ export default {
|
|||||||
MenuItemWithSubmenu,
|
MenuItemWithSubmenu,
|
||||||
AgentLoadingPlaceholder,
|
AgentLoadingPlaceholder,
|
||||||
},
|
},
|
||||||
mixins: [agentMixin],
|
|
||||||
props: {
|
props: {
|
||||||
chatId: {
|
chatId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -112,13 +114,19 @@ export default {
|
|||||||
labels: 'labels/getLabels',
|
labels: 'labels/getLabels',
|
||||||
teams: 'teams/getTeams',
|
teams: 'teams/getTeams',
|
||||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||||
|
currentUser: 'getCurrentUser',
|
||||||
|
currentAccountId: 'getCurrentAccountId',
|
||||||
}),
|
}),
|
||||||
filteredAgentOnAvailability() {
|
filteredAgentOnAvailability() {
|
||||||
const agents = this.$store.getters[
|
const agents = this.$store.getters[
|
||||||
'inboxAssignableAgents/getAssignableAgents'
|
'inboxAssignableAgents/getAssignableAgents'
|
||||||
](this.inboxId);
|
](this.inboxId);
|
||||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||||
const filteredAgents = this.sortedAgentsByAvailability(
|
agents,
|
||||||
|
this.currentUser,
|
||||||
|
this.currentAccountId
|
||||||
|
);
|
||||||
|
const filteredAgents = getSortedAgentsByAvailability(
|
||||||
agentsByUpdatedPresence
|
agentsByUpdatedPresence
|
||||||
);
|
);
|
||||||
return filteredAgents;
|
return filteredAgents;
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
// components
|
// components
|
||||||
import WootButton from '../../../ui/WootButton.vue';
|
import WootButton from '../../../ui/WootButton.vue';
|
||||||
import Avatar from '../../Avatar.vue';
|
import Avatar from '../../Avatar.vue';
|
||||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
|
||||||
|
// composables
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
|
||||||
// store & api
|
// store & api
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
@@ -18,7 +20,6 @@ export default {
|
|||||||
Avatar,
|
Avatar,
|
||||||
WootButton,
|
WootButton,
|
||||||
},
|
},
|
||||||
mixins: [aiMixin],
|
|
||||||
props: {
|
props: {
|
||||||
suggestedLabels: {
|
suggestedLabels: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -30,6 +31,11 @@ export default {
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
setup() {
|
||||||
|
const { isAIIntegrationEnabled } = useAI();
|
||||||
|
|
||||||
|
return { isAIIntegrationEnabled };
|
||||||
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isDismissed: false,
|
isDismissed: false,
|
||||||
@@ -41,7 +47,11 @@ export default {
|
|||||||
...mapGetters({
|
...mapGetters({
|
||||||
allLabels: 'labels/getLabels',
|
allLabels: 'labels/getLabels',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
currentAccountId: 'getCurrentAccountId',
|
||||||
|
currentChat: 'getSelectedChat',
|
||||||
}),
|
}),
|
||||||
|
conversationId() {
|
||||||
|
return this.currentChat?.id;
|
||||||
|
},
|
||||||
labelTooltip() {
|
labelTooltip() {
|
||||||
if (this.preparedLabels.length > 1) {
|
if (this.preparedLabels.length > 1) {
|
||||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
||||||
@@ -162,7 +172,7 @@ export default {
|
|||||||
delay: { show: 600, hide: 0 },
|
delay: { show: 600, hide: 0 },
|
||||||
hideOnClick: true,
|
hideOnClick: true,
|
||||||
}"
|
}"
|
||||||
class="label-suggestion--option"
|
class="label-suggestion--option !px-0"
|
||||||
@click="pushOrAddLabel(label.title)"
|
@click="pushOrAddLabel(label.title)"
|
||||||
>
|
>
|
||||||
<woot-label
|
<woot-label
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
import AgentSelector from './AgentSelector.vue';
|
import AgentSelector from './AgentSelector.vue';
|
||||||
import UpdateActions from './UpdateActions.vue';
|
import UpdateActions from './UpdateActions.vue';
|
||||||
|
|||||||
@@ -48,11 +48,7 @@ const loadLinkedIssue = async () => {
|
|||||||
const issues = response.data;
|
const issues = response.data;
|
||||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMessage = parseLinearAPIErrorResponse(
|
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
|
||||||
error,
|
|
||||||
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
|
|
||||||
);
|
|
||||||
useAlert(errorMessage);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const onSelect = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef: mentionsListContainerRef,
|
|
||||||
items: computed(() => props.items),
|
items: computed(() => props.items),
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -91,22 +90,24 @@ const variableKey = (item = {}) => {
|
|||||||
}"
|
}"
|
||||||
@click="onListItemSelection(index)"
|
@click="onListItemSelection(index)"
|
||||||
>
|
>
|
||||||
<p
|
<slot :item="item" :index="index" :selected="index === selectedIndex">
|
||||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
<p
|
||||||
:class="{
|
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
:class="{
|
||||||
}"
|
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||||
>
|
}"
|
||||||
{{ item.description }}
|
>
|
||||||
</p>
|
{{ item.description }}
|
||||||
<p
|
</p>
|
||||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
<p
|
||||||
:class="{
|
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
:class="{
|
||||||
}"
|
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||||
>
|
}"
|
||||||
{{ variableKey(item) }}
|
>
|
||||||
</p>
|
{{ variableKey(item) }}
|
||||||
|
</p>
|
||||||
|
</slot>
|
||||||
</button>
|
</button>
|
||||||
</woot-dropdown-item>
|
</woot-dropdown-item>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
197
app/javascript/dashboard/composables/commands/spec/fixtures.js
Normal file
197
app/javascript/dashboard/composables/commands/spec/fixtures.js
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
export const mockAssignableAgents = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
auto_offline: true,
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@doe.com',
|
||||||
|
available_name: 'John Doe',
|
||||||
|
name: 'John Doe',
|
||||||
|
role: 'administrator',
|
||||||
|
thumbnail: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockCurrentChat = {
|
||||||
|
meta: {
|
||||||
|
sender: {
|
||||||
|
additional_attributes: {},
|
||||||
|
availability_status: 'offline',
|
||||||
|
email: null,
|
||||||
|
id: 212,
|
||||||
|
name: 'Chatwoot',
|
||||||
|
phone_number: null,
|
||||||
|
identifier: null,
|
||||||
|
thumbnail: '',
|
||||||
|
custom_attributes: {},
|
||||||
|
last_activity_at: 1723553344,
|
||||||
|
created_at: 1722588710,
|
||||||
|
},
|
||||||
|
channel: 'Channel::WebWidget',
|
||||||
|
assignee: {
|
||||||
|
id: 1,
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
auto_offline: true,
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@doe.com',
|
||||||
|
available_name: 'John Doe',
|
||||||
|
name: 'John Doe',
|
||||||
|
role: 'administrator',
|
||||||
|
thumbnail: '',
|
||||||
|
},
|
||||||
|
hmac_verified: false,
|
||||||
|
},
|
||||||
|
id: 138,
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: 3348,
|
||||||
|
content: 'Hello, how can I assist you today?',
|
||||||
|
account_id: 1,
|
||||||
|
inbox_id: 1,
|
||||||
|
conversation_id: 138,
|
||||||
|
message_type: 1,
|
||||||
|
created_at: 1724398739,
|
||||||
|
updated_at: '2024-08-23T07:38:59.763Z',
|
||||||
|
private: false,
|
||||||
|
status: 'sent',
|
||||||
|
source_id: null,
|
||||||
|
content_type: 'text',
|
||||||
|
content_attributes: {},
|
||||||
|
sender_type: 'User',
|
||||||
|
sender_id: 1,
|
||||||
|
external_source_ids: {},
|
||||||
|
additional_attributes: {},
|
||||||
|
processed_message_content: 'Hello, how can I assist you today?',
|
||||||
|
sentiment: {},
|
||||||
|
conversation: {
|
||||||
|
assignee_id: 1,
|
||||||
|
unread_count: 0,
|
||||||
|
last_activity_at: 1724398739,
|
||||||
|
contact_inbox: {
|
||||||
|
source_id: '5e57317d-053b-4a72-8292-a25b9f29c401',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
sender: {
|
||||||
|
id: 1,
|
||||||
|
name: 'John Doe',
|
||||||
|
available_name: 'John Doe',
|
||||||
|
avatar_url: '',
|
||||||
|
type: 'user',
|
||||||
|
availability_status: 'online',
|
||||||
|
thumbnail: '',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
account_id: 1,
|
||||||
|
uuid: '69dd6922-2f0c-4317-8796-bbeb3679cead',
|
||||||
|
additional_attributes: {
|
||||||
|
browser: {
|
||||||
|
device_name: 'Unknown',
|
||||||
|
browser_name: 'Chrome',
|
||||||
|
platform_name: 'macOS',
|
||||||
|
browser_version: '127.0.0.0',
|
||||||
|
platform_version: '10.15.7',
|
||||||
|
},
|
||||||
|
referer: 'http://chatwoot.com/widget_tests?dark_mode=auto',
|
||||||
|
initiated_at: {
|
||||||
|
timestamp: 'Fri Aug 02 2024 15:21:18 GMT+0530 (India Standard Time)',
|
||||||
|
},
|
||||||
|
browser_language: 'en',
|
||||||
|
},
|
||||||
|
agent_last_seen_at: 1724400730,
|
||||||
|
assignee_last_seen_at: 1724400686,
|
||||||
|
can_reply: true,
|
||||||
|
contact_last_seen_at: 1723553351,
|
||||||
|
custom_attributes: {},
|
||||||
|
inbox_id: 1,
|
||||||
|
labels: ['billing'],
|
||||||
|
muted: false,
|
||||||
|
snoozed_until: null,
|
||||||
|
status: 'open',
|
||||||
|
created_at: 1722592278,
|
||||||
|
timestamp: 1724398739,
|
||||||
|
first_reply_created_at: 1722592316,
|
||||||
|
unread_count: 0,
|
||||||
|
last_non_activity_message: {},
|
||||||
|
last_activity_at: 1724398739,
|
||||||
|
priority: null,
|
||||||
|
waiting_since: 0,
|
||||||
|
sla_policy_id: 10,
|
||||||
|
applied_sla: {
|
||||||
|
id: 143,
|
||||||
|
sla_id: 10,
|
||||||
|
sla_status: 'missed',
|
||||||
|
created_at: 1722592279,
|
||||||
|
updated_at: 1722874214,
|
||||||
|
sla_description: '',
|
||||||
|
sla_name: 'Hacker SLA',
|
||||||
|
sla_first_response_time_threshold: 600,
|
||||||
|
sla_next_response_time_threshold: 240,
|
||||||
|
sla_only_during_business_hours: false,
|
||||||
|
sla_resolution_time_threshold: 259200,
|
||||||
|
},
|
||||||
|
sla_events: [
|
||||||
|
{
|
||||||
|
id: 270,
|
||||||
|
event_type: 'nrt',
|
||||||
|
meta: {
|
||||||
|
message_id: 2743,
|
||||||
|
},
|
||||||
|
updated_at: 1722592819,
|
||||||
|
created_at: 1722592819,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 275,
|
||||||
|
event_type: 'rt',
|
||||||
|
meta: {},
|
||||||
|
updated_at: 1722852322,
|
||||||
|
created_at: 1722852322,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
allMessagesLoaded: false,
|
||||||
|
dataFetched: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const mockTeamsList = [
|
||||||
|
{
|
||||||
|
id: 5,
|
||||||
|
name: 'design',
|
||||||
|
description: 'design team',
|
||||||
|
allow_auto_assign: true,
|
||||||
|
account_id: 1,
|
||||||
|
is_member: false,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockActiveLabels = [
|
||||||
|
{
|
||||||
|
id: 16,
|
||||||
|
title: 'billing',
|
||||||
|
description: '',
|
||||||
|
color: '#D8EA19',
|
||||||
|
show_on_sidebar: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const mockInactiveLabels = [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
title: 'Feature Request',
|
||||||
|
description: '',
|
||||||
|
color: '#D8EA19',
|
||||||
|
show_on_sidebar: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MOCK_FEATURE_FLAGS = {
|
||||||
|
CRM: 'crm',
|
||||||
|
AGENT_MANAGEMENT: 'agent_management',
|
||||||
|
TEAM_MANAGEMENT: 'team_management',
|
||||||
|
INBOX_MANAGEMENT: 'inbox_management',
|
||||||
|
REPORTS: 'reports',
|
||||||
|
LABELS: 'labels',
|
||||||
|
CANNED_RESPONSES: 'canned_responses',
|
||||||
|
INTEGRATIONS: 'integrations',
|
||||||
|
};
|
||||||
@@ -0,0 +1,83 @@
|
|||||||
|
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/useI18n');
|
||||||
|
vi.mock('shared/helpers/localStorage');
|
||||||
|
vi.mock('dashboard/helper/themeHelper.js');
|
||||||
|
|
||||||
|
describe('useAppearanceHotKeys', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useI18n.mockReturnValue({
|
||||||
|
t: vi.fn(key => key),
|
||||||
|
});
|
||||||
|
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({ matches: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return goToAppearanceHotKeys computed property', () => {
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
expect(goToAppearanceHotKeys.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct number of appearance options', () => {
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
expect(goToAppearanceHotKeys.value.length).toBe(4); // 1 parent + 3 theme options
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct parent option', () => {
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
const parentOption = goToAppearanceHotKeys.value.find(
|
||||||
|
option => option.id === 'appearance_settings'
|
||||||
|
);
|
||||||
|
expect(parentOption).toBeDefined();
|
||||||
|
expect(parentOption.children.length).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct theme options', () => {
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
const themeOptions = goToAppearanceHotKeys.value.filter(
|
||||||
|
option => option.parent === 'appearance_settings'
|
||||||
|
);
|
||||||
|
expect(themeOptions.length).toBe(3);
|
||||||
|
expect(themeOptions.map(option => option.id)).toEqual([
|
||||||
|
'light',
|
||||||
|
'dark',
|
||||||
|
'auto',
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call setAppearance when a theme option is selected', () => {
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
const lightThemeOption = goToAppearanceHotKeys.value.find(
|
||||||
|
option => option.id === 'light'
|
||||||
|
);
|
||||||
|
|
||||||
|
lightThemeOption.handler();
|
||||||
|
|
||||||
|
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||||
|
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
|
||||||
|
'light'
|
||||||
|
);
|
||||||
|
expect(setColorTheme).toHaveBeenCalledWith(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle system dark mode preference', () => {
|
||||||
|
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
|
||||||
|
|
||||||
|
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||||
|
const autoThemeOption = goToAppearanceHotKeys.value.find(
|
||||||
|
option => option.id === 'auto'
|
||||||
|
);
|
||||||
|
|
||||||
|
autoThemeOption.handler();
|
||||||
|
|
||||||
|
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||||
|
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
|
||||||
|
'auto'
|
||||||
|
);
|
||||||
|
expect(setColorTheme).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables/useI18n');
|
||||||
|
vi.mock('shared/helpers/mitt');
|
||||||
|
|
||||||
|
describe('useBulkActionsHotKeys', () => {
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {
|
||||||
|
getters: {
|
||||||
|
'bulkActions/getSelectedConversationIds': [],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useStore.mockReturnValue(store);
|
||||||
|
useMapGetter.mockImplementation(key => ({
|
||||||
|
value: store.getters[key],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||||
|
emitter.emit = vi.fn();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return bulk actions when conversations are selected', () => {
|
||||||
|
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||||
|
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||||
|
|
||||||
|
expect(bulkActionsHotKeys.value.length).toBeGreaterThan(0);
|
||||||
|
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'bulk_action_snooze_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'bulk_action_reopen_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||||
|
expect.objectContaining({
|
||||||
|
id: 'bulk_action_resolve_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include snooze options in bulk actions', () => {
|
||||||
|
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||||
|
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||||
|
|
||||||
|
const snoozeAction = bulkActionsHotKeys.value.find(
|
||||||
|
action => action.id === 'bulk_action_snooze_conversation'
|
||||||
|
);
|
||||||
|
expect(snoozeAction).toBeDefined();
|
||||||
|
expect(snoozeAction.children).toEqual(
|
||||||
|
Object.values(wootConstants.SNOOZE_OPTIONS)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should create handlers for reopen and resolve actions', () => {
|
||||||
|
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||||
|
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||||
|
|
||||||
|
const reopenAction = bulkActionsHotKeys.value.find(
|
||||||
|
action => action.id === 'bulk_action_reopen_conversation'
|
||||||
|
);
|
||||||
|
const resolveAction = bulkActionsHotKeys.value.find(
|
||||||
|
action => action.id === 'bulk_action_resolve_conversation'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(reopenAction.handler).toBeDefined();
|
||||||
|
expect(resolveAction.handler).toBeDefined();
|
||||||
|
|
||||||
|
reopenAction.handler();
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith(
|
||||||
|
'CMD_BULK_ACTION_REOPEN_CONVERSATION'
|
||||||
|
);
|
||||||
|
|
||||||
|
resolveAction.handler();
|
||||||
|
expect(emitter.emit).toHaveBeenCalledWith(
|
||||||
|
'CMD_BULK_ACTION_RESOLVE_CONVERSATION'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when no conversations are selected', () => {
|
||||||
|
store.getters['bulkActions/getSelectedConversationIds'] = [];
|
||||||
|
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||||
|
|
||||||
|
expect(bulkActionsHotKeys.value).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { useConversationHotKeys } from '../useConversationHotKeys';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useRoute } from 'dashboard/composables/route';
|
||||||
|
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||||
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
|
import {
|
||||||
|
mockAssignableAgents,
|
||||||
|
mockCurrentChat,
|
||||||
|
mockTeamsList,
|
||||||
|
mockActiveLabels,
|
||||||
|
mockInactiveLabels,
|
||||||
|
} from './fixtures';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables/useI18n');
|
||||||
|
vi.mock('dashboard/composables/route');
|
||||||
|
vi.mock('dashboard/composables/useConversationLabels');
|
||||||
|
vi.mock('dashboard/composables/useAI');
|
||||||
|
vi.mock('dashboard/composables/useAgentsList');
|
||||||
|
|
||||||
|
describe('useConversationHotKeys', () => {
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
getters: {
|
||||||
|
getSelectedChat: mockCurrentChat,
|
||||||
|
'draftMessages/getReplyEditorMode': REPLY_EDITOR_MODES.REPLY,
|
||||||
|
getContextMenuChatId: null,
|
||||||
|
'teams/getTeams': mockTeamsList,
|
||||||
|
'draftMessages/get': vi.fn(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useStore.mockReturnValue(store);
|
||||||
|
useMapGetter.mockImplementation(key => ({
|
||||||
|
value: store.getters[key],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||||
|
useRoute.mockReturnValue({ name: 'inbox_conversation' });
|
||||||
|
useConversationLabels.mockReturnValue({
|
||||||
|
activeLabels: { value: mockActiveLabels },
|
||||||
|
inactiveLabels: { value: mockInactiveLabels },
|
||||||
|
addLabelToConversation: vi.fn(),
|
||||||
|
removeLabelFromConversation: vi.fn(),
|
||||||
|
});
|
||||||
|
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
|
||||||
|
useAgentsList.mockReturnValue({
|
||||||
|
agentsList: { value: [] },
|
||||||
|
assignableAgents: { value: mockAssignableAgents },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct computed properties', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
|
||||||
|
expect(conversationHotKeys.value).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should generate conversation hot keys', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include AI assist actions when AI integration is enabled', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const aiAssistAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'ai_assist'
|
||||||
|
);
|
||||||
|
expect(aiAssistAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not include AI assist actions when AI integration is disabled', () => {
|
||||||
|
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const aiAssistAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'ai_assist'
|
||||||
|
);
|
||||||
|
expect(aiAssistAction).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should dispatch actions when handlers are called', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const assignAgentAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'assign_an_agent'
|
||||||
|
);
|
||||||
|
expect(assignAgentAction).toBeDefined();
|
||||||
|
|
||||||
|
if (assignAgentAction && assignAgentAction.children) {
|
||||||
|
const childAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === assignAgentAction.children[0]
|
||||||
|
);
|
||||||
|
if (childAction && childAction.handler) {
|
||||||
|
childAction.handler({ agentInfo: { id: 2 } });
|
||||||
|
expect(store.dispatch).toHaveBeenCalledWith('assignAgent', {
|
||||||
|
conversationId: 1,
|
||||||
|
agentId: 2,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return snooze actions when in snooze context', () => {
|
||||||
|
store.getters.getContextMenuChatId = 1;
|
||||||
|
useMapGetter.mockImplementation(key => ({
|
||||||
|
value: store.getters[key],
|
||||||
|
}));
|
||||||
|
useRoute.mockReturnValue({ name: 'inbox_conversation' });
|
||||||
|
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const snoozeAction = conversationHotKeys.value.find(action =>
|
||||||
|
action.id.includes('snooze_conversation')
|
||||||
|
);
|
||||||
|
expect(snoozeAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct label actions when there are active labels', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const addLabelAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'add_a_label_to_the_conversation'
|
||||||
|
);
|
||||||
|
const removeLabelAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'remove_a_label_to_the_conversation'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(addLabelAction).toBeDefined();
|
||||||
|
expect(removeLabelAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return only add label actions when there are no active labels', () => {
|
||||||
|
useConversationLabels.mockReturnValue({
|
||||||
|
activeLabels: { value: [] },
|
||||||
|
inactiveLabels: { value: [{ title: 'inactive_label' }] },
|
||||||
|
addLabelToConversation: vi.fn(),
|
||||||
|
removeLabelFromConversation: vi.fn(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const addLabelAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'add_a_label_to_the_conversation'
|
||||||
|
);
|
||||||
|
const removeLabelAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'remove_a_label_to_the_conversation'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(addLabelAction).toBeDefined();
|
||||||
|
expect(removeLabelAction).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct team assignment actions', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const assignTeamAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'assign_a_team'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assignTeamAction).toBeDefined();
|
||||||
|
expect(assignTeamAction.children.length).toBe(mockTeamsList.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct priority assignment actions', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const assignPriorityAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'assign_priority'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(assignPriorityAction).toBeDefined();
|
||||||
|
expect(assignPriorityAction.children.length).toBe(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct conversation additional actions', () => {
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const muteAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'mute_conversation'
|
||||||
|
);
|
||||||
|
const sendTranscriptAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'send_transcript'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(muteAction).toBeDefined();
|
||||||
|
expect(sendTranscriptAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return unmute action when conversation is muted', () => {
|
||||||
|
store.getters.getSelectedChat = { ...mockCurrentChat, muted: true };
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
const unmuteAction = conversationHotKeys.value.find(
|
||||||
|
action => action.id === 'unmute_conversation'
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(unmuteAction).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return conversation hot keys when not in conversation or inbox route', () => {
|
||||||
|
useRoute.mockReturnValue({ name: 'some_other_route' });
|
||||||
|
const { conversationHotKeys } = useConversationHotKeys();
|
||||||
|
|
||||||
|
expect(conversationHotKeys.value.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useRouter } from 'dashboard/composables/route';
|
||||||
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
|
import { MOCK_FEATURE_FLAGS } from './fixtures';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables/useI18n');
|
||||||
|
vi.mock('dashboard/composables/route');
|
||||||
|
vi.mock('dashboard/composables/useAdmin');
|
||||||
|
vi.mock('dashboard/helper/URLHelper');
|
||||||
|
|
||||||
|
const mockRoutes = [
|
||||||
|
{ path: 'accounts/:accountId/dashboard', name: 'dashboard' },
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/contacts',
|
||||||
|
name: 'contacts',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.CRM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/agents/list',
|
||||||
|
name: 'agent_settings',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.AGENT_MANAGEMENT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/teams/list',
|
||||||
|
name: 'team_settings',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.TEAM_MANAGEMENT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/inboxes/list',
|
||||||
|
name: 'inbox_settings',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.INBOX_MANAGEMENT,
|
||||||
|
},
|
||||||
|
{ path: 'accounts/:accountId/profile/settings', name: 'profile_settings' },
|
||||||
|
{ path: 'accounts/:accountId/notifications', name: 'notifications' },
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/reports/overview',
|
||||||
|
name: 'reports_overview',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.REPORTS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/labels/list',
|
||||||
|
name: 'label_settings',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.LABELS,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/canned-response/list',
|
||||||
|
name: 'canned_responses',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.CANNED_RESPONSES,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'accounts/:accountId/settings/applications',
|
||||||
|
name: 'applications',
|
||||||
|
featureFlag: MOCK_FEATURE_FLAGS.INTEGRATIONS,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('useGoToCommandHotKeys', () => {
|
||||||
|
let store;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
store = {
|
||||||
|
getters: {
|
||||||
|
getCurrentAccountId: 1,
|
||||||
|
'accounts/isFeatureEnabledonAccount': vi.fn().mockReturnValue(true),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
useStore.mockReturnValue(store);
|
||||||
|
useMapGetter.mockImplementation(key => ({
|
||||||
|
value: store.getters[key],
|
||||||
|
}));
|
||||||
|
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||||
|
useRouter.mockReturnValue({ push: vi.fn() });
|
||||||
|
useAdmin.mockReturnValue({ isAdmin: { value: true } });
|
||||||
|
frontendURL.mockImplementation(url => url);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return goToCommandHotKeys computed property', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
expect(goToCommandHotKeys.value).toBeDefined();
|
||||||
|
expect(goToCommandHotKeys.value.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter commands based on feature flags', () => {
|
||||||
|
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(
|
||||||
|
(accountId, flag) => flag !== MOCK_FEATURE_FLAGS.CRM
|
||||||
|
);
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
|
||||||
|
mockRoutes.forEach(route => {
|
||||||
|
const command = goToCommandHotKeys.value.find(cmd =>
|
||||||
|
cmd.id.includes(route.name)
|
||||||
|
);
|
||||||
|
if (route.featureFlag === MOCK_FEATURE_FLAGS.CRM) {
|
||||||
|
expect(command).toBeUndefined();
|
||||||
|
} else if (!route.featureFlag) {
|
||||||
|
expect(command).toBeDefined();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should filter commands for non-admin users', () => {
|
||||||
|
useAdmin.mockReturnValue({ isAdmin: { value: false } });
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
|
||||||
|
const adminOnlyCommands = goToCommandHotKeys.value.filter(
|
||||||
|
cmd =>
|
||||||
|
cmd.id.includes('agent_settings') ||
|
||||||
|
cmd.id.includes('team_settings') ||
|
||||||
|
cmd.id.includes('inbox_settings')
|
||||||
|
);
|
||||||
|
expect(adminOnlyCommands.length).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include commands for both admin and agent roles when user is admin', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
const adminCommand = goToCommandHotKeys.value.find(cmd =>
|
||||||
|
cmd.id.includes('agent_settings')
|
||||||
|
);
|
||||||
|
const agentCommand = goToCommandHotKeys.value.find(cmd =>
|
||||||
|
cmd.id.includes('profile_settings')
|
||||||
|
);
|
||||||
|
expect(adminCommand).toBeDefined();
|
||||||
|
expect(agentCommand).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should translate section and title for each command', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
goToCommandHotKeys.value.forEach(command => {
|
||||||
|
expect(useI18n().t).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('COMMAND_BAR.SECTIONS.')
|
||||||
|
);
|
||||||
|
expect(useI18n().t).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('COMMAND_BAR.COMMANDS.')
|
||||||
|
);
|
||||||
|
expect(command.section).toBeDefined();
|
||||||
|
expect(command.title).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should call router.push with correct URL when handler is called', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
goToCommandHotKeys.value.forEach(command => {
|
||||||
|
command.handler();
|
||||||
|
expect(useRouter().push).toHaveBeenCalledWith(expect.any(String));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use current account ID in the path', () => {
|
||||||
|
store.getters.getCurrentAccountId = 42;
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
goToCommandHotKeys.value.forEach(command => {
|
||||||
|
command.handler();
|
||||||
|
expect(useRouter().push).toHaveBeenCalledWith(
|
||||||
|
expect.stringContaining('42')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include icon for each command', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
goToCommandHotKeys.value.forEach(command => {
|
||||||
|
expect(command.icon).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return commands for all enabled features', () => {
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
const enabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
|
||||||
|
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
|
||||||
|
);
|
||||||
|
expect(enabledFeatureCommands.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should not return commands for disabled features', () => {
|
||||||
|
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(() => false);
|
||||||
|
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||||
|
const disabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
|
||||||
|
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
|
||||||
|
);
|
||||||
|
expect(disabledFeatureCommands.length).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { useInboxHotKeys } from '../useInboxHotKeys';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useRoute } from 'dashboard/composables/route';
|
||||||
|
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/useI18n');
|
||||||
|
vi.mock('dashboard/composables/route');
|
||||||
|
vi.mock('dashboard/helper/routeHelpers');
|
||||||
|
vi.mock('shared/helpers/mitt');
|
||||||
|
|
||||||
|
describe('useInboxHotKeys', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||||
|
useRoute.mockReturnValue({ name: 'inbox_dashboard' });
|
||||||
|
isAInboxViewRoute.mockReturnValue(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return inbox hot keys when on an inbox view route', () => {
|
||||||
|
const { inboxHotKeys } = useInboxHotKeys();
|
||||||
|
expect(inboxHotKeys.value.length).toBeGreaterThan(0);
|
||||||
|
expect(inboxHotKeys.value[0].id).toBe('snooze_notification');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return an empty array when not on an inbox view route', () => {
|
||||||
|
isAInboxViewRoute.mockReturnValue(false);
|
||||||
|
const { inboxHotKeys } = useInboxHotKeys();
|
||||||
|
expect(inboxHotKeys.value).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should have the correct structure for snooze actions', () => {
|
||||||
|
const { inboxHotKeys } = useInboxHotKeys();
|
||||||
|
const snoozeNotificationAction = inboxHotKeys.value.find(
|
||||||
|
action => action.id === 'snooze_notification'
|
||||||
|
);
|
||||||
|
expect(snoozeNotificationAction).toBeDefined();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,70 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import {
|
||||||
|
ICON_APPEARANCE,
|
||||||
|
ICON_LIGHT_MODE,
|
||||||
|
ICON_DARK_MODE,
|
||||||
|
ICON_SYSTEM_MODE,
|
||||||
|
} from 'dashboard/helper/commandbar/icons';
|
||||||
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
|
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||||
|
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
|
||||||
|
|
||||||
|
const getThemeOptions = t => [
|
||||||
|
{
|
||||||
|
key: 'light',
|
||||||
|
label: t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
|
||||||
|
icon: ICON_LIGHT_MODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'dark',
|
||||||
|
label: t('COMMAND_BAR.COMMANDS.DARK_MODE'),
|
||||||
|
icon: ICON_DARK_MODE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'auto',
|
||||||
|
label: t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
|
||||||
|
icon: ICON_SYSTEM_MODE,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const setAppearance = theme => {
|
||||||
|
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
|
||||||
|
const isOSOnDarkMode = window.matchMedia(
|
||||||
|
'(prefers-color-scheme: dark)'
|
||||||
|
).matches;
|
||||||
|
setColorTheme(isOSOnDarkMode);
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useAppearanceHotKeys() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const themeOptions = computed(() => getThemeOptions(t));
|
||||||
|
|
||||||
|
const goToAppearanceHotKeys = computed(() => {
|
||||||
|
const options = themeOptions.value.map(theme => ({
|
||||||
|
id: theme.key,
|
||||||
|
title: theme.label,
|
||||||
|
parent: 'appearance_settings',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
|
||||||
|
icon: theme.icon,
|
||||||
|
handler: () => {
|
||||||
|
setAppearance(theme.key);
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'appearance_settings',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
|
||||||
|
icon: ICON_APPEARANCE,
|
||||||
|
children: options.map(option => option.id),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
goToAppearanceHotKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
import {
|
||||||
|
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||||
|
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||||
|
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||||
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
import {
|
||||||
|
ICON_SNOOZE_CONVERSATION,
|
||||||
|
ICON_REOPEN_CONVERSATION,
|
||||||
|
ICON_RESOLVE_CONVERSATION,
|
||||||
|
} from 'dashboard/helper/commandbar/icons';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
|
import { createSnoozeHandlers } from 'dashboard/helper/commandbar/actions';
|
||||||
|
|
||||||
|
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||||
|
|
||||||
|
const createEmitHandler = event => () => emitter.emit(event);
|
||||||
|
|
||||||
|
const SNOOZE_CONVERSATION_BULK_ACTIONS = [
|
||||||
|
{
|
||||||
|
id: 'bulk_action_snooze_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
icon: ICON_SNOOZE_CONVERSATION,
|
||||||
|
children: Object.values(SNOOZE_OPTIONS),
|
||||||
|
},
|
||||||
|
...createSnoozeHandlers(
|
||||||
|
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||||
|
'bulk_action_snooze_conversation',
|
||||||
|
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
const RESOLVED_CONVERSATION_BULK_ACTIONS = [
|
||||||
|
{
|
||||||
|
id: 'bulk_action_reopen_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
icon: ICON_REOPEN_CONVERSATION,
|
||||||
|
handler: createEmitHandler(CMD_BULK_ACTION_REOPEN_CONVERSATION),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const OPEN_CONVERSATION_BULK_ACTIONS = [
|
||||||
|
{
|
||||||
|
id: 'bulk_action_resolve_conversation',
|
||||||
|
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||||
|
icon: ICON_RESOLVE_CONVERSATION,
|
||||||
|
handler: createEmitHandler(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export function useBulkActionsHotKeys() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const selectedConversations = useMapGetter(
|
||||||
|
'bulkActions/getSelectedConversationIds'
|
||||||
|
);
|
||||||
|
|
||||||
|
const prepareActions = actions => {
|
||||||
|
return actions.map(action => ({
|
||||||
|
...action,
|
||||||
|
title: t(action.title),
|
||||||
|
section: t(action.section),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const bulkActionsHotKeys = computed(() => {
|
||||||
|
let actions = [];
|
||||||
|
if (selectedConversations.value.length > 0) {
|
||||||
|
actions = [
|
||||||
|
...SNOOZE_CONVERSATION_BULK_ACTIONS,
|
||||||
|
...RESOLVED_CONVERSATION_BULK_ACTIONS,
|
||||||
|
...OPEN_CONVERSATION_BULK_ACTIONS,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return prepareActions(actions);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
bulkActionsHotKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,408 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useRoute } from 'dashboard/composables/route';
|
||||||
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||||
|
import { useAI } from 'dashboard/composables/useAI';
|
||||||
|
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||||
|
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||||
|
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||||
|
|
||||||
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
import {
|
||||||
|
ICON_ADD_LABEL,
|
||||||
|
ICON_ASSIGN_AGENT,
|
||||||
|
ICON_ASSIGN_PRIORITY,
|
||||||
|
ICON_ASSIGN_TEAM,
|
||||||
|
ICON_REMOVE_LABEL,
|
||||||
|
ICON_PRIORITY_URGENT,
|
||||||
|
ICON_PRIORITY_HIGH,
|
||||||
|
ICON_PRIORITY_LOW,
|
||||||
|
ICON_PRIORITY_MEDIUM,
|
||||||
|
ICON_PRIORITY_NONE,
|
||||||
|
ICON_AI_ASSIST,
|
||||||
|
ICON_AI_SUMMARY,
|
||||||
|
ICON_AI_SHORTEN,
|
||||||
|
ICON_AI_EXPAND,
|
||||||
|
ICON_AI_GRAMMAR,
|
||||||
|
} from 'dashboard/helper/commandbar/icons';
|
||||||
|
|
||||||
|
import {
|
||||||
|
OPEN_CONVERSATION_ACTIONS,
|
||||||
|
SNOOZE_CONVERSATION_ACTIONS,
|
||||||
|
RESOLVED_CONVERSATION_ACTIONS,
|
||||||
|
SEND_TRANSCRIPT_ACTION,
|
||||||
|
UNMUTE_ACTION,
|
||||||
|
MUTE_ACTION,
|
||||||
|
} from 'dashboard/helper/commandbar/actions';
|
||||||
|
import {
|
||||||
|
isAConversationRoute,
|
||||||
|
isAInboxViewRoute,
|
||||||
|
} from 'dashboard/helper/routeHelpers';
|
||||||
|
|
||||||
|
const prepareActions = (actions, t) => {
|
||||||
|
return actions.map(action => ({
|
||||||
|
...action,
|
||||||
|
title: t(action.title),
|
||||||
|
section: t(action.section),
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
const createPriorityOptions = (t, currentPriority) => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
||||||
|
key: null,
|
||||||
|
icon: ICON_PRIORITY_NONE,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
||||||
|
key: 'urgent',
|
||||||
|
icon: ICON_PRIORITY_URGENT,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
||||||
|
key: 'high',
|
||||||
|
icon: ICON_PRIORITY_HIGH,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
||||||
|
key: 'medium',
|
||||||
|
icon: ICON_PRIORITY_MEDIUM,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
||||||
|
key: 'low',
|
||||||
|
icon: ICON_PRIORITY_LOW,
|
||||||
|
},
|
||||||
|
].filter(item => item.key !== currentPriority);
|
||||||
|
};
|
||||||
|
|
||||||
|
const createNonDraftMessageAIAssistActions = (t, replyMode) => {
|
||||||
|
if (replyMode === REPLY_EDITOR_MODES.REPLY) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'),
|
||||||
|
key: 'reply_suggestion',
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
|
||||||
|
key: 'summarize',
|
||||||
|
icon: ICON_AI_SUMMARY,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
const createDraftMessageAIAssistActions = t => {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
|
||||||
|
key: 'rephrase',
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'),
|
||||||
|
key: 'fix_spelling_grammar',
|
||||||
|
icon: ICON_AI_GRAMMAR,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
|
||||||
|
key: 'expand',
|
||||||
|
icon: ICON_AI_EXPAND,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
|
||||||
|
key: 'shorten',
|
||||||
|
icon: ICON_AI_SHORTEN,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
|
||||||
|
key: 'make_friendly',
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
|
||||||
|
key: 'make_formal',
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
|
||||||
|
key: 'simplify',
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|
||||||
|
export function useConversationHotKeys() {
|
||||||
|
const { t } = useI18n();
|
||||||
|
const store = useStore();
|
||||||
|
const route = useRoute();
|
||||||
|
|
||||||
|
const {
|
||||||
|
activeLabels,
|
||||||
|
inactiveLabels,
|
||||||
|
addLabelToConversation,
|
||||||
|
removeLabelFromConversation,
|
||||||
|
} = useConversationLabels();
|
||||||
|
|
||||||
|
const { isAIIntegrationEnabled } = useAI();
|
||||||
|
const { agentsList } = useAgentsList();
|
||||||
|
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||||
|
const contextMenuChatId = useMapGetter('getContextMenuChatId');
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
const getDraftMessage = useMapGetter('draftMessages/get');
|
||||||
|
|
||||||
|
const conversationId = computed(() => currentChat.value?.id);
|
||||||
|
const draftKey = computed(
|
||||||
|
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const draftMessage = computed(() => getDraftMessage.value(draftKey.value));
|
||||||
|
|
||||||
|
const hasAnAssignedTeam = computed(() => !!currentChat.value?.meta?.team);
|
||||||
|
|
||||||
|
const teamsList = computed(() => {
|
||||||
|
if (hasAnAssignedTeam.value) {
|
||||||
|
return [{ id: 0, name: t('TEAMS_SETTINGS.LIST.NONE') }, ...teams.value];
|
||||||
|
}
|
||||||
|
return teams.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const onChangeAssignee = action => {
|
||||||
|
store.dispatch('assignAgent', {
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
agentId: action.agentInfo.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangePriority = action => {
|
||||||
|
store.dispatch('assignPriority', {
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
priority: action.priority.key,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onChangeTeam = action => {
|
||||||
|
store.dispatch('assignTeam', {
|
||||||
|
conversationId: currentChat.value.id,
|
||||||
|
teamId: action.teamInfo.id,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusActions = computed(() => {
|
||||||
|
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||||
|
const isSnoozed =
|
||||||
|
currentChat.value?.status === wootConstants.STATUS_TYPE.SNOOZED;
|
||||||
|
const isResolved =
|
||||||
|
currentChat.value?.status === wootConstants.STATUS_TYPE.RESOLVED;
|
||||||
|
|
||||||
|
let actions = [];
|
||||||
|
if (isOpen) {
|
||||||
|
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
|
||||||
|
} else if (isResolved || isSnoozed) {
|
||||||
|
actions = RESOLVED_CONVERSATION_ACTIONS;
|
||||||
|
}
|
||||||
|
return prepareActions(actions, t);
|
||||||
|
});
|
||||||
|
|
||||||
|
const priorityOptions = computed(() =>
|
||||||
|
createPriorityOptions(t, currentChat.value?.priority)
|
||||||
|
);
|
||||||
|
|
||||||
|
const assignAgentActions = computed(() => {
|
||||||
|
const agentOptions = agentsList.value.map(agent => ({
|
||||||
|
id: `agent-${agent.id}`,
|
||||||
|
title: agent.name,
|
||||||
|
parent: 'assign_an_agent',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
|
||||||
|
agentInfo: agent,
|
||||||
|
icon: ICON_ASSIGN_AGENT,
|
||||||
|
handler: onChangeAssignee,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'assign_an_agent',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||||
|
icon: ICON_ASSIGN_AGENT,
|
||||||
|
children: agentOptions.map(option => option.id),
|
||||||
|
},
|
||||||
|
...agentOptions,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignPriorityActions = computed(() => {
|
||||||
|
const options = priorityOptions.value.map(priority => ({
|
||||||
|
id: `priority-${priority.key}`,
|
||||||
|
title: priority.label,
|
||||||
|
parent: 'assign_priority',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
|
||||||
|
priority: priority,
|
||||||
|
icon: priority.icon,
|
||||||
|
handler: onChangePriority,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'assign_priority',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||||
|
icon: ICON_ASSIGN_PRIORITY,
|
||||||
|
children: options.map(option => option.id),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const assignTeamActions = computed(() => {
|
||||||
|
const teamOptions = teamsList.value.map(team => ({
|
||||||
|
id: `team-${team.id}`,
|
||||||
|
title: team.name,
|
||||||
|
parent: 'assign_a_team',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
|
||||||
|
teamInfo: team,
|
||||||
|
icon: ICON_ASSIGN_TEAM,
|
||||||
|
handler: onChangeTeam,
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'assign_a_team',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||||
|
icon: ICON_ASSIGN_TEAM,
|
||||||
|
children: teamOptions.map(option => option.id),
|
||||||
|
},
|
||||||
|
...teamOptions,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const addLabelActions = computed(() => {
|
||||||
|
const availableLabels = inactiveLabels.value.map(label => ({
|
||||||
|
id: label.title,
|
||||||
|
title: `#${label.title}`,
|
||||||
|
parent: 'add_a_label_to_the_conversation',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
|
||||||
|
icon: ICON_ADD_LABEL,
|
||||||
|
handler: action => addLabelToConversation({ title: action.id }),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
...availableLabels,
|
||||||
|
{
|
||||||
|
id: 'add_a_label_to_the_conversation',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||||
|
icon: ICON_ADD_LABEL,
|
||||||
|
children: inactiveLabels.value.map(label => label.title),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const removeLabelActions = computed(() => {
|
||||||
|
const activeLabelsComputed = activeLabels.value.map(label => ({
|
||||||
|
id: label.title,
|
||||||
|
title: `#${label.title}`,
|
||||||
|
parent: 'remove_a_label_to_the_conversation',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
|
||||||
|
icon: ICON_REMOVE_LABEL,
|
||||||
|
handler: action => removeLabelFromConversation(action.id),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
...activeLabelsComputed,
|
||||||
|
{
|
||||||
|
id: 'remove_a_label_to_the_conversation',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||||
|
icon: ICON_REMOVE_LABEL,
|
||||||
|
children: activeLabels.value.map(label => label.title),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const labelActions = computed(() => {
|
||||||
|
if (activeLabels.value.length) {
|
||||||
|
return [...addLabelActions.value, ...removeLabelActions.value];
|
||||||
|
}
|
||||||
|
return addLabelActions.value;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversationAdditionalActions = computed(() => {
|
||||||
|
return prepareActions(
|
||||||
|
[
|
||||||
|
currentChat.value.muted ? UNMUTE_ACTION : MUTE_ACTION,
|
||||||
|
SEND_TRANSCRIPT_ACTION,
|
||||||
|
],
|
||||||
|
t
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const AIAssistActions = computed(() => {
|
||||||
|
const aiOptions = draftMessage.value
|
||||||
|
? createDraftMessageAIAssistActions(t)
|
||||||
|
: createNonDraftMessageAIAssistActions(t, replyMode.value);
|
||||||
|
const options = aiOptions.map(item => ({
|
||||||
|
id: `ai-assist-${item.key}`,
|
||||||
|
title: item.label,
|
||||||
|
parent: 'ai_assist',
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
|
||||||
|
priority: item,
|
||||||
|
icon: item.icon,
|
||||||
|
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
|
||||||
|
}));
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: 'ai_assist',
|
||||||
|
title: t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
|
||||||
|
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
|
||||||
|
icon: ICON_AI_ASSIST,
|
||||||
|
children: options.map(option => option.id),
|
||||||
|
},
|
||||||
|
...options,
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
const isConversationOrInboxRoute = computed(() => {
|
||||||
|
return isAConversationRoute(route.name) || isAInboxViewRoute(route.name);
|
||||||
|
});
|
||||||
|
|
||||||
|
const shouldShowSnoozeOption = computed(() => {
|
||||||
|
return (
|
||||||
|
isAConversationRoute(route.name, true, false) && contextMenuChatId.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const getDefaultConversationHotKeys = computed(() => {
|
||||||
|
const defaultConversationHotKeys = [
|
||||||
|
...statusActions.value,
|
||||||
|
...conversationAdditionalActions.value,
|
||||||
|
...assignAgentActions.value,
|
||||||
|
...assignTeamActions.value,
|
||||||
|
...labelActions.value,
|
||||||
|
...assignPriorityActions.value,
|
||||||
|
];
|
||||||
|
if (isAIIntegrationEnabled.value) {
|
||||||
|
return [...defaultConversationHotKeys, ...AIAssistActions.value];
|
||||||
|
}
|
||||||
|
return defaultConversationHotKeys;
|
||||||
|
});
|
||||||
|
|
||||||
|
const conversationHotKeys = computed(() => {
|
||||||
|
if (shouldShowSnoozeOption.value) {
|
||||||
|
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
|
||||||
|
}
|
||||||
|
if (isConversationOrInboxRoute.value) {
|
||||||
|
return getDefaultConversationHotKeys.value;
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversationHotKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,3 +1,8 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useRouter } from 'dashboard/composables/route';
|
||||||
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import {
|
import {
|
||||||
ICON_ACCOUNT_SETTINGS,
|
ICON_ACCOUNT_SETTINGS,
|
||||||
ICON_AGENT_REPORTS,
|
ICON_AGENT_REPORTS,
|
||||||
@@ -14,11 +19,9 @@ import {
|
|||||||
ICON_TEAM_REPORTS,
|
ICON_TEAM_REPORTS,
|
||||||
ICON_USER_PROFILE,
|
ICON_USER_PROFILE,
|
||||||
ICON_CONVERSATION_REPORTS,
|
ICON_CONVERSATION_REPORTS,
|
||||||
} from './CommandBarIcons';
|
} from 'dashboard/helper/commandbar/icons';
|
||||||
import { frontendURL } from '../../../helper/URLHelper';
|
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||||
import { mapGetters } from 'vuex';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
|
||||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
|
||||||
|
|
||||||
const GO_TO_COMMANDS = [
|
const GO_TO_COMMANDS = [
|
||||||
{
|
{
|
||||||
@@ -172,45 +175,45 @@ const GO_TO_COMMANDS = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default {
|
export function useGoToCommandHotKeys() {
|
||||||
setup() {
|
const { t } = useI18n();
|
||||||
const { isAdmin } = useAdmin();
|
const router = useRouter();
|
||||||
return {
|
const { isAdmin } = useAdmin();
|
||||||
isAdmin,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters({
|
|
||||||
accountId: 'getCurrentAccountId',
|
|
||||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
|
||||||
}),
|
|
||||||
goToCommandHotKeys() {
|
|
||||||
let commands = GO_TO_COMMANDS.filter(cmd => {
|
|
||||||
if (cmd.featureFlag) {
|
|
||||||
return this.isFeatureEnabledonAccount(
|
|
||||||
this.accountId,
|
|
||||||
cmd.featureFlag
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!this.isAdmin) {
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
commands = commands.filter(command => command.role.includes('agent'));
|
const isFeatureEnabledOnAccount = useMapGetter(
|
||||||
|
'accounts/isFeatureEnabledonAccount'
|
||||||
|
);
|
||||||
|
|
||||||
|
const openRoute = url => {
|
||||||
|
router.push(frontendURL(url));
|
||||||
|
};
|
||||||
|
|
||||||
|
const goToCommandHotKeys = computed(() => {
|
||||||
|
let commands = GO_TO_COMMANDS.filter(cmd => {
|
||||||
|
if (cmd.featureFlag) {
|
||||||
|
return isFeatureEnabledOnAccount.value(
|
||||||
|
currentAccountId.value,
|
||||||
|
cmd.featureFlag
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return commands.map(command => ({
|
if (!isAdmin.value) {
|
||||||
id: command.id,
|
commands = commands.filter(command => command.role.includes('agent'));
|
||||||
section: this.$t(command.section),
|
}
|
||||||
title: this.$t(command.title),
|
|
||||||
icon: command.icon,
|
return commands.map(command => ({
|
||||||
handler: () => this.openRoute(command.path(this.accountId)),
|
id: command.id,
|
||||||
}));
|
section: t(command.section),
|
||||||
},
|
title: t(command.title),
|
||||||
},
|
icon: command.icon,
|
||||||
methods: {
|
handler: () => openRoute(command.path(currentAccountId.value)),
|
||||||
openRoute(url) {
|
}));
|
||||||
this.$router.push(frontendURL(url));
|
});
|
||||||
},
|
|
||||||
},
|
return {
|
||||||
};
|
goToCommandHotKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1,13 +1,19 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useI18n } from 'dashboard/composables/useI18n';
|
||||||
|
import { useRoute } from 'dashboard/composables/route';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
|
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
|
||||||
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
|
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
|
||||||
import { emitter } from 'shared/helpers/mitt';
|
import { emitter } from 'shared/helpers/mitt';
|
||||||
|
|
||||||
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||||
|
|
||||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||||
|
|
||||||
|
const createSnoozeHandler = option => () =>
|
||||||
|
emitter.emit(CMD_SNOOZE_NOTIFICATION, option);
|
||||||
|
|
||||||
const INBOX_SNOOZE_EVENTS = [
|
const INBOX_SNOOZE_EVENTS = [
|
||||||
{
|
{
|
||||||
id: 'snooze_notification',
|
id: 'snooze_notification',
|
||||||
@@ -21,8 +27,7 @@ const INBOX_SNOOZE_EVENTS = [
|
|||||||
parent: 'snooze_notification',
|
parent: 'snooze_notification',
|
||||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||||
icon: ICON_SNOOZE_NOTIFICATION,
|
icon: ICON_SNOOZE_NOTIFICATION,
|
||||||
handler: () =>
|
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
|
||||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
|
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
|
||||||
@@ -30,8 +35,7 @@ const INBOX_SNOOZE_EVENTS = [
|
|||||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||||
parent: 'snooze_notification',
|
parent: 'snooze_notification',
|
||||||
icon: ICON_SNOOZE_NOTIFICATION,
|
icon: ICON_SNOOZE_NOTIFICATION,
|
||||||
handler: () =>
|
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
|
||||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
|
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
|
||||||
@@ -39,8 +43,7 @@ const INBOX_SNOOZE_EVENTS = [
|
|||||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||||
parent: 'snooze_notification',
|
parent: 'snooze_notification',
|
||||||
icon: ICON_SNOOZE_NOTIFICATION,
|
icon: ICON_SNOOZE_NOTIFICATION,
|
||||||
handler: () =>
|
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
|
||||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
|
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
|
||||||
@@ -48,8 +51,7 @@ const INBOX_SNOOZE_EVENTS = [
|
|||||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||||
parent: 'snooze_notification',
|
parent: 'snooze_notification',
|
||||||
icon: ICON_SNOOZE_NOTIFICATION,
|
icon: ICON_SNOOZE_NOTIFICATION,
|
||||||
handler: () =>
|
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
|
||||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
|
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
|
||||||
@@ -57,26 +59,30 @@ const INBOX_SNOOZE_EVENTS = [
|
|||||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||||
parent: 'snooze_notification',
|
parent: 'snooze_notification',
|
||||||
icon: ICON_SNOOZE_NOTIFICATION,
|
icon: ICON_SNOOZE_NOTIFICATION,
|
||||||
handler: () =>
|
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
||||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
export default {
|
|
||||||
computed: {
|
export function useInboxHotKeys() {
|
||||||
inboxHotKeys() {
|
const { t } = useI18n();
|
||||||
if (isAInboxViewRoute(this.$route.name)) {
|
const route = useRoute();
|
||||||
return this.prepareActions(INBOX_SNOOZE_EVENTS);
|
|
||||||
}
|
const prepareActions = actions => {
|
||||||
return [];
|
return actions.map(action => ({
|
||||||
},
|
...action,
|
||||||
},
|
title: t(action.title),
|
||||||
methods: {
|
section: action.section ? t(action.section) : undefined,
|
||||||
prepareActions(actions) {
|
}));
|
||||||
return actions.map(action => ({
|
};
|
||||||
...action,
|
|
||||||
title: this.$t(action.title),
|
const inboxHotKeys = computed(() => {
|
||||||
section: this.$t(action.section),
|
if (isAInboxViewRoute(route.name)) {
|
||||||
}));
|
return prepareActions(INBOX_SNOOZE_EVENTS);
|
||||||
},
|
}
|
||||||
},
|
return [];
|
||||||
};
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
inboxHotKeys,
|
||||||
|
};
|
||||||
|
}
|
||||||
63
app/javascript/dashboard/composables/spec/fixtures/agentFixtures.js
vendored
Normal file
63
app/javascript/dashboard/composables/spec/fixtures/agentFixtures.js
vendored
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
|
||||||
|
|
||||||
|
export { allAgentsData };
|
||||||
|
export const formattedAgentsData = [
|
||||||
|
{
|
||||||
|
account_id: 0,
|
||||||
|
confirmed: true,
|
||||||
|
email: 'None',
|
||||||
|
id: 0,
|
||||||
|
name: 'None',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abraham@chatwoot.com',
|
||||||
|
id: 5,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'John K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'John Kennady',
|
||||||
|
role: 'administrator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Honey',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'bee@chatwoot.com',
|
||||||
|
id: 4,
|
||||||
|
name: 'Honey Bee',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Samuel K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'samuel@chatwoot.com',
|
||||||
|
id: 2,
|
||||||
|
name: 'Samuel Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'offline',
|
||||||
|
available_name: 'James K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'james@chatwoot.com',
|
||||||
|
id: 3,
|
||||||
|
name: 'James Koti',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
37
app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js
vendored
Normal file
37
app/javascript/dashboard/composables/spec/fixtures/reportFixtures.js
vendored
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
export const summary = {
|
||||||
|
avg_first_response_time: '198.6666666666667',
|
||||||
|
avg_resolution_time: '208.3333333333333',
|
||||||
|
conversations_count: 5000,
|
||||||
|
incoming_messages_count: 5,
|
||||||
|
outgoing_messages_count: 3,
|
||||||
|
previous: {
|
||||||
|
avg_first_response_time: '89.0',
|
||||||
|
avg_resolution_time: '145.0',
|
||||||
|
conversations_count: 4,
|
||||||
|
incoming_messages_count: 5,
|
||||||
|
outgoing_messages_count: 4,
|
||||||
|
resolutions_count: 0,
|
||||||
|
},
|
||||||
|
resolutions_count: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const botSummary = {
|
||||||
|
bot_resolutions_count: 10,
|
||||||
|
bot_handoffs_count: 20,
|
||||||
|
previous: {
|
||||||
|
bot_resolutions_count: 8,
|
||||||
|
bot_handoffs_count: 5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const report = {
|
||||||
|
data: [
|
||||||
|
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||||
|
{ value: '0.00', timestamp: 1647628200, count: 0 },
|
||||||
|
{ value: '0.00', timestamp: 1647714600, count: 0 },
|
||||||
|
{ value: '0.00', timestamp: 1647801000, count: 0 },
|
||||||
|
{ value: '0.01', timestamp: 1647887400, count: 4 },
|
||||||
|
{ value: '0.00', timestamp: 1647973800, count: 0 },
|
||||||
|
{ value: '0.00', timestamp: 1648060200, count: 0 },
|
||||||
|
],
|
||||||
|
};
|
||||||
119
app/javascript/dashboard/composables/spec/useAI.spec.js
Normal file
119
app/javascript/dashboard/composables/spec/useAI.spec.js
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import { useAI } from '../useAI';
|
||||||
|
import {
|
||||||
|
useStore,
|
||||||
|
useStoreGetters,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { useI18n } from '../useI18n';
|
||||||
|
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables');
|
||||||
|
vi.mock('../useI18n');
|
||||||
|
vi.mock('dashboard/api/integrations/openapi');
|
||||||
|
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||||
|
OPEN_AI_EVENTS: {
|
||||||
|
TEST_EVENT: 'open_ai_test_event',
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useAI', () => {
|
||||||
|
const mockStore = {
|
||||||
|
dispatch: vi.fn(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockGetters = {
|
||||||
|
'integrations/getUIFlags': { value: { isFetching: false } },
|
||||||
|
'draftMessages/get': { value: () => 'Draft message' },
|
||||||
|
};
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useStore.mockReturnValue(mockStore);
|
||||||
|
useStoreGetters.mockReturnValue(mockGetters);
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
'draftMessages/getReplyEditorMode': 'reply',
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
useTrack.mockReturnValue(vi.fn());
|
||||||
|
useI18n.mockReturnValue({ t: vi.fn() });
|
||||||
|
useAlert.mockReturnValue(vi.fn());
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes computed properties correctly', async () => {
|
||||||
|
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
|
||||||
|
useAI();
|
||||||
|
|
||||||
|
expect(uiFlags.value).toEqual({ isFetching: false });
|
||||||
|
expect(appIntegrations.value).toEqual([]);
|
||||||
|
expect(currentChat.value).toEqual({ id: '123' });
|
||||||
|
expect(replyMode.value).toBe('reply');
|
||||||
|
expect(draftMessage.value).toBe('Draft message');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches integrations if required', async () => {
|
||||||
|
const { fetchIntegrationsIfRequired } = useAI();
|
||||||
|
await fetchIntegrationsIfRequired();
|
||||||
|
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not fetch integrations if already loaded', async () => {
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [{ id: 'openai' }],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
'draftMessages/getReplyEditorMode': 'reply',
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchIntegrationsIfRequired } = useAI();
|
||||||
|
await fetchIntegrationsIfRequired();
|
||||||
|
expect(mockStore.dispatch).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('records analytics correctly', async () => {
|
||||||
|
const mockTrack = vi.fn();
|
||||||
|
useTrack.mockReturnValue(mockTrack);
|
||||||
|
const { recordAnalytics } = useAI();
|
||||||
|
|
||||||
|
await recordAnalytics('TEST_EVENT', { data: 'test' });
|
||||||
|
|
||||||
|
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
|
||||||
|
type: 'TEST_EVENT',
|
||||||
|
data: 'test',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fetches label suggestions', async () => {
|
||||||
|
OpenAPI.processEvent.mockResolvedValue({
|
||||||
|
data: { message: 'label1, label2' },
|
||||||
|
});
|
||||||
|
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const mockValues = {
|
||||||
|
'integrations/getAppIntegrations': [
|
||||||
|
{ id: 'openai', hooks: [{ id: 'hook1' }] },
|
||||||
|
],
|
||||||
|
getSelectedChat: { id: '123' },
|
||||||
|
};
|
||||||
|
return { value: mockValues[getter] };
|
||||||
|
});
|
||||||
|
|
||||||
|
const { fetchLabelSuggestions } = useAI();
|
||||||
|
const result = await fetchLabelSuggestions();
|
||||||
|
|
||||||
|
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
|
||||||
|
type: 'label_suggestion',
|
||||||
|
hookId: 'hook1',
|
||||||
|
conversationId: '123',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result).toEqual(['label1', 'label2']);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
import { useAgentsList } from '../useAgentsList';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||||
|
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/helper/agentHelper');
|
||||||
|
|
||||||
|
const mockUseMapGetter = (overrides = {}) => {
|
||||||
|
const defaultGetters = {
|
||||||
|
getCurrentUser: ref(allAgentsData[0]),
|
||||||
|
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||||
|
getCurrentAccountId: ref(1),
|
||||||
|
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||||
|
};
|
||||||
|
|
||||||
|
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||||
|
|
||||||
|
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('useAgentsList', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
|
||||||
|
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
|
||||||
|
formattedAgentsData.slice(1)
|
||||||
|
);
|
||||||
|
agentHelper.getCombinedAgents.mockImplementation(
|
||||||
|
(agents, includeNone, isAgentSelected) => {
|
||||||
|
if (includeNone && isAgentSelected) {
|
||||||
|
return [agentHelper.createNoneAgent, ...agents];
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
mockUseMapGetter();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns agentsList and assignableAgents', () => {
|
||||||
|
const { agentsList, assignableAgents } = useAgentsList();
|
||||||
|
|
||||||
|
expect(assignableAgents.value).toEqual(allAgentsData);
|
||||||
|
expect(agentsList.value).toEqual([
|
||||||
|
agentHelper.createNoneAgent,
|
||||||
|
...formattedAgentsData.slice(1),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes None agent when includeNoneAgent is true', () => {
|
||||||
|
const { agentsList } = useAgentsList(true);
|
||||||
|
|
||||||
|
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
|
||||||
|
expect(agentsList.value.length).toBe(formattedAgentsData.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes None agent when includeNoneAgent is false', () => {
|
||||||
|
const { agentsList } = useAgentsList(false);
|
||||||
|
|
||||||
|
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
|
||||||
|
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty assignable agents', () => {
|
||||||
|
mockUseMapGetter({
|
||||||
|
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||||
|
});
|
||||||
|
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
|
||||||
|
|
||||||
|
const { agentsList, assignableAgents } = useAgentsList();
|
||||||
|
|
||||||
|
expect(assignableAgents.value).toEqual([]);
|
||||||
|
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles missing inbox_id', () => {
|
||||||
|
mockUseMapGetter({
|
||||||
|
getSelectedChat: ref({ meta: { assignee: true } }),
|
||||||
|
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||||
|
});
|
||||||
|
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
|
||||||
|
|
||||||
|
const { agentsList, assignableAgents } = useAgentsList();
|
||||||
|
|
||||||
|
expect(assignableAgents.value).toEqual([]);
|
||||||
|
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||||
|
});
|
||||||
|
});
|
||||||
295
app/javascript/dashboard/composables/spec/useAutomation.spec.js
Normal file
295
app/javascript/dashboard/composables/spec/useAutomation.spec.js
Normal file
@@ -0,0 +1,295 @@
|
|||||||
|
import { useAutomation } from '../useAutomation';
|
||||||
|
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from '../useI18n';
|
||||||
|
import * as automationHelper from 'dashboard/helper/automationHelper';
|
||||||
|
import {
|
||||||
|
customAttributes,
|
||||||
|
agents,
|
||||||
|
teams,
|
||||||
|
labels,
|
||||||
|
statusFilterOptions,
|
||||||
|
campaigns,
|
||||||
|
contacts,
|
||||||
|
inboxes,
|
||||||
|
languages,
|
||||||
|
countries,
|
||||||
|
slaPolicies,
|
||||||
|
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
|
||||||
|
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('dashboard/composables');
|
||||||
|
vi.mock('../useI18n');
|
||||||
|
vi.mock('dashboard/helper/automationHelper');
|
||||||
|
|
||||||
|
describe('useAutomation', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
useStoreGetters.mockReturnValue({
|
||||||
|
'attributes/getAttributes': { value: customAttributes },
|
||||||
|
'attributes/getAttributesByModel': {
|
||||||
|
value: model => {
|
||||||
|
return model === 'conversation_attribute'
|
||||||
|
? [{ id: 1, name: 'Conversation Attribute' }]
|
||||||
|
: [{ id: 2, name: 'Contact Attribute' }];
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
useMapGetter.mockImplementation(getter => {
|
||||||
|
const getterMap = {
|
||||||
|
'agents/getAgents': agents,
|
||||||
|
'campaigns/getAllCampaigns': campaigns,
|
||||||
|
'contacts/getContacts': contacts,
|
||||||
|
'inboxes/getInboxes': inboxes,
|
||||||
|
'labels/getLabels': labels,
|
||||||
|
'teams/getTeams': teams,
|
||||||
|
'sla/getSLA': slaPolicies,
|
||||||
|
};
|
||||||
|
return { value: getterMap[getter] };
|
||||||
|
});
|
||||||
|
useI18n.mockReturnValue({ t: key => key });
|
||||||
|
useAlert.mockReturnValue(vi.fn());
|
||||||
|
|
||||||
|
// Mock getConditionOptions for different types
|
||||||
|
automationHelper.getConditionOptions.mockImplementation(options => {
|
||||||
|
const { type } = options;
|
||||||
|
switch (type) {
|
||||||
|
case 'status':
|
||||||
|
return statusFilterOptions;
|
||||||
|
case 'team_id':
|
||||||
|
return teams;
|
||||||
|
case 'assignee_id':
|
||||||
|
return agents;
|
||||||
|
case 'contact':
|
||||||
|
return contacts;
|
||||||
|
case 'inbox_id':
|
||||||
|
return inboxes;
|
||||||
|
case 'campaigns':
|
||||||
|
return campaigns;
|
||||||
|
case 'browser_language':
|
||||||
|
return languages;
|
||||||
|
case 'country_code':
|
||||||
|
return countries;
|
||||||
|
case 'message_type':
|
||||||
|
return MESSAGE_CONDITION_VALUES;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Mock getActionOptions for different types
|
||||||
|
automationHelper.getActionOptions.mockImplementation(options => {
|
||||||
|
const { type } = options;
|
||||||
|
switch (type) {
|
||||||
|
case 'add_label':
|
||||||
|
return labels;
|
||||||
|
case 'assign_team':
|
||||||
|
return teams;
|
||||||
|
case 'assign_agent':
|
||||||
|
return agents;
|
||||||
|
case 'send_email_to_team':
|
||||||
|
return teams;
|
||||||
|
case 'send_message':
|
||||||
|
return [];
|
||||||
|
case 'add_sla':
|
||||||
|
return slaPolicies;
|
||||||
|
default:
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('initializes computed properties correctly', () => {
|
||||||
|
const {
|
||||||
|
agents: computedAgents,
|
||||||
|
campaigns: computedCampaigns,
|
||||||
|
contacts: computedContacts,
|
||||||
|
inboxes: computedInboxes,
|
||||||
|
labels: computedLabels,
|
||||||
|
teams: computedTeams,
|
||||||
|
slaPolicies: computedSlaPolicies,
|
||||||
|
} = useAutomation();
|
||||||
|
|
||||||
|
expect(computedAgents.value).toEqual(agents);
|
||||||
|
expect(computedCampaigns.value).toEqual(campaigns);
|
||||||
|
expect(computedContacts.value).toEqual(contacts);
|
||||||
|
expect(computedInboxes.value).toEqual(inboxes);
|
||||||
|
expect(computedLabels.value).toEqual(labels);
|
||||||
|
expect(computedTeams.value).toEqual(teams);
|
||||||
|
expect(computedSlaPolicies.value).toEqual(slaPolicies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends new condition and action correctly', () => {
|
||||||
|
const { appendNewCondition, appendNewAction } = useAutomation();
|
||||||
|
const mockAutomation = {
|
||||||
|
event_name: 'message_created',
|
||||||
|
conditions: [],
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
automationHelper.getDefaultConditions.mockReturnValue([{}]);
|
||||||
|
automationHelper.getDefaultActions.mockReturnValue([{}]);
|
||||||
|
|
||||||
|
appendNewCondition(mockAutomation);
|
||||||
|
appendNewAction(mockAutomation);
|
||||||
|
|
||||||
|
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
|
||||||
|
'message_created'
|
||||||
|
);
|
||||||
|
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
|
||||||
|
expect(mockAutomation.conditions).toHaveLength(1);
|
||||||
|
expect(mockAutomation.actions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes filter and action correctly', () => {
|
||||||
|
const { removeFilter, removeAction } = useAutomation();
|
||||||
|
const mockAutomation = {
|
||||||
|
conditions: [{ id: 1 }, { id: 2 }],
|
||||||
|
actions: [{ id: 1 }, { id: 2 }],
|
||||||
|
};
|
||||||
|
|
||||||
|
removeFilter(mockAutomation, 0);
|
||||||
|
removeAction(mockAutomation, 0);
|
||||||
|
|
||||||
|
expect(mockAutomation.conditions).toHaveLength(1);
|
||||||
|
expect(mockAutomation.actions).toHaveLength(1);
|
||||||
|
expect(mockAutomation.conditions[0].id).toBe(2);
|
||||||
|
expect(mockAutomation.actions[0].id).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resets filter and action correctly', () => {
|
||||||
|
const { resetFilter, resetAction } = useAutomation();
|
||||||
|
const mockAutomation = {
|
||||||
|
event_name: 'message_created',
|
||||||
|
conditions: [
|
||||||
|
{
|
||||||
|
attribute_key: 'status',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: 'open',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
actions: [{ action_name: 'assign_agent', action_params: [1] }],
|
||||||
|
};
|
||||||
|
const mockAutomationTypes = {
|
||||||
|
message_created: {
|
||||||
|
conditions: [
|
||||||
|
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
resetFilter(
|
||||||
|
mockAutomation,
|
||||||
|
mockAutomationTypes,
|
||||||
|
0,
|
||||||
|
mockAutomation.conditions[0]
|
||||||
|
);
|
||||||
|
resetAction(mockAutomation, 0);
|
||||||
|
|
||||||
|
expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to');
|
||||||
|
expect(mockAutomation.conditions[0].values).toBe('');
|
||||||
|
expect(mockAutomation.actions[0].action_params).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats automation correctly', () => {
|
||||||
|
const { formatAutomation } = useAutomation();
|
||||||
|
const mockAutomation = {
|
||||||
|
conditions: [{ attribute_key: 'status', values: ['open'] }],
|
||||||
|
actions: [{ action_name: 'assign_agent', action_params: [1] }],
|
||||||
|
};
|
||||||
|
const mockAutomationTypes = {};
|
||||||
|
const mockAutomationActionTypes = [
|
||||||
|
{ key: 'assign_agent', inputType: 'search_select' },
|
||||||
|
];
|
||||||
|
|
||||||
|
automationHelper.getConditionOptions.mockReturnValue([
|
||||||
|
{ id: 'open', name: 'open' },
|
||||||
|
]);
|
||||||
|
automationHelper.getActionOptions.mockReturnValue([
|
||||||
|
{ id: 1, name: 'Agent 1' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const result = formatAutomation(
|
||||||
|
mockAutomation,
|
||||||
|
customAttributes,
|
||||||
|
mockAutomationTypes,
|
||||||
|
mockAutomationActionTypes
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]);
|
||||||
|
expect(result.actions[0].action_params).toEqual([
|
||||||
|
{ id: 1, name: 'Agent 1' },
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('manifests custom attributes correctly', () => {
|
||||||
|
const { manifestCustomAttributes } = useAutomation();
|
||||||
|
const mockAutomationTypes = {
|
||||||
|
message_created: { conditions: [] },
|
||||||
|
conversation_created: { conditions: [] },
|
||||||
|
conversation_updated: { conditions: [] },
|
||||||
|
conversation_opened: { conditions: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
||||||
|
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
||||||
|
|
||||||
|
manifestCustomAttributes(mockAutomationTypes);
|
||||||
|
|
||||||
|
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
|
||||||
|
2
|
||||||
|
);
|
||||||
|
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
|
||||||
|
Object.values(mockAutomationTypes).forEach(type => {
|
||||||
|
expect(type.conditions).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets condition dropdown values correctly', () => {
|
||||||
|
const { getConditionDropdownValues } = useAutomation();
|
||||||
|
|
||||||
|
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
|
||||||
|
expect(getConditionDropdownValues('team_id')).toEqual(teams);
|
||||||
|
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
|
||||||
|
expect(getConditionDropdownValues('contact')).toEqual(contacts);
|
||||||
|
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
|
||||||
|
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
|
||||||
|
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
|
||||||
|
expect(getConditionDropdownValues('country_code')).toEqual(countries);
|
||||||
|
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||||
|
MESSAGE_CONDITION_VALUES
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('gets action dropdown values correctly', () => {
|
||||||
|
const { getActionDropdownValues } = useAutomation();
|
||||||
|
|
||||||
|
expect(getActionDropdownValues('add_label')).toEqual(labels);
|
||||||
|
expect(getActionDropdownValues('assign_team')).toEqual(teams);
|
||||||
|
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
|
||||||
|
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||||
|
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||||
|
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles event change correctly', () => {
|
||||||
|
const { onEventChange } = useAutomation();
|
||||||
|
const mockAutomation = {
|
||||||
|
event_name: 'message_created',
|
||||||
|
conditions: [],
|
||||||
|
actions: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
automationHelper.getDefaultConditions.mockReturnValue([{}]);
|
||||||
|
automationHelper.getDefaultActions.mockReturnValue([{}]);
|
||||||
|
|
||||||
|
onEventChange(mockAutomation);
|
||||||
|
|
||||||
|
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
|
||||||
|
'message_created'
|
||||||
|
);
|
||||||
|
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
|
||||||
|
expect(mockAutomation.conditions).toHaveLength(1);
|
||||||
|
expect(mockAutomation.actions).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { useIntegrationHook } from '../useIntegrationHook';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { nextTick } from 'vue';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
|
||||||
|
describe('useIntegrationHook', () => {
|
||||||
|
let integrationGetter;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
integrationGetter = vi.fn();
|
||||||
|
useMapGetter.mockReturnValue({ value: integrationGetter });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return the correct computed properties', async () => {
|
||||||
|
const mockIntegration = {
|
||||||
|
id: 1,
|
||||||
|
hook_type: 'inbox',
|
||||||
|
hooks: ['hook1', 'hook2'],
|
||||||
|
allow_multiple_hooks: true,
|
||||||
|
};
|
||||||
|
integrationGetter.mockReturnValue(mockIntegration);
|
||||||
|
|
||||||
|
const hook = useIntegrationHook(1);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(hook.integration.value).toEqual(mockIntegration);
|
||||||
|
expect(hook.integrationType.value).toBe('multiple');
|
||||||
|
expect(hook.isIntegrationMultiple.value).toBe(true);
|
||||||
|
expect(hook.isIntegrationSingle.value).toBe(false);
|
||||||
|
expect(hook.isHookTypeInbox.value).toBe(true);
|
||||||
|
expect(hook.hasConnectedHooks.value).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should handle single integration type correctly', async () => {
|
||||||
|
const mockIntegration = {
|
||||||
|
id: 2,
|
||||||
|
hook_type: 'channel',
|
||||||
|
hooks: [],
|
||||||
|
allow_multiple_hooks: false,
|
||||||
|
};
|
||||||
|
integrationGetter.mockReturnValue(mockIntegration);
|
||||||
|
|
||||||
|
const hook = useIntegrationHook(2);
|
||||||
|
|
||||||
|
await nextTick();
|
||||||
|
|
||||||
|
expect(hook.integration.value).toEqual(mockIntegration);
|
||||||
|
expect(hook.integrationType.value).toBe('single');
|
||||||
|
expect(hook.isIntegrationMultiple.value).toBe(false);
|
||||||
|
expect(hook.isIntegrationSingle.value).toBe(true);
|
||||||
|
expect(hook.isHookTypeInbox.value).toBe(false);
|
||||||
|
expect(hook.hasConnectedHooks.value).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { unref } from 'vue';
|
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
|
||||||
describe('useKeyboardEvents', () => {
|
describe('useKeyboardEvents', () => {
|
||||||
@@ -11,15 +10,13 @@ describe('useKeyboardEvents', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||||
const el = document.createElement('div');
|
|
||||||
const elRef = unref({ value: el });
|
|
||||||
const events = {
|
const events = {
|
||||||
'ALT+KeyL': () => {},
|
'ALT+KeyL': () => {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mountedMock = vi.fn();
|
const mountedMock = vi.fn();
|
||||||
const unmountedMock = vi.fn();
|
const unmountedMock = vi.fn();
|
||||||
useKeyboardEvents(events, elRef);
|
useKeyboardEvents(events);
|
||||||
mountedMock();
|
mountedMock();
|
||||||
unmountedMock();
|
unmountedMock();
|
||||||
|
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ vi.mock('../useKeyboardEvents', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('useKeyboardNavigableList', () => {
|
describe('useKeyboardNavigableList', () => {
|
||||||
let elementRef;
|
|
||||||
let items;
|
let items;
|
||||||
let onSelect;
|
let onSelect;
|
||||||
let adjustScroll;
|
let adjustScroll;
|
||||||
@@ -18,7 +17,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
const createMockEvent = () => ({ preventDefault: vi.fn() });
|
const createMockEvent = () => ({ preventDefault: vi.fn() });
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
elementRef = ref(null);
|
|
||||||
items = ref(['item1', 'item2', 'item3']);
|
items = ref(['item1', 'item2', 'item3']);
|
||||||
onSelect = vi.fn();
|
onSelect = vi.fn();
|
||||||
adjustScroll = vi.fn();
|
adjustScroll = vi.fn();
|
||||||
@@ -28,7 +26,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should return moveSelectionUp and moveSelectionDown functions', () => {
|
it('should return moveSelectionUp and moveSelectionDown functions', () => {
|
||||||
const result = useKeyboardNavigableList({
|
const result = useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -43,7 +40,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should move selection up correctly', () => {
|
it('should move selection up correctly', () => {
|
||||||
const { moveSelectionUp } = useKeyboardNavigableList({
|
const { moveSelectionUp } = useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -65,7 +61,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should move selection down correctly', () => {
|
it('should move selection down correctly', () => {
|
||||||
const { moveSelectionDown } = useKeyboardNavigableList({
|
const { moveSelectionDown } = useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -87,7 +82,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should call adjustScroll after moving selection', () => {
|
it('should call adjustScroll after moving selection', () => {
|
||||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -103,7 +97,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should include Enter key handler when onSelect is provided', () => {
|
it('should include Enter key handler when onSelect is provided', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -118,7 +111,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should not include Enter key handler when onSelect is not provided', () => {
|
it('should not include Enter key handler when onSelect is not provided', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@@ -131,7 +123,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should not trigger onSelect when items are empty', () => {
|
it('should not trigger onSelect when items are empty', () => {
|
||||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items: ref([]),
|
items: ref([]),
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -145,23 +136,18 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should call useKeyboardEvents with correct parameters', () => {
|
it('should call useKeyboardEvents with correct parameters', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(useKeyboardEvents).toHaveBeenCalledWith(
|
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
|
||||||
expect.any(Object),
|
|
||||||
elementRef
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Keyboard event handlers
|
// Keyboard event handlers
|
||||||
it('should handle ArrowUp key', () => {
|
it('should handle ArrowUp key', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -178,7 +164,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle Control+KeyP', () => {
|
it('should handle Control+KeyP', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -195,7 +180,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle ArrowDown key', () => {
|
it('should handle ArrowDown key', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -212,7 +196,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle Control+KeyN', () => {
|
it('should handle Control+KeyN', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -229,7 +212,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle Enter key when onSelect is provided', () => {
|
it('should handle Enter key when onSelect is provided', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -245,7 +227,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should not have Enter key handler when onSelect is not provided', () => {
|
it('should not have Enter key handler when onSelect is not provided', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
selectedIndex,
|
selectedIndex,
|
||||||
@@ -257,7 +238,6 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should set allowOnFocusedInput to true for all key handlers', () => {
|
it('should set allowOnFocusedInput to true for all key handlers', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, it, expect, vi } from 'vitest';
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
import { useMacros } from '../useMacros';
|
import { useMacros } from '../useMacros';
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||||
|
|
||||||
vi.mock('dashboard/composables/store');
|
vi.mock('dashboard/composables/store');
|
||||||
vi.mock('dashboard/helper/automationHelper.js');
|
vi.mock('dashboard/helper/automationHelper.js');
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ref } from 'vue';
|
||||||
|
import { useReportMetrics } from '../useReportMetrics';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { summary, botSummary } from './fixtures/reportFixtures';
|
||||||
|
|
||||||
|
vi.mock('dashboard/composables/store');
|
||||||
|
vi.mock('@chatwoot/utils', () => ({
|
||||||
|
formatTime: vi.fn(time => `formatted_${time}`),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('useReportMetrics', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
useMapGetter.mockReturnValue(ref(summary));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('calculates trend correctly', () => {
|
||||||
|
const { calculateTrend } = useReportMetrics();
|
||||||
|
|
||||||
|
expect(calculateTrend('conversations_count')).toBe(124900);
|
||||||
|
expect(calculateTrend('incoming_messages_count')).toBe(0);
|
||||||
|
expect(calculateTrend('avg_first_response_time')).toBe(123);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns 0 for trend when previous value is not available', () => {
|
||||||
|
const { calculateTrend } = useReportMetrics();
|
||||||
|
|
||||||
|
expect(calculateTrend('non_existent_key')).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('identifies average metric types correctly', () => {
|
||||||
|
const { isAverageMetricType } = useReportMetrics();
|
||||||
|
|
||||||
|
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
|
||||||
|
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
|
||||||
|
expect(isAverageMetricType('reply_time')).toBe(true);
|
||||||
|
expect(isAverageMetricType('conversations_count')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays metrics correctly for account', () => {
|
||||||
|
const { displayMetric } = useReportMetrics();
|
||||||
|
|
||||||
|
expect(displayMetric('conversations_count')).toBe('5,000');
|
||||||
|
expect(displayMetric('incoming_messages_count')).toBe('5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('displays the metric for bot', () => {
|
||||||
|
const customKey = 'getBotSummary';
|
||||||
|
useMapGetter.mockReturnValue(ref(botSummary));
|
||||||
|
const { displayMetric } = useReportMetrics(customKey);
|
||||||
|
|
||||||
|
expect(displayMetric('bot_resolutions_count')).toBe('10');
|
||||||
|
expect(displayMetric('bot_handoffs_count')).toBe('20');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles non-existent metrics', () => {
|
||||||
|
const { displayMetric } = useReportMetrics();
|
||||||
|
|
||||||
|
expect(displayMetric('non_existent_key')).toBe('0');
|
||||||
|
});
|
||||||
|
});
|
||||||
204
app/javascript/dashboard/composables/useAI.js
Normal file
204
app/javascript/dashboard/composables/useAI.js
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { computed, onMounted } from 'vue';
|
||||||
|
import {
|
||||||
|
useStore,
|
||||||
|
useStoreGetters,
|
||||||
|
useMapGetter,
|
||||||
|
} from 'dashboard/composables/store';
|
||||||
|
import { useAlert, useTrack } from 'dashboard/composables';
|
||||||
|
import { useI18n } from './useI18n';
|
||||||
|
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||||
|
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleans and normalizes a list of labels.
|
||||||
|
* @param {string} labels - A comma-separated string of labels.
|
||||||
|
* @returns {string[]} An array of cleaned and unique labels.
|
||||||
|
*/
|
||||||
|
const cleanLabels = labels => {
|
||||||
|
return labels
|
||||||
|
.toLowerCase() // Set it to lowercase
|
||||||
|
.split(',') // split the string into an array
|
||||||
|
.filter(label => label.trim()) // remove any empty strings
|
||||||
|
.map(label => label.trim()) // trim the words
|
||||||
|
.filter((label, index, self) => self.indexOf(label) === index);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable function for AI-related operations in the dashboard.
|
||||||
|
* @returns {Object} An object containing AI-related methods and computed properties.
|
||||||
|
*/
|
||||||
|
export function useAI() {
|
||||||
|
const store = useStore();
|
||||||
|
const getters = useStoreGetters();
|
||||||
|
const track = useTrack();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for UI flags.
|
||||||
|
* @type {import('vue').ComputedRef<Object>}
|
||||||
|
*/
|
||||||
|
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
|
||||||
|
|
||||||
|
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the AI integration.
|
||||||
|
* @type {import('vue').ComputedRef<Object|undefined>}
|
||||||
|
*/
|
||||||
|
const aiIntegration = computed(
|
||||||
|
() =>
|
||||||
|
appIntegrations.value.find(
|
||||||
|
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||||
|
)?.hooks[0]
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if AI integration is enabled.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if label suggestion feature is enabled.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||||
|
if (aiIntegration.value) {
|
||||||
|
const { settings = {} } = aiIntegration.value || {};
|
||||||
|
return settings.label_suggestion;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property to check if app integrations are being fetched.
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the hook ID.
|
||||||
|
* @type {import('vue').ComputedRef<string|undefined>}
|
||||||
|
*/
|
||||||
|
const hookId = computed(() => aiIntegration.value?.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the conversation ID.
|
||||||
|
* @type {import('vue').ComputedRef<string|undefined>}
|
||||||
|
*/
|
||||||
|
const conversationId = computed(() => currentChat.value?.id);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the draft key.
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const draftKey = computed(
|
||||||
|
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Computed property for the draft message.
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const draftMessage = computed(() =>
|
||||||
|
getters['draftMessages/get'].value(draftKey.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches integrations if they haven't been loaded yet.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const fetchIntegrationsIfRequired = async () => {
|
||||||
|
if (!appIntegrations.value.length) {
|
||||||
|
await store.dispatch('integrations/get');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Records analytics for AI-related events.
|
||||||
|
* @param {string} type - The type of event.
|
||||||
|
* @param {Object} payload - Additional data for the event.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
const recordAnalytics = async (type, payload) => {
|
||||||
|
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||||
|
if (event) {
|
||||||
|
track(event, {
|
||||||
|
type,
|
||||||
|
...payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetches label suggestions for the current conversation.
|
||||||
|
* @returns {Promise<string[]>} An array of suggested labels.
|
||||||
|
*/
|
||||||
|
const fetchLabelSuggestions = async () => {
|
||||||
|
if (!conversationId.value) return [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await OpenAPI.processEvent({
|
||||||
|
type: 'label_suggestion',
|
||||||
|
hookId: hookId.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
});
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: { message: labels },
|
||||||
|
} = result;
|
||||||
|
|
||||||
|
return cleanLabels(labels);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes an AI event, such as rephrasing content.
|
||||||
|
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||||
|
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
|
||||||
|
*/
|
||||||
|
const processEvent = async (type = 'rephrase') => {
|
||||||
|
try {
|
||||||
|
const result = await OpenAPI.processEvent({
|
||||||
|
hookId: hookId.value,
|
||||||
|
type,
|
||||||
|
content: draftMessage.value,
|
||||||
|
conversationId: conversationId.value,
|
||||||
|
});
|
||||||
|
const {
|
||||||
|
data: { message: generatedMessage },
|
||||||
|
} = result;
|
||||||
|
return generatedMessage;
|
||||||
|
} catch (error) {
|
||||||
|
const errorData = error.response.data.error;
|
||||||
|
const errorMessage =
|
||||||
|
errorData?.error?.message ||
|
||||||
|
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||||
|
useAlert(errorMessage);
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchIntegrationsIfRequired();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
draftMessage,
|
||||||
|
uiFlags,
|
||||||
|
appIntegrations,
|
||||||
|
currentChat,
|
||||||
|
replyMode,
|
||||||
|
isAIIntegrationEnabled,
|
||||||
|
isLabelSuggestionFeatureEnabled,
|
||||||
|
isFetchingAppIntegrations,
|
||||||
|
fetchIntegrationsIfRequired,
|
||||||
|
recordAnalytics,
|
||||||
|
fetchLabelSuggestions,
|
||||||
|
processEvent,
|
||||||
|
};
|
||||||
|
}
|
||||||
57
app/javascript/dashboard/composables/useAgentsList.js
Normal file
57
app/javascript/dashboard/composables/useAgentsList.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import {
|
||||||
|
getAgentsByUpdatedPresence,
|
||||||
|
getSortedAgentsByAvailability,
|
||||||
|
getCombinedAgents,
|
||||||
|
} from 'dashboard/helper/agentHelper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable function that provides a list of agents for assignment.
|
||||||
|
*
|
||||||
|
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||||
|
* @returns {Object} An object containing the agents list and assignable agents.
|
||||||
|
*/
|
||||||
|
export function useAgentsList(includeNoneAgent = true) {
|
||||||
|
const currentUser = useMapGetter('getCurrentUser');
|
||||||
|
const currentChat = useMapGetter('getSelectedChat');
|
||||||
|
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||||
|
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||||
|
|
||||||
|
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||||
|
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('vue').ComputedRef<Array>}
|
||||||
|
*/
|
||||||
|
const assignableAgents = computed(() => {
|
||||||
|
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @type {import('vue').ComputedRef<Array>}
|
||||||
|
*/
|
||||||
|
const agentsList = computed(() => {
|
||||||
|
const agents = assignableAgents.value || [];
|
||||||
|
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||||
|
agents,
|
||||||
|
currentUser.value,
|
||||||
|
currentAccountId.value
|
||||||
|
);
|
||||||
|
|
||||||
|
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
|
||||||
|
agentsByUpdatedPresence
|
||||||
|
);
|
||||||
|
|
||||||
|
return getCombinedAgents(
|
||||||
|
filteredAgentsByAvailability,
|
||||||
|
includeNoneAgent,
|
||||||
|
isAgentSelected.value
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
agentsList,
|
||||||
|
assignableAgents,
|
||||||
|
};
|
||||||
|
}
|
||||||
349
app/javascript/dashboard/composables/useAutomation.js
Normal file
349
app/javascript/dashboard/composables/useAutomation.js
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
import { useI18n } from './useI18n';
|
||||||
|
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||||
|
import countries from 'shared/constants/countries';
|
||||||
|
import {
|
||||||
|
generateCustomAttributeTypes,
|
||||||
|
getActionOptions,
|
||||||
|
getConditionOptions,
|
||||||
|
getCustomAttributeInputType,
|
||||||
|
getDefaultConditions,
|
||||||
|
getDefaultActions,
|
||||||
|
filterCustomAttributes,
|
||||||
|
getStandardAttributeInputType,
|
||||||
|
isCustomAttribute,
|
||||||
|
generateCustomAttributes,
|
||||||
|
} from 'dashboard/helper/automationHelper';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for handling automation-related functionality.
|
||||||
|
* @returns {Object} An object containing various automation-related functions and computed properties.
|
||||||
|
*/
|
||||||
|
export function useAutomation() {
|
||||||
|
const getters = useStoreGetters();
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const agents = useMapGetter('agents/getAgents');
|
||||||
|
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||||
|
const contacts = useMapGetter('contacts/getContacts');
|
||||||
|
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||||
|
const labels = useMapGetter('labels/getLabels');
|
||||||
|
const teams = useMapGetter('teams/getTeams');
|
||||||
|
const slaPolicies = useMapGetter('sla/getSLA');
|
||||||
|
|
||||||
|
const booleanFilterOptions = computed(() => [
|
||||||
|
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
|
||||||
|
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
|
||||||
|
]);
|
||||||
|
|
||||||
|
const statusFilterOptions = computed(() => {
|
||||||
|
const statusFilters = t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
|
||||||
|
return [
|
||||||
|
...Object.keys(statusFilters).map(status => ({
|
||||||
|
id: status,
|
||||||
|
name: statusFilters[status].TEXT,
|
||||||
|
})),
|
||||||
|
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handles the event change for an automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
*/
|
||||||
|
const onEventChange = automation => {
|
||||||
|
automation.conditions = getDefaultConditions(automation.event_name);
|
||||||
|
automation.actions = getDefaultActions();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the condition dropdown values for a given type.
|
||||||
|
* @param {string} type - The type of condition.
|
||||||
|
* @returns {Array} An array of condition dropdown values.
|
||||||
|
*/
|
||||||
|
const getConditionDropdownValues = type => {
|
||||||
|
return getConditionOptions({
|
||||||
|
agents: agents.value,
|
||||||
|
booleanFilterOptions: booleanFilterOptions.value,
|
||||||
|
campaigns: campaigns.value,
|
||||||
|
contacts: contacts.value,
|
||||||
|
customAttributes: getters['attributes/getAttributes'].value,
|
||||||
|
inboxes: inboxes.value,
|
||||||
|
statusFilterOptions: statusFilterOptions.value,
|
||||||
|
teams: teams.value,
|
||||||
|
languages,
|
||||||
|
countries,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a new condition to the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
*/
|
||||||
|
const appendNewCondition = automation => {
|
||||||
|
automation.conditions.push(...getDefaultConditions(automation.event_name));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Appends a new action to the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
*/
|
||||||
|
const appendNewAction = automation => {
|
||||||
|
automation.actions.push(...getDefaultActions());
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes a filter from the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
* @param {number} index - The index of the filter to remove.
|
||||||
|
*/
|
||||||
|
const removeFilter = (automation, index) => {
|
||||||
|
if (automation.conditions.length <= 1) {
|
||||||
|
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
|
||||||
|
} else {
|
||||||
|
automation.conditions.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Removes an action from the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
* @param {number} index - The index of the action to remove.
|
||||||
|
*/
|
||||||
|
const removeAction = (automation, index) => {
|
||||||
|
if (automation.actions.length <= 1) {
|
||||||
|
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
|
||||||
|
} else {
|
||||||
|
automation.actions.splice(index, 1);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets a filter in the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
* @param {Object} automationTypes - The automation types object.
|
||||||
|
* @param {number} index - The index of the filter to reset.
|
||||||
|
* @param {Object} currentCondition - The current condition object.
|
||||||
|
*/
|
||||||
|
const resetFilter = (
|
||||||
|
automation,
|
||||||
|
automationTypes,
|
||||||
|
index,
|
||||||
|
currentCondition
|
||||||
|
) => {
|
||||||
|
automation.conditions[index].filter_operator = automationTypes[
|
||||||
|
automation.event_name
|
||||||
|
].conditions.find(
|
||||||
|
condition => condition.key === currentCondition.attribute_key
|
||||||
|
).filterOperators[0].value;
|
||||||
|
automation.conditions[index].values = '';
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resets an action in the automation.
|
||||||
|
* @param {Object} automation - The automation object to update.
|
||||||
|
* @param {number} index - The index of the action to reset.
|
||||||
|
*/
|
||||||
|
const resetAction = (automation, index) => {
|
||||||
|
automation.actions[index].action_params = [];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function sets the conditions for automation.
|
||||||
|
* It help to format the conditions for the automation when we open the edit automation modal.
|
||||||
|
* @param {Object} automation - The automation object containing conditions to manifest.
|
||||||
|
* @param {Array} allCustomAttributes - List of all custom attributes.
|
||||||
|
* @param {Object} automationTypes - Object containing automation type definitions.
|
||||||
|
* @returns {Array} An array of manifested conditions.
|
||||||
|
*/
|
||||||
|
const manifestConditions = (
|
||||||
|
automation,
|
||||||
|
allCustomAttributes,
|
||||||
|
automationTypes
|
||||||
|
) => {
|
||||||
|
const customAttributes = filterCustomAttributes(allCustomAttributes);
|
||||||
|
return automation.conditions.map(condition => {
|
||||||
|
const customAttr = isCustomAttribute(
|
||||||
|
customAttributes,
|
||||||
|
condition.attribute_key
|
||||||
|
);
|
||||||
|
let inputType = 'plain_text';
|
||||||
|
if (customAttr) {
|
||||||
|
inputType = getCustomAttributeInputType(customAttr.type);
|
||||||
|
} else {
|
||||||
|
inputType = getStandardAttributeInputType(
|
||||||
|
automationTypes,
|
||||||
|
automation.event_name,
|
||||||
|
condition.attribute_key
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (inputType === 'plain_text' || inputType === 'date') {
|
||||||
|
return { ...condition, values: condition.values[0] };
|
||||||
|
}
|
||||||
|
if (inputType === 'comma_separated_plain_text') {
|
||||||
|
return { ...condition, values: condition.values.join(',') };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
...condition,
|
||||||
|
query_operator: condition.query_operator || 'and',
|
||||||
|
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
|
||||||
|
item => [...condition.values].includes(item.id)
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets the action dropdown values for a given type.
|
||||||
|
* @param {string} type - The type of action.
|
||||||
|
* @returns {Array} An array of action dropdown values.
|
||||||
|
*/
|
||||||
|
const getActionDropdownValues = type => {
|
||||||
|
return getActionOptions({
|
||||||
|
agents: agents.value,
|
||||||
|
labels: labels.value,
|
||||||
|
teams: teams.value,
|
||||||
|
slaPolicies: slaPolicies.value,
|
||||||
|
languages,
|
||||||
|
type,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generates an array of actions for the automation.
|
||||||
|
* @param {Object} action - The action object.
|
||||||
|
* @param {Array} automationActionTypes - List of available automation action types.
|
||||||
|
* @returns {Array|Object} Generated actions array or object based on input type.
|
||||||
|
*/
|
||||||
|
const generateActionsArray = (action, automationActionTypes) => {
|
||||||
|
const params = action.action_params;
|
||||||
|
const inputType = automationActionTypes.find(
|
||||||
|
item => item.key === action.action_name
|
||||||
|
).inputType;
|
||||||
|
if (inputType === 'multi_select' || inputType === 'search_select') {
|
||||||
|
return [...getActionDropdownValues(action.action_name)].filter(item =>
|
||||||
|
[...params].includes(item.id)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (inputType === 'team_message') {
|
||||||
|
return {
|
||||||
|
team_ids: [...getActionDropdownValues(action.action_name)].filter(
|
||||||
|
item => [...params[0].team_ids].includes(item.id)
|
||||||
|
),
|
||||||
|
message: params[0].message,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return [...params];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function sets the actions for automation.
|
||||||
|
* It help to format the actions for the automation when we open the edit automation modal.
|
||||||
|
* @param {Object} automation - The automation object containing actions.
|
||||||
|
* @param {Array} automationActionTypes - List of available automation action types.
|
||||||
|
* @returns {Array} An array of manifested actions.
|
||||||
|
*/
|
||||||
|
const manifestActions = (automation, automationActionTypes) => {
|
||||||
|
return automation.actions.map(action => ({
|
||||||
|
...action,
|
||||||
|
action_params: action.action_params.length
|
||||||
|
? generateActionsArray(action, automationActionTypes)
|
||||||
|
: [],
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats the automation object for use when we edit the automation.
|
||||||
|
* It help to format the conditions and actions for the automation when we open the edit automation modal.
|
||||||
|
* @param {Object} automation - The automation object to format.
|
||||||
|
* @param {Array} allCustomAttributes - List of all custom attributes.
|
||||||
|
* @param {Object} automationTypes - Object containing automation type definitions.
|
||||||
|
* @param {Array} automationActionTypes - List of available automation action types.
|
||||||
|
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
|
||||||
|
*/
|
||||||
|
const formatAutomation = (
|
||||||
|
automation,
|
||||||
|
allCustomAttributes,
|
||||||
|
automationTypes,
|
||||||
|
automationActionTypes
|
||||||
|
) => {
|
||||||
|
return {
|
||||||
|
...automation,
|
||||||
|
conditions: manifestConditions(
|
||||||
|
automation,
|
||||||
|
allCustomAttributes,
|
||||||
|
automationTypes
|
||||||
|
),
|
||||||
|
actions: manifestActions(automation, automationActionTypes),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This function formats the custom attributes for automation types.
|
||||||
|
* It retrieves custom attributes for conversations and contacts,
|
||||||
|
* generates custom attribute types, and adds them to the relevant automation types.
|
||||||
|
* @param {Object} automationTypes - The automation types object to update with custom attributes.
|
||||||
|
*/
|
||||||
|
const manifestCustomAttributes = automationTypes => {
|
||||||
|
const conversationCustomAttributesRaw = getters[
|
||||||
|
'attributes/getAttributesByModel'
|
||||||
|
].value('conversation_attribute');
|
||||||
|
const contactCustomAttributesRaw =
|
||||||
|
getters['attributes/getAttributesByModel'].value('contact_attribute');
|
||||||
|
|
||||||
|
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
|
||||||
|
conversationCustomAttributesRaw,
|
||||||
|
'conversation_attribute'
|
||||||
|
);
|
||||||
|
const contactCustomAttributeTypes = generateCustomAttributeTypes(
|
||||||
|
contactCustomAttributesRaw,
|
||||||
|
'contact_attribute'
|
||||||
|
);
|
||||||
|
|
||||||
|
const manifestedCustomAttributes = generateCustomAttributes(
|
||||||
|
conversationCustomAttributeTypes,
|
||||||
|
contactCustomAttributeTypes,
|
||||||
|
t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
|
||||||
|
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
|
||||||
|
);
|
||||||
|
|
||||||
|
automationTypes.message_created.conditions.push(
|
||||||
|
...manifestedCustomAttributes
|
||||||
|
);
|
||||||
|
automationTypes.conversation_created.conditions.push(
|
||||||
|
...manifestedCustomAttributes
|
||||||
|
);
|
||||||
|
automationTypes.conversation_updated.conditions.push(
|
||||||
|
...manifestedCustomAttributes
|
||||||
|
);
|
||||||
|
automationTypes.conversation_opened.conditions.push(
|
||||||
|
...manifestedCustomAttributes
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
agents,
|
||||||
|
campaigns,
|
||||||
|
contacts,
|
||||||
|
inboxes,
|
||||||
|
labels,
|
||||||
|
teams,
|
||||||
|
slaPolicies,
|
||||||
|
booleanFilterOptions,
|
||||||
|
statusFilterOptions,
|
||||||
|
onEventChange,
|
||||||
|
getConditionDropdownValues,
|
||||||
|
appendNewCondition,
|
||||||
|
appendNewAction,
|
||||||
|
removeFilter,
|
||||||
|
removeAction,
|
||||||
|
resetFilter,
|
||||||
|
resetAction,
|
||||||
|
formatAutomation,
|
||||||
|
getActionDropdownValues,
|
||||||
|
manifestCustomAttributes,
|
||||||
|
};
|
||||||
|
}
|
||||||
68
app/javascript/dashboard/composables/useIntegrationHook.js
Normal file
68
app/javascript/dashboard/composables/useIntegrationHook.js
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
import { computed } from 'vue';
|
||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for managing integration hooks
|
||||||
|
* @param {string|number} integrationId - The ID of the integration
|
||||||
|
* @returns {Object} An object containing computed properties for the integration
|
||||||
|
*/
|
||||||
|
export const useIntegrationHook = integrationId => {
|
||||||
|
const integrationGetter = useMapGetter('integrations/getIntegration');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The integration object
|
||||||
|
* @type {import('vue').ComputedRef<Object>}
|
||||||
|
*/
|
||||||
|
const integration = computed(() => {
|
||||||
|
return integrationGetter.value(integrationId);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the integration hook type is 'inbox'
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isHookTypeInbox = computed(() => {
|
||||||
|
return integration.value.hook_type === 'inbox';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the integration has any connected hooks
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const hasConnectedHooks = computed(() => {
|
||||||
|
return !!integration.value.hooks.length;
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The type of integration: 'multiple' or 'single'
|
||||||
|
* @type {import('vue').ComputedRef<string>}
|
||||||
|
*/
|
||||||
|
const integrationType = computed(() => {
|
||||||
|
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the integration allows multiple hooks
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isIntegrationMultiple = computed(() => {
|
||||||
|
return integrationType.value === 'multiple';
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether the integration allows only a single hook
|
||||||
|
* @type {import('vue').ComputedRef<boolean>}
|
||||||
|
*/
|
||||||
|
const isIntegrationSingle = computed(() => {
|
||||||
|
return integrationType.value === 'single';
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
integration,
|
||||||
|
integrationType,
|
||||||
|
isIntegrationMultiple,
|
||||||
|
isIntegrationSingle,
|
||||||
|
isHookTypeInbox,
|
||||||
|
hasConnectedHooks,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { onMounted, onBeforeUnmount, unref } from 'vue';
|
|
||||||
import {
|
import {
|
||||||
isActiveElementTypeable,
|
isActiveElementTypeable,
|
||||||
isEscape,
|
isEscape,
|
||||||
@@ -7,8 +6,7 @@ import {
|
|||||||
} from 'shared/helpers/KeyboardHelpers';
|
} from 'shared/helpers/KeyboardHelpers';
|
||||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||||
import { createKeybindingsHandler } from 'tinykeys';
|
import { createKeybindingsHandler } from 'tinykeys';
|
||||||
|
import { onUnmounted, onMounted } from 'vue';
|
||||||
const keyboardListenerMap = new WeakMap();
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
||||||
@@ -69,49 +67,24 @@ async function wrapEventsInKeybindingsHandler(events) {
|
|||||||
return wrappedEvents;
|
return wrappedEvents;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Sets up keyboard event listeners on the specified element.
|
|
||||||
* @param {Element} root - The DOM element to attach listeners to.
|
|
||||||
* @param {Object} events - The events to listen for.
|
|
||||||
*/
|
|
||||||
const setupListeners = (root, events) => {
|
|
||||||
if (root instanceof Element && events) {
|
|
||||||
const keydownHandler = createKeybindingsHandler(events);
|
|
||||||
document.addEventListener('keydown', keydownHandler);
|
|
||||||
keyboardListenerMap.set(root, keydownHandler);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes keyboard event listeners from the specified element.
|
|
||||||
* @param {Element} root - The DOM element to remove listeners from.
|
|
||||||
*/
|
|
||||||
const removeListeners = root => {
|
|
||||||
// In the future, let's use the abort controller to remove the listeners
|
|
||||||
// https://caniuse.com/abortcontroller
|
|
||||||
if (root instanceof Element) {
|
|
||||||
const handlerToRemove = keyboardListenerMap.get(root);
|
|
||||||
document.removeEventListener('keydown', handlerToRemove);
|
|
||||||
keyboardListenerMap.delete(root);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
||||||
* @param {Object} keyboardEvents - The keyboard events to handle.
|
* @param {Object} keyboardEvents - The keyboard events to handle.
|
||||||
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
|
|
||||||
*/
|
*/
|
||||||
export function useKeyboardEvents(keyboardEvents, elRef = null) {
|
export async function useKeyboardEvents(keyboardEvents) {
|
||||||
|
let abortController = new AbortController();
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
const el = unref(elRef);
|
if (!keyboardEvents) return;
|
||||||
const getKeyboardEvents = () => keyboardEvents || null;
|
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
|
||||||
const events = getKeyboardEvents();
|
const keydownHandler = createKeybindingsHandler(wrappedEvents);
|
||||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
|
|
||||||
setupListeners(el, wrappedEvents);
|
document.addEventListener('keydown', keydownHandler, {
|
||||||
|
signal: abortController.signal,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
onBeforeUnmount(() => {
|
onUnmounted(() => {
|
||||||
const el = unref(elRef);
|
abortController.abort();
|
||||||
removeListeners(el);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,7 +84,6 @@ const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
|
|||||||
* }} An object containing functions to move the selection up and down.
|
* }} An object containing functions to move the selection up and down.
|
||||||
*/
|
*/
|
||||||
export function useKeyboardNavigableList({
|
export function useKeyboardNavigableList({
|
||||||
elementRef,
|
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -109,7 +108,7 @@ export function useKeyboardNavigableList({
|
|||||||
items
|
items
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents, elementRef);
|
useKeyboardEvents(keyboardEvents);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
moveSelectionUp,
|
moveSelectionUp,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
import { useStoreGetters } from 'dashboard/composables/store';
|
||||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for handling macro-related functionality
|
* Composable for handling macro-related functionality
|
||||||
|
|||||||
57
app/javascript/dashboard/composables/useReportMetrics.js
Normal file
57
app/javascript/dashboard/composables/useReportMetrics.js
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { formatTime } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A composable function for report metrics calculations and display.
|
||||||
|
*
|
||||||
|
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
|
||||||
|
* @returns {Object} An object containing utility functions for report metrics.
|
||||||
|
*/
|
||||||
|
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
||||||
|
const accountSummary = useMapGetter(accountSummaryKey);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the trend percentage for a given metric.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key of the metric to calculate trend for.
|
||||||
|
* @returns {number} The calculated trend percentage, rounded to the nearest integer.
|
||||||
|
*/
|
||||||
|
const calculateTrend = key => {
|
||||||
|
if (!accountSummary.value.previous[key]) return 0;
|
||||||
|
const diff = accountSummary.value[key] - accountSummary.value.previous[key];
|
||||||
|
return Math.round((diff / accountSummary.value.previous[key]) * 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if a given metric key represents an average metric type.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key of the metric to check.
|
||||||
|
* @returns {boolean} True if the metric is an average type, false otherwise.
|
||||||
|
*/
|
||||||
|
const isAverageMetricType = key => {
|
||||||
|
return [
|
||||||
|
'avg_first_response_time',
|
||||||
|
'avg_resolution_time',
|
||||||
|
'reply_time',
|
||||||
|
].includes(key);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formats and displays a metric value based on its type.
|
||||||
|
*
|
||||||
|
* @param {string} key - The key of the metric to display.
|
||||||
|
* @returns {string} The formatted metric value as a string.
|
||||||
|
*/
|
||||||
|
const displayMetric = key => {
|
||||||
|
if (isAverageMetricType(key)) {
|
||||||
|
return formatTime(accountSummary.value[key]);
|
||||||
|
}
|
||||||
|
return Number(accountSummary.value[key] || '').toLocaleString();
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
calculateTrend,
|
||||||
|
isAverageMetricType,
|
||||||
|
displayMetric,
|
||||||
|
};
|
||||||
|
}
|
||||||
70
app/javascript/dashboard/constants/automation.js
Normal file
70
app/javascript/dashboard/constants/automation.js
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
export const DEFAULT_MESSAGE_CREATED_CONDITION = [
|
||||||
|
{
|
||||||
|
attribute_key: 'message_type',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: '',
|
||||||
|
query_operator: 'and',
|
||||||
|
custom_attribute_type: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
|
||||||
|
{
|
||||||
|
attribute_key: 'browser_language',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: '',
|
||||||
|
query_operator: 'and',
|
||||||
|
custom_attribute_type: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_OTHER_CONDITION = [
|
||||||
|
{
|
||||||
|
attribute_key: 'status',
|
||||||
|
filter_operator: 'equal_to',
|
||||||
|
values: '',
|
||||||
|
query_operator: 'and',
|
||||||
|
custom_attribute_type: '',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const DEFAULT_ACTIONS = [
|
||||||
|
{
|
||||||
|
action_name: 'assign_agent',
|
||||||
|
action_params: [],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MESSAGE_CONDITION_VALUES = [
|
||||||
|
{
|
||||||
|
id: 'incoming',
|
||||||
|
name: 'Incoming Message',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'outgoing',
|
||||||
|
name: 'Outgoing Message',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const PRIORITY_CONDITION_VALUES = [
|
||||||
|
{
|
||||||
|
id: 'nil',
|
||||||
|
name: 'None',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'low',
|
||||||
|
name: 'Low',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'medium',
|
||||||
|
name: 'Medium',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'high',
|
||||||
|
name: 'High',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'urgent',
|
||||||
|
name: 'Urgent',
|
||||||
|
},
|
||||||
|
];
|
||||||
53
app/javascript/dashboard/constants/permissions.js
Normal file
53
app/javascript/dashboard/constants/permissions.js
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
export const AVAILABLE_CUSTOM_ROLE_PERMISSIONS = [
|
||||||
|
'conversation_manage',
|
||||||
|
'conversation_unassigned_manage',
|
||||||
|
'conversation_participating_manage',
|
||||||
|
'contact_manage',
|
||||||
|
'report_manage',
|
||||||
|
'knowledge_base_manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const ROLES = ['agent', 'administrator'];
|
||||||
|
|
||||||
|
export const CONVERSATION_PERMISSIONS = [
|
||||||
|
'conversation_manage',
|
||||||
|
'conversation_unassigned_manage',
|
||||||
|
'conversation_participating_manage',
|
||||||
|
];
|
||||||
|
|
||||||
|
export const MANAGE_ALL_CONVERSATION_PERMISSIONS = 'conversation_manage';
|
||||||
|
|
||||||
|
export const CONVERSATION_UNASSIGNED_PERMISSIONS =
|
||||||
|
'conversation_unassigned_manage';
|
||||||
|
|
||||||
|
export const CONVERSATION_PARTICIPATING_PERMISSIONS =
|
||||||
|
'conversation_participating_manage';
|
||||||
|
|
||||||
|
export const CONTACT_PERMISSIONS = 'contact_manage';
|
||||||
|
|
||||||
|
export const REPORTS_PERMISSIONS = 'report_manage';
|
||||||
|
|
||||||
|
export const PORTAL_PERMISSIONS = 'knowledge_base_manage';
|
||||||
|
|
||||||
|
export const ASSIGNEE_TYPE_TAB_PERMISSIONS = {
|
||||||
|
me: {
|
||||||
|
count: 'mineCount',
|
||||||
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
},
|
||||||
|
unassigned: {
|
||||||
|
count: 'unAssignedCount',
|
||||||
|
permissions: [
|
||||||
|
...ROLES,
|
||||||
|
MANAGE_ALL_CONVERSATION_PERMISSIONS,
|
||||||
|
CONVERSATION_UNASSIGNED_PERMISSIONS,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
all: {
|
||||||
|
count: 'allCount',
|
||||||
|
permissions: [
|
||||||
|
...ROLES,
|
||||||
|
MANAGE_ALL_CONVERSATION_PERMISSIONS,
|
||||||
|
CONVERSATION_PARTICIPATING_PERMISSIONS,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -16,7 +16,6 @@ export const FEATURE_FLAGS = {
|
|||||||
TEAM_MANAGEMENT: 'team_management',
|
TEAM_MANAGEMENT: 'team_management',
|
||||||
VOICE_RECORDER: 'voice_recorder',
|
VOICE_RECORDER: 'voice_recorder',
|
||||||
AUDIT_LOGS: 'audit_logs',
|
AUDIT_LOGS: 'audit_logs',
|
||||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
|
||||||
INBOX_VIEW: 'inbox_view',
|
INBOX_VIEW: 'inbox_view',
|
||||||
SLA: 'sla',
|
SLA: 'sla',
|
||||||
RESPONSE_BOT: 'response_bot',
|
RESPONSE_BOT: 'response_bot',
|
||||||
@@ -32,4 +31,5 @@ export const FEATURE_FLAGS = {
|
|||||||
IP_LOOKUP: 'ip_lookup',
|
IP_LOOKUP: 'ip_lookup',
|
||||||
LINEAR: 'linear_integration',
|
LINEAR: 'linear_integration',
|
||||||
CAPTAIN: 'captain_integration',
|
CAPTAIN: 'captain_integration',
|
||||||
|
CUSTOM_ROLES: 'custom_roles',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
|||||||
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
||||||
TRANSLATE_A_MESSAGE: 'Translated a message',
|
TRANSLATE_A_MESSAGE: 'Translated a message',
|
||||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||||
|
INSERTED_AN_EMOJI: 'Inserted an emoji',
|
||||||
USED_MENTIONS: 'Used mentions',
|
USED_MENTIONS: 'Used mentions',
|
||||||
SEARCH_CONVERSATION: 'Searched conversations',
|
SEARCH_CONVERSATION: 'Searched conversations',
|
||||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||||
|
|||||||
@@ -5,6 +5,11 @@ import {
|
|||||||
getAlertAudio,
|
getAlertAudio,
|
||||||
initOnEvents,
|
initOnEvents,
|
||||||
} from 'shared/helpers/AudioNotificationHelper';
|
} from 'shared/helpers/AudioNotificationHelper';
|
||||||
|
import {
|
||||||
|
ROLES,
|
||||||
|
CONVERSATION_PERMISSIONS,
|
||||||
|
} from 'dashboard/constants/permissions.js';
|
||||||
|
import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js';
|
||||||
|
|
||||||
const NOTIFICATION_TIME = 30000;
|
const NOTIFICATION_TIME = 30000;
|
||||||
|
|
||||||
@@ -14,12 +19,13 @@ class DashboardAudioNotificationHelper {
|
|||||||
this.audioAlertType = 'none';
|
this.audioAlertType = 'none';
|
||||||
this.playAlertOnlyWhenHidden = true;
|
this.playAlertOnlyWhenHidden = true;
|
||||||
this.alertIfUnreadConversationExist = false;
|
this.alertIfUnreadConversationExist = false;
|
||||||
|
this.currentUser = null;
|
||||||
this.currentUserId = null;
|
this.currentUserId = null;
|
||||||
this.audioAlertTone = 'ding';
|
this.audioAlertTone = 'ding';
|
||||||
}
|
}
|
||||||
|
|
||||||
setInstanceValues = ({
|
setInstanceValues = ({
|
||||||
currentUserId,
|
currentUser,
|
||||||
alwaysPlayAudioAlert,
|
alwaysPlayAudioAlert,
|
||||||
alertIfUnreadConversationExist,
|
alertIfUnreadConversationExist,
|
||||||
audioAlertType,
|
audioAlertType,
|
||||||
@@ -28,7 +34,8 @@ class DashboardAudioNotificationHelper {
|
|||||||
this.audioAlertType = audioAlertType;
|
this.audioAlertType = audioAlertType;
|
||||||
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
|
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
|
||||||
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
|
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
|
||||||
this.currentUserId = currentUserId;
|
this.currentUser = currentUser;
|
||||||
|
this.currentUserId = currentUser.id;
|
||||||
this.audioAlertTone = audioAlertTone;
|
this.audioAlertTone = audioAlertTone;
|
||||||
initOnEvents.forEach(e => {
|
initOnEvents.forEach(e => {
|
||||||
document.addEventListener(e, this.onAudioListenEvent, false);
|
document.addEventListener(e, this.onAudioListenEvent, false);
|
||||||
@@ -112,6 +119,20 @@ class DashboardAudioNotificationHelper {
|
|||||||
return message?.sender_id === this.currentUserId;
|
return message?.sender_id === this.currentUserId;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
isUserHasConversationPermission = () => {
|
||||||
|
const currentAccountId = window.WOOT.$store.getters.getCurrentAccountId;
|
||||||
|
// Get the user permissions for the current account
|
||||||
|
const userPermissions = getUserPermissions(
|
||||||
|
this.currentUser,
|
||||||
|
currentAccountId
|
||||||
|
);
|
||||||
|
// Check if the user has the required permissions
|
||||||
|
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
|
||||||
|
permission => userPermissions.includes(permission)
|
||||||
|
);
|
||||||
|
return hasRequiredPermission;
|
||||||
|
};
|
||||||
|
|
||||||
shouldNotifyOnMessage = message => {
|
shouldNotifyOnMessage = message => {
|
||||||
if (this.audioAlertType === 'mine') {
|
if (this.audioAlertType === 'mine') {
|
||||||
return this.isConversationAssignedToCurrentUser(message);
|
return this.isConversationAssignedToCurrentUser(message);
|
||||||
@@ -120,6 +141,11 @@ class DashboardAudioNotificationHelper {
|
|||||||
};
|
};
|
||||||
|
|
||||||
onNewMessage = message => {
|
onNewMessage = message => {
|
||||||
|
// If the user does not have the permission to view the conversation, then dismiss the alert
|
||||||
|
if (!this.isUserHasConversationPermission()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// If the message is sent by the current user or the
|
// If the message is sent by the current user or the
|
||||||
// correct notification is not enabled, then dismiss the alert
|
// correct notification is not enabled, then dismiss the alert
|
||||||
if (
|
if (
|
||||||
|
|||||||
83
app/javascript/dashboard/helper/agentHelper.js
Normal file
83
app/javascript/dashboard/helper/agentHelper.js
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* Default agent object representing 'None'
|
||||||
|
* @type {Object}
|
||||||
|
*/
|
||||||
|
export const createNoneAgent = {
|
||||||
|
confirmed: true,
|
||||||
|
name: 'None',
|
||||||
|
id: 0,
|
||||||
|
role: 'agent',
|
||||||
|
account_id: 0,
|
||||||
|
email: 'None',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters and sorts agents by availability status
|
||||||
|
* @param {Array} agents - List of agents
|
||||||
|
* @param {string} availability - Availability status to filter by
|
||||||
|
* @returns {Array} Filtered and sorted list of agents
|
||||||
|
*/
|
||||||
|
export const getAgentsByAvailability = (agents, availability) => {
|
||||||
|
return agents
|
||||||
|
.filter(agent => agent.availability_status === availability)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sorts agents by availability status: online, busy, then offline
|
||||||
|
* @param {Array} agents - List of agents
|
||||||
|
* @returns {Array} Sorted list of agents
|
||||||
|
*/
|
||||||
|
export const getSortedAgentsByAvailability = agents => {
|
||||||
|
const onlineAgents = getAgentsByAvailability(agents, 'online');
|
||||||
|
const busyAgents = getAgentsByAvailability(agents, 'busy');
|
||||||
|
const offlineAgents = getAgentsByAvailability(agents, 'offline');
|
||||||
|
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
|
||||||
|
return filteredAgents;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the availability status of the current user based on the current account
|
||||||
|
* @param {Array} agents - List of agents
|
||||||
|
* @param {Object} currentUser - Current user object
|
||||||
|
* @param {number} currentAccountId - ID of the current account
|
||||||
|
* @returns {Array} Updated list of agents with dynamic presence
|
||||||
|
*/
|
||||||
|
// Here we are updating the availability status of the current user dynamically
|
||||||
|
// based on the current account availability status
|
||||||
|
export const getAgentsByUpdatedPresence = (
|
||||||
|
agents,
|
||||||
|
currentUser,
|
||||||
|
currentAccountId
|
||||||
|
) => {
|
||||||
|
const agentsWithDynamicPresenceUpdate = agents.map(item =>
|
||||||
|
item.id === currentUser.id
|
||||||
|
? {
|
||||||
|
...item,
|
||||||
|
availability_status: currentUser.accounts.find(
|
||||||
|
account => account.id === currentAccountId
|
||||||
|
).availability_status,
|
||||||
|
}
|
||||||
|
: item
|
||||||
|
);
|
||||||
|
return agentsWithDynamicPresenceUpdate;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Combines the filtered agents with the 'None' agent option if applicable.
|
||||||
|
*
|
||||||
|
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
|
||||||
|
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
|
||||||
|
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
|
||||||
|
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
|
||||||
|
*/
|
||||||
|
export const getCombinedAgents = (
|
||||||
|
filteredAgentsByAvailability,
|
||||||
|
includeNoneAgent,
|
||||||
|
isAgentSelected
|
||||||
|
) => {
|
||||||
|
return [
|
||||||
|
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
|
||||||
|
...filteredAgentsByAvailability,
|
||||||
|
];
|
||||||
|
};
|
||||||
@@ -3,41 +3,16 @@ import {
|
|||||||
OPERATOR_TYPES_3,
|
OPERATOR_TYPES_3,
|
||||||
OPERATOR_TYPES_4,
|
OPERATOR_TYPES_4,
|
||||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||||
|
import {
|
||||||
|
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||||
|
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||||
|
DEFAULT_OTHER_CONDITION,
|
||||||
|
DEFAULT_ACTIONS,
|
||||||
|
MESSAGE_CONDITION_VALUES,
|
||||||
|
PRIORITY_CONDITION_VALUES,
|
||||||
|
} from 'dashboard/constants/automation';
|
||||||
import filterQueryGenerator from './filterQueryGenerator';
|
import filterQueryGenerator from './filterQueryGenerator';
|
||||||
import actionQueryGenerator from './actionQueryGenerator';
|
import actionQueryGenerator from './actionQueryGenerator';
|
||||||
const MESSAGE_CONDITION_VALUES = [
|
|
||||||
{
|
|
||||||
id: 'incoming',
|
|
||||||
name: 'Incoming Message',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'outgoing',
|
|
||||||
name: 'Outgoing Message',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const PRIORITY_CONDITION_VALUES = [
|
|
||||||
{
|
|
||||||
id: 'nil',
|
|
||||||
name: 'None',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'low',
|
|
||||||
name: 'Low',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'medium',
|
|
||||||
name: 'Medium',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'high',
|
|
||||||
name: 'High',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'urgent',
|
|
||||||
name: 'Urgent',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const getCustomAttributeInputType = key => {
|
export const getCustomAttributeInputType = key => {
|
||||||
const customAttributeMap = {
|
const customAttributeMap = {
|
||||||
@@ -198,45 +173,16 @@ export const getFileName = (action, files = []) => {
|
|||||||
|
|
||||||
export const getDefaultConditions = eventName => {
|
export const getDefaultConditions = eventName => {
|
||||||
if (eventName === 'message_created') {
|
if (eventName === 'message_created') {
|
||||||
return [
|
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||||
{
|
|
||||||
attribute_key: 'message_type',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: '',
|
|
||||||
query_operator: 'and',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
if (eventName === 'conversation_opened') {
|
if (eventName === 'conversation_opened') {
|
||||||
return [
|
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||||
{
|
|
||||||
attribute_key: 'browser_language',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: '',
|
|
||||||
query_operator: 'and',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
}
|
||||||
return [
|
return DEFAULT_OTHER_CONDITION;
|
||||||
{
|
|
||||||
attribute_key: 'status',
|
|
||||||
filter_operator: 'equal_to',
|
|
||||||
values: '',
|
|
||||||
query_operator: 'and',
|
|
||||||
custom_attribute_type: '',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getDefaultActions = () => {
|
export const getDefaultActions = () => {
|
||||||
return [
|
return DEFAULT_ACTIONS;
|
||||||
{
|
|
||||||
action_name: 'assign_agent',
|
|
||||||
action_params: [],
|
|
||||||
},
|
|
||||||
];
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const filterCustomAttributes = customAttributes => {
|
export const filterCustomAttributes = customAttributes => {
|
||||||
@@ -297,3 +243,100 @@ export const generateCustomAttributes = (
|
|||||||
}
|
}
|
||||||
return customAttributes;
|
return customAttributes;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get attributes for a given key from automation types.
|
||||||
|
* @param {Object} automationTypes - Object containing automation types.
|
||||||
|
* @param {string} key - The key to get attributes for.
|
||||||
|
* @returns {Array} Array of condition objects for the given key.
|
||||||
|
*/
|
||||||
|
export const getAttributes = (automationTypes, key) => {
|
||||||
|
return automationTypes[key].conditions;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the automation type for a given key.
|
||||||
|
* @param {Object} automationTypes - Object containing automation types.
|
||||||
|
* @param {Object} automation - The automation object.
|
||||||
|
* @param {string} key - The key to get the automation type for.
|
||||||
|
* @returns {Object} The automation type object.
|
||||||
|
*/
|
||||||
|
export const getAutomationType = (automationTypes, automation, key) => {
|
||||||
|
return automationTypes[automation.event_name].conditions.find(
|
||||||
|
condition => condition.key === key
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the input type for a given key.
|
||||||
|
* @param {Array} allCustomAttributes - Array of all custom attributes.
|
||||||
|
* @param {Object} automationTypes - Object containing automation types.
|
||||||
|
* @param {Object} automation - The automation object.
|
||||||
|
* @param {string} key - The key to get the input type for.
|
||||||
|
* @returns {string} The input type.
|
||||||
|
*/
|
||||||
|
export const getInputType = (
|
||||||
|
allCustomAttributes,
|
||||||
|
automationTypes,
|
||||||
|
automation,
|
||||||
|
key
|
||||||
|
) => {
|
||||||
|
const customAttribute = isACustomAttribute(allCustomAttributes, key);
|
||||||
|
if (customAttribute) {
|
||||||
|
return getCustomAttributeInputType(customAttribute.attribute_display_type);
|
||||||
|
}
|
||||||
|
const type = getAutomationType(automationTypes, automation, key);
|
||||||
|
return type.inputType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get operators for a given key.
|
||||||
|
* @param {Array} allCustomAttributes - Array of all custom attributes.
|
||||||
|
* @param {Object} automationTypes - Object containing automation types.
|
||||||
|
* @param {Object} automation - The automation object.
|
||||||
|
* @param {string} mode - The mode ('edit' or other).
|
||||||
|
* @param {string} key - The key to get operators for.
|
||||||
|
* @returns {Array} Array of operators.
|
||||||
|
*/
|
||||||
|
export const getOperators = (
|
||||||
|
allCustomAttributes,
|
||||||
|
automationTypes,
|
||||||
|
automation,
|
||||||
|
mode,
|
||||||
|
key
|
||||||
|
) => {
|
||||||
|
if (mode === 'edit') {
|
||||||
|
const customAttribute = isACustomAttribute(allCustomAttributes, key);
|
||||||
|
if (customAttribute) {
|
||||||
|
return getOperatorTypes(customAttribute.attribute_display_type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const type = getAutomationType(automationTypes, automation, key);
|
||||||
|
return type.filterOperators;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the custom attribute type for a given key.
|
||||||
|
* @param {Object} automationTypes - Object containing automation types.
|
||||||
|
* @param {Object} automation - The automation object.
|
||||||
|
* @param {string} key - The key to get the custom attribute type for.
|
||||||
|
* @returns {string} The custom attribute type.
|
||||||
|
*/
|
||||||
|
export const getCustomAttributeType = (automationTypes, automation, key) => {
|
||||||
|
return automationTypes[automation.event_name].conditions.find(
|
||||||
|
i => i.key === key
|
||||||
|
).customAttributeType;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine if an action input should be shown.
|
||||||
|
* @param {Array} automationActionTypes - Array of automation action type objects.
|
||||||
|
* @param {string} action - The action to check.
|
||||||
|
* @returns {boolean} True if the action input should be shown, false otherwise.
|
||||||
|
*/
|
||||||
|
export const showActionInput = (automationActionTypes, action) => {
|
||||||
|
if (action === 'send_email_to_team' || action === 'send_message')
|
||||||
|
return false;
|
||||||
|
const type = automationActionTypes.find(i => i.key === action).inputType;
|
||||||
|
return !!type;
|
||||||
|
};
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
CMD_SEND_TRANSCRIPT,
|
CMD_SEND_TRANSCRIPT,
|
||||||
CMD_SNOOZE_CONVERSATION,
|
CMD_SNOOZE_CONVERSATION,
|
||||||
CMD_UNMUTE_CONVERSATION,
|
CMD_UNMUTE_CONVERSATION,
|
||||||
} from './commandBarBusEvents';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ICON_MUTE_CONVERSATION,
|
ICON_MUTE_CONVERSATION,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
ICON_SEND_TRANSCRIPT,
|
ICON_SEND_TRANSCRIPT,
|
||||||
ICON_SNOOZE_CONVERSATION,
|
ICON_SNOOZE_CONVERSATION,
|
||||||
ICON_UNMUTE_CONVERSATION,
|
ICON_UNMUTE_CONVERSATION,
|
||||||
} from './CommandBarIcons';
|
} from 'dashboard/helper/commandbar/icons';
|
||||||
|
|
||||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||||
|
|
||||||
@@ -46,6 +46,7 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
|
|||||||
{
|
{
|
||||||
id: 'snooze_conversation',
|
id: 'snooze_conversation',
|
||||||
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||||
|
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||||
icon: ICON_SNOOZE_CONVERSATION,
|
icon: ICON_SNOOZE_CONVERSATION,
|
||||||
children: Object.values(SNOOZE_OPTIONS),
|
children: Object.values(SNOOZE_OPTIONS),
|
||||||
},
|
},
|
||||||
@@ -3,6 +3,8 @@ import {
|
|||||||
MessageMarkdownTransformer,
|
MessageMarkdownTransformer,
|
||||||
MessageMarkdownSerializer,
|
MessageMarkdownSerializer,
|
||||||
} from '@chatwoot/prosemirror-schema';
|
} from '@chatwoot/prosemirror-schema';
|
||||||
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
|
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -281,3 +283,92 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Content Node Creation Helper Functions for
|
||||||
|
* - mention
|
||||||
|
* - canned response
|
||||||
|
* - variable
|
||||||
|
* - emoji
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||||
|
* @param {Object} editorView - The editor view instance.
|
||||||
|
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||||
|
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
|
||||||
|
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
|
||||||
|
*/
|
||||||
|
const createNode = (editorView, nodeType, content) => {
|
||||||
|
const { state } = editorView;
|
||||||
|
switch (nodeType) {
|
||||||
|
case 'mention':
|
||||||
|
return state.schema.nodes.mention.create({
|
||||||
|
userId: content.id,
|
||||||
|
userFullName: content.name,
|
||||||
|
});
|
||||||
|
case 'cannedResponse':
|
||||||
|
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||||
|
case 'variable':
|
||||||
|
return state.schema.text(`{{${content}}}`);
|
||||||
|
case 'emoji':
|
||||||
|
return state.schema.text(content);
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Object mapping types to their respective node creation functions.
|
||||||
|
*/
|
||||||
|
const nodeCreators = {
|
||||||
|
mention: (editorView, content, from, to) => ({
|
||||||
|
node: createNode(editorView, 'mention', content),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}),
|
||||||
|
cannedResponse: (editorView, content, from, to, variables) => {
|
||||||
|
const updatedMessage = replaceVariablesInMessage({
|
||||||
|
message: content,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
const node = createNode(editorView, 'cannedResponse', updatedMessage);
|
||||||
|
return {
|
||||||
|
node,
|
||||||
|
from: node.textContent === updatedMessage ? from : from - 1,
|
||||||
|
to,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
variable: (editorView, content, from, to) => ({
|
||||||
|
node: createNode(editorView, 'variable', content),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}),
|
||||||
|
emoji: (editorView, content, from, to) => ({
|
||||||
|
node: createNode(editorView, 'emoji', content),
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
|
||||||
|
* @param {Object} editorView - The editor view instance.
|
||||||
|
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||||
|
* @param {string|Object} content - The content to be transformed into a node.
|
||||||
|
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
|
||||||
|
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
|
||||||
|
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
|
||||||
|
*/
|
||||||
|
export const getContentNode = (
|
||||||
|
editorView,
|
||||||
|
type,
|
||||||
|
content,
|
||||||
|
{ from, to },
|
||||||
|
variables
|
||||||
|
) => {
|
||||||
|
const creator = nodeCreators[type];
|
||||||
|
return creator
|
||||||
|
? creator(editorView, content, from, to, variables)
|
||||||
|
: { node: null, from, to };
|
||||||
|
};
|
||||||
|
|||||||
@@ -9,12 +9,15 @@ const FEATURE_HELP_URLS = {
|
|||||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||||
help_center: 'https://chwt.app/hc/help-center',
|
help_center: 'https://chwt.app/hc/help-center',
|
||||||
|
inboxes: 'https://chwt.app/hc/inboxes',
|
||||||
integrations: 'https://chwt.app/hc/integrations',
|
integrations: 'https://chwt.app/hc/integrations',
|
||||||
labels: 'https://chwt.app/hc/labels',
|
labels: 'https://chwt.app/hc/labels',
|
||||||
|
macros: 'https://chwt.app/hc/macros',
|
||||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||||
reports: 'https://chwt.app/hc/reports',
|
reports: 'https://chwt.app/hc/reports',
|
||||||
sla: 'https://chwt.app/hc/sla',
|
sla: 'https://chwt.app/hc/sla',
|
||||||
team_management: 'https://chwt.app/hc/teams',
|
team_management: 'https://chwt.app/hc/teams',
|
||||||
|
webhook: 'https://chwt.app/hc/webhooks',
|
||||||
};
|
};
|
||||||
|
|
||||||
export function getHelpUrlForFeature(featureName) {
|
export function getHelpUrlForFeature(featureName) {
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ export const hasPermissions = (
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
|
||||||
|
return accounts.find(account => Number(account.id) === Number(accountId));
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getUserPermissions = (user, accountId) => {
|
||||||
|
const currentAccount = getCurrentAccount(user, accountId) || {};
|
||||||
|
return currentAccount.permissions || [];
|
||||||
|
};
|
||||||
|
|
||||||
const isPermissionsPresentInRoute = route =>
|
const isPermissionsPresentInRoute = route =>
|
||||||
route.meta && route.meta.permissions;
|
route.meta && route.meta.permissions;
|
||||||
|
|
||||||
@@ -32,3 +41,32 @@ export const buildPermissionsFromRouter = (routes = []) =>
|
|||||||
|
|
||||||
return acc;
|
return acc;
|
||||||
}, {});
|
}, {});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Filters and transforms items based on user permissions.
|
||||||
|
*
|
||||||
|
* @param {Object} items - An object containing items to be filtered.
|
||||||
|
* @param {Array} userPermissions - Array of permissions the user has.
|
||||||
|
* @param {Function} getPermissions - Function to extract required permissions from an item.
|
||||||
|
* @param {Function} [transformItem] - Optional function to transform each item after filtering.
|
||||||
|
* @returns {Array} Filtered and transformed items.
|
||||||
|
*/
|
||||||
|
export const filterItemsByPermission = (
|
||||||
|
items,
|
||||||
|
userPermissions,
|
||||||
|
getPermissions,
|
||||||
|
transformItem = (key, item) => ({ key, ...item })
|
||||||
|
) => {
|
||||||
|
// Helper function to check if an item has the required permissions
|
||||||
|
const hasRequiredPermissions = item => {
|
||||||
|
const requiredPermissions = getPermissions(item);
|
||||||
|
return (
|
||||||
|
requiredPermissions.length === 0 ||
|
||||||
|
hasPermissions(requiredPermissions, userPermissions)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return Object.entries(items)
|
||||||
|
.filter(([, item]) => hasRequiredPermissions(item)) // Keep only items with required permissions
|
||||||
|
.map(([key, item]) => transformItem(key, item)); // Transform each remaining item
|
||||||
|
};
|
||||||
|
|||||||
@@ -1,15 +1,42 @@
|
|||||||
import { hasPermissions } from './permissionsHelper';
|
import {
|
||||||
|
hasPermissions,
|
||||||
|
getUserPermissions,
|
||||||
|
getCurrentAccount,
|
||||||
|
} from './permissionsHelper';
|
||||||
|
|
||||||
// eslint-disable-next-line default-param-last
|
import {
|
||||||
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
|
ROLES,
|
||||||
return accounts.find(account => account.id === accountId);
|
CONVERSATION_PERMISSIONS,
|
||||||
};
|
CONTACT_PERMISSIONS,
|
||||||
|
REPORTS_PERMISSIONS,
|
||||||
|
PORTAL_PERMISSIONS,
|
||||||
|
} from 'dashboard/constants/permissions.js';
|
||||||
|
|
||||||
export const routeIsAccessibleFor = (route, userPermissions = []) => {
|
export const routeIsAccessibleFor = (route, userPermissions = []) => {
|
||||||
const { meta: { permissions: routePermissions = [] } = {} } = route;
|
const { meta: { permissions: routePermissions = [] } = {} } = route;
|
||||||
return hasPermissions(routePermissions, userPermissions);
|
return hasPermissions(routePermissions, userPermissions);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const defaultRedirectPage = (to, permissions) => {
|
||||||
|
const { accountId } = to.params;
|
||||||
|
|
||||||
|
const permissionRoutes = [
|
||||||
|
{
|
||||||
|
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||||
|
path: 'dashboard',
|
||||||
|
},
|
||||||
|
{ permissions: [CONTACT_PERMISSIONS], path: 'contacts' },
|
||||||
|
{ permissions: [REPORTS_PERMISSIONS], path: 'reports/overview' },
|
||||||
|
{ permissions: [PORTAL_PERMISSIONS], path: 'portals' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const route = permissionRoutes.find(({ permissions: routePermissions }) =>
|
||||||
|
hasPermissions(routePermissions, permissions)
|
||||||
|
);
|
||||||
|
|
||||||
|
return `accounts/${accountId}/${route ? route.path : 'dashboard'}`;
|
||||||
|
};
|
||||||
|
|
||||||
const validateActiveAccountRoutes = (to, user) => {
|
const validateActiveAccountRoutes = (to, user) => {
|
||||||
// If the current account is active, then check for the route permissions
|
// If the current account is active, then check for the route permissions
|
||||||
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
|
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
|
||||||
@@ -19,9 +46,11 @@ const validateActiveAccountRoutes = (to, user) => {
|
|||||||
return accountDashboardURL;
|
return accountDashboardURL;
|
||||||
}
|
}
|
||||||
|
|
||||||
const isAccessible = routeIsAccessibleFor(to, user.permissions);
|
const userPermissions = getUserPermissions(user, to.params.accountId);
|
||||||
|
|
||||||
|
const isAccessible = routeIsAccessibleFor(to, userPermissions);
|
||||||
// If the route is not accessible for the user, return to dashboard screen
|
// If the route is not accessible for the user, return to dashboard screen
|
||||||
return isAccessible ? null : accountDashboardURL;
|
return isAccessible ? null : defaultRedirectPage(to, userPermissions);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const validateLoggedInRoutes = (to, user) => {
|
export const validateLoggedInRoutes = (to, user) => {
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ const initializeAudioAlerts = user => {
|
|||||||
} = uiSettings || {};
|
} = uiSettings || {};
|
||||||
|
|
||||||
DashboardAudioNotificationHelper.setInstanceValues({
|
DashboardAudioNotificationHelper.setInstanceValues({
|
||||||
currentUserId: user.id,
|
currentUser: user,
|
||||||
audioAlertType: audioAlertType || 'none',
|
audioAlertType: audioAlertType || 'none',
|
||||||
audioAlertTone: audioAlertTone || 'ding',
|
audioAlertTone: audioAlertTone || 'ding',
|
||||||
alwaysPlayAudioAlert: alwaysPlayAudioAlert || false,
|
alwaysPlayAudioAlert: alwaysPlayAudioAlert || false,
|
||||||
|
|||||||
131
app/javascript/dashboard/helper/specs/agentHelper.spec.js
Normal file
131
app/javascript/dashboard/helper/specs/agentHelper.spec.js
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
import {
|
||||||
|
getAgentsByAvailability,
|
||||||
|
getSortedAgentsByAvailability,
|
||||||
|
getAgentsByUpdatedPresence,
|
||||||
|
getCombinedAgents,
|
||||||
|
createNoneAgent,
|
||||||
|
} from '../agentHelper';
|
||||||
|
import {
|
||||||
|
allAgentsData,
|
||||||
|
onlineAgentsData,
|
||||||
|
busyAgentsData,
|
||||||
|
offlineAgentsData,
|
||||||
|
sortedByAvailability,
|
||||||
|
formattedAgentsByPresenceOnline,
|
||||||
|
formattedAgentsByPresenceOffline,
|
||||||
|
} from 'dashboard/helper/specs/fixtures/agentFixtures';
|
||||||
|
|
||||||
|
describe('agentHelper', () => {
|
||||||
|
describe('getAgentsByAvailability', () => {
|
||||||
|
it('returns agents by availability', () => {
|
||||||
|
expect(getAgentsByAvailability(allAgentsData, 'online')).toEqual(
|
||||||
|
onlineAgentsData
|
||||||
|
);
|
||||||
|
expect(getAgentsByAvailability(allAgentsData, 'busy')).toEqual(
|
||||||
|
busyAgentsData
|
||||||
|
);
|
||||||
|
expect(getAgentsByAvailability(allAgentsData, 'offline')).toEqual(
|
||||||
|
offlineAgentsData
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getSortedAgentsByAvailability', () => {
|
||||||
|
it('returns sorted agents by availability', () => {
|
||||||
|
expect(getSortedAgentsByAvailability(allAgentsData)).toEqual(
|
||||||
|
sortedByAvailability
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns an empty array when given an empty input', () => {
|
||||||
|
expect(getSortedAgentsByAvailability([])).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maintains the order of agents with the same availability status', () => {
|
||||||
|
const result = getSortedAgentsByAvailability(allAgentsData);
|
||||||
|
expect(result[2].name).toBe('Honey Bee');
|
||||||
|
expect(result[3].name).toBe('Samuel Keta');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAgentsByUpdatedPresence', () => {
|
||||||
|
it('returns agents with updated presence', () => {
|
||||||
|
const currentUser = {
|
||||||
|
id: 1,
|
||||||
|
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||||
|
};
|
||||||
|
const currentAccountId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAgentsByUpdatedPresence(
|
||||||
|
formattedAgentsByPresenceOnline,
|
||||||
|
currentUser,
|
||||||
|
currentAccountId
|
||||||
|
)
|
||||||
|
).toEqual(formattedAgentsByPresenceOffline);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not modify other agents presence', () => {
|
||||||
|
const currentUser = {
|
||||||
|
id: 2,
|
||||||
|
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||||
|
};
|
||||||
|
const currentAccountId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAgentsByUpdatedPresence(
|
||||||
|
formattedAgentsByPresenceOnline,
|
||||||
|
currentUser,
|
||||||
|
currentAccountId
|
||||||
|
)
|
||||||
|
).toEqual(formattedAgentsByPresenceOnline);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty agent list', () => {
|
||||||
|
const currentUser = {
|
||||||
|
id: 1,
|
||||||
|
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||||
|
};
|
||||||
|
const currentAccountId = 1;
|
||||||
|
|
||||||
|
expect(
|
||||||
|
getAgentsByUpdatedPresence([], currentUser, currentAccountId)
|
||||||
|
).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCombinedAgents', () => {
|
||||||
|
it('includes None agent when includeNoneAgent is true and isAgentSelected is true', () => {
|
||||||
|
const result = getCombinedAgents(sortedByAvailability, true, true);
|
||||||
|
expect(result).toEqual([createNoneAgent, ...sortedByAvailability]);
|
||||||
|
expect(result.length).toBe(sortedByAvailability.length + 1);
|
||||||
|
expect(result[0]).toEqual(createNoneAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes None agent when includeNoneAgent is false', () => {
|
||||||
|
const result = getCombinedAgents(sortedByAvailability, false, true);
|
||||||
|
expect(result).toEqual(sortedByAvailability);
|
||||||
|
expect(result.length).toBe(sortedByAvailability.length);
|
||||||
|
expect(result[0]).not.toEqual(createNoneAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('excludes None agent when isAgentSelected is false', () => {
|
||||||
|
const result = getCombinedAgents(sortedByAvailability, true, false);
|
||||||
|
expect(result).toEqual(sortedByAvailability);
|
||||||
|
expect(result.length).toBe(sortedByAvailability.length);
|
||||||
|
expect(result[0]).not.toEqual(createNoneAgent);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns only filtered agents when both includeNoneAgent and isAgentSelected are false', () => {
|
||||||
|
const result = getCombinedAgents(sortedByAvailability, false, false);
|
||||||
|
expect(result).toEqual(sortedByAvailability);
|
||||||
|
expect(result.length).toBe(sortedByAvailability.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty filteredAgentsByAvailability array', () => {
|
||||||
|
const result = getCombinedAgents([], true, true);
|
||||||
|
expect(result).toEqual([createNoneAgent]);
|
||||||
|
expect(result.length).toBe(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -11,11 +11,11 @@ import {
|
|||||||
contactAttrs,
|
contactAttrs,
|
||||||
conversationAttrs,
|
conversationAttrs,
|
||||||
expectedOutputForCustomAttributeGenerator,
|
expectedOutputForCustomAttributeGenerator,
|
||||||
} from './automationFixtures';
|
} from './fixtures/automationFixtures';
|
||||||
import { AUTOMATIONS } from 'dashboard/routes/dashboard/settings/automation/constants';
|
import { AUTOMATIONS } from 'dashboard/routes/dashboard/settings/automation/constants';
|
||||||
|
|
||||||
describe('automationMethodsMixin', () => {
|
describe('getCustomAttributeInputType', () => {
|
||||||
it('getCustomAttributeInputType returns the attribute input type', () => {
|
it('returns the attribute input type', () => {
|
||||||
expect(helpers.getCustomAttributeInputType('date')).toEqual('date');
|
expect(helpers.getCustomAttributeInputType('date')).toEqual('date');
|
||||||
expect(helpers.getCustomAttributeInputType('date')).not.toEqual(
|
expect(helpers.getCustomAttributeInputType('date')).not.toEqual(
|
||||||
'some_random_value'
|
'some_random_value'
|
||||||
@@ -31,33 +31,32 @@ describe('automationMethodsMixin', () => {
|
|||||||
'plain_text'
|
'plain_text'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('isACustomAttribute returns the custom attribute value if true', () => {
|
});
|
||||||
|
|
||||||
|
describe('isACustomAttribute', () => {
|
||||||
|
it('returns the custom attribute value if true', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.isACustomAttribute(customAttributes, 'signed_up_at')
|
helpers.isACustomAttribute(customAttributes, 'signed_up_at')
|
||||||
).toBeTruthy();
|
).toBeTruthy();
|
||||||
expect(helpers.isACustomAttribute(customAttributes, 'status')).toBeFalsy();
|
expect(helpers.isACustomAttribute(customAttributes, 'status')).toBeFalsy();
|
||||||
});
|
});
|
||||||
it('getCustomAttributeListDropdownValues returns the attribute dropdown values', () => {
|
});
|
||||||
|
|
||||||
|
describe('getCustomAttributeListDropdownValues', () => {
|
||||||
|
it('returns the attribute dropdown values', () => {
|
||||||
const myListValues = [
|
const myListValues = [
|
||||||
{
|
{ id: 'item1', name: 'item1' },
|
||||||
id: 'item1',
|
{ id: 'item2', name: 'item2' },
|
||||||
name: 'item1',
|
{ id: 'item3', name: 'item3' },
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item2',
|
|
||||||
name: 'item2',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'item3',
|
|
||||||
name: 'item3',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
helpers.getCustomAttributeListDropdownValues(customAttributes, 'my_list')
|
helpers.getCustomAttributeListDropdownValues(customAttributes, 'my_list')
|
||||||
).toEqual(myListValues);
|
).toEqual(myListValues);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('isCustomAttributeCheckbox checks if attribute is a checkbox', () => {
|
describe('isCustomAttributeCheckbox', () => {
|
||||||
|
it('checks if attribute is a checkbox', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.isCustomAttributeCheckbox(customAttributes, 'prime_user')
|
helpers.isCustomAttributeCheckbox(customAttributes, 'prime_user')
|
||||||
.attribute_display_type
|
.attribute_display_type
|
||||||
@@ -70,13 +69,19 @@ describe('automationMethodsMixin', () => {
|
|||||||
helpers.isCustomAttributeCheckbox(customAttributes, 'my_list')
|
helpers.isCustomAttributeCheckbox(customAttributes, 'my_list')
|
||||||
).not.toEqual('checkbox');
|
).not.toEqual('checkbox');
|
||||||
});
|
});
|
||||||
it('isCustomAttributeList checks if attribute is a list', () => {
|
});
|
||||||
|
|
||||||
|
describe('isCustomAttributeList', () => {
|
||||||
|
it('checks if attribute is a list', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.isCustomAttributeList(customAttributes, 'my_list')
|
helpers.isCustomAttributeList(customAttributes, 'my_list')
|
||||||
.attribute_display_type
|
.attribute_display_type
|
||||||
).toEqual('list');
|
).toEqual('list');
|
||||||
});
|
});
|
||||||
it('getOperatorTypes returns the correct custom attribute operators', () => {
|
});
|
||||||
|
|
||||||
|
describe('getOperatorTypes', () => {
|
||||||
|
it('returns the correct custom attribute operators', () => {
|
||||||
expect(helpers.getOperatorTypes('list')).toEqual(OPERATOR_TYPES_1);
|
expect(helpers.getOperatorTypes('list')).toEqual(OPERATOR_TYPES_1);
|
||||||
expect(helpers.getOperatorTypes('text')).toEqual(OPERATOR_TYPES_3);
|
expect(helpers.getOperatorTypes('text')).toEqual(OPERATOR_TYPES_3);
|
||||||
expect(helpers.getOperatorTypes('number')).toEqual(OPERATOR_TYPES_1);
|
expect(helpers.getOperatorTypes('number')).toEqual(OPERATOR_TYPES_1);
|
||||||
@@ -85,93 +90,44 @@ describe('automationMethodsMixin', () => {
|
|||||||
expect(helpers.getOperatorTypes('checkbox')).toEqual(OPERATOR_TYPES_1);
|
expect(helpers.getOperatorTypes('checkbox')).toEqual(OPERATOR_TYPES_1);
|
||||||
expect(helpers.getOperatorTypes('some_random')).toEqual(OPERATOR_TYPES_1);
|
expect(helpers.getOperatorTypes('some_random')).toEqual(OPERATOR_TYPES_1);
|
||||||
});
|
});
|
||||||
it('generateConditionOptions returns expected conditions options array', () => {
|
});
|
||||||
const testConditions = [
|
|
||||||
{
|
|
||||||
id: 123,
|
|
||||||
title: 'Fayaz',
|
|
||||||
email: 'test@test.com',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'John',
|
|
||||||
id: 324,
|
|
||||||
email: 'test@john.com',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
|
describe('generateConditionOptions', () => {
|
||||||
|
it('returns expected conditions options array', () => {
|
||||||
|
const testConditions = [
|
||||||
|
{ id: 123, title: 'Fayaz', email: 'test@test.com' },
|
||||||
|
{ title: 'John', id: 324, email: 'test@john.com' },
|
||||||
|
];
|
||||||
const expectedConditions = [
|
const expectedConditions = [
|
||||||
{
|
{ id: 123, name: 'Fayaz' },
|
||||||
id: 123,
|
{ id: 324, name: 'John' },
|
||||||
name: 'Fayaz',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 324,
|
|
||||||
name: 'John',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(helpers.generateConditionOptions(testConditions)).toEqual(
|
expect(helpers.generateConditionOptions(testConditions)).toEqual(
|
||||||
expectedConditions
|
expectedConditions
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('getActionOptions returns expected actions options array', () => {
|
});
|
||||||
|
|
||||||
|
describe('getActionOptions', () => {
|
||||||
|
it('returns expected actions options array', () => {
|
||||||
const expectedOptions = [
|
const expectedOptions = [
|
||||||
{
|
{ id: 'testlabel', name: 'testlabel' },
|
||||||
id: 'testlabel',
|
{ id: 'snoozes', name: 'snoozes' },
|
||||||
name: 'testlabel',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'snoozes',
|
|
||||||
name: 'snoozes',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(helpers.getActionOptions({ labels, type: 'add_label' })).toEqual(
|
expect(helpers.getActionOptions({ labels, type: 'add_label' })).toEqual(
|
||||||
expectedOptions
|
expectedOptions
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('getConditionOptions returns expected conditions options', () => {
|
});
|
||||||
|
|
||||||
|
describe('getConditionOptions', () => {
|
||||||
|
it('returns expected conditions options', () => {
|
||||||
const testOptions = [
|
const testOptions = [
|
||||||
{
|
{ id: 'open', name: 'Open' },
|
||||||
id: 'open',
|
{ id: 'resolved', name: 'Resolved' },
|
||||||
name: 'Open',
|
{ id: 'pending', name: 'Pending' },
|
||||||
},
|
{ id: 'snoozed', name: 'Snoozed' },
|
||||||
{
|
{ id: 'all', name: 'All' },
|
||||||
id: 'resolved',
|
|
||||||
name: 'Resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pending',
|
|
||||||
name: 'Pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'snoozed',
|
|
||||||
name: 'Snoozed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all',
|
|
||||||
name: 'All',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
const expectedOptions = [
|
|
||||||
{
|
|
||||||
id: 'open',
|
|
||||||
name: 'Open',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'resolved',
|
|
||||||
name: 'Resolved',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'pending',
|
|
||||||
name: 'Pending',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'snoozed',
|
|
||||||
name: 'Snoozed',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'all',
|
|
||||||
name: 'All',
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
expect(
|
expect(
|
||||||
helpers.getConditionOptions({
|
helpers.getConditionOptions({
|
||||||
@@ -180,14 +136,20 @@ describe('automationMethodsMixin', () => {
|
|||||||
statusFilterOptions: testOptions,
|
statusFilterOptions: testOptions,
|
||||||
type: 'status',
|
type: 'status',
|
||||||
})
|
})
|
||||||
).toEqual(expectedOptions);
|
).toEqual(testOptions);
|
||||||
});
|
});
|
||||||
it('getFileName returns the correct file name', () => {
|
});
|
||||||
|
|
||||||
|
describe('getFileName', () => {
|
||||||
|
it('returns the correct file name', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.getFileName(automation.actions[0], automation.files)
|
helpers.getFileName(automation.actions[0], automation.files)
|
||||||
).toEqual('pfp.jpeg');
|
).toEqual('pfp.jpeg');
|
||||||
});
|
});
|
||||||
it('getDefaultConditions returns the resp default condition model', () => {
|
});
|
||||||
|
|
||||||
|
describe('getDefaultConditions', () => {
|
||||||
|
it('returns the resp default condition model', () => {
|
||||||
const messageCreatedModel = [
|
const messageCreatedModel = [
|
||||||
{
|
{
|
||||||
attribute_key: 'message_type',
|
attribute_key: 'message_type',
|
||||||
@@ -211,7 +173,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
);
|
);
|
||||||
expect(helpers.getDefaultConditions()).toEqual(genericConditionModel);
|
expect(helpers.getDefaultConditions()).toEqual(genericConditionModel);
|
||||||
});
|
});
|
||||||
it('getDefaultActions returns the resp default action model', () => {
|
});
|
||||||
|
|
||||||
|
describe('getDefaultActions', () => {
|
||||||
|
it('returns the resp default action model', () => {
|
||||||
const genericActionModel = [
|
const genericActionModel = [
|
||||||
{
|
{
|
||||||
action_name: 'assign_agent',
|
action_name: 'assign_agent',
|
||||||
@@ -220,7 +185,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
];
|
];
|
||||||
expect(helpers.getDefaultActions()).toEqual(genericActionModel);
|
expect(helpers.getDefaultActions()).toEqual(genericActionModel);
|
||||||
});
|
});
|
||||||
it('filterCustomAttributes filters the raw custom attributes', () => {
|
});
|
||||||
|
|
||||||
|
describe('filterCustomAttributes', () => {
|
||||||
|
it('filters the raw custom attributes', () => {
|
||||||
const filteredAttributes = [
|
const filteredAttributes = [
|
||||||
{ key: 'signed_up_at', name: 'Signed Up At', type: 'date' },
|
{ key: 'signed_up_at', name: 'Signed Up At', type: 'date' },
|
||||||
{ key: 'prime_user', name: 'Prime User', type: 'checkbox' },
|
{ key: 'prime_user', name: 'Prime User', type: 'checkbox' },
|
||||||
@@ -235,7 +203,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
filteredAttributes
|
filteredAttributes
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('getStandardAttributeInputType returns the resp default action model', () => {
|
});
|
||||||
|
|
||||||
|
describe('getStandardAttributeInputType', () => {
|
||||||
|
it('returns the resp default action model', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.getStandardAttributeInputType(
|
helpers.getStandardAttributeInputType(
|
||||||
AUTOMATIONS,
|
AUTOMATIONS,
|
||||||
@@ -258,7 +229,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
)
|
)
|
||||||
).toEqual('plain_text');
|
).toEqual('plain_text');
|
||||||
});
|
});
|
||||||
it('generateAutomationPayload returns the resp default action model', () => {
|
});
|
||||||
|
|
||||||
|
describe('generateAutomationPayload', () => {
|
||||||
|
it('returns the resp default action model', () => {
|
||||||
const testPayload = {
|
const testPayload = {
|
||||||
name: 'Test',
|
name: 'Test',
|
||||||
description: 'This is a test',
|
description: 'This is a test',
|
||||||
@@ -300,7 +274,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
expectedPayload
|
expectedPayload
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
it('isCustomAttribute returns the resp default action model', () => {
|
});
|
||||||
|
|
||||||
|
describe('isCustomAttribute', () => {
|
||||||
|
it('returns the resp default action model', () => {
|
||||||
const attrs = helpers.filterCustomAttributes(customAttributes);
|
const attrs = helpers.filterCustomAttributes(customAttributes);
|
||||||
expect(helpers.isCustomAttribute(attrs, 'my_list')).toBeTruthy();
|
expect(helpers.isCustomAttribute(attrs, 'my_list')).toBeTruthy();
|
||||||
expect(helpers.isCustomAttribute(attrs, 'my_check')).toBeTruthy();
|
expect(helpers.isCustomAttribute(attrs, 'my_check')).toBeTruthy();
|
||||||
@@ -309,8 +286,10 @@ describe('automationMethodsMixin', () => {
|
|||||||
expect(helpers.isCustomAttribute(attrs, 'prime_user')).toBeTruthy();
|
expect(helpers.isCustomAttribute(attrs, 'prime_user')).toBeTruthy();
|
||||||
expect(helpers.isCustomAttribute(attrs, 'hello')).toBeFalsy();
|
expect(helpers.isCustomAttribute(attrs, 'hello')).toBeFalsy();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('generateCustomAttributes generates and returns correct condition attribute', () => {
|
describe('generateCustomAttributes', () => {
|
||||||
|
it('generates and returns correct condition attribute', () => {
|
||||||
expect(
|
expect(
|
||||||
helpers.generateCustomAttributes(
|
helpers.generateCustomAttributes(
|
||||||
conversationAttrs,
|
conversationAttrs,
|
||||||
@@ -321,3 +300,116 @@ describe('automationMethodsMixin', () => {
|
|||||||
).toEqual(expectedOutputForCustomAttributeGenerator);
|
).toEqual(expectedOutputForCustomAttributeGenerator);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('getAttributes', () => {
|
||||||
|
it('returns the conditions for the given automation type', () => {
|
||||||
|
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
|
||||||
|
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAttributes', () => {
|
||||||
|
it('returns the conditions for the given automation type', () => {
|
||||||
|
const result = helpers.getAttributes(AUTOMATIONS, 'message_created');
|
||||||
|
expect(result).toEqual(AUTOMATIONS.message_created.conditions);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getAutomationType', () => {
|
||||||
|
it('returns the automation type for the given key', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getAutomationType(
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'message_type'
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getInputType', () => {
|
||||||
|
it('returns the input type for a custom attribute', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getInputType(
|
||||||
|
customAttributes,
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'signed_up_at'
|
||||||
|
);
|
||||||
|
expect(result).toEqual('date');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns the input type for a standard attribute', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getInputType(
|
||||||
|
customAttributes,
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'message_type'
|
||||||
|
);
|
||||||
|
expect(result).toEqual('search_select');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getOperators', () => {
|
||||||
|
it('returns operators for a custom attribute in edit mode', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getOperators(
|
||||||
|
customAttributes,
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'edit',
|
||||||
|
'signed_up_at'
|
||||||
|
);
|
||||||
|
expect(result).toEqual(OPERATOR_TYPES_4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns operators for a standard attribute', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getOperators(
|
||||||
|
customAttributes,
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'create',
|
||||||
|
'message_type'
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
|
||||||
|
.filterOperators
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCustomAttributeType', () => {
|
||||||
|
it('returns the custom attribute type for the given key', () => {
|
||||||
|
const mockAutomation = { event_name: 'message_created' };
|
||||||
|
const result = helpers.getCustomAttributeType(
|
||||||
|
AUTOMATIONS,
|
||||||
|
mockAutomation,
|
||||||
|
'message_type'
|
||||||
|
);
|
||||||
|
expect(result).toEqual(
|
||||||
|
AUTOMATIONS.message_created.conditions.find(c => c.key === 'message_type')
|
||||||
|
.customAttributeType
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('showActionInput', () => {
|
||||||
|
it('returns false for send_email_to_team and send_message actions', () => {
|
||||||
|
expect(helpers.showActionInput([], 'send_email_to_team')).toBe(false);
|
||||||
|
expect(helpers.showActionInput([], 'send_message')).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns true if the action has an input type', () => {
|
||||||
|
const mockActionTypes = [{ key: 'add_label', inputType: 'select' }];
|
||||||
|
expect(helpers.showActionInput(mockActionTypes, 'add_label')).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns false if the action does not have an input type', () => {
|
||||||
|
const mockActionTypes = [{ key: 'some_action', inputType: null }];
|
||||||
|
expect(helpers.showActionInput(mockActionTypes, 'some_action')).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||||
|
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||||
|
import { getContentNode } from '../editorHelper';
|
||||||
|
import {
|
||||||
|
MessageMarkdownTransformer,
|
||||||
|
messageSchema,
|
||||||
|
} from '@chatwoot/prosemirror-schema';
|
||||||
|
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||||
|
|
||||||
|
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||||
|
MessageMarkdownTransformer: vi.fn(),
|
||||||
|
messageSchema: {},
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('@chatwoot/utils', () => ({
|
||||||
|
replaceVariablesInMessage: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('getContentNode', () => {
|
||||||
|
let editorView;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
editorView = {
|
||||||
|
state: {
|
||||||
|
schema: {
|
||||||
|
nodes: {
|
||||||
|
mention: {
|
||||||
|
create: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
text: vi.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getMentionNode', () => {
|
||||||
|
it('should create a mention node', () => {
|
||||||
|
const content = { id: 1, name: 'John Doe' };
|
||||||
|
const from = 0;
|
||||||
|
const to = 10;
|
||||||
|
getContentNode(editorView, 'mention', content, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
|
||||||
|
{
|
||||||
|
userId: content.id,
|
||||||
|
userFullName: content.name,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getCannedResponseNode', () => {
|
||||||
|
it('should create a canned response node', () => {
|
||||||
|
const content = 'Hello {{name}}';
|
||||||
|
const variables = { name: 'John' };
|
||||||
|
const from = 0;
|
||||||
|
const to = 10;
|
||||||
|
const updatedMessage = 'Hello John';
|
||||||
|
|
||||||
|
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||||
|
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||||
|
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const { node } = getContentNode(
|
||||||
|
editorView,
|
||||||
|
'cannedResponse',
|
||||||
|
content,
|
||||||
|
{ from, to },
|
||||||
|
variables
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
|
||||||
|
message: content,
|
||||||
|
variables,
|
||||||
|
});
|
||||||
|
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||||
|
expect(node.textContent).toBe(updatedMessage);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getVariableNode', () => {
|
||||||
|
it('should create a variable node', () => {
|
||||||
|
const content = 'name';
|
||||||
|
const from = 0;
|
||||||
|
const to = 10;
|
||||||
|
getContentNode(editorView, 'variable', content, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getEmojiNode', () => {
|
||||||
|
it('should create an emoji node', () => {
|
||||||
|
const content = '😊';
|
||||||
|
const from = 0;
|
||||||
|
const to = 2;
|
||||||
|
getContentNode(editorView, 'emoji', content, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('getContentNode', () => {
|
||||||
|
it('should return null for invalid type', () => {
|
||||||
|
const content = 'invalid';
|
||||||
|
const from = 0;
|
||||||
|
const to = 10;
|
||||||
|
const { node } = getContentNode(editorView, 'invalid', content, {
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(node).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
184
app/javascript/dashboard/helper/specs/fixtures/agentFixtures.js
vendored
Normal file
184
app/javascript/dashboard/helper/specs/fixtures/agentFixtures.js
vendored
Normal file
@@ -0,0 +1,184 @@
|
|||||||
|
export const allAgentsData = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'John K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'John Kennady',
|
||||||
|
role: 'administrator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Samuel K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'samuel@chatwoot.com',
|
||||||
|
id: 2,
|
||||||
|
name: 'Samuel Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'offline',
|
||||||
|
available_name: 'James K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'james@chatwoot.com',
|
||||||
|
id: 3,
|
||||||
|
name: 'James Koti',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Honey',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'bee@chatwoot.com',
|
||||||
|
id: 4,
|
||||||
|
name: 'Honey Bee',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abraham@chatwoot.com',
|
||||||
|
id: 5,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const onlineAgentsData = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abraham@chatwoot.com',
|
||||||
|
id: 5,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'John K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'John Kennady',
|
||||||
|
role: 'administrator',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const busyAgentsData = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Honey',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'bee@chatwoot.com',
|
||||||
|
id: 4,
|
||||||
|
name: 'Honey Bee',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Samuel K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'samuel@chatwoot.com',
|
||||||
|
id: 2,
|
||||||
|
name: 'Samuel Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const offlineAgentsData = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'offline',
|
||||||
|
available_name: 'James K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'james@chatwoot.com',
|
||||||
|
id: 3,
|
||||||
|
name: 'James Koti',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const sortedByAvailability = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abraham@chatwoot.com',
|
||||||
|
id: 5,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'John K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'john@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'John Kennady',
|
||||||
|
role: 'administrator',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Honey',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'bee@chatwoot.com',
|
||||||
|
id: 4,
|
||||||
|
name: 'Honey Bee',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'busy',
|
||||||
|
available_name: 'Samuel K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'samuel@chatwoot.com',
|
||||||
|
id: 2,
|
||||||
|
name: 'Samuel Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'offline',
|
||||||
|
available_name: 'James K',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'james@chatwoot.com',
|
||||||
|
id: 3,
|
||||||
|
name: 'James Koti',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const formattedAgentsByPresenceOnline = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'online',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abr@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
export const formattedAgentsByPresenceOffline = [
|
||||||
|
{
|
||||||
|
account_id: 1,
|
||||||
|
availability_status: 'offline',
|
||||||
|
available_name: 'Abraham',
|
||||||
|
confirmed: true,
|
||||||
|
email: 'abr@chatwoot.com',
|
||||||
|
id: 1,
|
||||||
|
name: 'Abraham Keta',
|
||||||
|
role: 'agent',
|
||||||
|
},
|
||||||
|
];
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import allLanguages from '../../../dashboard/components/widgets/conversation/advancedFilterItems/languages.js';
|
import allLanguages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||||
|
|
||||||
import allCountries from '../../../shared/constants/countries.js';
|
import allCountries from 'shared/constants/countries.js';
|
||||||
|
|
||||||
export const customAttributes = [
|
export const customAttributes = [
|
||||||
{
|
{
|
||||||
@@ -1,8 +1,32 @@
|
|||||||
import {
|
import {
|
||||||
buildPermissionsFromRouter,
|
buildPermissionsFromRouter,
|
||||||
|
getCurrentAccount,
|
||||||
|
getUserPermissions,
|
||||||
hasPermissions,
|
hasPermissions,
|
||||||
|
filterItemsByPermission,
|
||||||
} from '../permissionsHelper';
|
} from '../permissionsHelper';
|
||||||
|
|
||||||
|
describe('#getCurrentAccount', () => {
|
||||||
|
it('should return the current account', () => {
|
||||||
|
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
|
||||||
|
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('#getUserPermissions', () => {
|
||||||
|
it('should return the correct permissions', () => {
|
||||||
|
const user = {
|
||||||
|
accounts: [
|
||||||
|
{ id: 1, permissions: ['conversations_manage'] },
|
||||||
|
{ id: 3, permissions: ['contacts_manage'] },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
expect(getUserPermissions(user, 1)).toEqual(['conversations_manage']);
|
||||||
|
expect(getUserPermissions(user, '3')).toEqual(['contacts_manage']);
|
||||||
|
expect(getUserPermissions(user, 2)).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('hasPermissions', () => {
|
describe('hasPermissions', () => {
|
||||||
it('returns true if permission is present', () => {
|
it('returns true if permission is present', () => {
|
||||||
expect(
|
expect(
|
||||||
@@ -82,3 +106,113 @@ describe('buildPermissionsFromRouter', () => {
|
|||||||
}).toThrow("The route doesn't have the required permissions defined");
|
}).toThrow("The route doesn't have the required permissions defined");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('filterItemsByPermission', () => {
|
||||||
|
const items = {
|
||||||
|
item1: { name: 'Item 1', permissions: ['agent', 'administrator'] },
|
||||||
|
item2: {
|
||||||
|
name: 'Item 2',
|
||||||
|
permissions: [
|
||||||
|
'conversation_manage',
|
||||||
|
'conversation_unassigned_manage',
|
||||||
|
'conversation_participating_manage',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
item3: { name: 'Item 3', permissions: ['contact_manage'] },
|
||||||
|
item4: { name: 'Item 4', permissions: ['report_manage'] },
|
||||||
|
item5: { name: 'Item 5', permissions: ['knowledge_base_manage'] },
|
||||||
|
item6: {
|
||||||
|
name: 'Item 6',
|
||||||
|
permissions: [
|
||||||
|
'agent',
|
||||||
|
'administrator',
|
||||||
|
'conversation_manage',
|
||||||
|
'conversation_unassigned_manage',
|
||||||
|
'conversation_participating_manage',
|
||||||
|
'contact_manage',
|
||||||
|
'report_manage',
|
||||||
|
'knowledge_base_manage',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
item7: { name: 'Item 7', permissions: [] },
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissions = item => item.permissions;
|
||||||
|
|
||||||
|
it('filters items based on user permissions', () => {
|
||||||
|
const userPermissions = ['agent', 'contact_manage', 'report_manage'];
|
||||||
|
const result = filterItemsByPermission(
|
||||||
|
items,
|
||||||
|
userPermissions,
|
||||||
|
getPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(5);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item1', name: 'Item 1' })
|
||||||
|
);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item3', name: 'Item 3' })
|
||||||
|
);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item4', name: 'Item 4' })
|
||||||
|
);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item6', name: 'Item 6' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes items with empty permissions', () => {
|
||||||
|
const userPermissions = [];
|
||||||
|
const result = filterItemsByPermission(
|
||||||
|
items,
|
||||||
|
userPermissions,
|
||||||
|
getPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item7', name: 'Item 7' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses custom transform function when provided', () => {
|
||||||
|
const userPermissions = ['agent', 'contact_manage'];
|
||||||
|
const customTransform = (key, item) => ({ id: key, title: item.name });
|
||||||
|
const result = filterItemsByPermission(
|
||||||
|
items,
|
||||||
|
userPermissions,
|
||||||
|
getPermissions,
|
||||||
|
customTransform
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(4);
|
||||||
|
expect(result).toContainEqual({ id: 'item1', title: 'Item 1' });
|
||||||
|
expect(result).toContainEqual({ id: 'item3', title: 'Item 3' });
|
||||||
|
expect(result).toContainEqual({ id: 'item6', title: 'Item 6' });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles empty items object', () => {
|
||||||
|
const result = filterItemsByPermission({}, ['agent'], getPermissions);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles custom getPermissions function', () => {
|
||||||
|
const customItems = {
|
||||||
|
item1: { name: 'Item 1', requiredPerms: ['agent', 'administrator'] },
|
||||||
|
item2: { name: 'Item 2', requiredPerms: ['contact_manage'] },
|
||||||
|
};
|
||||||
|
const customGetPermissions = item => item.requiredPerms;
|
||||||
|
const result = filterItemsByPermission(
|
||||||
|
customItems,
|
||||||
|
['agent'],
|
||||||
|
customGetPermissions
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result).toContainEqual(
|
||||||
|
expect.objectContaining({ key: 'item1', name: 'Item 1' })
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -1,19 +1,12 @@
|
|||||||
import {
|
import {
|
||||||
getConversationDashboardRoute,
|
getConversationDashboardRoute,
|
||||||
getCurrentAccount,
|
|
||||||
isAConversationRoute,
|
isAConversationRoute,
|
||||||
|
defaultRedirectPage,
|
||||||
routeIsAccessibleFor,
|
routeIsAccessibleFor,
|
||||||
validateLoggedInRoutes,
|
validateLoggedInRoutes,
|
||||||
isAInboxViewRoute,
|
isAInboxViewRoute,
|
||||||
} from '../routeHelpers';
|
} from '../routeHelpers';
|
||||||
|
|
||||||
describe('#getCurrentAccount', () => {
|
|
||||||
it('should return the current account', () => {
|
|
||||||
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
|
|
||||||
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('#routeIsAccessibleFor', () => {
|
describe('#routeIsAccessibleFor', () => {
|
||||||
it('should return the correct access', () => {
|
it('should return the correct access', () => {
|
||||||
let route = { meta: { permissions: ['administrator'] } };
|
let route = { meta: { permissions: ['administrator'] } };
|
||||||
@@ -22,6 +15,57 @@ describe('#routeIsAccessibleFor', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('#defaultRedirectPage', () => {
|
||||||
|
const to = {
|
||||||
|
params: { accountId: '2' },
|
||||||
|
fullPath: '/app/accounts/2/dashboard',
|
||||||
|
name: 'home',
|
||||||
|
};
|
||||||
|
|
||||||
|
it('should return dashboard route for users with conversation permissions', () => {
|
||||||
|
const permissions = ['conversation_manage', 'agent'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return contacts route for users with contact permissions', () => {
|
||||||
|
const permissions = ['contact_manage'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/contacts');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return reports route for users with report permissions', () => {
|
||||||
|
const permissions = ['report_manage'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe(
|
||||||
|
'accounts/2/reports/overview'
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return portals route for users with portal permissions', () => {
|
||||||
|
const permissions = ['knowledge_base_manage'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/portals');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return dashboard route as default for users with custom roles', () => {
|
||||||
|
const permissions = ['custom_role'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return dashboard route for users with administrator role', () => {
|
||||||
|
const permissions = ['administrator'];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return dashboard route for users with multiple permissions', () => {
|
||||||
|
const permissions = [
|
||||||
|
'contact_manage',
|
||||||
|
'custom_role',
|
||||||
|
'conversation_manage',
|
||||||
|
'agent',
|
||||||
|
'administrator',
|
||||||
|
];
|
||||||
|
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('#validateLoggedInRoutes', () => {
|
describe('#validateLoggedInRoutes', () => {
|
||||||
describe('when account access is missing', () => {
|
describe('when account access is missing', () => {
|
||||||
it('should return the login route', () => {
|
it('should return the login route', () => {
|
||||||
|
|||||||
@@ -1,52 +1,93 @@
|
|||||||
import { uploadFile } from '../uploadHelper';
|
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
import { uploadExternalImage, uploadFile } from '../uploadHelper';
|
||||||
|
|
||||||
global.axios = axios;
|
global.axios = axios;
|
||||||
vi.mock('axios');
|
vi.mock('axios');
|
||||||
|
|
||||||
describe('#Upload Helpers', () => {
|
describe('Upload Helpers', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
// Cleaning up the mock after each test
|
|
||||||
axios.post.mockReset();
|
axios.post.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should send a POST request with correct data', async () => {
|
describe('uploadFile', () => {
|
||||||
const mockFile = new File(['dummy content'], 'example.png', {
|
it('should send a POST request with correct data', async () => {
|
||||||
type: 'image/png',
|
const mockFile = new File(['dummy content'], 'example.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
});
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
file_url: 'https://example.com/fileUrl',
|
||||||
|
blob_key: 'blobKey123',
|
||||||
|
blob_id: 'blobId456',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.post.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await uploadFile(mockFile, '1602');
|
||||||
|
|
||||||
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/accounts/1602/upload',
|
||||||
|
expect.any(FormData),
|
||||||
|
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
fileUrl: 'https://example.com/fileUrl',
|
||||||
|
blobKey: 'blobKey123',
|
||||||
|
blobId: 'blobId456',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const mockResponse = {
|
|
||||||
data: {
|
|
||||||
file_url: 'https://example.com/fileUrl',
|
|
||||||
blob_key: 'blobKey123',
|
|
||||||
blob_id: 'blobId456',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
axios.post.mockResolvedValueOnce(mockResponse);
|
it('should handle errors', async () => {
|
||||||
|
const mockFile = new File(['dummy content'], 'example.png', {
|
||||||
|
type: 'image/png',
|
||||||
|
});
|
||||||
|
const mockError = new Error('Failed to upload');
|
||||||
|
|
||||||
const result = await uploadFile(mockFile, '1602');
|
axios.post.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
expect(axios.post).toHaveBeenCalledWith(
|
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
|
||||||
'/api/v1/accounts/1602/upload',
|
|
||||||
expect.any(FormData),
|
|
||||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(result).toEqual({
|
|
||||||
fileUrl: 'https://example.com/fileUrl',
|
|
||||||
blobKey: 'blobKey123',
|
|
||||||
blobId: 'blobId456',
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle errors', async () => {
|
describe('uploadExternalImage', () => {
|
||||||
const mockFile = new File(['dummy content'], 'example.png', {
|
it('should send a POST request with correct data', async () => {
|
||||||
type: 'image/png',
|
const mockUrl = 'https://example.com/image.jpg';
|
||||||
|
const mockResponse = {
|
||||||
|
data: {
|
||||||
|
file_url: 'https://example.com/fileUrl',
|
||||||
|
blob_key: 'blobKey123',
|
||||||
|
blob_id: 'blobId456',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
axios.post.mockResolvedValueOnce(mockResponse);
|
||||||
|
|
||||||
|
const result = await uploadExternalImage(mockUrl, '1602');
|
||||||
|
|
||||||
|
expect(axios.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/accounts/1602/upload',
|
||||||
|
{ external_url: mockUrl },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toEqual({
|
||||||
|
fileUrl: 'https://example.com/fileUrl',
|
||||||
|
blobKey: 'blobKey123',
|
||||||
|
blobId: 'blobId456',
|
||||||
|
});
|
||||||
});
|
});
|
||||||
const mockError = new Error('Failed to upload');
|
|
||||||
|
|
||||||
axios.post.mockRejectedValueOnce(mockError);
|
it('should handle errors', async () => {
|
||||||
|
const mockUrl = 'https://example.com/image.jpg';
|
||||||
|
const mockError = new Error('Failed to upload');
|
||||||
|
|
||||||
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
|
axios.post.mockRejectedValueOnce(mockError);
|
||||||
|
|
||||||
|
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
|
||||||
|
'Failed to upload'
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,26 +19,47 @@ const HEADERS = {
|
|||||||
* The function uses FormData to wrap the file and axios to send the request.
|
* The function uses FormData to wrap the file and axios to send the request.
|
||||||
*
|
*
|
||||||
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
|
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
|
||||||
|
* @param {string} accountId - The account ID.
|
||||||
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
|
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
|
||||||
*/
|
*/
|
||||||
export async function uploadFile(file, accountId) {
|
export async function uploadFile(file, accountId) {
|
||||||
// Create a new FormData instance.
|
|
||||||
let formData = new FormData();
|
|
||||||
|
|
||||||
if (!accountId) {
|
if (!accountId) {
|
||||||
accountId = window.location.pathname.split('/')[3];
|
accountId = window.location.pathname.split('/')[3];
|
||||||
}
|
}
|
||||||
|
|
||||||
// Append the file to the FormData instance under the key 'attachment'.
|
// Append the file to the FormData instance under the key 'attachment'.
|
||||||
|
let formData = new FormData();
|
||||||
formData.append('attachment', file);
|
formData.append('attachment', file);
|
||||||
|
|
||||||
// Use axios to send a POST request to the upload endpoint.
|
|
||||||
const { data } = await axios.post(
|
const { data } = await axios.post(
|
||||||
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
||||||
formData,
|
formData,
|
||||||
{
|
{ headers: HEADERS }
|
||||||
headers: HEADERS,
|
);
|
||||||
}
|
|
||||||
|
return {
|
||||||
|
fileUrl: data.file_url,
|
||||||
|
blobKey: data.blob_key,
|
||||||
|
blobId: data.blob_id,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image from an external URL.
|
||||||
|
*
|
||||||
|
* @param {string} url - The external URL of the image.
|
||||||
|
* @param {string} accountId - The account ID.
|
||||||
|
* @returns {Promise} A promise that resolves with the server's response.
|
||||||
|
*/
|
||||||
|
export async function uploadExternalImage(url, accountId) {
|
||||||
|
if (!accountId) {
|
||||||
|
accountId = window.location.pathname.split('/')[3];
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data } = await axios.post(
|
||||||
|
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
||||||
|
{ external_url: url },
|
||||||
|
{ headers: { 'Content-Type': 'application/json' } }
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user