Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4caa9b1539 | ||
|
|
8ee2419ea5 | ||
|
|
9bcc3c25e3 | ||
|
|
106352cdce | ||
|
|
e74a0e5684 | ||
|
|
de6ab10714 |
@@ -25,5 +25,13 @@ 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.
|
||||||
|
|||||||
@@ -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', '>= 0.24.0'
|
gem 'google-cloud-dialogflow-v2'
|
||||||
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', '>= 0.7.0'
|
gem 'google-cloud-translate-v3'
|
||||||
|
|
||||||
##-- apm and error monitoring ---#
|
##-- apm and error monitoring ---#
|
||||||
# loaded only when environment variables are set.
|
# loaded only when environment variables are set.
|
||||||
@@ -111,12 +111,12 @@ gem 'elastic-apm', require: false
|
|||||||
gem 'newrelic_rpm', require: false
|
gem 'newrelic_rpm', require: false
|
||||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||||
gem 'scout_apm', require: false
|
gem 'scout_apm', require: false
|
||||||
gem 'sentry-rails', '>= 5.19.0', require: false
|
gem 'sentry-rails', '>= 5.18.2', require: false
|
||||||
gem 'sentry-ruby', require: false
|
gem 'sentry-ruby', require: false
|
||||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
gem 'sentry-sidekiq', '>= 5.18.2', require: false
|
||||||
|
|
||||||
##-- background job processing --##
|
##-- background job processing --##
|
||||||
gem 'sidekiq', '>= 7.3.1'
|
gem 'sidekiq', '>= 7.3.0'
|
||||||
# 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.3'
|
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||||
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.3'
|
gem 'meta_request', '>= 0.8.0'
|
||||||
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.5'
|
gem 'rspec-rails', '>= 6.1.3'
|
||||||
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
|
||||||
|
|||||||
+47
-47
@@ -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.4)
|
concurrent-ruby (1.3.3)
|
||||||
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.11)
|
et-orbi (1.2.7)
|
||||||
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.2.1)
|
faraday-retry (2.1.0)
|
||||||
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.11.1)
|
fugit (1.9.0)
|
||||||
et-orbi (~> 1, >= 1.2.11)
|
et-orbi (~> 1, >= 1.2.7)
|
||||||
raabro (~> 1.4)
|
raabro (~> 1.4)
|
||||||
gapic-common (0.20.0)
|
gapic-common (0.18.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.31.0)
|
google-cloud-dialogflow-v2 (0.23.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.18.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.6.0)
|
google-cloud-location (0.4.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.17.1, < 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.10.0)
|
google-cloud-translate-v3 (0.6.0)
|
||||||
gapic-common (>= 0.20.0, < 2.a)
|
gapic-common (>= 0.17.1, < 2.a)
|
||||||
google-cloud-errors (~> 1.0)
|
google-cloud-errors (~> 1.0)
|
||||||
google-protobuf (3.25.5)
|
google-protobuf (3.25.3)
|
||||||
google-protobuf (3.25.5-arm64-darwin)
|
google-protobuf (3.25.3-arm64-darwin)
|
||||||
google-protobuf (3.25.5-x86_64-darwin)
|
google-protobuf (3.25.3-x86_64-darwin)
|
||||||
google-protobuf (3.25.5-x86_64-linux)
|
google-protobuf (3.25.3-x86_64-linux)
|
||||||
googleapis-common-protos (1.6.0)
|
googleapis-common-protos (1.4.0)
|
||||||
google-protobuf (>= 3.18, < 5.a)
|
google-protobuf (~> 3.14)
|
||||||
googleapis-common-protos-types (~> 1.7)
|
googleapis-common-protos-types (~> 1.2)
|
||||||
grpc (~> 1.41)
|
grpc (~> 1.27)
|
||||||
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.3)
|
meta_request (0.8.2)
|
||||||
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.25.1)
|
minitest (5.24.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.14)
|
net-imap (0.4.12)
|
||||||
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.3)
|
omniauth-google-oauth2 (1.1.2)
|
||||||
jwt (>= 2.0)
|
jwt (>= 2.0)
|
||||||
oauth2 (~> 2.0)
|
oauth2 (~> 2.0)
|
||||||
omniauth (~> 2.0)
|
omniauth (~> 2.0)
|
||||||
@@ -552,7 +552,7 @@ GEM
|
|||||||
pry-rails (0.3.9)
|
pry-rails (0.3.9)
|
||||||
pry (>= 0.10.4)
|
pry (>= 0.10.4)
|
||||||
public_suffix (6.0.0)
|
public_suffix (6.0.0)
|
||||||
puma (6.4.3)
|
puma (6.4.2)
|
||||||
nio4r (~> 2.0)
|
nio4r (~> 2.0)
|
||||||
pundit (2.3.0)
|
pundit (2.3.0)
|
||||||
activesupport (>= 3.0.0)
|
activesupport (>= 3.0.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.6)
|
rexml (3.3.4)
|
||||||
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.2)
|
rspec-expectations (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-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 (7.0.1)
|
rspec-rails (6.1.3)
|
||||||
actionpack (>= 7.0)
|
actionpack (>= 6.1)
|
||||||
activesupport (>= 7.0)
|
activesupport (>= 6.1)
|
||||||
railties (>= 7.0)
|
railties (>= 6.1)
|
||||||
rspec-core (~> 3.13)
|
rspec-core (~> 3.13)
|
||||||
rspec-expectations (~> 3.13)
|
rspec-expectations (~> 3.13)
|
||||||
rspec-mocks (~> 3.13)
|
rspec-mocks (~> 3.13)
|
||||||
@@ -710,19 +710,19 @@ GEM
|
|||||||
activesupport (>= 4)
|
activesupport (>= 4)
|
||||||
selectize-rails (0.12.6)
|
selectize-rails (0.12.6)
|
||||||
semantic_range (3.0.0)
|
semantic_range (3.0.0)
|
||||||
sentry-rails (5.19.0)
|
sentry-rails (5.18.2)
|
||||||
railties (>= 5.0)
|
railties (>= 5.0)
|
||||||
sentry-ruby (~> 5.19.0)
|
sentry-ruby (~> 5.18.2)
|
||||||
sentry-ruby (5.19.0)
|
sentry-ruby (5.18.2)
|
||||||
bigdecimal
|
bigdecimal
|
||||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||||
sentry-sidekiq (5.19.0)
|
sentry-sidekiq (5.18.2)
|
||||||
sentry-ruby (~> 5.19.0)
|
sentry-ruby (~> 5.18.2)
|
||||||
sidekiq (>= 3.0)
|
sidekiq (>= 3.0)
|
||||||
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.1)
|
sidekiq (7.3.0)
|
||||||
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 (5.2.6)
|
valid_email2 (4.0.6)
|
||||||
activemodel (>= 3.2)
|
activemodel (>= 3.2)
|
||||||
mail (~> 2.5)
|
mail (~> 2.5)
|
||||||
version_gem (1.1.4)
|
version_gem (1.1.4)
|
||||||
@@ -819,7 +819,7 @@ GEM
|
|||||||
rack-proxy (>= 0.6.1)
|
rack-proxy (>= 0.6.1)
|
||||||
railties (>= 5.2)
|
railties (>= 5.2)
|
||||||
semantic_range (>= 2.3.0)
|
semantic_range (>= 2.3.0)
|
||||||
webrick (1.8.2)
|
webrick (1.8.1)
|
||||||
websocket-driver (0.7.6)
|
websocket-driver (0.7.6)
|
||||||
websocket-extensions (>= 0.1.0)
|
websocket-extensions (>= 0.1.0)
|
||||||
websocket-extensions (0.1.5)
|
websocket-extensions (0.1.5)
|
||||||
@@ -881,9 +881,9 @@ DEPENDENCIES
|
|||||||
foreman
|
foreman
|
||||||
geocoder
|
geocoder
|
||||||
gmail_xoauth
|
gmail_xoauth
|
||||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
google-cloud-dialogflow-v2
|
||||||
google-cloud-storage
|
google-cloud-storage
|
||||||
google-cloud-translate-v3 (>= 0.7.0)
|
google-cloud-translate-v3
|
||||||
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.3)
|
meta_request (>= 0.8.0)
|
||||||
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.3)
|
omniauth-google-oauth2 (>= 1.1.2)
|
||||||
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.5)
|
rspec-rails (>= 6.1.3)
|
||||||
rspec_junit_formatter
|
rspec_junit_formatter
|
||||||
rubocop
|
rubocop
|
||||||
rubocop-performance
|
rubocop-performance
|
||||||
@@ -939,11 +939,11 @@ DEPENDENCIES
|
|||||||
scout_apm
|
scout_apm
|
||||||
scss_lint
|
scss_lint
|
||||||
seed_dump
|
seed_dump
|
||||||
sentry-rails (>= 5.19.0)
|
sentry-rails (>= 5.18.2)
|
||||||
sentry-ruby
|
sentry-ruby
|
||||||
sentry-sidekiq (>= 5.19.0)
|
sentry-sidekiq (>= 5.18.2)
|
||||||
shoulda-matchers
|
shoulda-matchers
|
||||||
sidekiq (>= 7.3.1)
|
sidekiq (>= 7.3.0)
|
||||||
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,13 +32,11 @@ 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?, disposable: address.disposable? })
|
raise InvalidEmail.new(valid: address.valid?)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -81,21 +79,4 @@ 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(*account_user_attributes).compact)
|
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
|
||||||
end
|
end
|
||||||
|
|
||||||
def destroy
|
def destroy
|
||||||
@@ -67,16 +67,8 @@ 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(allowed_agent_params)
|
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
||||||
end
|
end
|
||||||
|
|
||||||
def new_agent_params
|
def new_agent_params
|
||||||
@@ -109,5 +101,3 @@ 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_type: :text, content_attributes: { deleted: true })
|
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
|
||||||
message.attachments.destroy_all
|
message.attachments.destroy_all
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -11,17 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
|||||||
end
|
end
|
||||||
|
|
||||||
def process_event
|
def process_event
|
||||||
response = @hook.process_event(params[:event])
|
render json: { message: @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,68 +1,13 @@
|
|||||||
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||||
def create
|
def create
|
||||||
result = if params[:attachment].present?
|
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||||
create_from_file
|
key: nil,
|
||||||
elsif params[:external_url].present?
|
io: params[:attachment].tempfile,
|
||||||
create_from_url
|
filename: params[:attachment].original_filename,
|
||||||
else
|
content_type: params[:attachment].content_type
|
||||||
render_error('No file or URL provided', :unprocessable_entity)
|
|
||||||
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
|
file_blob.save!
|
||||||
|
|
||||||
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,8 +11,6 @@ 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,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
|||||||
end
|
end
|
||||||
|
|
||||||
def check_authorization
|
def check_authorization
|
||||||
return if Current.account_user.administrator?
|
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||||
|
|
||||||
raise Pundit::NotAuthorizedError
|
|
||||||
end
|
end
|
||||||
|
|
||||||
def common_params
|
def common_params
|
||||||
@@ -137,5 +135,3 @@ 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.published
|
@articles = @portal.articles
|
||||||
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?
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
|||||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||||
import vueActionCable from './helper/actionCable';
|
import vueActionCable from './helper/actionCable';
|
||||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||||
|
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||||
import { setColorTheme } from './helper/themeHelper';
|
import { setColorTheme } from './helper/themeHelper';
|
||||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||||
import {
|
import {
|
||||||
@@ -31,6 +32,9 @@ export default {
|
|||||||
UpgradeBanner,
|
UpgradeBanner,
|
||||||
PendingEmailVerificationBanner,
|
PendingEmailVerificationBanner,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
mixins: [rtlMixin],
|
||||||
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showAddAccountModal: false,
|
showAddAccountModal: false,
|
||||||
@@ -42,7 +46,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
getAccount: 'accounts/getAccount',
|
getAccount: 'accounts/getAccount',
|
||||||
isRTL: 'accounts/isRTL',
|
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
authUIFlags: 'getAuthUIFlags',
|
authUIFlags: 'getAuthUIFlags',
|
||||||
accountUIFlags: 'accounts/getUIFlags',
|
accountUIFlags: 'accounts/getUIFlags',
|
||||||
@@ -99,6 +102,7 @@ export default {
|
|||||||
this.getAccount(this.currentAccountId);
|
this.getAccount(this.currentAccountId);
|
||||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||||
this.setLocale(locale);
|
this.setLocale(locale);
|
||||||
|
this.updateRTLDirectionView(locale);
|
||||||
this.latestChatwootVersion = latestChatwootVersion;
|
this.latestChatwootVersion = latestChatwootVersion;
|
||||||
vueActionCable.init(pubsubToken);
|
vueActionCable.init(pubsubToken);
|
||||||
this.reconnectService = new ReconnectService(this.$store, router);
|
this.reconnectService = new ReconnectService(this.$store, router);
|
||||||
@@ -120,8 +124,8 @@ export default {
|
|||||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||||
id="app"
|
id="app"
|
||||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
:class="{ 'app-rtl--wrapper': isRTLView }"
|
||||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
:dir="isRTLView ? 'rtl' : 'ltr'"
|
||||||
>
|
>
|
||||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||||
<template v-if="currentAccountId">
|
<template v-if="currentAccountId">
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
import ApiClient from './ApiClient';
|
|
||||||
|
|
||||||
class CustomRole extends ApiClient {
|
|
||||||
constructor() {
|
|
||||||
super('custom_roles', { accountScoped: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export default new CustomRole();
|
|
||||||
@@ -4,19 +4,20 @@ 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';
|
||||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||||
import ConversationItem from './ConversationItem.vue';
|
import ConversationItem from './ConversationItem.vue';
|
||||||
|
import conversationMixin from '../mixins/conversations';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
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,11 +28,6 @@ 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: {
|
||||||
@@ -46,6 +42,7 @@ export default {
|
|||||||
IntersectionObserver,
|
IntersectionObserver,
|
||||||
VirtualList,
|
VirtualList,
|
||||||
},
|
},
|
||||||
|
mixins: [conversationMixin, 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.
|
||||||
@@ -95,15 +92,6 @@ 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'
|
||||||
@@ -122,52 +110,43 @@ export default {
|
|||||||
lastConversationIndex,
|
lastConversationIndex,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
const handleConversationNavigation = direction => {
|
const handlePreviousConversation = () => {
|
||||||
|
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) {
|
||||||
// Determine the new index based on the direction
|
allConversations[lastConversationIndex].click();
|
||||||
const newIndex =
|
} else if (activeConversationIndex < lastConversationIndex) {
|
||||||
direction === 'previous'
|
allConversations[activeConversationIndex + 1].click();
|
||||||
? 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: () => handleConversationNavigation('previous'),
|
action: () => handlePreviousConversation(),
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
'Alt+KeyK': {
|
'Alt+KeyK': {
|
||||||
action: () => handleConversationNavigation('next'),
|
action: () => handleNextConversation(),
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, conversationListRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
conversationListRef,
|
conversationListRef,
|
||||||
setFilterAttributes,
|
|
||||||
initializeStatusAndAssigneeFilterToModal,
|
|
||||||
initializeInboxTeamAndLabelFilterToModal,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -209,7 +188,6 @@ export default {
|
|||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
currentAccountId: 'getCurrentAccountId',
|
|
||||||
chatLists: 'getAllConversations',
|
chatLists: 'getAllConversations',
|
||||||
mineChatsList: 'getMineChats',
|
mineChatsList: 'getMineChats',
|
||||||
allChatList: 'getAllStatusChats',
|
allChatList: 'getAllStatusChats',
|
||||||
@@ -249,19 +227,20 @@ export default {
|
|||||||
name,
|
name,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
userPermissions() {
|
|
||||||
return getUserPermissions(this.currentUser, this.currentAccountId);
|
|
||||||
},
|
|
||||||
assigneeTabItems() {
|
assigneeTabItems() {
|
||||||
return filterItemsByPermission(
|
const ASSIGNEE_TYPE_TAB_KEYS = {
|
||||||
ASSIGNEE_TYPE_TAB_PERMISSIONS,
|
me: 'mineCount',
|
||||||
this.userPermissions,
|
unassigned: 'unAssignedCount',
|
||||||
item => item.permissions
|
all: 'allCount',
|
||||||
).map(({ key, count: countKey }) => ({
|
};
|
||||||
key,
|
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
|
||||||
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
|
||||||
count: this.conversationStats[countKey] || 0,
|
return {
|
||||||
}));
|
key,
|
||||||
|
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
||||||
|
count,
|
||||||
|
};
|
||||||
|
});
|
||||||
},
|
},
|
||||||
showAssigneeInConversationCard() {
|
showAssigneeInConversationCard() {
|
||||||
return (
|
return (
|
||||||
@@ -882,25 +861,6 @@ 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>
|
||||||
@@ -946,6 +906,7 @@ export default {
|
|||||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||||
:items="assigneeTabItems"
|
:items="assigneeTabItems"
|
||||||
:active-tab="activeAssigneeTab"
|
:active-tab="activeAssigneeTab"
|
||||||
|
class="tab--chat-type"
|
||||||
@chatTabChange="updateAssigneeTab"
|
@chatTabChange="updateAssigneeTab"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -1033,4 +994,14 @@ export default {
|
|||||||
.conversations-list {
|
.conversations-list {
|
||||||
@apply overflow-hidden hover:overflow-y-auto;
|
@apply overflow-hidden hover:overflow-y-auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.tab--chat-type {
|
||||||
|
@apply py-0 px-4;
|
||||||
|
|
||||||
|
::v-deep {
|
||||||
|
.tabs {
|
||||||
|
@apply p-0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -331,12 +331,10 @@ 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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
|
import accountMixin from 'dashboard/mixins/account';
|
||||||
|
|
||||||
const EMPTY_SUBSCRIPTION_INFO = {
|
const EMPTY_SUBSCRIPTION_INFO = {
|
||||||
status: null,
|
status: null,
|
||||||
@@ -11,13 +11,10 @@ const EMPTY_SUBSCRIPTION_INFO = {
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { Banner },
|
components: { Banner },
|
||||||
|
mixins: [accountMixin],
|
||||||
setup() {
|
setup() {
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
|
||||||
const { accountId } = useAccount();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
accountId,
|
|
||||||
isAdmin,
|
isAdmin,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
import accountMixin from 'dashboard/mixins/account';
|
||||||
import { useAlert } from 'dashboard/composables';
|
import { useAlert } from 'dashboard/composables';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { Banner },
|
components: { Banner },
|
||||||
|
mixins: [accountMixin],
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
|
|||||||
@@ -1,17 +1,12 @@
|
|||||||
<script>
|
<script>
|
||||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import accountMixin from 'dashboard/mixins/account';
|
||||||
import { differenceInDays } from 'date-fns';
|
import { differenceInDays } from 'date-fns';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { Banner },
|
components: { Banner },
|
||||||
setup() {
|
mixins: [accountMixin],
|
||||||
const { accountId } = useAccount();
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return { conversationMeta: {} };
|
return { conversationMeta: {} };
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -13,12 +13,13 @@ import wootConstants from 'dashboard/constants/globals';
|
|||||||
import {
|
import {
|
||||||
CMD_REOPEN_CONVERSATION,
|
CMD_REOPEN_CONVERSATION,
|
||||||
CMD_RESOLVE_CONVERSATION,
|
CMD_RESOLVE_CONVERSATION,
|
||||||
} from 'dashboard/helper/commandbar/events';
|
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
@@ -130,14 +131,17 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, resolveActionsRef);
|
||||||
|
|
||||||
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 class="relative flex items-center justify-end resolve-actions">
|
<div
|
||||||
|
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,4 +1,5 @@
|
|||||||
<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';
|
||||||
@@ -7,10 +8,7 @@ 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 {
|
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||||
getUserPermissions,
|
|
||||||
hasPermissions,
|
|
||||||
} from '../../helper/permissionsHelper';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
@@ -28,6 +26,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const sidebarRef = ref(null);
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -65,10 +64,11 @@ export default {
|
|||||||
action: () => navigateToRoute('agent_list'),
|
action: () => navigateToRoute('agent_list'),
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, sidebarRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toggleKeyShortcutModal,
|
toggleKeyShortcutModal,
|
||||||
|
sidebarRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -113,10 +113,7 @@ export default {
|
|||||||
return getSidebarItems(this.accountId);
|
return getSidebarItems(this.accountId);
|
||||||
},
|
},
|
||||||
primaryMenuItems() {
|
primaryMenuItems() {
|
||||||
const userPermissions = getUserPermissions(
|
const userPermissions = this.currentUser.permissions;
|
||||||
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(
|
||||||
@@ -198,7 +195,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<aside class="flex h-full">
|
<aside ref="sidebarRef" class="flex h-full">
|
||||||
<PrimarySidebar
|
<PrimarySidebar
|
||||||
:logo-source="globalConfig.logoThumbnail"
|
:logo-source="globalConfig.logoThumbnail"
|
||||||
:installation-name="globalConfig.installationName"
|
:installation-name="globalConfig.installationName"
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ const settings = accountId => ({
|
|||||||
'settings_teams_list',
|
'settings_teams_list',
|
||||||
'settings_teams_new',
|
'settings_teams_new',
|
||||||
'sla_list',
|
'sla_list',
|
||||||
'custom_roles_list',
|
|
||||||
],
|
],
|
||||||
menuItems: [
|
menuItems: [
|
||||||
{
|
{
|
||||||
@@ -179,18 +178,6 @@ 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,13 +52,9 @@ export default {
|
|||||||
{{ account.name }}
|
{{ account.name }}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="text-xs font-medium lowercase text-slate-500 dark:text-slate-500 hover:underline-offset-4"
|
class="text-xs font-medium 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,10 +4,7 @@ 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 {
|
import { hasPermissions } from '../../../helper/permissionsHelper';
|
||||||
getUserPermissions,
|
|
||||||
hasPermissions,
|
|
||||||
} from '../../../helper/permissionsHelper';
|
|
||||||
import { routesWithPermissions } from '../../../routes';
|
import { routesWithPermissions } from '../../../routes';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -62,10 +59,7 @@ export default {
|
|||||||
accessibleMenuItems() {
|
accessibleMenuItems() {
|
||||||
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
||||||
menuItem => {
|
menuItem => {
|
||||||
const userPermissions = getUserPermissions(
|
const { permissions: userPermissions = [] } = this.currentUser;
|
||||||
this.currentUser,
|
|
||||||
this.accountId
|
|
||||||
);
|
|
||||||
return hasPermissions(
|
return hasPermissions(
|
||||||
routesWithPermissions[menuItem.toStateName],
|
routesWithPermissions[menuItem.toStateName],
|
||||||
userPermissions
|
userPermissions
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import { useConfig } from 'dashboard/composables/useConfig';
|
import configMixin from 'shared/mixins/configMixin';
|
||||||
import {
|
import {
|
||||||
getInboxClassByType,
|
getInboxClassByType,
|
||||||
getInboxWarningIconClass,
|
getInboxWarningIconClass,
|
||||||
@@ -16,6 +16,7 @@ import Policy from '../../policy.vue';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: { SecondaryChildNavItem, Policy },
|
components: { SecondaryChildNavItem, Policy },
|
||||||
|
mixins: [configMixin],
|
||||||
props: {
|
props: {
|
||||||
menuItem: {
|
menuItem: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -24,10 +25,8 @@ export default {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
const { isEnterprise } = useConfig();
|
|
||||||
return {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
isEnterprise,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
|
|||||||
@@ -1,11 +1,7 @@
|
|||||||
<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 {
|
import { hasPermissions } from '../helper/permissionsHelper';
|
||||||
getUserPermissions,
|
|
||||||
hasPermissions,
|
|
||||||
} from '../helper/permissionsHelper';
|
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
permissions: {
|
permissions: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -14,17 +10,12 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
const getters = useStoreGetters();
|
||||||
const user = computed(() => getters.getCurrentUser.value);
|
const user = getters.getCurrentUser.value;
|
||||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
const hasPermission = computed(() =>
|
||||||
const userPermissions = computed(() => {
|
hasPermissions(props.permissions, user.permissions)
|
||||||
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 { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||||
|
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 = draftMsg => {
|
const initializeMessage = draftMessage => {
|
||||||
initialMessage.value = draftMsg;
|
initialMessage.value = draftMessage;
|
||||||
};
|
};
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'$mod+KeyZ': {
|
'$mod+KeyZ': {
|
||||||
@@ -39,17 +39,15 @@ export default {
|
|||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
uiSettings,
|
uiSettings,
|
||||||
updateUISettings,
|
updateUISettings,
|
||||||
isAdmin,
|
isAdmin,
|
||||||
|
aiAssistanceButtonRef,
|
||||||
initialMessage,
|
initialMessage,
|
||||||
initializeMessage,
|
initializeMessage,
|
||||||
recordAnalytics,
|
|
||||||
isAIIntegrationEnabled,
|
|
||||||
draftMessage,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data: () => ({
|
data: () => ({
|
||||||
@@ -120,7 +118,7 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div ref="aiAssistanceButtonRef">
|
||||||
<div v-if="isAIIntegrationEnabled" class="relative">
|
<div v-if="isAIIntegrationEnabled" class="relative">
|
||||||
<AIAssistanceCTAButton
|
<AIAssistanceCTAButton
|
||||||
v-if="shouldShowAIAssistCTAButton"
|
v-if="shouldShowAIAssistCTAButton"
|
||||||
|
|||||||
@@ -1,23 +1,20 @@
|
|||||||
<script>
|
<script>
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
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 { useAI } from 'dashboard/composables/useAI';
|
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||||
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$, recordAnalytics };
|
return { updateUISettings, v$ };
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { ref, 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,16 +16,15 @@ 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 (selectedTabIndex >= 0 && selectedTabIndex < props.items.length) {
|
if (props.items[selectedTabIndex].key !== props.activeTab) {
|
||||||
const selectedItem = props.items[selectedTabIndex];
|
emit('chatTabChange', props.items[selectedTabIndex].key);
|
||||||
if (selectedItem.key !== props.activeTab) {
|
|
||||||
emit('chatTabChange', selectedItem.key);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -35,37 +34,23 @@ const keyboardEvents = {
|
|||||||
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||||
onTabChange(0);
|
onTabChange(0);
|
||||||
} else {
|
} else {
|
||||||
const nextIndex = (activeTabIndex.value + 1) % props.items.length;
|
onTabChange(activeTabIndex.value + 1);
|
||||||
onTabChange(nextIndex);
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
|
||||||
useKeyboardEvents(keyboardEvents);
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<woot-tabs
|
<div ref="chatTypeTabsRef">
|
||||||
:index="activeTabIndex"
|
<woot-tabs :index="activeTabIndex" @change="onTabChange">
|
||||||
class="w-full px-4 py-0 tab--chat-type"
|
<woot-tabs-item
|
||||||
@change="onTabChange"
|
v-for="item in items"
|
||||||
>
|
:key="item.key"
|
||||||
<woot-tabs-item
|
:name="item.name"
|
||||||
v-for="item in items"
|
:count="item.count"
|
||||||
:key="item.key"
|
/>
|
||||||
:name="item.name"
|
</woot-tabs>
|
||||||
:count="item.count"
|
</div>
|
||||||
/>
|
|
||||||
</woot-tabs>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped lang="scss">
|
|
||||||
.tab--chat-type {
|
|
||||||
::v-deep {
|
|
||||||
.tabs {
|
|
||||||
@apply p-0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -18,6 +18,8 @@ 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);
|
||||||
@@ -55,11 +57,15 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
<div
|
||||||
|
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,34 +1,38 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import { computed, ref } from 'vue';
|
export default {
|
||||||
import { useI18n } from 'dashboard/composables/useI18n';
|
props: {
|
||||||
|
text: {
|
||||||
const props = defineProps({
|
type: String,
|
||||||
text: {
|
default: '',
|
||||||
type: String,
|
},
|
||||||
default: '',
|
limit: {
|
||||||
|
type: Number,
|
||||||
|
default: 120,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
limit: {
|
data() {
|
||||||
type: Number,
|
return {
|
||||||
default: 120,
|
showMore: false,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
});
|
computed: {
|
||||||
const { t } = useI18n();
|
textToBeDisplayed() {
|
||||||
const showMore = ref(false);
|
if (this.showMore || this.text.length <= this.limit) {
|
||||||
|
return this.text;
|
||||||
|
}
|
||||||
|
|
||||||
const textToBeDisplayed = computed(() => {
|
return this.text.slice(0, this.limit) + '...';
|
||||||
if (showMore.value || props.text.length <= props.limit) {
|
},
|
||||||
return props.text;
|
buttonLabel() {
|
||||||
}
|
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||||
|
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||||
return props.text.slice(0, props.limit) + '...';
|
},
|
||||||
});
|
},
|
||||||
const buttonLabel = computed(() => {
|
methods: {
|
||||||
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
|
toggleShowMore() {
|
||||||
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
this.showMore = !this.showMore;
|
||||||
});
|
},
|
||||||
|
},
|
||||||
const toggleShowMore = () => {
|
|
||||||
showMore.value = !showMore.value;
|
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -37,10 +41,16 @@ const toggleShowMore = () => {
|
|||||||
{{ textToBeDisplayed }}
|
{{ textToBeDisplayed }}
|
||||||
<button
|
<button
|
||||||
v-if="text.length > limit"
|
v-if="text.length > limit"
|
||||||
class="text-woot-500 !p-0 !border-0 align-top"
|
class="show-more--button"
|
||||||
@click="toggleShowMore"
|
@click="toggleShowMore"
|
||||||
>
|
>
|
||||||
{{ buttonLabel }}
|
{{ buttonLabel }}
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.show-more--button {
|
||||||
|
color: var(--w-500);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -17,8 +17,6 @@ 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,
|
||||||
@@ -26,7 +24,6 @@ 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;
|
||||||
@@ -38,8 +35,10 @@ 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 {
|
||||||
import { createTypingIndicator } from '@chatwoot/utils';
|
replaceVariablesInMessage,
|
||||||
|
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';
|
||||||
@@ -72,12 +71,7 @@ const createState = (
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'WootMessageEditor',
|
name: 'WootMessageEditor',
|
||||||
components: {
|
components: { TagAgents, CannedResponse, VariableList },
|
||||||
TagAgents,
|
|
||||||
CannedResponse,
|
|
||||||
VariableList,
|
|
||||||
KeyboardEmojiSelector,
|
|
||||||
},
|
|
||||||
mixins: [keyboardEventListenerMixins],
|
mixins: [keyboardEventListenerMixins],
|
||||||
props: {
|
props: {
|
||||||
value: { type: String, default: '' },
|
value: { type: String, default: '' },
|
||||||
@@ -97,7 +91,6 @@ 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 {
|
||||||
@@ -126,11 +119,9 @@ 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,
|
||||||
@@ -178,7 +169,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.mentionSearchKey = args.text;
|
this.mentionSearchKey = args.text.replace('@', '');
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
@@ -207,7 +198,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.cannedSearchTerm = args.text;
|
this.cannedSearchTerm = args.text.replace('/', '');
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
@@ -235,7 +226,7 @@ export default {
|
|||||||
this.editorView = args.view;
|
this.editorView = args.view;
|
||||||
this.range = args.range;
|
this.range = args.range;
|
||||||
|
|
||||||
this.variableSearchTerm = args.text;
|
this.variableSearchTerm = args.text.replace('{{', '');
|
||||||
return false;
|
return false;
|
||||||
},
|
},
|
||||||
onExit: () => {
|
onExit: () => {
|
||||||
@@ -247,31 +238,6 @@ 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() {
|
||||||
@@ -301,8 +267,6 @@ 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);
|
||||||
},
|
},
|
||||||
@@ -347,9 +311,7 @@ export default {
|
|||||||
mounted() {
|
mounted() {
|
||||||
this.createEditorView();
|
this.createEditorView();
|
||||||
this.editorView.updateState(this.state);
|
this.editorView.updateState(this.state);
|
||||||
if (this.focusOnMount) {
|
this.focusEditorInputField();
|
||||||
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.
|
||||||
@@ -386,7 +348,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 if (this.focusOnMount) {
|
} else {
|
||||||
// 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');
|
||||||
@@ -555,36 +517,57 @@ 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;
|
return null;
|
||||||
|
}
|
||||||
|
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, from, to } = getContentNode(
|
let node = new MessageMarkdownTransformer(messageSchema).parse(
|
||||||
this.editorView,
|
updatedMessage
|
||||||
type,
|
|
||||||
content,
|
|
||||||
this.range,
|
|
||||||
this.variables
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!node) return;
|
const from =
|
||||||
|
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;
|
||||||
const event_map = {
|
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
|
||||||
mention: CONVERSATION_EVENTS.USED_MENTIONS,
|
return false;
|
||||||
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();
|
||||||
@@ -704,22 +687,17 @@ export default {
|
|||||||
<TagAgents
|
<TagAgents
|
||||||
v-if="showUserMentions && isPrivate"
|
v-if="showUserMentions && isPrivate"
|
||||||
:search-key="mentionSearchKey"
|
:search-key="mentionSearchKey"
|
||||||
@click="content => insertSpecialContent('mention', content)"
|
@click="insertMentionNode"
|
||||||
/>
|
/>
|
||||||
<CannedResponse
|
<CannedResponse
|
||||||
v-if="shouldShowCannedResponses"
|
v-if="shouldShowCannedResponses"
|
||||||
:search-key="cannedSearchTerm"
|
:search-key="cannedSearchTerm"
|
||||||
@click="content => insertSpecialContent('cannedResponse', content)"
|
@click="insertCannedResponse"
|
||||||
/>
|
/>
|
||||||
<VariableList
|
<VariableList
|
||||||
v-if="shouldShowVariables"
|
v-if="shouldShowVariables"
|
||||||
:search-key="variableSearchTerm"
|
:search-key="variableSearchTerm"
|
||||||
@click="content => insertSpecialContent('variable', content)"
|
@click="insertVariable"
|
||||||
/>
|
|
||||||
<KeyboardEmojiSelector
|
|
||||||
v-if="showEmojiMenu"
|
|
||||||
:search-key="emojiSearchTerm"
|
|
||||||
@click="emoji => insertSpecialContent('emoji', emoji)"
|
|
||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
ref="imageUpload"
|
ref="imageUpload"
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ 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';
|
||||||
@@ -56,7 +55,7 @@ export default {
|
|||||||
return {
|
return {
|
||||||
editorView: null,
|
editorView: null,
|
||||||
state: undefined,
|
state: undefined,
|
||||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
plugins: [],
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -77,7 +76,6 @@ export default {
|
|||||||
this.reloadState();
|
this.reloadState();
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
created() {
|
created() {
|
||||||
this.state = createState(
|
this.state = createState(
|
||||||
this.value,
|
this.value,
|
||||||
@@ -97,24 +95,6 @@ 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];
|
||||||
|
|
||||||
@@ -140,6 +120,7 @@ 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'));
|
||||||
}
|
}
|
||||||
@@ -192,18 +173,6 @@ 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,4 +1,5 @@
|
|||||||
<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';
|
||||||
@@ -115,28 +116,35 @@ export default {
|
|||||||
setup() {
|
setup() {
|
||||||
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 uploadTriggerButton = computed(() => {
|
||||||
|
if (uploadRef.value) {
|
||||||
|
return uploadRef.value.$children[1].$el;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
|
||||||
const keyboardEvents = {
|
const keyboardEvents = {
|
||||||
'Alt+KeyA': {
|
'Alt+KeyA': {
|
||||||
action: () => {
|
action: () => {
|
||||||
// TODO: This is really hacky, we need to replace the file picker component with
|
uploadTriggerButton.value.click();
|
||||||
// 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(
|
|
||||||
'#conversationAttachment'
|
|
||||||
);
|
|
||||||
uploadTriggerButton.click();
|
|
||||||
},
|
},
|
||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
watchEffect(() => {
|
||||||
|
useKeyboardEvents(keyboardEvents, uploadTriggerButton);
|
||||||
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
setSignatureFlagForInbox,
|
setSignatureFlagForInbox,
|
||||||
fetchSignatureFlagFromUISettings,
|
fetchSignatureFlagFromUISettings,
|
||||||
|
uploadRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -220,7 +228,11 @@ export default {
|
|||||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||||
},
|
},
|
||||||
enableInsertArticleInReply() {
|
enableInsertArticleInReply() {
|
||||||
return this.portalSlug;
|
const isFeatEnabled = this.isFeatureEnabledonAccount(
|
||||||
|
this.accountId,
|
||||||
|
FEATURE_FLAGS.INSERT_ARTICLE_IN_REPLY
|
||||||
|
);
|
||||||
|
return isFeatEnabled && this.portalSlug;
|
||||||
},
|
},
|
||||||
isFetchingAppIntegrations() {
|
isFetchingAppIntegrations() {
|
||||||
return this.uiFlags.isFetching;
|
return this.uiFlags.isFetching;
|
||||||
@@ -257,6 +269,7 @@ 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"
|
||||||
@@ -399,7 +412,6 @@ 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,4 +1,5 @@
|
|||||||
<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 {
|
||||||
@@ -22,6 +23,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup(props, { emit }) {
|
setup(props, { emit }) {
|
||||||
|
const replyTopRef = ref(null);
|
||||||
|
|
||||||
const setReplyMode = mode => {
|
const setReplyMode = mode => {
|
||||||
emit('setReplyMode', mode);
|
emit('setReplyMode', mode);
|
||||||
};
|
};
|
||||||
@@ -41,11 +44,12 @@ export default {
|
|||||||
allowOnFocusedInput: true,
|
allowOnFocusedInput: true,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, replyTopRef);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
handleReplyClick,
|
handleReplyClick,
|
||||||
handleNoteClick,
|
handleNoteClick,
|
||||||
|
replyTopRef,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -72,7 +76,10 @@ export default {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
<div
|
||||||
|
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"
|
||||||
|
|||||||
@@ -1,70 +0,0 @@
|
|||||||
<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,11 +41,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"
|
||||||
:items="items"
|
:items="items"
|
||||||
@mentionSelect="handleMentionClick"
|
@mentionSelect="handleMentionClick"
|
||||||
/>
|
>
|
||||||
|
<template slot-scope="{ item }">
|
||||||
|
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||||
|
</template>
|
||||||
|
</MentionBox>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
+4
-20
@@ -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 { useFilter } from 'shared/composables/useFilter';
|
import filterMixin from 'shared/mixins/filterMixin';
|
||||||
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,6 +14,7 @@ export default {
|
|||||||
components: {
|
components: {
|
||||||
FilterInputBox,
|
FilterInputBox,
|
||||||
},
|
},
|
||||||
|
mixins: [filterMixin],
|
||||||
props: {
|
props: {
|
||||||
onClose: {
|
onClose: {
|
||||||
type: Function,
|
type: Function,
|
||||||
@@ -36,15 +37,6 @@ 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,
|
||||||
@@ -75,11 +67,7 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
mounted() {
|
mounted() {
|
||||||
const { filterGroups, filterTypes } = this.setFilterAttributes();
|
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 = [];
|
||||||
@@ -338,11 +326,7 @@ 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="
|
:error-message="validationErrors[`filter_${i}`]"
|
||||||
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,8 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
|
||||||
import Thumbnail from '../Thumbnail.vue';
|
import Thumbnail from '../Thumbnail.vue';
|
||||||
import MessagePreview from './MessagePreview.vue';
|
import MessagePreview from './MessagePreview.vue';
|
||||||
|
import conversationMixin from '../../../mixins/conversations';
|
||||||
import router from '../../../routes';
|
import router from '../../../routes';
|
||||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||||
import InboxName from '../InboxName.vue';
|
import InboxName from '../InboxName.vue';
|
||||||
@@ -24,7 +24,7 @@ export default {
|
|||||||
PriorityMark,
|
PriorityMark,
|
||||||
SLACardLabel,
|
SLACardLabel,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin, conversationMixin],
|
||||||
props: {
|
props: {
|
||||||
activeLabel: {
|
activeLabel: {
|
||||||
type: String,
|
type: String,
|
||||||
@@ -118,7 +118,7 @@ export default {
|
|||||||
},
|
},
|
||||||
|
|
||||||
lastMessageInChat() {
|
lastMessageInChat() {
|
||||||
return getLastMessage(this.chat);
|
return this.lastMessage(this.chat);
|
||||||
},
|
},
|
||||||
|
|
||||||
inbox() {
|
inbox() {
|
||||||
@@ -316,11 +316,7 @@ export default {
|
|||||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<CardLabels
|
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
|
||||||
:conversation-id="chat.id"
|
|
||||||
:conversation-labels="chat.labels"
|
|
||||||
class="mt-0.5 mx-2 mb-0"
|
|
||||||
>
|
|
||||||
<template v-if="hasSlaPolicyId" #before>
|
<template v-if="hasSlaPolicyId" #before>
|
||||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
<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';
|
||||||
@@ -22,7 +24,7 @@ export default {
|
|||||||
SLACardLabel,
|
SLACardLabel,
|
||||||
Linear,
|
Linear,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [inboxMixin, agentMixin],
|
||||||
props: {
|
props: {
|
||||||
chat: {
|
chat: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -42,12 +44,18 @@ 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);
|
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
|
||||||
|
|
||||||
|
return {
|
||||||
|
conversationHeaderActionsRef,
|
||||||
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
@@ -175,6 +183,7 @@ 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,7 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||||
import { useAccount } from 'dashboard/composables/useAccount';
|
import accountMixin from 'dashboard/mixins/account';
|
||||||
import OnboardingView from '../OnboardingView.vue';
|
import OnboardingView from '../OnboardingView.vue';
|
||||||
import EmptyStateMessage from './EmptyStateMessage.vue';
|
import EmptyStateMessage from './EmptyStateMessage.vue';
|
||||||
|
|
||||||
@@ -10,6 +10,7 @@ export default {
|
|||||||
OnboardingView,
|
OnboardingView,
|
||||||
EmptyStateMessage,
|
EmptyStateMessage,
|
||||||
},
|
},
|
||||||
|
mixins: [accountMixin],
|
||||||
props: {
|
props: {
|
||||||
isOnExpandedLayout: {
|
isOnExpandedLayout: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -18,12 +19,8 @@ export default {
|
|||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
const { isAdmin } = useAdmin();
|
const { isAdmin } = useAdmin();
|
||||||
|
|
||||||
const { accountScopedUrl } = useAccount();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isAdmin,
|
isAdmin,
|
||||||
accountScopedUrl,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
@@ -47,7 +44,7 @@ export default {
|
|||||||
return this.$t('CONVERSATION.404');
|
return this.$t('CONVERSATION.404');
|
||||||
},
|
},
|
||||||
newInboxURL() {
|
newInboxURL() {
|
||||||
return this.accountScopedUrl('settings/inboxes/new');
|
return this.addAccountScoping('settings/inboxes/new');
|
||||||
},
|
},
|
||||||
emptyClassName() {
|
emptyClassName() {
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script>
|
<script>
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
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,6 +39,7 @@ export default {
|
|||||||
InstagramStoryReply,
|
InstagramStoryReply,
|
||||||
Spinner,
|
Spinner,
|
||||||
},
|
},
|
||||||
|
mixins: [messageFormatterMixin],
|
||||||
props: {
|
props: {
|
||||||
data: {
|
data: {
|
||||||
type: Object,
|
type: Object,
|
||||||
@@ -73,12 +74,6 @@ export default {
|
|||||||
default: () => ({}),
|
default: () => ({}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
|
||||||
const { formatMessage } = useMessageFormatter();
|
|
||||||
return {
|
|
||||||
formatMessage,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
showContextMenu: false,
|
showContextMenu: false,
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
<script>
|
<script>
|
||||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||||
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,
|
||||||
@@ -19,12 +20,6 @@ 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;
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<script>
|
<script>
|
||||||
import { ref } from 'vue';
|
import { ref } from 'vue';
|
||||||
// composable
|
// composable
|
||||||
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,17 +13,17 @@ import Banner from 'dashboard/components/ui/Banner.vue';
|
|||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
|
|
||||||
// mixins
|
// mixins
|
||||||
|
import conversationMixin, {
|
||||||
|
filterDuplicateSourceMessages,
|
||||||
|
} from '../../../mixins/conversations';
|
||||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||||
|
import configMixin from 'shared/mixins/configMixin';
|
||||||
|
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||||
|
|
||||||
// utils
|
// utils
|
||||||
import { getTypingUsersText } from '../../../helper/commons';
|
import { getTypingUsersText } from '../../../helper/commons';
|
||||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||||
import {
|
|
||||||
filterDuplicateSourceMessages,
|
|
||||||
getReadMessages,
|
|
||||||
getUnreadMessages,
|
|
||||||
} from 'dashboard/helper/conversationHelper';
|
|
||||||
|
|
||||||
// constants
|
// constants
|
||||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||||
@@ -40,7 +38,7 @@ export default {
|
|||||||
Banner,
|
Banner,
|
||||||
ConversationLabelSuggestion,
|
ConversationLabelSuggestion,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin],
|
mixins: [conversationMixin, inboxMixin, configMixin, aiMixin],
|
||||||
props: {
|
props: {
|
||||||
isContactPanelOpen: {
|
isContactPanelOpen: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -52,8 +50,8 @@ export default {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
setup() {
|
||||||
|
const conversationFooterRef = ref(null);
|
||||||
const isPopOutReplyBox = ref(false);
|
const isPopOutReplyBox = ref(false);
|
||||||
const { isEnterprise } = useConfig();
|
|
||||||
|
|
||||||
const closePopOutReplyBox = () => {
|
const closePopOutReplyBox = () => {
|
||||||
isPopOutReplyBox.value = false;
|
isPopOutReplyBox.value = false;
|
||||||
@@ -69,24 +67,13 @@ export default {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
||||||
|
|
||||||
const {
|
|
||||||
isAIIntegrationEnabled,
|
|
||||||
isLabelSuggestionFeatureEnabled,
|
|
||||||
fetchIntegrationsIfRequired,
|
|
||||||
fetchLabelSuggestions,
|
|
||||||
} = useAI();
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isEnterprise,
|
conversationFooterRef,
|
||||||
isPopOutReplyBox,
|
isPopOutReplyBox,
|
||||||
closePopOutReplyBox,
|
closePopOutReplyBox,
|
||||||
showPopOutReplyBox,
|
showPopOutReplyBox,
|
||||||
isAIIntegrationEnabled,
|
|
||||||
isLabelSuggestionFeatureEnabled,
|
|
||||||
fetchIntegrationsIfRequired,
|
|
||||||
fetchLabelSuggestions,
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
@@ -151,14 +138,14 @@ export default {
|
|||||||
}
|
}
|
||||||
return messages;
|
return messages;
|
||||||
},
|
},
|
||||||
readMessages() {
|
getReadMessages() {
|
||||||
return getReadMessages(
|
return this.readMessages(
|
||||||
this.getMessages,
|
this.getMessages,
|
||||||
this.currentChat.agent_last_seen_at
|
this.currentChat.agent_last_seen_at
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
unReadMessages() {
|
getUnReadMessages() {
|
||||||
return getUnreadMessages(
|
return this.unReadMessages(
|
||||||
this.getMessages,
|
this.getMessages,
|
||||||
this.currentChat.agent_last_seen_at
|
this.currentChat.agent_last_seen_at
|
||||||
);
|
);
|
||||||
@@ -481,7 +468,7 @@ export default {
|
|||||||
</li>
|
</li>
|
||||||
</transition>
|
</transition>
|
||||||
<Message
|
<Message
|
||||||
v-for="message in readMessages"
|
v-for="message in getReadMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
class="message--read ph-no-capture"
|
class="message--read ph-no-capture"
|
||||||
data-clarity-mask="True"
|
data-clarity-mask="True"
|
||||||
@@ -506,7 +493,7 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
</li>
|
</li>
|
||||||
<Message
|
<Message
|
||||||
v-for="message in unReadMessages"
|
v-for="message in getUnReadMessages"
|
||||||
:key="message.id"
|
:key="message.id"
|
||||||
class="message--unread ph-no-capture"
|
class="message--unread ph-no-capture"
|
||||||
data-clarity-mask="True"
|
data-clarity-mask="True"
|
||||||
@@ -527,6 +514,7 @@ 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 'dashboard/helper/commandbar/events';
|
} from '../../../routes/dashboard/commands/commandBarBusEvents';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
components: {
|
components: {
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
<script setup>
|
|
||||||
defineProps({
|
|
||||||
imageSrc: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
imageAlt: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
title: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
description: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
to: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
linkText: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<template>
|
|
||||||
<div
|
|
||||||
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
|
|
||||||
>
|
|
||||||
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
|
|
||||||
<div class="mt-auto">
|
|
||||||
<p
|
|
||||||
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
|
|
||||||
>
|
|
||||||
{{ title }}
|
|
||||||
</p>
|
|
||||||
<p class="text-slate-600 dark:text-slate-400 text-sm">
|
|
||||||
{{ description }}
|
|
||||||
</p>
|
|
||||||
<router-link
|
|
||||||
v-if="to"
|
|
||||||
:to="{ name: to }"
|
|
||||||
class="no-underline text-woot-500 text-sm font-medium"
|
|
||||||
>
|
|
||||||
<span>{{ linkText }}</span>
|
|
||||||
<span>{{ `→` }}</span>
|
|
||||||
</router-link>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
@@ -1,80 +1,159 @@
|
|||||||
<script setup>
|
<script>
|
||||||
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
|
import { mapGetters } from 'vuex';
|
||||||
import { computed } from 'vue';
|
import accountMixin from '../../../mixins/account';
|
||||||
import { useI18n } from 'dashboard/composables/useI18n';
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
const getters = useStoreGetters();
|
export default {
|
||||||
const { t } = useI18n();
|
mixins: [accountMixin],
|
||||||
const globalConfig = computed(() => getters['globalConfig/get'].value);
|
computed: {
|
||||||
const currentUser = computed(() => getters.getCurrentUser.value);
|
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||||
|
newInboxURL() {
|
||||||
const greetingMessage = computed(() => {
|
return this.addAccountScoping('settings/inboxes/new');
|
||||||
const hours = new Date().getHours();
|
},
|
||||||
let translationKey;
|
newAgentURL() {
|
||||||
if (hours < 12) {
|
return this.addAccountScoping('settings/agents/list');
|
||||||
translationKey = 'ONBOARDING.GREETING_MORNING';
|
},
|
||||||
} else if (hours < 18) {
|
newLabelsURL() {
|
||||||
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
|
return this.addAccountScoping('settings/labels/list');
|
||||||
} else {
|
},
|
||||||
translationKey = 'ONBOARDING.GREETING_EVENING';
|
},
|
||||||
}
|
};
|
||||||
return t(translationKey, {
|
|
||||||
name: currentUser.value.name,
|
|
||||||
installationName: globalConfig.value.installationName,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<div class="onboarding-wrap">
|
||||||
class="min-h-screen max-w-4xl mx-auto grid grid-cols-1 xl:grid-cols-2 grid-rows-[auto_1fr_1fr] gap-4 p-8 w-full font-inter overflow-auto"
|
<div class="onboarding">
|
||||||
>
|
<div class="scroll-wrap">
|
||||||
<div class="col-span-full self-start">
|
<div class="features-item">
|
||||||
<p
|
<h1 class="text-2xl text-slate-900 dark:text-slate-50">
|
||||||
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
|
<span>{{
|
||||||
>
|
$t('ONBOARDING.TITLE', {
|
||||||
{{ greetingMessage }}
|
installationName: globalConfig.installationName,
|
||||||
</p>
|
})
|
||||||
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
|
}}</span>
|
||||||
{{
|
</h1>
|
||||||
$t('ONBOARDING.DESCRIPTION', {
|
<p class="intro-body">
|
||||||
installationName: globalConfig.installationName,
|
{{
|
||||||
})
|
$t('ONBOARDING.DESCRIPTION', {
|
||||||
}}
|
installationName: globalConfig.installationName,
|
||||||
</p>
|
})
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
<p v-if="globalConfig.installationName === 'Chatwoot'">
|
||||||
|
<a
|
||||||
|
href="https://www.chatwoot.com/changelog"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener nofollow noreferrer"
|
||||||
|
class="onboarding--link"
|
||||||
|
>
|
||||||
|
{{ $t('ONBOARDING.READ_LATEST_UPDATES') }}
|
||||||
|
</a>
|
||||||
|
<span>🎉</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-item">
|
||||||
|
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||||
|
<span class="emoji">💬</span>
|
||||||
|
<span class="conversation--title">{{
|
||||||
|
$t('ONBOARDING.ALL_CONVERSATION.TITLE')
|
||||||
|
}}</span>
|
||||||
|
</h2>
|
||||||
|
<p class="intro-body">
|
||||||
|
{{ $t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="features-item">
|
||||||
|
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||||
|
<span class="emoji">👥</span>
|
||||||
|
{{ $t('ONBOARDING.TEAM_MEMBERS.TITLE') }}
|
||||||
|
</h2>
|
||||||
|
<p class="intro-body">
|
||||||
|
{{ $t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
<router-link :to="newAgentURL" class="onboarding--link">
|
||||||
|
{{ $t('ONBOARDING.TEAM_MEMBERS.NEW_LINK') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="features-item">
|
||||||
|
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||||
|
<span class="emoji">📥</span>{{ $t('ONBOARDING.INBOXES.TITLE') }}
|
||||||
|
</h2>
|
||||||
|
<p class="intro-body">
|
||||||
|
{{ $t('ONBOARDING.INBOXES.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
<router-link :to="newInboxURL" class="onboarding--link">
|
||||||
|
{{ $t('ONBOARDING.INBOXES.NEW_LINK') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
<div class="features-item">
|
||||||
|
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||||
|
<span class="emoji">🔖</span>{{ $t('ONBOARDING.LABELS.TITLE') }}
|
||||||
|
</h2>
|
||||||
|
<p class="intro-body">
|
||||||
|
{{ $t('ONBOARDING.LABELS.DESCRIPTION') }}
|
||||||
|
</p>
|
||||||
|
<router-link :to="newLabelsURL" class="onboarding--link">
|
||||||
|
{{ $t('ONBOARDING.LABELS.NEW_LINK') }}
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<OnboardingFeatureCard
|
|
||||||
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
|
|
||||||
image-alt="Omnichannel"
|
|
||||||
to="settings_inbox_new"
|
|
||||||
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
|
|
||||||
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
|
|
||||||
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
|
|
||||||
/>
|
|
||||||
<OnboardingFeatureCard
|
|
||||||
image-src="/dashboard/images/onboarding/teams.png"
|
|
||||||
image-alt="Teams"
|
|
||||||
to="settings_teams_new"
|
|
||||||
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
|
|
||||||
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
|
|
||||||
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
|
|
||||||
/>
|
|
||||||
<OnboardingFeatureCard
|
|
||||||
image-src="/dashboard/images/onboarding/canned-responses.png"
|
|
||||||
image-alt="Canned responses"
|
|
||||||
to="canned_list"
|
|
||||||
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
|
|
||||||
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
|
|
||||||
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
|
|
||||||
/>
|
|
||||||
<OnboardingFeatureCard
|
|
||||||
image-src="/dashboard/images/onboarding/labels.png"
|
|
||||||
image-alt="Labels"
|
|
||||||
to="labels_list"
|
|
||||||
:title="$t('ONBOARDING.LABELS.TITLE')"
|
|
||||||
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
|
|
||||||
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.onboarding-wrap {
|
||||||
|
display: flex;
|
||||||
|
font-size: var(--font-size-small);
|
||||||
|
justify-content: center;
|
||||||
|
overflow: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
.onboarding {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.scroll-wrap {
|
||||||
|
padding: var(--space-larger) 8.5rem;
|
||||||
|
min-height: 100vh;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.features-item {
|
||||||
|
margin-bottom: var(--space-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.conversation--title {
|
||||||
|
margin-left: var(--space-minus-smaller);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: var(--font-size-big);
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin-bottom: var(--space-one);
|
||||||
|
}
|
||||||
|
|
||||||
|
.block-title {
|
||||||
|
font-weight: var(--font-weight-bold);
|
||||||
|
margin-bottom: var(--space-smaller);
|
||||||
|
margin-left: var(--space-minus-large);
|
||||||
|
}
|
||||||
|
|
||||||
|
.intro-body {
|
||||||
|
margin-bottom: var(--space-small);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.onboarding--link {
|
||||||
|
color: var(--w-500);
|
||||||
|
font-weight: var(--font-weight-medium);
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.emoji {
|
||||||
|
width: var(--space-large);
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ 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 {
|
||||||
@@ -30,6 +31,7 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
|||||||
import { trimContent, debounce } from '@chatwoot/utils';
|
import { trimContent, debounce } from '@chatwoot/utils';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||||
|
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||||
import {
|
import {
|
||||||
appendSignature,
|
appendSignature,
|
||||||
@@ -60,7 +62,13 @@ export default {
|
|||||||
MessageSignatureMissingAlert,
|
MessageSignatureMissingAlert,
|
||||||
ArticleSearchPopover,
|
ArticleSearchPopover,
|
||||||
},
|
},
|
||||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
mixins: [
|
||||||
|
inboxMixin,
|
||||||
|
messageFormatterMixin,
|
||||||
|
rtlMixin,
|
||||||
|
fileUploadMixin,
|
||||||
|
keyboardEventListenerMixins,
|
||||||
|
],
|
||||||
props: {
|
props: {
|
||||||
popoutReplyBox: {
|
popoutReplyBox: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
@@ -113,7 +121,6 @@ export default {
|
|||||||
},
|
},
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters({
|
...mapGetters({
|
||||||
isRTL: 'accounts/isRTL',
|
|
||||||
currentChat: 'getSelectedChat',
|
currentChat: 'getSelectedChat',
|
||||||
messageSignature: 'getMessageSignature',
|
messageSignature: 'getMessageSignature',
|
||||||
currentUser: 'getCurrentUser',
|
currentUser: 'getCurrentUser',
|
||||||
@@ -237,9 +244,6 @@ 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() {
|
||||||
@@ -303,7 +307,7 @@ export default {
|
|||||||
if (this.isOnExpandedLayout || this.popoutReplyBox) {
|
if (this.isOnExpandedLayout || this.popoutReplyBox) {
|
||||||
return 'emoji-dialog--expanded';
|
return 'emoji-dialog--expanded';
|
||||||
}
|
}
|
||||||
if (this.isRTL) {
|
if (this.isRTLView) {
|
||||||
return 'emoji-dialog--rtl';
|
return 'emoji-dialog--rtl';
|
||||||
}
|
}
|
||||||
return '';
|
return '';
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ const onSelect = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
|
elementRef: tagAgentsRef,
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
|
|||||||
@@ -56,14 +56,20 @@ 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>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
import { mapGetters } from 'vuex';
|
import { mapGetters } from 'vuex';
|
||||||
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
||||||
import GalleryView from '../components/GalleryView.vue';
|
import GalleryView from '../components/GalleryView.vue';
|
||||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
|
||||||
|
|
||||||
const ALLOWED_FILE_TYPES = {
|
const ALLOWED_FILE_TYPES = {
|
||||||
IMAGE: 'image',
|
IMAGE: 'image',
|
||||||
@@ -43,9 +42,6 @@ export default {
|
|||||||
isAudio() {
|
isAudio() {
|
||||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
|
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
|
||||||
},
|
},
|
||||||
timeStampURL() {
|
|
||||||
return timeStampAppendedURL(this.dataUrl);
|
|
||||||
},
|
|
||||||
attachmentTypeClasses() {
|
attachmentTypeClasses() {
|
||||||
return {
|
return {
|
||||||
image: this.isImage,
|
image: this.isImage,
|
||||||
@@ -112,7 +108,7 @@ export default {
|
|||||||
@click="onClick"
|
@click="onClick"
|
||||||
/>
|
/>
|
||||||
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
|
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
|
||||||
<source :src="timeStampURL" />
|
<source :src="`${dataUrl}?t=${Date.now()}`" />
|
||||||
</audio>
|
</audio>
|
||||||
<GalleryView
|
<GalleryView
|
||||||
v-if="show"
|
v-if="show"
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ 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('');
|
||||||
@@ -201,7 +202,7 @@ const keyboardEvents = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, galleryViewRef);
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
setImageAndVideoSrc(props.attachment);
|
setImageAndVideoSrc(props.attachment);
|
||||||
@@ -217,6 +218,7 @@ 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,12 +1,9 @@
|
|||||||
<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: {
|
||||||
@@ -14,6 +11,7 @@ export default {
|
|||||||
MenuItemWithSubmenu,
|
MenuItemWithSubmenu,
|
||||||
AgentLoadingPlaceholder,
|
AgentLoadingPlaceholder,
|
||||||
},
|
},
|
||||||
|
mixins: [agentMixin],
|
||||||
props: {
|
props: {
|
||||||
chatId: {
|
chatId: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -114,19 +112,13 @@ 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 = getAgentsByUpdatedPresence(
|
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||||
agents,
|
const filteredAgents = this.sortedAgentsByAvailability(
|
||||||
this.currentUser,
|
|
||||||
this.currentAccountId
|
|
||||||
);
|
|
||||||
const filteredAgents = getSortedAgentsByAvailability(
|
|
||||||
agentsByUpdatedPresence
|
agentsByUpdatedPresence
|
||||||
);
|
);
|
||||||
return filteredAgents;
|
return filteredAgents;
|
||||||
|
|||||||
+3
-13
@@ -2,9 +2,7 @@
|
|||||||
// 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';
|
||||||
@@ -20,6 +18,7 @@ export default {
|
|||||||
Avatar,
|
Avatar,
|
||||||
WootButton,
|
WootButton,
|
||||||
},
|
},
|
||||||
|
mixins: [aiMixin],
|
||||||
props: {
|
props: {
|
||||||
suggestedLabels: {
|
suggestedLabels: {
|
||||||
type: Array,
|
type: Array,
|
||||||
@@ -31,11 +30,6 @@ export default {
|
|||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
setup() {
|
|
||||||
const { isAIIntegrationEnabled } = useAI();
|
|
||||||
|
|
||||||
return { isAIIntegrationEnabled };
|
|
||||||
},
|
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
isDismissed: false,
|
isDismissed: false,
|
||||||
@@ -47,11 +41,7 @@ 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');
|
||||||
@@ -172,7 +162,7 @@ export default {
|
|||||||
delay: { show: 600, hide: 0 },
|
delay: { show: 600, hide: 0 },
|
||||||
hideOnClick: true,
|
hideOnClick: true,
|
||||||
}"
|
}"
|
||||||
class="label-suggestion--option !px-0"
|
class="label-suggestion--option"
|
||||||
@click="pushOrAddLabel(label.title)"
|
@click="pushOrAddLabel(label.title)"
|
||||||
>
|
>
|
||||||
<woot-label
|
<woot-label
|
||||||
|
|||||||
+1
-1
@@ -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/helper/commandbar/events';
|
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||||
|
|
||||||
import AgentSelector from './AgentSelector.vue';
|
import AgentSelector from './AgentSelector.vue';
|
||||||
import UpdateActions from './UpdateActions.vue';
|
import UpdateActions from './UpdateActions.vue';
|
||||||
|
|||||||
+16
-19
@@ -1,26 +1,23 @@
|
|||||||
<script>
|
<script>
|
||||||
import { computed } from 'vue';
|
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
mixins: [conversationLabelMixin],
|
||||||
props: {
|
props: {
|
||||||
conversationLabels: {
|
// conversationId prop is used in /conversation/labelMixin,
|
||||||
type: Array,
|
// remove this props when refactoring to composable if not needed
|
||||||
|
// eslint-disable-next-line vue/no-unused-properties
|
||||||
|
conversationId: {
|
||||||
|
type: Number,
|
||||||
required: true,
|
required: true,
|
||||||
},
|
},
|
||||||
},
|
// conversationLabels prop is used in /conversation/labelMixin,
|
||||||
setup(props) {
|
// remove this props when refactoring to composable if not needed
|
||||||
const accountLabels = useMapGetter('labels/getLabels');
|
// eslint-disable-next-line vue/no-unused-properties
|
||||||
|
conversationLabels: {
|
||||||
const activeLabels = computed(() => {
|
type: String,
|
||||||
return accountLabels.value.filter(({ title }) =>
|
required: false,
|
||||||
props.conversationLabels.includes(title)
|
default: '',
|
||||||
);
|
},
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeLabels,
|
|
||||||
};
|
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -83,7 +80,7 @@ export default {
|
|||||||
<slot name="before" />
|
<slot name="before" />
|
||||||
<woot-label
|
<woot-label
|
||||||
v-for="(label, index) in activeLabels"
|
v-for="(label, index) in activeLabels"
|
||||||
:key="label ? label.id : index"
|
:key="label.id"
|
||||||
:title="label.title"
|
:title="label.title"
|
||||||
:description="label.description"
|
:description="label.description"
|
||||||
:color="label.color"
|
:color="label.color"
|
||||||
|
|||||||
@@ -48,7 +48,11 @@ 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) {
|
||||||
// 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.
|
const errorMessage = parseLinearAPIErrorResponse(
|
||||||
|
error,
|
||||||
|
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
|
||||||
|
);
|
||||||
|
useAlert(errorMessage);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,6 @@
|
|||||||
<script>
|
<script>
|
||||||
import countries from 'shared/constants/countries.js';
|
import countries from 'shared/constants/countries.js';
|
||||||
import parsePhoneNumber from 'libphonenumber-js';
|
import parsePhoneNumber from 'libphonenumber-js';
|
||||||
import {
|
|
||||||
getActiveCountryCode,
|
|
||||||
getActiveDialCode,
|
|
||||||
} from 'shared/components/PhoneInput/helper';
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
props: {
|
props: {
|
||||||
@@ -34,8 +30,8 @@ export default {
|
|||||||
selectedIndex: -1,
|
selectedIndex: -1,
|
||||||
showDropdown: false,
|
showDropdown: false,
|
||||||
searchCountry: '',
|
searchCountry: '',
|
||||||
activeCountryCode: getActiveCountryCode(),
|
activeCountryCode: '',
|
||||||
activeDialCode: getActiveDialCode(),
|
activeDialCode: '',
|
||||||
phoneNumber: this.value,
|
phoneNumber: this.value,
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ const onSelect = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
|
elementRef: mentionsListContainerRef,
|
||||||
items: computed(() => props.items),
|
items: computed(() => props.items),
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -90,24 +91,22 @@ const variableKey = (item = {}) => {
|
|||||||
}"
|
}"
|
||||||
@click="onListItemSelection(index)"
|
@click="onListItemSelection(index)"
|
||||||
>
|
>
|
||||||
<slot :item="item" :index="index" :selected="index === selectedIndex">
|
<p
|
||||||
<p
|
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"
|
||||||
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"
|
:class="{
|
||||||
:class="{
|
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
}"
|
||||||
}"
|
>
|
||||||
>
|
{{ item.description }}
|
||||||
{{ item.description }}
|
</p>
|
||||||
</p>
|
<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"
|
||||||
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"
|
:class="{
|
||||||
:class="{
|
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
}"
|
||||||
}"
|
>
|
||||||
>
|
{{ variableKey(item) }}
|
||||||
{{ variableKey(item) }}
|
</p>
|
||||||
</p>
|
|
||||||
</slot>
|
|
||||||
</button>
|
</button>
|
||||||
</woot-dropdown-item>
|
</woot-dropdown-item>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
@@ -1,197 +0,0 @@
|
|||||||
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',
|
|
||||||
};
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
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([]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,204 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,89 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,408 +0,0 @@
|
|||||||
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,63 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
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 },
|
|
||||||
],
|
|
||||||
};
|
|
||||||
@@ -1,119 +0,0 @@
|
|||||||
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']);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
import { ref } from 'vue';
|
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import { useAccount } from '../useAccount';
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
vi.mock('dashboard/composables/store');
|
|
||||||
|
|
||||||
describe('useAccount', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useStoreGetters.mockReturnValue({
|
|
||||||
getCurrentAccountId: ref(123),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns accountId as a computed property', () => {
|
|
||||||
const { accountId } = useAccount();
|
|
||||||
expect(accountId.value).toBe(123);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('generates account-scoped URLs correctly', () => {
|
|
||||||
const { accountScopedUrl } = useAccount();
|
|
||||||
const result = accountScopedUrl('settings/inbox/new');
|
|
||||||
expect(result).toBe('/app/accounts/123/settings/inbox/new');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles URLs with leading slash', () => {
|
|
||||||
const { accountScopedUrl } = useAccount();
|
|
||||||
const result = accountScopedUrl('users');
|
|
||||||
expect(result).toBe('/app/accounts/123/users');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles empty URL', () => {
|
|
||||||
const { accountScopedUrl } = useAccount();
|
|
||||||
const result = accountScopedUrl('');
|
|
||||||
expect(result).toBe('/app/accounts/123/');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
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]);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,295 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,51 +0,0 @@
|
|||||||
import { useConfig } from '../useConfig';
|
|
||||||
|
|
||||||
describe('useConfig', () => {
|
|
||||||
const originalChatwootConfig = window.chatwootConfig;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
window.chatwootConfig = {
|
|
||||||
hostURL: 'https://example.com',
|
|
||||||
vapidPublicKey: 'vapid-key',
|
|
||||||
enabledLanguages: ['en', 'fr'],
|
|
||||||
isEnterprise: 'true',
|
|
||||||
enterprisePlanName: 'enterprise',
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
window.chatwootConfig = originalChatwootConfig;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns the correct configuration values', () => {
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
expect(config.hostURL).toBe('https://example.com');
|
|
||||||
expect(config.vapidPublicKey).toBe('vapid-key');
|
|
||||||
expect(config.enabledLanguages).toEqual(['en', 'fr']);
|
|
||||||
expect(config.isEnterprise).toBe(true);
|
|
||||||
expect(config.enterprisePlanName).toBe('enterprise');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles missing configuration values', () => {
|
|
||||||
window.chatwootConfig = {};
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
expect(config.hostURL).toBeUndefined();
|
|
||||||
expect(config.vapidPublicKey).toBeUndefined();
|
|
||||||
expect(config.enabledLanguages).toBeUndefined();
|
|
||||||
expect(config.isEnterprise).toBe(false);
|
|
||||||
expect(config.enterprisePlanName).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('handles undefined window.chatwootConfig', () => {
|
|
||||||
window.chatwootConfig = undefined;
|
|
||||||
const config = useConfig();
|
|
||||||
|
|
||||||
expect(config.hostURL).toBeUndefined();
|
|
||||||
expect(config.vapidPublicKey).toBeUndefined();
|
|
||||||
expect(config.enabledLanguages).toBeUndefined();
|
|
||||||
expect(config.isEnterprise).toBe(false);
|
|
||||||
expect(config.enterprisePlanName).toBeUndefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { useConversationLabels } from '../useConversationLabels';
|
|
||||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
vi.mock('dashboard/composables/store');
|
|
||||||
|
|
||||||
describe('useConversationLabels', () => {
|
|
||||||
let store;
|
|
||||||
let getters;
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
store = {
|
|
||||||
getters: {
|
|
||||||
'conversationLabels/getConversationLabels': vi.fn(),
|
|
||||||
},
|
|
||||||
dispatch: vi.fn(),
|
|
||||||
};
|
|
||||||
getters = {
|
|
||||||
getSelectedChat: { value: { id: 1 } },
|
|
||||||
'labels/getLabels': {
|
|
||||||
value: [
|
|
||||||
{ id: 1, title: 'Label 1' },
|
|
||||||
{ id: 2, title: 'Label 2' },
|
|
||||||
{ id: 3, title: 'Label 3' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
useStore.mockReturnValue(store);
|
|
||||||
useStoreGetters.mockReturnValue(getters);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should return the correct computed properties', () => {
|
|
||||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
|
||||||
'Label 1',
|
|
||||||
'Label 2',
|
|
||||||
]);
|
|
||||||
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
|
|
||||||
useConversationLabels();
|
|
||||||
|
|
||||||
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
|
|
||||||
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
|
|
||||||
expect(activeLabels.value).toEqual([
|
|
||||||
{ id: 1, title: 'Label 1' },
|
|
||||||
{ id: 2, title: 'Label 2' },
|
|
||||||
]);
|
|
||||||
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should update labels correctly', async () => {
|
|
||||||
const { onUpdateLabels } = useConversationLabels();
|
|
||||||
await onUpdateLabels(['Label 1', 'Label 3']);
|
|
||||||
|
|
||||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
|
||||||
conversationId: 1,
|
|
||||||
labels: ['Label 1', 'Label 3'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should add a label to the conversation', () => {
|
|
||||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
|
||||||
'Label 1',
|
|
||||||
]);
|
|
||||||
const { addLabelToConversation } = useConversationLabels();
|
|
||||||
|
|
||||||
addLabelToConversation({ title: 'Label 2' });
|
|
||||||
|
|
||||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
|
||||||
conversationId: 1,
|
|
||||||
labels: ['Label 1', 'Label 2'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('should remove a label from the conversation', () => {
|
|
||||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
|
||||||
'Label 1',
|
|
||||||
'Label 2',
|
|
||||||
]);
|
|
||||||
const { removeLabelFromConversation } = useConversationLabels();
|
|
||||||
|
|
||||||
removeLabelFromConversation('Label 2');
|
|
||||||
|
|
||||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
|
||||||
conversationId: 1,
|
|
||||||
labels: ['Label 1'],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -1,56 +0,0 @@
|
|||||||
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,3 +1,4 @@
|
|||||||
|
import { unref } from 'vue';
|
||||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||||
|
|
||||||
describe('useKeyboardEvents', () => {
|
describe('useKeyboardEvents', () => {
|
||||||
@@ -10,13 +11,15 @@ 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);
|
useKeyboardEvents(events, elRef);
|
||||||
mountedMock();
|
mountedMock();
|
||||||
unmountedMock();
|
unmountedMock();
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ vi.mock('../useKeyboardEvents', () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
describe('useKeyboardNavigableList', () => {
|
describe('useKeyboardNavigableList', () => {
|
||||||
|
let elementRef;
|
||||||
let items;
|
let items;
|
||||||
let onSelect;
|
let onSelect;
|
||||||
let adjustScroll;
|
let adjustScroll;
|
||||||
@@ -17,6 +18,7 @@ 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();
|
||||||
@@ -26,6 +28,7 @@ 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,
|
||||||
@@ -40,6 +43,7 @@ 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,
|
||||||
@@ -61,6 +65,7 @@ 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,
|
||||||
@@ -82,6 +87,7 @@ 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,
|
||||||
@@ -97,6 +103,7 @@ 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,
|
||||||
@@ -111,6 +118,7 @@ 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,
|
||||||
@@ -123,6 +131,7 @@ 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,
|
||||||
@@ -136,18 +145,23 @@ 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.any(Object));
|
expect(useKeyboardEvents).toHaveBeenCalledWith(
|
||||||
|
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,
|
||||||
@@ -164,6 +178,7 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle Control+KeyP', () => {
|
it('should handle Control+KeyP', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
|
elementRef,
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -180,6 +195,7 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle ArrowDown key', () => {
|
it('should handle ArrowDown key', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
|
elementRef,
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -196,6 +212,7 @@ describe('useKeyboardNavigableList', () => {
|
|||||||
|
|
||||||
it('should handle Control+KeyN', () => {
|
it('should handle Control+KeyN', () => {
|
||||||
useKeyboardNavigableList({
|
useKeyboardNavigableList({
|
||||||
|
elementRef,
|
||||||
items,
|
items,
|
||||||
onSelect,
|
onSelect,
|
||||||
adjustScroll,
|
adjustScroll,
|
||||||
@@ -212,6 +229,7 @@ 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,
|
||||||
@@ -227,6 +245,7 @@ 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,
|
||||||
@@ -238,6 +257,7 @@ 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/constants/automation';
|
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
||||||
|
|
||||||
vi.mock('dashboard/composables/store');
|
vi.mock('dashboard/composables/store');
|
||||||
vi.mock('dashboard/helper/automationHelper.js');
|
vi.mock('dashboard/helper/automationHelper.js');
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
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');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const useMapGetter = key => {
|
export const mapGetter = key => {
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
return computed(() => store.getters[key]);
|
return computed(() => store.getters[key]);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,204 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,30 +0,0 @@
|
|||||||
import { computed } from 'vue';
|
|
||||||
import { useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for account-related operations.
|
|
||||||
* @returns {Object} An object containing account-related properties and methods.
|
|
||||||
*/
|
|
||||||
export function useAccount() {
|
|
||||||
const getters = useStoreGetters();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Computed property for the current account ID.
|
|
||||||
* @type {import('vue').ComputedRef<number>}
|
|
||||||
*/
|
|
||||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generates an account-scoped URL.
|
|
||||||
* @param {string} url - The URL to be scoped to the account.
|
|
||||||
* @returns {string} The account-scoped URL.
|
|
||||||
*/
|
|
||||||
const accountScopedUrl = url => {
|
|
||||||
return `/app/accounts/${accountId.value}/${url}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountId,
|
|
||||||
accountScopedUrl,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,349 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
/**
|
|
||||||
* A function that provides access to various configuration values.
|
|
||||||
* @returns {Object} An object containing configuration values.
|
|
||||||
*/
|
|
||||||
export function useConfig() {
|
|
||||||
const config = window.chatwootConfig || {};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The host URL of the Chatwoot instance.
|
|
||||||
* @type {string|undefined}
|
|
||||||
*/
|
|
||||||
const hostURL = config.hostURL;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The VAPID public key for web push notifications.
|
|
||||||
* @type {string|undefined}
|
|
||||||
*/
|
|
||||||
const vapidPublicKey = config.vapidPublicKey;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* An array of enabled languages in the Chatwoot instance.
|
|
||||||
* @type {string[]|undefined}
|
|
||||||
*/
|
|
||||||
const enabledLanguages = config.enabledLanguages;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Indicates whether the current instance is an enterprise version.
|
|
||||||
* @type {boolean}
|
|
||||||
*/
|
|
||||||
const isEnterprise = config.isEnterprise === 'true';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The name of the enterprise plan, if applicable.
|
|
||||||
* Returns "community" or "enterprise"
|
|
||||||
* @type {string|undefined}
|
|
||||||
*/
|
|
||||||
const enterprisePlanName = config.enterprisePlanName;
|
|
||||||
|
|
||||||
return {
|
|
||||||
hostURL,
|
|
||||||
vapidPublicKey,
|
|
||||||
enabledLanguages,
|
|
||||||
isEnterprise,
|
|
||||||
enterprisePlanName,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
import { computed } from 'vue';
|
|
||||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Composable for managing conversation labels
|
|
||||||
* @returns {Object} An object containing methods and computed properties for conversation labels
|
|
||||||
*/
|
|
||||||
export function useConversationLabels() {
|
|
||||||
const store = useStore();
|
|
||||||
const getters = useStoreGetters();
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The currently selected chat
|
|
||||||
* @type {import('vue').ComputedRef<Object>}
|
|
||||||
*/
|
|
||||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The ID of the current conversation
|
|
||||||
* @type {import('vue').ComputedRef<number|null>}
|
|
||||||
*/
|
|
||||||
const conversationId = computed(() => currentChat.value?.id);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* All labels available for the account
|
|
||||||
* @type {import('vue').ComputedRef<Array>}
|
|
||||||
*/
|
|
||||||
const accountLabels = computed(() => getters['labels/getLabels'].value);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Labels currently saved to the conversation
|
|
||||||
* @type {import('vue').ComputedRef<Array>}
|
|
||||||
*/
|
|
||||||
const savedLabels = computed(() => {
|
|
||||||
return store.getters['conversationLabels/getConversationLabels'](
|
|
||||||
conversationId.value
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Labels currently active on the conversation
|
|
||||||
* @type {import('vue').ComputedRef<Array>}
|
|
||||||
*/
|
|
||||||
const activeLabels = computed(() =>
|
|
||||||
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Labels available but not active on the conversation
|
|
||||||
* @type {import('vue').ComputedRef<Array>}
|
|
||||||
*/
|
|
||||||
const inactiveLabels = computed(() =>
|
|
||||||
accountLabels.value.filter(
|
|
||||||
({ title }) => !savedLabels.value.includes(title)
|
|
||||||
)
|
|
||||||
);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Updates the labels for the current conversation
|
|
||||||
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
|
|
||||||
* @returns {Promise<void>}
|
|
||||||
*/
|
|
||||||
const onUpdateLabels = async selectedLabels => {
|
|
||||||
await store.dispatch('conversationLabels/update', {
|
|
||||||
conversationId: conversationId.value,
|
|
||||||
labels: selectedLabels,
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adds a label to the current conversation
|
|
||||||
* @param {Object} value - The label object to be added
|
|
||||||
* @param {string} value.title - The title of the label to be added
|
|
||||||
*/
|
|
||||||
const addLabelToConversation = value => {
|
|
||||||
const result = activeLabels.value.map(item => item.title);
|
|
||||||
result.push(value.title);
|
|
||||||
onUpdateLabels(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Removes a label from the current conversation
|
|
||||||
* @param {string} value - The title of the label to be removed
|
|
||||||
*/
|
|
||||||
const removeLabelFromConversation = value => {
|
|
||||||
const result = activeLabels.value
|
|
||||||
.map(label => label.title)
|
|
||||||
.filter(label => label !== value);
|
|
||||||
onUpdateLabels(result);
|
|
||||||
};
|
|
||||||
|
|
||||||
return {
|
|
||||||
accountLabels,
|
|
||||||
savedLabels,
|
|
||||||
activeLabels,
|
|
||||||
inactiveLabels,
|
|
||||||
addLabelToConversation,
|
|
||||||
removeLabelFromConversation,
|
|
||||||
onUpdateLabels,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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,3 +1,4 @@
|
|||||||
|
import { onMounted, onBeforeUnmount, unref } from 'vue';
|
||||||
import {
|
import {
|
||||||
isActiveElementTypeable,
|
isActiveElementTypeable,
|
||||||
isEscape,
|
isEscape,
|
||||||
@@ -6,7 +7,8 @@ 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.
|
||||||
@@ -67,24 +69,49 @@ 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 async function useKeyboardEvents(keyboardEvents) {
|
export function useKeyboardEvents(keyboardEvents, elRef = null) {
|
||||||
let abortController = new AbortController();
|
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
if (!keyboardEvents) return;
|
const el = unref(elRef);
|
||||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
|
const getKeyboardEvents = () => keyboardEvents || null;
|
||||||
const keydownHandler = createKeybindingsHandler(wrappedEvents);
|
const events = getKeyboardEvents();
|
||||||
|
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
|
||||||
document.addEventListener('keydown', keydownHandler, {
|
setupListeners(el, wrappedEvents);
|
||||||
signal: abortController.signal,
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
onUnmounted(() => {
|
onBeforeUnmount(() => {
|
||||||
abortController.abort();
|
const el = unref(elRef);
|
||||||
|
removeListeners(el);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,7 @@ 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,
|
||||||
@@ -108,7 +109,7 @@ export function useKeyboardNavigableList({
|
|||||||
items
|
items
|
||||||
);
|
);
|
||||||
|
|
||||||
useKeyboardEvents(keyboardEvents);
|
useKeyboardEvents(keyboardEvents, elementRef);
|
||||||
|
|
||||||
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/constants/automation';
|
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Composable for handling macro-related functionality
|
* Composable for handling macro-related functionality
|
||||||
|
|||||||
@@ -1,57 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -1,70 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,53 +0,0 @@
|
|||||||
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,6 +16,7 @@ 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',
|
||||||
@@ -31,5 +32,4 @@ 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,7 +5,6 @@ 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,11 +5,6 @@ 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;
|
||||||
|
|
||||||
@@ -19,13 +14,12 @@ 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 = ({
|
||||||
currentUser,
|
currentUserId,
|
||||||
alwaysPlayAudioAlert,
|
alwaysPlayAudioAlert,
|
||||||
alertIfUnreadConversationExist,
|
alertIfUnreadConversationExist,
|
||||||
audioAlertType,
|
audioAlertType,
|
||||||
@@ -34,8 +28,7 @@ class DashboardAudioNotificationHelper {
|
|||||||
this.audioAlertType = audioAlertType;
|
this.audioAlertType = audioAlertType;
|
||||||
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
|
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
|
||||||
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
|
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
|
||||||
this.currentUser = currentUser;
|
this.currentUserId = currentUserId;
|
||||||
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);
|
||||||
@@ -119,20 +112,6 @@ 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);
|
||||||
@@ -141,11 +120,6 @@ 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 (
|
||||||
|
|||||||
@@ -108,12 +108,3 @@ export const hasValidAvatarUrl = avatarUrl => {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
export const timeStampAppendedURL = dataUrl => {
|
|
||||||
const url = new URL(dataUrl);
|
|
||||||
if (!url.searchParams.has('t')) {
|
|
||||||
url.searchParams.append('t', Date.now());
|
|
||||||
}
|
|
||||||
|
|
||||||
return url.toString();
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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,
|
|
||||||
];
|
|
||||||
};
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user