Merge branch 'release/3.10.0'

This commit is contained in:
Sojan
2024-06-17 23:59:25 -07:00
248 changed files with 7948 additions and 1340 deletions

View File

@@ -1,4 +1,9 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set # Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app # Replace with the URL you are planning to use for your app
@@ -80,6 +85,8 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server # Comment out the following environment variables if required by your SMTP server
# SMTP_TLS= # SMTP_TLS=
# SMTP_SSL= # SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming # Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled # This is the domain set for the reply emails when conversation continuity is enabled
@@ -184,12 +191,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN= # SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout ## Scout
## https://scoutapm.com/docs/ruby/configuration ## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY # SCOUT_KEY=YOURKEY

10
Gemfile
View File

@@ -4,7 +4,7 @@ ruby '3.2.2'
##-- base gems for rails --## ##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors' gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.0.8.1' gem 'rails', '~> 7.0.8.4'
# Reduces boot times through caching; required in config/boot.rb # Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false gem 'bootsnap', require: false
@@ -61,7 +61,7 @@ gem 'redis-namespace'
gem 'activerecord-import' gem 'activerecord-import'
##--- gems for server & infra configuration ---## ##--- gems for server & infra configuration ---##
gem 'dotenv-rails' gem 'dotenv-rails', '>= 3.0.0'
gem 'foreman' gem 'foreman'
gem 'puma' gem 'puma'
gem 'webpacker' gem 'webpacker'
@@ -77,7 +77,7 @@ gem 'jwt'
gem 'pundit' gem 'pundit'
# super admin # super admin
gem 'administrate', '>= 0.20.1' gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.2' gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-belongs_to_search', '>= 0.9.0' gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---## ##--- gems for pubsub service ---##
@@ -122,7 +122,7 @@ gem 'sidekiq-cron', '>= 1.12.0'
##-- Push notification service --## ##-- Push notification service --##
gem 'fcm' gem 'fcm'
gem 'web-push' gem 'web-push', '>= 3.0.1'
##-- geocoding / parse location from ip --## ##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/ # http://www.rubygeocoder.com/
@@ -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' gem 'rspec-rails', '>= 6.0.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

View File

@@ -33,70 +33,70 @@ GIT
GEM GEM
remote: https://rubygems.org/ remote: https://rubygems.org/
specs: specs:
actioncable (7.0.8.1) actioncable (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
nio4r (~> 2.0) nio4r (~> 2.0)
websocket-driver (>= 0.6.1) websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.1) actionmailbox (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
activejob (= 7.0.8.1) activejob (= 7.0.8.4)
activerecord (= 7.0.8.1) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.1) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
mail (>= 2.7.1) mail (>= 2.7.1)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
actionmailer (7.0.8.1) actionmailer (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
actionview (= 7.0.8.1) actionview (= 7.0.8.4)
activejob (= 7.0.8.1) activejob (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
mail (~> 2.5, >= 2.5.4) mail (~> 2.5, >= 2.5.4)
net-imap net-imap
net-pop net-pop
net-smtp net-smtp
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
actionpack (7.0.8.1) actionpack (7.0.8.4)
actionview (= 7.0.8.1) actionview (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
rack (~> 2.0, >= 2.2.4) rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3) rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0) rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.1) actiontext (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
activerecord (= 7.0.8.1) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.1) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
globalid (>= 0.6.0) globalid (>= 0.6.0)
nokogiri (>= 1.8.5) nokogiri (>= 1.8.5)
actionview (7.0.8.1) actionview (7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
builder (~> 3.1) builder (~> 3.1)
erubi (~> 1.4) erubi (~> 1.4)
rails-dom-testing (~> 2.0) rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.1, >= 1.2.0) rails-html-sanitizer (~> 1.1, >= 1.2.0)
active_record_query_trace (1.8) active_record_query_trace (1.8)
activejob (7.0.8.1) activejob (7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
globalid (>= 0.3.6) globalid (>= 0.3.6)
activemodel (7.0.8.1) activemodel (7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
activerecord (7.0.8.1) activerecord (7.0.8.4)
activemodel (= 7.0.8.1) activemodel (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
activerecord-import (1.4.1) activerecord-import (1.4.1)
activerecord (>= 4.2) activerecord (>= 4.2)
activestorage (7.0.8.1) activestorage (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
activejob (= 7.0.8.1) activejob (= 7.0.8.4)
activerecord (= 7.0.8.1) activerecord (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
marcel (~> 1.0) marcel (~> 1.0)
mini_mime (>= 1.1.0) mini_mime (>= 1.1.0)
activesupport (7.0.8.1) activesupport (7.0.8.4)
concurrent-ruby (~> 1.0, >= 1.0.2) concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2) i18n (>= 1.6, < 2)
minitest (>= 5.1) minitest (>= 5.1)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2) kaminari (~> 1.2.2)
sassc-rails (~> 2.1) sassc-rails (~> 2.1)
selectize-rails (~> 0.6) selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.2) administrate-field-active_storage (1.0.3)
administrate (>= 0.2.2) administrate (>= 0.2.2)
rails (>= 7.0) rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0) administrate-field-belongs_to_search (0.9.0)
@@ -156,7 +156,7 @@ GEM
msgpack (~> 1.2) msgpack (~> 1.2)
brakeman (5.4.1) brakeman (5.4.1)
browser (5.3.1) browser (5.3.1)
builder (3.2.4) builder (3.3.0)
bullet (7.0.7) bullet (7.0.7)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
uniform_notifier (~> 1.11) uniform_notifier (~> 1.11)
@@ -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.2.3) concurrent-ruby (1.3.3)
connection_pool (2.4.1) connection_pool (2.4.1)
crack (0.4.5) crack (0.4.5)
rexml rexml
@@ -204,16 +204,16 @@ GEM
bcrypt (~> 3.0) bcrypt (~> 3.0)
devise (> 3.5.2, < 5) devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2) rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.0) diff-lcs (1.5.1)
digest-crc (0.6.4) digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0) rake (>= 12.0.0, < 14.0.0)
docile (1.4.0) docile (1.4.0)
domain_name (0.5.20190701) domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0) unf (>= 0.0.5, < 1.0.0)
dotenv (2.8.1) dotenv (3.1.2)
dotenv-rails (2.8.1) dotenv-rails (3.1.2)
dotenv (= 2.8.1) dotenv (= 3.1.2)
railties (>= 3.2) railties (>= 6.1)
down (5.4.0) down (5.4.0)
addressable (~> 2.8) addressable (~> 2.8)
ecma-re-validator (0.4.0) ecma-re-validator (0.4.0)
@@ -355,7 +355,6 @@ GEM
hana (1.3.7) hana (1.3.7)
hashdiff (1.0.1) hashdiff (1.0.1)
hashie (5.0.0) hashie (5.0.0)
hkdf (1.0.0)
http (5.1.1) http (5.1.1)
addressable (~> 2.8) addressable (~> 2.8)
http-cookie (~> 1.0) http-cookie (~> 1.0)
@@ -460,8 +459,8 @@ GEM
mime-types-data (3.2023.0218.1) mime-types-data (3.2023.0218.1)
mini_magick (4.12.0) mini_magick (4.12.0)
mini_mime (1.1.5) mini_mime (1.1.5)
mini_portile2 (2.8.6) mini_portile2 (2.8.7)
minitest (5.22.3) minitest (5.23.1)
mock_redis (0.36.0) mock_redis (0.36.0)
ruby2_keywords ruby2_keywords
msgpack (1.7.0) msgpack (1.7.0)
@@ -474,7 +473,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.11) net-imap (0.4.12)
date date
net-protocol net-protocol
net-pop (0.1.2) net-pop (0.1.2)
@@ -527,7 +526,7 @@ GEM
omniauth-rails_csrf_protection (1.0.2) omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2) actionpack (>= 4.2)
omniauth (~> 2.0) omniauth (~> 2.0)
openssl (3.1.0) openssl (3.2.0)
orm_adapter (0.5.0) orm_adapter (0.5.0)
os (1.1.4) os (1.1.4)
parallel (1.23.0) parallel (1.23.0)
@@ -551,11 +550,11 @@ GEM
pundit (2.3.0) pundit (2.3.0)
activesupport (>= 3.0.0) activesupport (>= 3.0.0)
raabro (1.4.0) raabro (1.4.0)
racc (1.7.3) racc (1.8.0)
rack (2.2.9) rack (2.2.9)
rack-attack (6.7.0) rack-attack (6.7.0)
rack (>= 1.0, < 4) rack (>= 1.0, < 4)
rack-contrib (2.4.0) rack-contrib (2.5.0)
rack (< 4) rack (< 4)
rack-cors (2.0.0) rack-cors (2.0.0)
rack (>= 2.0.0) rack (>= 2.0.0)
@@ -569,20 +568,20 @@ GEM
rack-test (2.1.0) rack-test (2.1.0)
rack (>= 1.3) rack (>= 1.3)
rack-timeout (0.6.3) rack-timeout (0.6.3)
rails (7.0.8.1) rails (7.0.8.4)
actioncable (= 7.0.8.1) actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.1) actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.1) actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
actiontext (= 7.0.8.1) actiontext (= 7.0.8.4)
actionview (= 7.0.8.1) actionview (= 7.0.8.4)
activejob (= 7.0.8.1) activejob (= 7.0.8.4)
activemodel (= 7.0.8.1) activemodel (= 7.0.8.4)
activerecord (= 7.0.8.1) activerecord (= 7.0.8.4)
activestorage (= 7.0.8.1) activestorage (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
bundler (>= 1.15.0) bundler (>= 1.15.0)
railties (= 7.0.8.1) railties (= 7.0.8.4)
rails-dom-testing (2.2.0) rails-dom-testing (2.2.0)
activesupport (>= 5.0.0) activesupport (>= 5.0.0)
minitest minitest
@@ -590,9 +589,9 @@ GEM
rails-html-sanitizer (1.6.0) rails-html-sanitizer (1.6.0)
loofah (~> 2.21) loofah (~> 2.21)
nokogiri (~> 1.14) nokogiri (~> 1.14)
railties (7.0.8.1) railties (7.0.8.4)
actionpack (= 7.0.8.1) actionpack (= 7.0.8.4)
activesupport (= 7.0.8.1) activesupport (= 7.0.8.4)
method_source method_source
rake (>= 12.2) rake (>= 12.2)
thor (~> 1.0) thor (~> 1.0)
@@ -628,24 +627,25 @@ GEM
retriable (3.1.2) retriable (3.1.2)
reverse_markdown (2.1.1) reverse_markdown (2.1.1)
nokogiri nokogiri
rexml (3.2.5) rexml (3.2.8)
rspec-core (3.12.2) strscan (>= 3.0.9)
rspec-support (~> 3.12.0) rspec-core (3.13.0)
rspec-expectations (3.12.3) rspec-support (~> 3.13.0)
rspec-expectations (3.13.0)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-mocks (3.12.5) rspec-mocks (3.13.1)
diff-lcs (>= 1.2.0, < 2.0) diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.12.0) rspec-support (~> 3.13.0)
rspec-rails (6.0.2) rspec-rails (6.1.2)
actionpack (>= 6.1) actionpack (>= 6.1)
activesupport (>= 6.1) activesupport (>= 6.1)
railties (>= 6.1) railties (>= 6.1)
rspec-core (~> 3.12) rspec-core (~> 3.13)
rspec-expectations (~> 3.12) rspec-expectations (~> 3.13)
rspec-mocks (~> 3.12) rspec-mocks (~> 3.13)
rspec-support (~> 3.12) rspec-support (~> 3.13)
rspec-support (3.12.0) rspec-support (3.13.1)
rspec_junit_formatter (0.6.0) rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0) rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2) rubocop (1.50.2)
@@ -758,6 +758,7 @@ GEM
stackprof (0.2.25) stackprof (0.2.25)
statsd-ruby (1.5.0) statsd-ruby (1.5.0)
stripe (8.5.0) stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20) telephone_number (1.4.20)
test-prof (1.2.1) test-prof (1.2.1)
thor (1.3.1) thor (1.3.1)
@@ -798,8 +799,7 @@ GEM
activemodel (>= 6.0.0) activemodel (>= 6.0.0)
bindex (>= 0.4.0) bindex (>= 0.4.0)
railties (>= 6.0.0) railties (>= 6.0.0)
web-push (3.0.0) web-push (3.0.1)
hkdf (~> 1.0)
jwt (~> 2.0) jwt (~> 2.0)
openssl (~> 3.0) openssl (~> 3.0)
webmock (3.18.1) webmock (3.18.1)
@@ -819,7 +819,7 @@ GEM
working_hours (1.4.1) working_hours (1.4.1)
activesupport (>= 3.2) activesupport (>= 3.2)
tzinfo tzinfo
zeitwerk (2.6.14) zeitwerk (2.6.15)
PLATFORMS PLATFORMS
arm64-darwin-20 arm64-darwin-20
@@ -837,7 +837,7 @@ DEPENDENCIES
activerecord-import activerecord-import
acts-as-taggable-on acts-as-taggable-on
administrate (>= 0.20.1) administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.2) administrate-field-active_storage (>= 1.0.3)
administrate-field-belongs_to_search (>= 0.9.0) administrate-field-belongs_to_search (>= 0.9.0)
annotate annotate
attr_extras attr_extras
@@ -861,7 +861,7 @@ DEPENDENCIES
devise (>= 4.9.4) devise (>= 4.9.4)
devise-secure_password! devise-secure_password!
devise_token_auth (>= 1.2.3) devise_token_auth (>= 1.2.3)
dotenv-rails dotenv-rails (>= 3.0.0)
down down
elastic-apm elastic-apm
email_reply_trimmer email_reply_trimmer
@@ -916,13 +916,13 @@ DEPENDENCIES
rack-cors (= 2.0.0) rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0) rack-mini-profiler (>= 3.2.0)
rack-timeout rack-timeout
rails (~> 7.0.8.1) rails (~> 7.0.8.4)
redis redis
redis-namespace redis-namespace
responders (>= 3.1.1) responders (>= 3.1.1)
rest-client rest-client
reverse_markdown reverse_markdown
rspec-rails rspec-rails (>= 6.0.3)
rspec_junit_formatter rspec_junit_formatter
rubocop rubocop
rubocop-performance rubocop-performance
@@ -953,7 +953,7 @@ DEPENDENCIES
uglifier uglifier
valid_email2 valid_email2
web-console (>= 4.2.1) web-console (>= 4.2.1)
web-push web-push (>= 3.0.1)
webmock webmock
webpacker webpacker
wisper (= 2.0.0) wisper (= 2.0.0)

View File

@@ -1 +1 @@
3.3.1 3.9.0

View File

@@ -1 +1 @@
2.7.0 2.8.0

View File

@@ -0,0 +1,30 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end

View File

@@ -0,0 +1,30 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end

View File

@@ -0,0 +1,21 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end

View File

@@ -0,0 +1,48 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end

View File

@@ -0,0 +1,46 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end

View File

@@ -0,0 +1,71 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end

View File

@@ -0,0 +1,32 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end

View File

@@ -0,0 +1,93 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end

View File

@@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
} }
) )
if redirect_url if redirect_url
email = email.downcase cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(email, Current.account.id, 5.minutes) ::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url } render json: { success: true, url: redirect_url }
else else
render json: { success: false }, status: :unprocessable_entity render json: { success: false }, status: :unprocessable_entity

View File

@@ -5,19 +5,17 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization before_action :check_authorization
def index def index
builder = V2::ReportBuilder.new(Current.account, report_params) builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.build data = builder.timeseries
render json: data render json: data
end end
def summary def summary
render json: summary_metrics render json: build_summary(:summary)
end end
def bot_summary def bot_summary
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary render json: build_summary(:bot_summary)
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
render json: summary
end end
def agents def agents
@@ -126,10 +124,11 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
} }
end end
def summary_metrics def build_summary(method)
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary builder = V2::Reports::Conversations::MetricBuilder
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary current_summary = builder.new(Current.account, current_summary_params).send(method)
summary previous_summary = builder.new(Current.account, previous_summary_params).send(method)
current_summary.merge(previous: previous_summary)
end end
def conversation_metrics def conversation_metrics

View File

@@ -0,0 +1,20 @@
module GoogleConcern
extend ActiveSupport::Concern
def google_client
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://oauth2.googleapis.com',
authorize_url: 'https://accounts.google.com/o/oauth2/auth',
token_url: 'https://accounts.google.com/o/oauth2/token'
})
end
private
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end

View File

@@ -2,7 +2,10 @@ module MicrosoftConcern
extend ActiveSupport::Concern extend ActiveSupport::Concern
def microsoft_client def microsoft_client
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil), app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret,
{ {
site: 'https://login.microsoftonline.com', site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize', authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
@@ -12,10 +15,6 @@ module MicrosoftConcern
private private
def parsed_body
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
end
def base_url def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000') ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end end

View File

@@ -3,6 +3,7 @@ class DashboardController < ActionController::Base
before_action :set_application_pack before_action :set_application_pack
before_action :set_global_config before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index] before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index] before_action :render_hc_if_custom_domain, only: [:index]
@@ -35,6 +36,10 @@ class DashboardController < ActionController::Base
).merge(app_config) ).merge(app_config)
end end
def set_dashboard_scripts
@dashboard_scripts = GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING) redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end end
@@ -58,7 +63,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''), FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'), FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?, IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''), AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH GIT_SHA: GIT_HASH
} }
end end

View File

@@ -0,0 +1,18 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
private
def provider_name
'google'
end
def imap_address
'imap.gmail.com'
end
def oauth_client
# from GoogleConcern
google_client
end
end

View File

@@ -1,77 +1,17 @@
class Microsoft::CallbacksController < ApplicationController class Microsoft::CallbacksController < OauthCallbackController
include MicrosoftConcern include MicrosoftConcern
def show
@response = microsoft_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private private
def oauth_code def oauth_client
params[:code] microsoft_client
end end
def users_data def provider_name
decoded_token = JWT.decode parsed_body[:id_token], nil, false 'microsoft'
decoded_token[0]
end end
def parsed_body def imap_address
@parsed_body ||= @response.response.parsed 'outlook.office365.com'
end
def account_id
::Redis::Alfred.get(users_data['email'].downcase)
end
def account
@account ||= Account.find(account_id)
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
channel_email.inbox
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def create_microsoft_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def update_microsoft_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: 'outlook.office365.com',
imap_port: '993', imap_enabled: true,
provider: 'microsoft',
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end end
end end

View File

@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
end end
def microsoft_indentity def microsoft_indentity
@identity_json = ENV.fetch('AZURE_APP_ID', nil) @identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
end end
end end

View File

@@ -0,0 +1,108 @@
class OauthCallbackController < ApplicationController
def show
@response = oauth_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback"
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def handle_response
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_channel_with_inbox
update_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end
def provider_name
raise NotImplementedError
end
def oauth_client
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def oauth_code
params[:code]
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
end

View File

@@ -35,10 +35,12 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config @allowed_configs = case @config
when 'facebook' when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT] %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email' when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN'] ['MAILER_INBOUND_EMAIL_DOMAIN']
else else
%w[ENABLE_ACCOUNT_SIGNUP] %w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
end end
end end
end end

View File

@@ -0,0 +1,19 @@
module TimezoneHelper
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
#
# https://github.com/rails/rails/pull/22243
# https://github.com/rails/rails/issues/21501
# https://github.com/rails/rails/issues/7297
def timezone_name_from_offset(offset)
return 'UTC' if offset.blank?
offset_in_seconds = offset.to_f * 3600
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
zone.now.utc_offset == offset_in_seconds
end
return matching_zone.name if matching_zone
end
end

View File

@@ -27,6 +27,7 @@
<script> <script>
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue'; import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue'; import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue'; import NetworkNotification from './components/NetworkNotification.vue';
@@ -43,6 +44,7 @@ import {
registerSubscription, registerSubscription,
verifyServiceWorkerExistence, verifyServiceWorkerExistence,
} from './helper/pushHelper'; } from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default { export default {
name: 'App', name: 'App',
@@ -64,6 +66,7 @@ export default {
return { return {
showAddAccountModal: false, showAddAccountModal: false,
latestChatwootVersion: null, latestChatwootVersion: null,
reconnectService: null,
}; };
}, },
@@ -102,6 +105,11 @@ export default {
this.listenToThemeChanges(); this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale); this.setLocale(window.chatwootConfig.selectedLocale);
}, },
beforeDestroy() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
},
methods: { methods: {
initializeColorTheme() { initializeColorTheme() {
setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches); setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -125,6 +133,7 @@ export default {
this.updateRTLDirectionView(locale); this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion; this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken); vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration => verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => { registration.pushManager.getSubscription().then(subscription => {

View File

@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
createAccount(data) { createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data); return axios.post(`${this.apiVersion}/accounts`, data);
} }
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
} }
export default new AccountAPI(); export default new AccountAPI();

View File

@@ -0,0 +1,14 @@
/* global axios */
import ApiClient from '../ApiClient';
class MicrosoftClient extends ApiClient {
constructor() {
super('google', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new MicrosoftClient();

View File

@@ -15,6 +15,7 @@ class ConversationApi extends ApiClient {
teamId, teamId,
conversationType, conversationType,
sortBy, sortBy,
updatedWithin,
}) { }) {
return axios.get(this.url, { return axios.get(this.url, {
params: { params: {
@@ -26,6 +27,7 @@ class ConversationApi extends ApiClient {
labels, labels,
conversation_type: conversationType, conversation_type: conversationType,
sort_by: sortBy, sort_by: sortBy,
updated_within: updatedWithin,
}, },
}); });
} }

View File

@@ -0,0 +1,47 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();

View File

@@ -46,6 +46,7 @@ describe('#ConversationAPI', () => {
page: 1, page: 1,
labels: [], labels: [],
teamId: 1, teamId: 1,
updatedWithin: 20,
}); });
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', { expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
params: { params: {
@@ -55,6 +56,7 @@ describe('#ConversationAPI', () => {
assignee_type: 'me', assignee_type: 'me',
page: 1, page: 1,
labels: [], labels: [],
updated_within: 20,
}, },
}); });
}); });

View File

@@ -0,0 +1,202 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});

View File

@@ -1,6 +1,6 @@
<template> <template>
<button <button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60" class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')" @click="$emit('click')"
> >
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" /> <img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />

View File

@@ -7,79 +7,17 @@
]" ]"
> >
<slot /> <slot />
<div <chat-list-header
class="flex items-center justify-between px-4 py-0" :page-title="pageTitle"
:class="{ :has-applied-filters="hasAppliedFilters"
'pb-3 border-b border-slate-75 dark:border-slate-700': :has-active-folders="hasActiveFolders"
hasAppliedFiltersOrActiveFolders, :active-status="activeStatus"
}" @add-folders="onClickOpenAddFoldersModal"
> @delete-folders="onClickOpenDeleteFoldersModal"
<div class="flex max-w-[85%] justify-center items-center"> @filters-modal="onToggleAdvanceFiltersModal"
<h1 @reset-filters="resetAndFetchData"
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100" @basic-filter-change="onBasicFilterChange"
:title="pageTitle" />
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<add-custom-views <add-custom-views
v-if="showAddFoldersModal" v-if="showAddFoldersModal"
@@ -173,6 +111,15 @@
@updateFolder="onUpdateSavedFilter" @updateFolder="onUpdateSavedFilter"
/> />
</woot-modal> </woot-modal>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div> </div>
</template> </template>
@@ -180,8 +127,8 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list'; import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue'; import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue'; import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue'; import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time'; import timeMixin from '../mixins/time';
@@ -205,10 +152,15 @@ import {
isOnUnattendedView, isOnUnattendedView,
} 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 { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import { getUnixTime } from 'date-fns';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import IntersectionObserver from './IntersectionObserver.vue'; import IntersectionObserver from './IntersectionObserver.vue';
export default { export default {
components: { components: {
ChatListHeader,
AddCustomViews, AddCustomViews,
ChatTypeTabs, ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components // eslint-disable-next-line vue/no-unused-components
@@ -216,9 +168,9 @@ export default {
ConversationAdvancedFilter, ConversationAdvancedFilter,
DeleteCustomViews, DeleteCustomViews,
ConversationBulkActions, ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver, IntersectionObserver,
VirtualList, VirtualList,
CustomSnoozeModal,
}, },
mixins: [ mixins: [
timeMixin, timeMixin,
@@ -295,6 +247,7 @@ export default {
root: this.$refs.conversationList, root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px', rootMargin: '100px 0px 100px 0px',
}, },
showCustomSnoozeModal: false,
itemComponent: ConversationItem, itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component. // virtualListExtraProps is to pass the props to the conversationItem component.
@@ -315,6 +268,7 @@ export default {
chatLists: 'getAllConversations', chatLists: 'getAllConversations',
mineChatsList: 'getMineChats', mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats', allChatList: 'getAllStatusChats',
chatListFilters: 'getChatListFilters',
unAssignedChatsList: 'getUnAssignedChats', unAssignedChatsList: 'getUnAssignedChats',
chatListLoading: 'getChatListLoadingStatus', chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID', currentUserID: 'getCurrentUserID',
@@ -329,23 +283,17 @@ export default {
campaigns: 'campaigns/getAllCampaigns', campaigns: 'campaigns/getAllCampaigns',
labels: 'labels/getLabels', labels: 'labels/getLabels',
selectedConversations: 'bulkActions/getSelectedConversationIds', selectedConversations: 'bulkActions/getSelectedConversationIds',
contextMenuChatId: 'getContextMenuChatId',
}), }),
hasAppliedFilters() { hasAppliedFilters() {
return this.appliedFilters.length !== 0; return this.appliedFilters.length !== 0;
}, },
hasActiveFolders() { hasActiveFolders() {
return this.activeFolder && this.foldersId !== 0; return Boolean(this.activeFolder && this.foldersId !== 0);
}, },
hasAppliedFiltersOrActiveFolders() { hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders; return this.hasAppliedFilters || this.hasActiveFolders;
}, },
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
showEndOfListMessage() { showEndOfListMessage() {
return ( return (
this.conversationList.length && this.conversationList.length &&
@@ -421,7 +369,6 @@ export default {
labels: this.label ? [this.label] : undefined, labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined, teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined, conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
}; };
}, },
conversationListPagination() { conversationListPagination() {
@@ -534,7 +481,13 @@ export default {
this.resetAndFetchData(); this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType); this.updateVirtualListProps('conversationType', this.conversationType);
}, },
activeFolder() { activeFolder(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch(
'customViews/setActiveConversationFolder',
newVal || null
);
}
this.resetAndFetchData(); this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId); this.updateVirtualListProps('foldersId', this.foldersId);
}, },
@@ -544,8 +497,14 @@ export default {
showAssigneeInConversationCard(newVal) { showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal); this.updateVirtualListProps('showAssignee', newVal);
}, },
conversationFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('updateChatListFilters', newVal);
}
},
}, },
mounted() { mounted() {
this.$store.dispatch('setChatListFilters', this.conversationFilters);
this.setFiltersFromUISettings(); this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus); this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy); this.$store.dispatch('setChatSortFilter', this.activeSortBy);
@@ -555,9 +514,14 @@ export default {
this.$store.dispatch('campaigns/get'); this.$store.dispatch('campaigns/get');
} }
bus.$on('fetch_conversation_stats', () => { this.$emitter.on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters); this.$store.dispatch('conversationStats/get', this.conversationFilters);
}); });
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
},
beforeDestroy() {
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
}, },
methods: { methods: {
updateVirtualListProps(key, value) { updateVirtualListProps(key, value) {
@@ -736,8 +700,9 @@ export default {
this.fetchConversations(); this.fetchConversations();
}, },
fetchConversations() { fetchConversations() {
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
this.$store this.$store
.dispatch('fetchAllConversations', this.conversationFilters) .dispatch('fetchAllConversations')
.then(this.emitConversationLoaded); .then(this.emitConversationLoaded);
}, },
loadMoreConversations() { loadMoreConversations() {
@@ -777,7 +742,7 @@ export default {
updateAssigneeTab(selectedTab) { updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) { if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions(); this.resetBulkActions();
bus.$emit('clearSearchInput'); this.$emitter.emit('clearSearchInput');
this.activeAssigneeTab = selectedTab; this.activeAssigneeTab = selectedTab;
if (!this.currentPage) { if (!this.currentPage) {
this.fetchConversations(); this.fetchConversations();
@@ -1028,12 +993,49 @@ export default {
allSelectedConversationsStatus(status) { allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false; if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => { return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item).status === status; return this.$store.getters.getConversationById(item)?.status === status;
}); });
}, },
onContextMenuToggle(state) { onContextMenuToggle(state) {
this.isContextMenuOpen = state; this.isContextMenuOpen = state;
}, },
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
wootConstants.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
toggleStatus(status, snoozedUntil) {
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat?.id || this.contextMenuChatId,
status,
snoozedUntil,
})
.then(() => {
this.$store.dispatch('setContextMenuChatId', null);
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
});
},
hideCustomSnoozeModal() {
// if we select custom snooze and then the custom snooze modal is open
// Then if the custom snooze modal is closed and set the context menu chat id to null
this.$store.dispatch('setContextMenuChatId', null);
this.showCustomSnoozeModal = false;
},
}, },
}; };
</script> </script>

View File

@@ -0,0 +1,115 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emits = defineEmits([
'add-folders',
'delete-folders',
'reset-filters',
'basic-filter-change',
'filters-modal',
]);
const onBasicFilterChange = (value, type) => {
emits('basic-filter-change', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emits('add-folders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emits('reset-filters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emits('filters-modal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emits('delete-folders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emits('filters-modal')"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>

View File

@@ -25,8 +25,10 @@
<script> <script>
import 'highlight.js/styles/default.css'; import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default { export default {
mixins: [alertMixin],
props: { props: {
script: { script: {
type: String, type: String,
@@ -59,7 +61,7 @@ export default {
async onCopy(e) { async onCopy(e) {
e.preventDefault(); e.preventDefault();
await copyTextToClipboard(this.script); await copyTextToClipboard(this.script);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
}, },
}; };

View File

@@ -27,7 +27,7 @@
/> />
</span> </span>
<woot-button <woot-button
v-if="showCopyAndDeleteButton" v-if="showActions && value"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')" v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link" variant="link"
size="medium" size="medium"
@@ -90,7 +90,7 @@
</p> </p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0"> <div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button <woot-button
v-if="showCopyAndDeleteButton" v-if="showActions && value"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')" v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link" variant="link"
size="small" size="small"
@@ -100,7 +100,7 @@
@click="onCopy" @click="onCopy"
/> />
<woot-button <woot-button
v-if="showEditButton" v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')" v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link" variant="link"
size="small" size="small"
@@ -174,12 +174,6 @@ export default {
}; };
}, },
computed: { computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() { displayValue() {
if (this.isAttributeTypeDate) { if (this.isAttributeTypeDate) {
return this.value return this.value
@@ -276,10 +270,10 @@ export default {
}, },
mounted() { mounted() {
this.editedValue = this.formattedValue; this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute); this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
}, },
destroyed() { destroyed() {
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute); this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
}, },
methods: { methods: {
onFocusAttribute(focusAttributeKey) { onFocusAttribute(focusAttributeKey) {

View File

@@ -18,8 +18,10 @@
<script> <script>
import 'highlight.js/styles/default.css'; import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard'; import { copyTextToClipboard } from 'shared/helpers/clipboard';
import alertMixin from 'shared/mixins/alertMixin';
export default { export default {
mixins: [alertMixin],
props: { props: {
value: { value: {
type: String, type: String,
@@ -35,7 +37,7 @@ export default {
async onCopy(e) { async onCopy(e) {
e.preventDefault(); e.preventDefault();
await copyTextToClipboard(this.value); await copyTextToClipboard(this.value);
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL')); this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
}, },
toggleMasked() { toggleMasked() {
this.masked = !this.masked; this.masked = !this.masked;

View File

@@ -99,7 +99,9 @@ export default {
onMouseUp() { onMouseUp() {
if (this.mousedDownOnBackdrop) { if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false; this.mousedDownOnBackdrop = false;
this.onClose(); if (this.closeOnBackdropClick) {
this.onClose();
}
} }
}, },
}, },

View File

@@ -1,26 +1,133 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useEmitter } from 'dashboard/composables/emitter';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import { useEventListener } from '@vueuse/core';
const { t } = useI18n();
const route = useRoute();
const RECONNECTED_BANNER_TIMEOUT = 2000;
const showNotification = ref(!navigator.onLine);
const isDisconnected = ref(false);
const isReconnecting = ref(false);
const isReconnected = ref(false);
let reconnectTimeout = null;
const bannerText = computed(() => {
if (isReconnecting.value) return t('NETWORK.NOTIFICATION.RECONNECTING');
if (isReconnected.value) return t('NETWORK.NOTIFICATION.RECONNECT_SUCCESS');
return t('NETWORK.NOTIFICATION.OFFLINE');
});
const iconName = computed(() => (isReconnected.value ? 'wifi' : 'wifi-off'));
const canRefresh = computed(
() => !isReconnecting.value && !isReconnected.value
);
const refreshPage = () => {
window.location.reload();
};
const closeNotification = () => {
showNotification.value = false;
isReconnected.value = false;
clearTimeout(reconnectTimeout);
};
const isInAnyOfTheRoutes = routeName => {
return (
isAConversationRoute(routeName, true) ||
isAInboxViewRoute(routeName, true) ||
isNotificationRoute(routeName, true)
);
};
const updateWebsocketStatus = () => {
isDisconnected.value = true;
showNotification.value = true;
};
const handleReconnectionCompleted = () => {
isDisconnected.value = false;
isReconnecting.value = false;
isReconnected.value = true;
showNotification.value = true;
reconnectTimeout = setTimeout(closeNotification, RECONNECTED_BANNER_TIMEOUT);
};
const handleReconnecting = () => {
if (isInAnyOfTheRoutes(route.name)) {
isReconnecting.value = true;
isReconnected.value = false;
showNotification.value = true;
} else {
handleReconnectionCompleted();
}
};
const updateOnlineStatus = event => {
// Case: Websocket is not disconnected
// If the app goes offline, show the notification
// If the app goes online, close the notification
// Case: Websocket is disconnected
// If the app goes offline, show the notification
// If the app goes online but the websocket is disconnected, don't close the notification
// If the app goes online and the websocket is not disconnected, close the notification
if (event.type === 'offline') {
showNotification.value = true;
} else if (event.type === 'online' && !isDisconnected.value) {
handleReconnectionCompleted();
}
};
useEventListener('online', updateOnlineStatus);
useEventListener('offline', updateOnlineStatus);
useEmitter(BUS_EVENTS.WEBSOCKET_DISCONNECT, updateWebsocketStatus);
useEmitter(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED,
handleReconnectionCompleted
);
useEmitter(BUS_EVENTS.WEBSOCKET_RECONNECT, handleReconnecting);
onBeforeUnmount(() => {
clearTimeout(reconnectTimeout);
});
</script>
<template> <template>
<transition name="network-notification-fade" tag="div"> <transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group"> <div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
<div <div
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative" class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
> >
<fluent-icon <fluent-icon
icon="wifi-off" :icon="iconName"
class="text-yellow-700/50 dark:text-yellow-50" class="text-yellow-700/50 dark:text-yellow-50"
size="18" size="18"
/> />
<span <span
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50" class="px-2 text-xs font-medium tracking-wide text-yellow-700/70 dark:text-yellow-50"
> >
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }} {{ bannerText }}
</span> </span>
<woot-button <woot-button
v-if="canRefresh"
:title="$t('NETWORK.BUTTON.REFRESH')" :title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear" variant="clear"
size="small" size="small"
color-scheme="warning" color-scheme="warning"
icon="arrow-clockwise" icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage" @click="refreshPage"
/> />
<woot-button <woot-button
@@ -34,55 +141,3 @@
</div> </div>
</transition> </transition>
</template> </template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
mixins: [globalConfigMixin],
data() {
return {
showNotification: !navigator.onLine,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
});
},
beforeDestroy() {
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
refreshPage() {
window.location.reload();
},
closeNotification() {
this.showNotification = false;
},
updateOnlineStatus(event) {
if (event.type === 'offline') {
this.showNotification = true;
}
},
},
};
</script>

View File

@@ -21,7 +21,7 @@ export default {
}, },
methods: { methods: {
onMenuItemClick() { onMenuItemClick() {
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU); this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
}, },
}, },
}; };

View File

@@ -15,11 +15,13 @@
<script> <script>
import WootSnackbar from './Snackbar.vue'; import WootSnackbar from './Snackbar.vue';
import alertMixin from 'shared/mixins/alertMixin';
export default { export default {
components: { components: {
WootSnackbar, WootSnackbar,
}, },
mixins: [alertMixin],
props: { props: {
duration: { duration: {
type: Number, type: Number,
@@ -34,10 +36,10 @@ export default {
}, },
mounted() { mounted() {
bus.$on('newToastMessage', this.onNewToastMessage); this.$emitter.on('newToastMessage', this.onNewToastMessage);
}, },
beforeDestroy() { beforeDestroy() {
bus.$off('newToastMessage', this.onNewToastMessage); this.$emitter.off('newToastMessage', this.onNewToastMessage);
}, },
methods: { methods: {
onNewToastMessage(message, action) { onNewToastMessage(message, action) {

View File

@@ -1,5 +1,5 @@
<template> <template>
<div class="resolve-actions relative flex items-center justify-end"> <div 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"
@@ -73,25 +73,13 @@
</woot-dropdown-item> </woot-dropdown-item>
</woot-dropdown-menu> </woot-dropdown-menu>
</div> </div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div> </div>
</template> </template>
<script> <script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin'; import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins'; import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue'; import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue'; import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
@@ -99,14 +87,12 @@ import wootConstants from 'dashboard/constants/globals';
import { import {
CMD_REOPEN_CONVERSATION, CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION, CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents'; } from '../../routes/dashboard/commands/commandBarBusEvents';
export default { export default {
components: { components: {
WootDropdownItem, WootDropdownItem,
WootDropdownMenu, WootDropdownMenu,
CustomSnoozeModal,
}, },
mixins: [alertMixin, keyboardEventListenerMixins], mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } }, props: { conversationId: { type: [String, Number], required: true } },
@@ -115,7 +101,6 @@ export default {
isLoading: false, isLoading: false,
showActionsDropdown: false, showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE, STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
}; };
}, },
computed: { computed: {
@@ -143,14 +128,12 @@ export default {
}, },
}, },
mounted() { mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation); this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
}, },
destroyed() { destroyed() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation); this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
}, },
methods: { methods: {
getKeyboardEvents() { getKeyboardEvents() {
@@ -201,28 +184,6 @@ export default {
// error // error
} }
}, },
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() { onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN); this.toggleStatus(this.STATUS_TYPE.OPEN);
}, },

View File

@@ -7,7 +7,7 @@
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')" :header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')" :header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/> />
<div class="px-8 pt-4 pb-8"> <div class="px-8 py-4">
<div <div
v-for="account in currentUser.accounts" v-for="account in currentUser.accounts"
:id="`account-${account.id}`" :id="`account-${account.id}`"
@@ -45,10 +45,10 @@
<div <div
v-if="globalConfig.createNewAccountFromDashboard" v-if="globalConfig.createNewAccountFromDashboard"
class="flex justify-end items-center p-8 gap-2" class="flex justify-end items-center px-8 pb-8 pt-4 gap-2"
> >
<button <button
class="button success large expanded nice" class="button success large expanded nice w-full"
@click="$emit('show-create-account-modal')" @click="$emit('show-create-account-modal')"
> >
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }} {{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}

View File

@@ -65,11 +65,11 @@
</div> </div>
<span <span
v-if="warningIcon" v-if="warningIcon"
class="inline-flex rounded-sm mr-1 bg-slate-100" class="inline-flex mr-1 bg-red-50 dark:bg-red-900 p-0.5 rounded-sm"
> >
<fluent-icon <fluent-icon
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')" v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
class="text-xxs" class="text-xxs text-red-500 dark:text-red-300"
:icon="warningIcon" :icon="warningIcon"
size="12" size="12"
/> />

View File

@@ -22,7 +22,7 @@ import {
setYear, setYear,
isAfter, isAfter,
} from 'date-fns'; } from 'date-fns';
import { useAlert } from 'dashboard/composables';
import DatePickerButton from './components/DatePickerButton.vue'; import DatePickerButton from './components/DatePickerButton.vue';
import CalendarDateInput from './components/CalendarDateInput.vue'; import CalendarDateInput from './components/CalendarDateInput.vue';
import CalendarDateRange from './components/CalendarDateRange.vue'; import CalendarDateRange from './components/CalendarDateRange.vue';
@@ -185,7 +185,7 @@ const updateManualInput = (newDate, calendarType) => {
}; };
const handleManualInputError = message => { const handleManualInputError = message => {
bus.$emit('newToastMessage', message); useAlert(message);
}; };
const resetDatePicker = () => { const resetDatePicker = () => {
@@ -201,7 +201,7 @@ const resetDatePicker = () => {
const emitDateRange = () => { const emitDateRange = () => {
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) { if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
bus.$emit('newToastMessage', 'Please select a valid time range'); useAlert('Please select a valid time range');
} else { } else {
showDatePicker.value = false; showDatePicker.value = false;
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]); emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);

View File

@@ -1,9 +1,11 @@
<script setup> <script setup>
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import { debounce } from '@chatwoot/utils';
import { picoSearch } from '@scmmishra/pico-search'; import { picoSearch } from '@scmmishra/pico-search';
import FilterListItemButton from './FilterListItemButton.vue'; import ListItemButton from './DropdownListItemButton.vue';
import FilterDropdownSearch from './FilterDropdownSearch.vue'; import DropdownSearch from './DropdownSearch.vue';
import FilterDropdownEmptyState from './FilterDropdownEmptyState.vue'; import DropdownEmptyState from './DropdownEmptyState.vue';
import DropdownLoadingState from './DropdownLoadingState.vue';
const props = defineProps({ const props = defineProps({
listItems: { listItems: {
@@ -19,20 +21,31 @@ const props = defineProps({
default: '', default: '',
}, },
activeFilterId: { activeFilterId: {
type: Number, type: [String, Number],
default: null, default: null,
}, },
showClearFilter: { showClearFilter: {
type: Boolean, type: Boolean,
default: false, default: false,
}, },
isLoading: {
type: Boolean,
default: false,
},
loadingPlaceholder: {
type: String,
default: '',
},
}); });
const emits = defineEmits(['on-search']);
const searchTerm = ref(''); const searchTerm = ref('');
const onSearch = value => { const onSearch = debounce(value => {
searchTerm.value = value; searchTerm.value = value;
}; emits('on-search', value);
}, 300);
const filteredListItems = computed(() => { const filteredListItems = computed(() => {
if (!searchTerm.value) return props.listItems; if (!searchTerm.value) return props.listItems;
@@ -47,6 +60,16 @@ const isFilterActive = id => {
if (!props.activeFilterId) return false; if (!props.activeFilterId) return false;
return id === props.activeFilterId; return id === props.activeFilterId;
}; };
const shouldShowLoadingState = computed(() => {
return (
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
);
});
const shouldShowEmptyState = computed(() => {
return !props.isLoading && isDropdownListEmpty.value;
});
</script> </script>
<template> <template>
<div <div
@@ -54,8 +77,8 @@ const isFilterActive = id => {
@click.stop @click.stop
> >
<slot name="search"> <slot name="search">
<filter-dropdown-search <dropdown-search
v-if="enableSearch && listItems.length" v-if="enableSearch"
:input-value="searchTerm" :input-value="searchTerm"
:input-placeholder="inputPlaceholder" :input-placeholder="inputPlaceholder"
:show-clear-filter="showClearFilter" :show-clear-filter="showClearFilter"
@@ -64,11 +87,15 @@ const isFilterActive = id => {
/> />
</slot> </slot>
<slot name="listItem"> <slot name="listItem">
<filter-dropdown-empty-state <dropdown-loading-state
v-if="isDropdownListEmpty" v-if="shouldShowLoadingState"
:message="loadingPlaceholder"
/>
<dropdown-empty-state
v-else-if="shouldShowEmptyState"
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')" :message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
/> />
<filter-list-item-button <list-item-button
v-for="item in filteredListItems" v-for="item in filteredListItems"
:key="item.id" :key="item.id"
:is-active="isFilterActive(item.id)" :is-active="isFilterActive(item.id)"

View File

@@ -13,7 +13,7 @@ defineProps({
<template> <template>
<button <button
class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800" class="relative inline-flex items-center justify-start w-full p-3 border-0 rounded-none first:rounded-t-xl last:rounded-b-xl h-11 hover:bg-slate-50 dark:hover:bg-slate-700 active:bg-slate-75 dark:active:bg-slate-800"
@click.stop="$emit('click')" @click.stop.prevent="$emit('click')"
@mouseenter="$emit('mouseenter')" @mouseenter="$emit('mouseenter')"
@mouseleave="$emit('mouseleave')" @mouseleave="$emit('mouseleave')"
@focus="$emit('focus')" @focus="$emit('focus')"

View File

@@ -0,0 +1,15 @@
<script setup>
defineProps({
message: {
type: String,
default: '',
},
});
</script>
<template>
<div
class="flex items-center justify-center h-10 text-sm text-slate-500 dark:text-slate-300"
>
{{ message }}
</div>
</template>

View File

@@ -18,11 +18,11 @@ defineProps({
<div <div
class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700" class="flex items-center justify-between h-10 min-h-[40px] sticky top-0 bg-white z-10 dark:bg-slate-800 gap-2 px-3 border-b rounded-t-xl border-slate-50 dark:border-slate-700"
> >
<div class="flex items-center w-full gap-2"> <div class="flex items-center w-full gap-2" @keyup.space.prevent>
<fluent-icon <fluent-icon
icon="search" icon="search"
size="18" size="16"
class="text-slate-400 dark:text-slate-400" class="text-slate-400 dark:text-slate-400 flex-shrink-0"
/> />
<input <input
type="text" type="text"

View File

@@ -81,7 +81,7 @@ export default {
}, },
mounted() { mounted() {
bus.$on(CMD_AI_ASSIST, this.onAIAssist); this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
this.initialMessage = this.draftMessage; this.initialMessage = this.draftMessage;
}, },

View File

@@ -352,10 +352,16 @@ export default {
// Components using this // Components using this
// 1. SearchPopover.vue // 1. SearchPopover.vue
bus.$on(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); this.$emitter.on(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
}, },
beforeDestroy() { beforeDestroy() {
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor); this.$emitter.off(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
this.insertContentIntoEditor
);
}, },
methods: { methods: {
reloadState(content = this.value) { reloadState(content = this.value) {

View File

@@ -103,6 +103,7 @@
:status="chat.status" :status="chat.status"
:inbox-id="inbox.id" :inbox-id="inbox.id"
:priority="chat.priority" :priority="chat.priority"
:chat-id="chat.id"
:has-unread-messages="hasUnread" :has-unread-messages="hasUnread"
@update-conversation="onUpdateConversation" @update-conversation="onUpdateConversation"
@assign-agent="onAssignAgent" @assign-agent="onAssignAgent"

View File

@@ -71,6 +71,10 @@
:class="{ 'justify-end': isContactPanelOpen }" :class="{ 'justify-end': isContactPanelOpen }"
> >
<SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info /> <SLA-card-label v-if="hasSlaPolicyId" :chat="chat" show-extended-info />
<linear
v-if="isLinearIntegrationEnabled && isLinearFeatureEnabled"
:conversation-id="currentChat.id"
/>
<more-actions :conversation-id="currentChat.id" /> <more-actions :conversation-id="currentChat.id" />
</div> </div>
</div> </div>
@@ -89,6 +93,8 @@ import SLACardLabel from './components/SLACardLabel.vue';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper'; import { conversationListPageURL } from 'dashboard/helper/URLHelper';
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers'; import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import Linear from './linear/index.vue';
export default { export default {
components: { components: {
@@ -97,6 +103,7 @@ export default {
MoreActions, MoreActions,
Thumbnail, Thumbnail,
SLACardLabel, SLACardLabel,
Linear,
}, },
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins], mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
props: { props: {
@@ -121,6 +128,9 @@ export default {
...mapGetters({ ...mapGetters({
uiFlags: 'inboxAssignableAgents/getUIFlags', uiFlags: 'inboxAssignableAgents/getUIFlags',
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
appIntegrations: 'integrations/getAppIntegrations',
}), }),
chatMetadata() { chatMetadata() {
return this.chat.meta; return this.chat.meta;
@@ -178,6 +188,17 @@ export default {
hasSlaPolicyId() { hasSlaPolicyId() {
return this.chat?.sla_policy_id; return this.chat?.sla_policy_id;
}, },
isLinearIntegrationEnabled() {
return this.appIntegrations.find(
integration => integration.id === 'linear' && !!integration.hooks.length
);
},
isLinearFeatureEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.LINEAR
);
},
}, },
methods: { methods: {

View File

@@ -1,5 +1,9 @@
<template> <template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble"> <li
v-if="shouldRenderMessage"
:id="`message${data.id}`"
:class="[alignBubble, 'group']"
>
<div :class="wrapClass"> <div :class="wrapClass">
<div <div
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox" v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
@@ -121,7 +125,10 @@
</a> </a>
</div> </div>
</div> </div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap"> <div
v-if="shouldShowContextMenu"
class="context-menu-wrap invisible group-hover:visible"
>
<context-menu <context-menu
v-if="isBubble && !isMessageDeleted" v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition" :context-menu-position="contextMenuPosition"
@@ -473,11 +480,11 @@ export default {
}, },
mounted() { mounted() {
this.hasMediaLoadError = false; this.hasMediaLoadError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu); this.$emitter.on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer(); this.setupHighlightTimer();
}, },
beforeDestroy() { beforeDestroy() {
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu); this.$emitter.off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout); clearTimeout(this.higlightTimeout);
}, },
methods: { methods: {
@@ -531,7 +538,7 @@ export default {
const { conversation_id: conversationId, id: replyTo } = this.data; const { conversation_id: conversationId, id: replyTo } = this.data;
LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo); LocalStorage.updateJsonStore(replyStorageKey, conversationId, replyTo);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data); this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.data);
}, },
setupHighlightTimer() { setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) { if (Number(this.$route.query.messageId) !== Number(this.data.id)) {

View File

@@ -324,12 +324,12 @@ export default {
}, },
created() { created() {
bus.$on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); this.$emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
// when a new message comes in, we refetch the label suggestions // when a new message comes in, we refetch the label suggestions
bus.$on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions); this.$emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
// when a message is sent we set the flag to true this hides the label suggestions, // when a message is sent we set the flag to true this hides the label suggestions,
// until the chat is changed and the flag is reset in the watch for currentChat // until the chat is changed and the flag is reset in the watch for currentChat
bus.$on(BUS_EVENTS.MESSAGE_SENT, () => { this.$emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
this.messageSentSinceOpened = true; this.messageSentSinceOpened = true;
}); });
}, },
@@ -396,7 +396,7 @@ export default {
this.$store.dispatch('fetchAllAttachments', this.currentChat.id); this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
}, },
removeBusListeners() { removeBusListeners() {
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage); this.$emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
}, },
onScrollToMessage({ messageId = '' } = {}) { onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => { this.$nextTick(() => {
@@ -514,7 +514,7 @@ export default {
} else { } else {
this.hasUserScrolled = true; this.hasUserScrolled = true;
} }
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL); this.$emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop); this.fetchPreviousMessages(e.target.scrollTop);
}, },

View File

@@ -61,14 +61,14 @@ export default {
...mapGetters({ currentChat: 'getSelectedChat' }), ...mapGetters({ currentChat: 'getSelectedChat' }),
}, },
mounted() { mounted() {
bus.$on(CMD_MUTE_CONVERSATION, this.mute); this.$emitter.on(CMD_MUTE_CONVERSATION, this.mute);
bus.$on(CMD_UNMUTE_CONVERSATION, this.unmute); this.$emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal); this.$emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
}, },
destroyed() { destroyed() {
bus.$off(CMD_MUTE_CONVERSATION, this.mute); this.$emitter.off(CMD_MUTE_CONVERSATION, this.mute);
bus.$off(CMD_UNMUTE_CONVERSATION, this.unmute); this.$emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
bus.$off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal); this.$emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
}, },
methods: { methods: {
mute() { mute() {

View File

@@ -596,12 +596,15 @@ export default {
); );
this.fetchAndSetReplyTo(); this.fetchAndSetReplyTo();
bus.$on(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo); this.$emitter.on(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
// A hacky fix to solve the drag and drop // A hacky fix to solve the drag and drop
// Is showing on top of new conversation modal drag and drop // Is showing on top of new conversation modal drag and drop
// TODO need to find a better solution // TODO need to find a better solution
bus.$on( this.$emitter.on(
BUS_EVENTS.NEW_CONVERSATION_MODAL, BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive this.onNewConversationModalActive
); );
@@ -609,10 +612,13 @@ export default {
destroyed() { destroyed() {
document.removeEventListener('paste', this.onPaste); document.removeEventListener('paste', this.onPaste);
document.removeEventListener('keydown', this.handleKeyEvents); document.removeEventListener('keydown', this.handleKeyEvents);
bus.$off(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE, this.fetchAndSetReplyTo); this.$emitter.off(
BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE,
this.fetchAndSetReplyTo
);
}, },
beforeDestroy() { beforeDestroy() {
bus.$off( this.$emitter.off(
BUS_EVENTS.NEW_CONVERSATION_MODAL, BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.onNewConversationModalActive this.onNewConversationModalActive
); );
@@ -625,7 +631,7 @@ export default {
const lines = title.split('\n'); const lines = title.split('\n');
const nonEmptyLines = lines.filter(line => line.trim() !== ''); const nonEmptyLines = lines.filter(line => line.trim() !== '');
const filteredMarkdown = nonEmptyLines.join(' '); const filteredMarkdown = nonEmptyLines.join(' ');
bus.$emit( this.$emitter.emit(
BUS_EVENTS.INSERT_INTO_RICH_EDITOR, BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
`[${filteredMarkdown}](${url})` `[${filteredMarkdown}](${url})`
); );
@@ -867,8 +873,8 @@ export default {
'createPendingMessageAndSend', 'createPendingMessageAndSend',
messagePayload messagePayload
); );
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE); this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
bus.$emit(BUS_EVENTS.MESSAGE_SENT); this.$emitter.emit(BUS_EVENTS.MESSAGE_SENT);
this.removeFromDraft(); this.removeFromDraft();
this.sendMessageAnalyticsData(messagePayload.private); this.sendMessageAnalyticsData(messagePayload.private);
} catch (error) { } catch (error) {
@@ -1194,7 +1200,7 @@ export default {
resetReplyToMessage() { resetReplyToMessage() {
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO; const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId); LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE); this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
}, },
onNewConversationModalActive(isActive) { onNewConversationModalActive(isActive) {
// Issue is if the new conversation modal is open and we drag and drop the file // Issue is if the new conversation modal is open and we drag and drop the file

View File

@@ -47,7 +47,9 @@ export default {
}, },
methods: { methods: {
scrollToMessage() { scrollToMessage() {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id }); this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
messageId: this.message.id,
});
}, },
}, },
}; };

View File

@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
</script> </script>
<template> <template>
<div <div
class="absolute flex flex-col items-start bg-[#fdfdfd] dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto" class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 p-4 border border-solid border-slate-75 dark:border-slate-700 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
> >
<span class="text-sm font-medium text-slate-900 dark:text-slate-25"> <span class="text-sm font-medium text-slate-900 dark:text-slate-25">
{{ $t('SLA.EVENTS.TITLE') }} {{ $t('SLA.EVENTS.TITLE') }}

View File

@@ -16,7 +16,7 @@
/> />
</template> </template>
<menu-item <menu-item
v-if="show(snoozeOption.key)" v-if="showSnooze"
:option="snoozeOption" :option="snoozeOption"
variant="icon" variant="icon"
@click="snoozeConversation()" @click="snoozeConversation()"
@@ -86,6 +86,10 @@ export default {
}, },
mixins: [agentMixin], mixins: [agentMixin],
props: { props: {
chatId: {
type: Number,
default: null,
},
status: { status: {
type: String, type: String,
default: '', default: '',
@@ -205,6 +209,10 @@ export default {
...this.filteredAgentOnAvailability, ...this.filteredAgentOnAvailability,
]; ];
}, },
showSnooze() {
// Don't show snooze if the conversation is already snoozed/resolved/pending
return this.status === wootConstants.STATUS_TYPE.OPEN;
},
}, },
mounted() { mounted() {
this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]); this.$store.dispatch('inboxAssignableAgents/fetch', [this.inboxId]);
@@ -213,7 +221,8 @@ export default {
toggleStatus(status, snoozedUntil) { toggleStatus(status, snoozedUntil) {
this.$emit('update-conversation', status, snoozedUntil); this.$emit('update-conversation', status, snoozedUntil);
}, },
snoozeConversation() { async snoozeConversation() {
await this.$store.dispatch('setContextMenuChatId', this.chatId);
const ninja = document.querySelector('ninja-keys'); const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' }); ninja.open({ parent: 'snooze_conversation' });
}, },

View File

@@ -167,17 +167,29 @@ export default {
}; };
}, },
mounted() { mounted() {
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); this.$emitter.on(
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation); CMD_BULK_ACTION_SNOOZE_CONVERSATION,
bus.$on( this.onCmdSnoozeConversation
);
this.$emitter.on(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.on(
CMD_BULK_ACTION_RESOLVE_CONVERSATION, CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation this.onCmdResolveConversation
); );
}, },
destroyed() { destroyed() {
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation); this.$emitter.off(
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation); CMD_BULK_ACTION_SNOOZE_CONVERSATION,
bus.$off( this.onCmdSnoozeConversation
);
this.$emitter.off(
CMD_BULK_ACTION_REOPEN_CONVERSATION,
this.onCmdReopenConversation
);
this.$emitter.off(
CMD_BULK_ACTION_RESOLVE_CONVERSATION, CMD_BULK_ACTION_RESOLVE_CONVERSATION,
this.onCmdResolveConversation this.onCmdResolveConversation
); );

View File

@@ -0,0 +1,268 @@
<template>
<div>
<woot-input
v-model="formState.title"
:class="{ error: v$.title.$error }"
class="w-full"
:styles="{ ...inputStyles, padding: '6px 12px' }"
:label="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.LABEL')"
:placeholder="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.PLACEHOLDER')
"
:error="nameError"
@input="v$.title.$touch"
/>
<label>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.LABEL') }}
<textarea
v-model="formState.description"
:style="{ ...inputStyles, padding: '8px 12px' }"
rows="3"
class="text-sm"
:placeholder="
$t(
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.DESCRIPTION.PLACEHOLDER'
)
"
/>
</label>
<div class="flex flex-col gap-4">
<searchable-dropdown
v-for="dropdown in dropdowns"
:key="dropdown.type"
:type="dropdown.type"
:value="formState[dropdown.type]"
:label="$t(dropdown.label)"
:items="dropdown.items"
:placeholder="$t(dropdown.placeholder)"
:error="dropdown.error"
@change="onChange"
/>
</div>
<div class="flex items-center justify-end w-full gap-2 mt-8">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isCreating"
@click.prevent="createIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { reactive, computed, onMounted, ref } from 'vue';
import { useVuelidate } from '@vuelidate/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import validations from './validations';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
import SearchableDropdown from './SearchableDropdown.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emit = defineEmits(['close']);
const { t } = useI18n();
const teams = ref([]);
const assignees = ref([]);
const projects = ref([]);
const labels = ref([]);
const statuses = ref([]);
const priorities = [
{ id: 0, name: 'No priority' },
{ id: 1, name: 'Urgent' },
{ id: 2, name: 'High' },
{ id: 3, name: 'Normal' },
{ id: 4, name: 'Low' },
];
const statusDesiredOrder = [
'Backlog',
'Todo',
'In Progress',
'Done',
'Canceled',
];
const isCreating = ref(false);
const inputStyles = { borderRadius: '12px', fontSize: '14px' };
const formState = reactive({
title: '',
description: '',
teamId: '',
assigneeId: '',
labelId: '',
stateId: '',
priority: '',
projectId: '',
});
const v$ = useVuelidate(validations, formState);
const isSubmitDisabled = computed(
() => v$.value.title.$invalid || isCreating.value
);
const nameError = computed(() =>
v$.value.title.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TITLE.REQUIRED_ERROR')
: ''
);
const teamError = computed(() =>
v$.value.teamId.$error
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.REQUIRED_ERROR')
: ''
);
const dropdowns = computed(() => {
return [
{
type: 'teamId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.LABEL',
items: teams.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.TEAM.SEARCH',
error: teamError.value,
},
{
type: 'assigneeId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.LABEL',
items: assignees.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.ASSIGNEE.SEARCH',
error: '',
},
{
type: 'labelId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.LABEL',
items: labels.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.LABEL.SEARCH',
error: '',
},
{
type: 'priority',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.LABEL',
items: priorities,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PRIORITY.SEARCH',
error: '',
},
{
type: 'projectId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.LABEL',
items: projects.value,
placeholder:
'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.PROJECT.SEARCH',
error: '',
},
{
type: 'stateId',
label: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.LABEL',
items: statuses.value,
placeholder: 'INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.FORM.STATUS.SEARCH',
error: '',
},
];
});
const onClose = () => emit('close');
const getTeams = async () => {
try {
const response = await LinearAPI.getTeams();
teams.value = response.data;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ERROR')
);
useAlert(errorMessage);
}
};
const getTeamEntities = async () => {
try {
const response = await LinearAPI.getTeamEntities(formState.teamId);
assignees.value = response.data.users;
labels.value = response.data.labels;
projects.value = response.data.projects;
statuses.value = statusDesiredOrder
.map(name => response.data.states.find(status => status.name === name))
.filter(Boolean);
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.LOADING_TEAM_ENTITIES_ERROR')
);
useAlert(errorMessage);
}
};
const onChange = (item, type) => {
formState[type] = item.id;
if (type === 'teamId') {
formState.assigneeId = '';
formState.stateId = '';
formState.labelId = '';
formState.projectId = '';
getTeamEntities();
}
};
const createIssue = async () => {
v$.value.$touch();
if (v$.value.$invalid) return;
const payload = {
team_id: formState.teamId,
title: formState.title,
description: formState.description || undefined,
assignee_id: formState.assigneeId || undefined,
project_id: formState.projectId || undefined,
state_id: formState.stateId || undefined,
priority: formState.priority || undefined,
label_ids: formState.labelId ? [formState.labelId] : undefined,
};
try {
isCreating.value = true;
const response = await LinearAPI.createIssue(payload);
const { id: issueId } = response.data;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_SUCCESS'));
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CREATE_ERROR')
);
useAlert(errorMessage);
} finally {
isCreating.value = false;
}
};
onMounted(getTeams);
</script>

View File

@@ -0,0 +1,92 @@
<template>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.TITLE')"
:header-content="
$t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.DESCRIPTION')
"
/>
<div class="flex flex-col h-auto overflow-auto">
<div class="flex flex-col px-8 pb-4">
<woot-tabs
class="ltr:[&>ul]:pl-0 rtl:[&>ul]:pr-0"
:index="selectedTabIndex"
@change="onClickTabChange"
>
<woot-tabs-item
v-for="tab in tabs"
:key="tab.key"
:name="tab.name"
:show-badge="false"
/>
</woot-tabs>
</div>
<div v-if="selectedTabIndex === 0" class="flex flex-col px-8 pb-4">
<create-issue
:account-id="accountId"
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
<div v-else class="flex flex-col px-8 pb-4">
<link-issue
:conversation-id="conversation.id"
:title="title"
@close="onClose"
/>
</div>
</div>
</div>
</template>
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { computed, ref } from 'vue';
import LinkIssue from './LinkIssue.vue';
import CreateIssue from './CreateIssue.vue';
const props = defineProps({
accountId: {
type: [Number, String],
required: true,
},
conversation: {
type: Object,
required: true,
},
});
const { t } = useI18n();
const selectedTabIndex = ref(0);
const title = computed(() => {
const { meta: { sender: { name = null } = {} } = {} } = props.conversation;
return t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_TITLE', {
conversationId: props.conversation.id,
name,
});
});
const emits = defineEmits(['close']);
const tabs = ref([
{
key: 0,
name: t('INTEGRATION_SETTINGS.LINEAR.CREATE'),
},
{
key: 1,
name: t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE'),
},
]);
const onClose = () => {
emits('close');
};
const onClickTabChange = index => {
selectedTabIndex.value = index;
};
</script>

View File

@@ -0,0 +1,123 @@
<script setup>
import { format } from 'date-fns';
import UserAvatarWithName from 'dashboard/components/widgets/UserAvatarWithName.vue';
import IssueHeader from './IssueHeader.vue';
import { computed } from 'vue';
const priorityMap = {
1: 'Urgent',
2: 'High',
3: 'Medium',
4: 'Low',
};
const props = defineProps({
issue: {
type: Object,
required: true,
},
linkId: {
type: String,
required: true,
},
});
const emit = defineEmits(['unlink-issue']);
const formattedDate = computed(() => {
const { createdAt } = props.issue;
return format(new Date(createdAt), 'hh:mm a, MMM dd');
});
const assignee = computed(() => {
const assigneeDetails = props.issue.assignee;
if (!assigneeDetails) return null;
const { name, avatarUrl } = assigneeDetails;
return {
name,
thumbnail: avatarUrl,
};
});
const labels = computed(() => {
return props.issue.labels?.nodes || [];
});
const priorityLabel = computed(() => {
return priorityMap[props.issue.priority];
});
const unlinkIssue = () => {
emit('unlink-issue', props.linkId);
};
</script>
<template>
<div
class="absolute flex flex-col items-start bg-white dark:bg-slate-800 z-50 px-4 py-3 border border-solid border-ash-200 w-[384px] rounded-xl gap-4 max-h-96 overflow-auto"
>
<div class="flex flex-col w-full">
<issue-header
:identifier="issue.identifier"
:link-id="linkId"
:issue-url="issue.url"
@unlink-issue="unlinkIssue"
/>
<span class="mt-2 text-sm font-medium text-ash-900">
{{ issue.title }}
</span>
<span
v-if="issue.description"
class="mt-1 text-sm text-ash-800 line-clamp-3"
>
{{ issue.description }}
</span>
</div>
<div class="flex flex-row items-center h-6 gap-2">
<user-avatar-with-name v-if="assignee" :user="assignee" class="py-1" />
<div v-if="assignee" class="w-px h-3 bg-ash-200" />
<div class="flex items-center gap-1 py-1">
<fluent-icon
icon="status"
size="14"
:style="{ color: issue.state.color }"
/>
<h6 class="text-xs text-ash-900">
{{ issue.state.name }}
</h6>
</div>
<div v-if="priorityLabel" class="w-px h-3 bg-ash-200" />
<div v-if="priorityLabel" class="flex items-center gap-1 py-1">
<fluent-icon
:icon="`priority-${priorityLabel.toLowerCase()}`"
size="14"
view-box="0 0 12 12"
/>
<h6 class="text-xs text-ash-900">{{ priorityLabel }}</h6>
</div>
</div>
<div v-if="labels.length" class="flex flex-wrap items-center gap-1">
<woot-label
v-for="label in labels"
:key="label.id"
:title="label.name"
:description="label.description"
:color="label.color"
variant="smooth"
small
/>
</div>
<div class="flex items-center">
<span class="text-xs text-ash-800">
{{
$t('INTEGRATION_SETTINGS.LINEAR.ISSUE.CREATED_AT', {
createdAt: formattedDate,
})
}}
</span>
</div>
</div>
</template>

View File

@@ -0,0 +1,66 @@
<template>
<div class="flex flex-row justify-between">
<div
class="flex items-center justify-center gap-1 h-[24px] px-2 py-1 border rounded-lg border-ash-200"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span class="text-xs font-medium text-ash-900">{{ identifier }}</span>
</div>
<div class="flex items-center gap-0.5">
<woot-button
variant="clear"
color-scheme="secondary"
class="h-[24px]"
:is-loading="isUnlinking"
@click="unlinkIssue"
>
<fluent-icon
v-if="!isUnlinking"
icon="unlink"
size="12"
type="outline"
icon-lib="lucide"
/>
</woot-button>
<woot-button
variant="clear"
class="h-[24px]"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon icon="arrow-up-right" size="14" />
</woot-button>
</div>
</div>
</template>
<script setup>
import { inject } from 'vue';
const props = defineProps({
identifier: {
type: String,
required: true,
},
issueUrl: {
type: String,
required: true,
},
});
const isUnlinking = inject('isUnlinking');
const emit = defineEmits(['unlink-issue']);
const unlinkIssue = () => {
emit('unlink-issue');
};
const openIssue = () => {
window.open(props.issueUrl, '_blank');
};
</script>

View File

@@ -0,0 +1,143 @@
<template>
<div
class="flex flex-col justify-between"
:class="shouldShowDropdown ? 'h-[256px]' : 'gap-2'"
>
<filter-button
right-icon="chevron-down"
:button-text="linkIssueTitle"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-if="issues"
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="issues"
:active-filter-id="selectedOption.id"
:is-loading="isFetching"
:input-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.SEARCH')"
:loading-placeholder="$t('INTEGRATION_SETTINGS.LINEAR.LINK.LOADING')"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@on-search="onSearch"
@click="onSelectIssue"
/>
</template>
</filter-button>
<div class="flex items-center justify-end w-full gap-2 mt-2">
<woot-button
class="px-4 rounded-xl button clear outline-woot-200/50 outline"
@click.prevent="onClose"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK.CANCEL') }}
</woot-button>
<woot-button
:is-disabled="isSubmitDisabled"
class="px-4 rounded-xl"
:is-loading="isLinking"
@click.prevent="linkIssue"
>
{{ $t('INTEGRATION_SETTINGS.LINEAR.LINK.TITLE') }}
</woot-button>
</div>
</div>
</template>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useAlert } from 'dashboard/composables';
import LinearAPI from 'dashboard/api/integrations/linear';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
title: {
type: String,
default: null,
},
});
const emits = defineEmits(['close']);
const { t } = useI18n();
const issues = ref([]);
const shouldShowDropdown = ref(false);
const selectedOption = ref({ id: null, name: '' });
const isFetching = ref(false);
const isLinking = ref(false);
const searchQuery = ref('');
const toggleDropdown = () => {
issues.value = [];
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const linkIssueTitle = computed(() => {
return selectedOption.value.id
? selectedOption.value.name
: t('INTEGRATION_SETTINGS.LINEAR.LINK.SELECT');
});
const isSubmitDisabled = computed(() => {
return !selectedOption.value.id || isLinking.value;
});
const onSelectIssue = item => {
selectedOption.value = item;
toggleDropdown();
};
const onClose = () => {
emits('close');
};
const onSearch = async value => {
issues.value = [];
if (!value) return;
searchQuery.value = value;
try {
isFetching.value = true;
const response = await LinearAPI.searchIssues(value);
issues.value = response.data.map(issue => ({
id: issue.id,
name: `${issue.identifier} ${issue.title}`,
}));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.ERROR')
);
useAlert(errorMessage);
} finally {
isFetching.value = false;
}
};
const linkIssue = async () => {
const { id: issueId } = selectedOption.value;
try {
isLinking.value = true;
await LinearAPI.link_issue(props.conversationId, issueId, props.title);
useAlert(t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_SUCCESS'));
searchQuery.value = '';
issues.value = [];
onClose();
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LINK.LINK_ERROR')
);
useAlert(errorMessage);
} finally {
isLinking.value = false;
}
};
</script>

View File

@@ -0,0 +1,73 @@
<template>
<div
class="flex w-full"
:class="type === 'stateId' && shouldShowDropdown ? 'h-[150px]' : 'gap-2'"
>
<label class="w-full" :class="{ error: hasError }">
{{ label }}
<filter-button
right-icon="chevron-down"
:button-text="selectedItemName"
class="justify-between w-full h-[2.5rem] py-1.5 px-3 rounded-xl border border-slate-50 bg-slate-25 dark:border-slate-600 dark:bg-slate-900 hover:bg-slate-50 dark:hover:bg-slate-900/50"
@click="toggleDropdown"
>
<template v-if="shouldShowDropdown" #dropdown>
<filter-list-dropdown
v-on-clickaway="toggleDropdown"
:show-clear-filter="false"
:list-items="items"
:active-filter-id="selectedItemId"
:input-placeholder="placeholder"
enable-search
class="left-0 flex flex-col w-full overflow-y-auto h-fit !max-h-[160px] md:left-auto md:right-0 top-10"
@click="onSelect"
/>
</template>
</filter-button>
<span v-if="hasError" class="mt-1 message">{{ error }}</span>
</label>
</div>
</template>
<script setup>
import { ref, computed, defineComponent } from 'vue';
import FilterButton from 'dashboard/components/ui/Dropdown/DropdownButton.vue';
import FilterListDropdown from 'dashboard/components/ui/Dropdown/DropdownList.vue';
defineComponent({
name: 'SearchableDropdown',
});
const props = defineProps({
type: { type: String, required: true },
label: { type: String, default: null },
items: { type: Array, required: true },
value: { type: [Number, String], default: null },
placeholder: { type: String, default: null },
error: { type: String, default: null },
});
const emit = defineEmits(['change']);
const shouldShowDropdown = ref(false);
const toggleDropdown = () => {
shouldShowDropdown.value = !shouldShowDropdown.value;
};
const onSelect = item => {
emit('change', item, props.type);
toggleDropdown();
};
const hasError = computed(() => !!props.error);
const selectedItem = computed(() => {
if (!props.value) return null;
return props.items.find(i => i.id === props.value);
});
const selectedItemName = computed(
() => selectedItem.value?.name || props.placeholder
);
const selectedItemId = computed(() => selectedItem.value?.id || null);
</script>

View File

@@ -0,0 +1,141 @@
<template>
<div class="relative" :class="{ group: linkedIssue }">
<woot-button
v-on-clickaway="closeIssue"
v-tooltip="tooltipText"
variant="clear"
color-scheme="secondary"
@click="openIssue"
>
<fluent-icon
icon="linear"
size="19"
class="text-[#5E6AD2]"
view-box="0 0 19 19"
/>
<span v-if="linkedIssue" class="text-xs font-medium text-ash-800">
{{ linkedIssue.issue.identifier }}
</span>
</woot-button>
<issue
v-if="linkedIssue"
:issue="linkedIssue.issue"
:link-id="linkedIssue.id"
class="absolute right-0 top-[40px] invisible group-hover:visible"
@unlink-issue="unlinkIssue"
/>
<woot-modal
:show.sync="shouldShowPopup"
:on-close="closePopup"
:close-on-backdrop-click="false"
class="!items-start [&>div]:!top-12 [&>div]:sticky"
>
<create-or-link-issue
:conversation="conversation"
:account-id="currentAccountId"
@close="closePopup"
/>
</woot-modal>
</div>
</template>
<script setup>
import { computed, ref, onMounted, watch, defineComponent, provide } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import LinearAPI from 'dashboard/api/integrations/linear';
import CreateOrLinkIssue from './CreateOrLinkIssue.vue';
import Issue from './Issue.vue';
import { parseLinearAPIErrorResponse } from 'dashboard/store/utils/api';
defineComponent({
name: 'Linear',
});
const props = defineProps({
conversationId: {
type: [Number, String],
required: true,
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const linkedIssue = ref(null);
const shouldShow = ref(false);
const shouldShowPopup = ref(false);
const isUnlinking = ref(false);
provide('isUnlinking', isUnlinking);
const currentAccountId = getters.getCurrentAccountId;
const conversation = computed(() =>
getters.getConversationById.value(props.conversationId)
);
const tooltipText = computed(() => {
return linkedIssue.value === null
? t('INTEGRATION_SETTINGS.LINEAR.ADD_OR_LINK_BUTTON')
: null;
});
const loadLinkedIssue = async () => {
linkedIssue.value = null;
try {
const response = await LinearAPI.getLinkedIssue(props.conversationId);
const issues = response.data;
linkedIssue.value = issues && issues.length ? issues[0] : null;
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
);
useAlert(errorMessage);
}
};
const unlinkIssue = async linkId => {
try {
isUnlinking.value = true;
await LinearAPI.unlinkIssue(linkId);
linkedIssue.value = null;
useAlert(t('INTEGRATION_SETTINGS.LINEAR.UNLINK.SUCCESS'));
} catch (error) {
const errorMessage = parseLinearAPIErrorResponse(
error,
t('INTEGRATION_SETTINGS.LINEAR.UNLINK.ERROR')
);
useAlert(errorMessage);
} finally {
isUnlinking.value = false;
}
};
const openIssue = () => {
if (!linkedIssue.value) shouldShowPopup.value = true;
shouldShow.value = true;
};
const closePopup = () => {
shouldShowPopup.value = false;
loadLinkedIssue();
};
const closeIssue = () => {
shouldShow.value = false;
};
watch(
() => props.conversationId,
() => {
loadLinkedIssue();
}
);
onMounted(() => {
loadLinkedIssue();
});
</script>

View File

@@ -0,0 +1,10 @@
import { required } from '@vuelidate/validators';
export default {
title: {
required,
},
teamId: {
required,
},
};

View File

@@ -2,12 +2,21 @@ import { createLocalVue, mount } from '@vue/test-utils';
import Vuex from 'vuex'; import Vuex from 'vuex';
import VueI18n from 'vue-i18n'; import VueI18n from 'vue-i18n';
import VTooltip from 'v-tooltip'; import VTooltip from 'v-tooltip';
import Button from 'dashboard/components/buttons/Button'; import Button from 'dashboard/components/buttons/Button';
import i18n from 'dashboard/i18n'; import i18n from 'dashboard/i18n';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon'; import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
import MoreActions from '../MoreActions'; import MoreActions from '../MoreActions';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
}));
import { emitter } from 'shared/helpers/mitt';
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(Vuex); localVue.use(Vuex);
localVue.use(VueI18n); localVue.use(VueI18n);
@@ -16,6 +25,12 @@ localVue.use(VTooltip);
localVue.component('fluent-icon', FluentIcon); localVue.component('fluent-icon', FluentIcon);
localVue.component('woot-button', Button); localVue.component('woot-button', Button);
localVue.prototype.$emitter = {
emit: jest.fn(),
on: jest.fn(),
off: jest.fn(),
};
const i18nConfig = new VueI18n({ locale: 'en', messages: i18n }); const i18nConfig = new VueI18n({ locale: 'en', messages: i18n });
describe('MoveActions', () => { describe('MoveActions', () => {
@@ -29,12 +44,6 @@ describe('MoveActions', () => {
let moreActions = null; let moreActions = null;
beforeEach(() => { beforeEach(() => {
window.bus = {
$emit: jest.fn(),
$on: jest.fn(),
$off: jest.fn(),
};
state = { state = {
authenticated: true, authenticated: true,
currentChat, currentChat,
@@ -76,7 +85,7 @@ describe('MoveActions', () => {
it('shows alert', async () => { it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click'); await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith( expect(emitter.emit).toBeCalledWith(
'newToastMessage', 'newToastMessage',
'This contact is blocked successfully. You will not be notified of any future conversations.', 'This contact is blocked successfully. You will not be notified of any future conversations.',
undefined undefined
@@ -102,7 +111,7 @@ describe('MoveActions', () => {
it('shows alert', async () => { it('shows alert', async () => {
await moreActions.find('button:first-child').trigger('click'); await moreActions.find('button:first-child').trigger('click');
expect(window.bus.$emit).toBeCalledWith( expect(emitter.emit).toBeCalledWith(
'newToastMessage', 'newToastMessage',
'This contact is unblocked successfully.', 'This contact is unblocked successfully.',
undefined undefined

View File

@@ -0,0 +1,20 @@
import { emitter } from 'shared/helpers/mitt';
import { onMounted, onBeforeUnmount } from 'vue';
// this will automatically add event listeners to the emitter
// and remove them when the component is destroyed
const useEmitter = (eventName, callback) => {
const cleanup = () => {
emitter.off(eventName, callback);
};
onMounted(() => {
emitter.on(eventName, callback);
});
onBeforeUnmount(cleanup);
return cleanup;
};
export { useEmitter };

View File

@@ -1,4 +1,5 @@
import { getCurrentInstance } from 'vue'; import { getCurrentInstance } from 'vue';
import { emitter } from 'shared/helpers/mitt';
export const useTrack = () => { export const useTrack = () => {
const vm = getCurrentInstance(); const vm = getCurrentInstance();
@@ -8,5 +9,5 @@ export const useTrack = () => {
}; };
export function useAlert(message, action) { export function useAlert(message, action) {
bus.$emit('newToastMessage', message, action); emitter.emit('newToastMessage', message, action);
} }

View File

@@ -0,0 +1,51 @@
import { shallowMount } from '@vue/test-utils';
import { emitter } from 'shared/helpers/mitt';
import { useEmitter } from '../emitter';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
on: jest.fn(),
off: jest.fn(),
},
}));
describe('useEmitter', () => {
let wrapper;
const eventName = 'my-event';
const callback = jest.fn();
beforeEach(() => {
wrapper = shallowMount({
template: `
<div>
Hello world
</div>
`,
setup() {
return {
cleanup: useEmitter(eventName, callback),
};
},
});
});
afterEach(() => {
jest.resetAllMocks();
});
it('should add an event listener on mount', () => {
expect(emitter.on).toHaveBeenCalledWith(eventName, callback);
});
it('should remove the event listener when the component is unmounted', () => {
wrapper.destroy();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
it('should return the cleanup function', () => {
const cleanup = wrapper.vm.cleanup;
expect(typeof cleanup).toBe('function');
cleanup();
expect(emitter.off).toHaveBeenCalledWith(eventName, callback);
});
});

View File

@@ -30,4 +30,5 @@ export const FEATURE_FLAGS = {
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel', EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
INBOUND_EMAILS: 'inbound_emails', INBOUND_EMAILS: 'inbound_emails',
IP_LOOKUP: 'ip_lookup', IP_LOOKUP: 'ip_lookup',
LINEAR: 'linear_integration',
}; };

View File

@@ -0,0 +1,140 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
const MAX_DISCONNECT_SECONDS = 10800;
class ReconnectService {
constructor(store, router) {
this.store = store;
this.router = router;
this.disconnectTime = null;
this.setupEventListeners();
}
disconnect = () => this.removeEventListeners();
setupEventListeners = () => {
window.addEventListener('online', this.handleOnlineEvent);
emitter.on(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.on(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
removeEventListeners = () => {
window.removeEventListener('online', this.handleOnlineEvent);
emitter.off(BUS_EVENTS.WEBSOCKET_RECONNECT, this.onReconnect);
emitter.off(BUS_EVENTS.WEBSOCKET_DISCONNECT, this.onDisconnect);
};
getSecondsSinceDisconnect = () =>
this.disconnectTime
? Math.max(differenceInSeconds(new Date(), this.disconnectTime), 0)
: 0;
// Force reload if the user is disconnected for more than 3 hours
handleOnlineEvent = () => {
if (this.getSecondsSinceDisconnect() >= MAX_DISCONNECT_SECONDS) {
window.location.reload();
}
};
fetchConversations = async () => {
await this.store.dispatch('updateChatListFilters', {
page: null,
updatedWithin: this.getSecondsSinceDisconnect(),
});
await this.store.dispatch('fetchAllConversations');
// Reset the updatedWithin in the store chat list filter after fetching conversations when the user is reconnected
await this.store.dispatch('updateChatListFilters', {
updatedWithin: null,
});
};
fetchFilteredOrSavedConversations = async queryData => {
await this.store.dispatch('fetchFilteredConversations', {
queryData,
page: 1,
});
};
fetchConversationsOnReconnect = async () => {
const {
getAppliedConversationFiltersQuery,
'customViews/getActiveConversationFolder': activeFolder,
} = this.store.getters;
const query = getAppliedConversationFiltersQuery?.payload?.length
? getAppliedConversationFiltersQuery
: activeFolder?.query;
if (query) {
await this.fetchFilteredOrSavedConversations(query);
} else {
await this.fetchConversations();
}
};
fetchConversationMessagesOnReconnect = async () => {
const { conversation_id: conversationId } = this.router.currentRoute.params;
if (conversationId) {
await this.store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversationId),
});
}
};
fetchNotificationsOnReconnect = async filter => {
await this.store.dispatch('notifications/index', { ...filter, page: 1 });
};
revalidateCaches = async () => {
const { label, inbox, team } = await this.store.dispatch(
'accounts/getCacheKeys'
);
await Promise.all([
this.store.dispatch('labels/revalidate', { newKey: label }),
this.store.dispatch('inboxes/revalidate', { newKey: inbox }),
this.store.dispatch('teams/revalidate', { newKey: team }),
]);
};
handleRouteSpecificFetch = async () => {
const currentRoute = this.router.currentRoute.name;
if (isAConversationRoute(currentRoute, true)) {
await this.fetchConversationsOnReconnect();
await this.fetchConversationMessagesOnReconnect();
} else if (isAInboxViewRoute(currentRoute, true)) {
await this.fetchNotificationsOnReconnect(
this.store.getters['notifications/getNotificationFilters']
);
} else if (isNotificationRoute(currentRoute)) {
await this.fetchNotificationsOnReconnect();
}
};
setConversationLastMessageId = async () => {
const { conversation_id: conversationId } = this.router.currentRoute.params;
if (conversationId) {
await this.store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversationId),
});
}
};
onDisconnect = () => {
this.disconnectTime = new Date();
this.setConversationLastMessageId();
};
onReconnect = async () => {
await this.handleRouteSpecificFetch();
await this.revalidateCaches();
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED);
};
}
export default ReconnectService;

View File

@@ -1,6 +1,8 @@
import AuthAPI from '../api/auth'; import AuthAPI from '../api/auth';
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector'; import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
class ActionCableConnector extends BaseActionCableConnector { class ActionCableConnector extends BaseActionCableConnector {
constructor(app, pubsubToken) { constructor(app, pubsubToken) {
@@ -32,34 +34,14 @@ class ActionCableConnector extends BaseActionCableConnector {
}; };
} }
// eslint-disable-next-line class-methods-use-this
onReconnect = () => { onReconnect = () => {
this.syncActiveConversationMessages(); emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
}; };
// eslint-disable-next-line class-methods-use-this
onDisconnected = () => { onDisconnected = () => {
this.setActiveConversationLastMessageId(); emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
};
setActiveConversationLastMessageId = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('setConversationLastMessageId', {
conversationId: Number(conversation_id),
});
}
};
syncActiveConversationMessages = () => {
const {
params: { conversation_id },
} = this.app.$route;
if (conversation_id) {
this.app.$store.dispatch('syncActiveConversationMessages', {
conversationId: Number(conversation_id),
});
}
}; };
isAValidEvent = data => { isAValidEvent = data => {
@@ -177,8 +159,8 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
fetchConversationStats = () => { fetchConversationStats = () => {
bus.$emit('fetch_conversation_stats'); emitter.emit('fetch_conversation_stats');
bus.$emit('fetch_overview_reports'); emitter.emit('fetch_overview_reports');
}; };
onContactDelete = data => { onContactDelete = data => {
@@ -207,7 +189,7 @@ class ActionCableConnector extends BaseActionCableConnector {
// eslint-disable-next-line class-methods-use-this // eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => { onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports'); emitter.emit('fetch_overview_reports');
}; };
onCacheInvalidate = data => { onCacheInvalidate = data => {

View File

@@ -87,7 +87,8 @@ export const getInboxClassByType = (type, phoneNumber) => {
}; };
export const getInboxWarningIconClass = (type, reauthorizationRequired) => { export const getInboxWarningIconClass = (type, reauthorizationRequired) => {
if (type === INBOX_TYPES.FB && reauthorizationRequired) { const allowedInboxTypes = [INBOX_TYPES.FB, INBOX_TYPES.EMAIL];
if (allowedInboxTypes.includes(type) && reauthorizationRequired) {
return 'warning'; return 'warning';
} }
return ''; return '';

View File

@@ -1,6 +1,7 @@
/* eslint-disable no-console */ /* eslint-disable no-console */
import NotificationSubscriptions from '../api/notificationSubscription'; import NotificationSubscriptions from '../api/notificationSubscription';
import auth from '../api/auth'; import auth from '../api/auth';
import { emitter } from 'shared/helpers/mitt';
export const verifyServiceWorkerExistence = (callback = () => {}) => { export const verifyServiceWorkerExistence = (callback = () => {}) => {
if (!('serviceWorker' in navigator)) { if (!('serviceWorker' in navigator)) {
@@ -68,7 +69,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
onSuccess(); onSuccess();
}) })
.catch(() => { .catch(() => {
window.bus.$emit( emitter.emit(
'newToastMessage', 'newToastMessage',
'This browser does not support desktop notification' 'This browser does not support desktop notification'
); );
@@ -77,7 +78,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
export const requestPushPermissions = ({ onSuccess }) => { export const requestPushPermissions = ({ onSuccess }) => {
if (!('Notification' in window)) { if (!('Notification' in window)) {
window.bus.$emit( emitter.emit(
'newToastMessage', 'newToastMessage',
'This browser does not support desktop notification' 'This browser does not support desktop notification'
); );

View File

@@ -52,8 +52,22 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
return null; return null;
}; };
export const isAConversationRoute = routeName => export const isAConversationRoute = (
[ routeName,
includeBase = false,
includeExtended = true
) => {
const baseRoutes = [
'home',
'conversation_mentions',
'conversation_unattended',
'inbox_dashboard',
'label_conversations',
'team_conversations',
'folder_conversations',
'conversation_participating',
];
const extendedRoutes = [
'inbox_conversation', 'inbox_conversation',
'conversation_through_mentions', 'conversation_through_mentions',
'conversation_through_unattended', 'conversation_through_unattended',
@@ -62,7 +76,15 @@ export const isAConversationRoute = routeName =>
'conversations_through_team', 'conversations_through_team',
'conversations_through_folders', 'conversations_through_folders',
'conversation_through_participating', 'conversation_through_participating',
].includes(routeName); ];
const routes = [
...(includeBase ? baseRoutes : []),
...(includeExtended ? extendedRoutes : []),
];
return routes.includes(routeName);
};
export const getConversationDashboardRoute = routeName => { export const getConversationDashboardRoute = routeName => {
switch (routeName) { switch (routeName) {
@@ -87,5 +109,14 @@ export const getConversationDashboardRoute = routeName => {
} }
}; };
export const isAInboxViewRoute = routeName => export const isAInboxViewRoute = (routeName, includeBase = false) => {
['inbox_view_conversation'].includes(routeName); const baseRoutes = ['inbox_view'];
const extendedRoutes = ['inbox_view_conversation'];
const routeNames = includeBase
? [...baseRoutes, ...extendedRoutes]
: extendedRoutes;
return routeNames.includes(routeName);
};
export const isNotificationRoute = routeName =>
routeName === 'notifications_index';

View File

@@ -1,5 +1,6 @@
import AnalyticsHelper from './AnalyticsHelper'; import AnalyticsHelper from './AnalyticsHelper';
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper'; import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
import { emitter } from 'shared/helpers/mitt';
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER'; export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
export const CHATWOOT_RESET = 'CHATWOOT_RESET'; export const CHATWOOT_RESET = 'CHATWOOT_RESET';
@@ -8,7 +9,7 @@ export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
export const ANALYTICS_RESET = 'ANALYTICS_RESET'; export const ANALYTICS_RESET = 'ANALYTICS_RESET';
export const initializeAnalyticsEvents = () => { export const initializeAnalyticsEvents = () => {
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => { emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
AnalyticsHelper.identify(user); AnalyticsHelper.identify(user);
}); });
}; };
@@ -34,12 +35,12 @@ const initializeAudioAlerts = user => {
}; };
export const initializeChatwootEvents = () => { export const initializeChatwootEvents = () => {
window.bus.$on(CHATWOOT_RESET, () => { emitter.on(CHATWOOT_RESET, () => {
if (window.$chatwoot) { if (window.$chatwoot) {
window.$chatwoot.reset(); window.$chatwoot.reset();
} }
}); });
window.bus.$on(CHATWOOT_SET_USER, ({ user }) => { emitter.on(CHATWOOT_SET_USER, ({ user }) => {
if (window.$chatwoot) { if (window.$chatwoot) {
window.$chatwoot.setUser(user.email, { window.$chatwoot.setUser(user.email, {
avatar_url: user.avatar_url, avatar_url: user.avatar_url,

View File

@@ -0,0 +1,342 @@
import { emitter } from 'shared/helpers/mitt';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { differenceInSeconds } from 'date-fns';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import ReconnectService from 'dashboard/helper/ReconnectService';
jest.mock('shared/helpers/mitt', () => ({
emitter: {
on: jest.fn(),
off: jest.fn(),
emit: jest.fn(),
},
}));
jest.mock('date-fns', () => ({
differenceInSeconds: jest.fn(),
}));
jest.mock('dashboard/helper/routeHelpers', () => ({
isAConversationRoute: jest.fn(),
isAInboxViewRoute: jest.fn(),
isNotificationRoute: jest.fn(),
}));
const storeMock = {
dispatch: jest.fn(),
getters: {
getAppliedConversationFiltersQuery: [],
'customViews/getActiveConversationFolder': { query: {} },
'notifications/getNotificationFilters': {},
},
};
const routerMock = {
currentRoute: {
name: '',
params: { conversation_id: null },
},
};
describe('ReconnectService', () => {
let reconnectService;
beforeEach(() => {
window.addEventListener = jest.fn();
window.removeEventListener = jest.fn();
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: jest.fn() },
});
reconnectService = new ReconnectService(storeMock, routerMock);
});
afterEach(() => {
jest.clearAllMocks();
});
describe('constructor', () => {
it('should initialize with store, router, and setup event listeners', () => {
expect(reconnectService.store).toBe(storeMock);
expect(reconnectService.router).toBe(routerMock);
expect(window.addEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.on).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('disconnect', () => {
it('should remove event listeners', () => {
reconnectService.disconnect();
expect(window.removeEventListener).toHaveBeenCalledWith(
'online',
reconnectService.handleOnlineEvent
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT,
reconnectService.onReconnect
);
expect(emitter.off).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_DISCONNECT,
reconnectService.onDisconnect
);
});
});
describe('getSecondsSinceDisconnect', () => {
it('should return 0 if disconnectTime is null', () => {
reconnectService.disconnectTime = null;
expect(reconnectService.getSecondsSinceDisconnect()).toBe(0);
});
it('should return the number of seconds since disconnect', () => {
reconnectService.disconnectTime = new Date();
differenceInSeconds.mockReturnValue(100);
expect(reconnectService.getSecondsSinceDisconnect()).toBe(100);
});
});
describe('handleOnlineEvent', () => {
it('should reload the page if disconnected for more than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(10801);
reconnectService.handleOnlineEvent();
expect(window.location.reload).toHaveBeenCalled();
});
it('should not reload the page if disconnected for less than 3 hours', () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(10799);
reconnectService.handleOnlineEvent();
expect(window.location.reload).not.toHaveBeenCalled();
});
});
describe('fetchConversations', () => {
it('should dispatch updateChatListFilters and fetchAllConversations', async () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
page: null,
updatedWithin: 100,
});
expect(storeMock.dispatch).toHaveBeenCalledWith('fetchAllConversations');
});
it('should dispatch updateChatListFilters and reset updatedWithin', async () => {
reconnectService.getSecondsSinceDisconnect = jest
.fn()
.mockReturnValue(100);
await reconnectService.fetchConversations();
expect(storeMock.dispatch).toHaveBeenCalledWith('updateChatListFilters', {
updatedWithin: null,
});
});
});
describe('fetchFilteredOrSavedConversations', () => {
it('should dispatch fetchFilteredConversations', async () => {
const payload = { test: 'data' };
await reconnectService.fetchFilteredOrSavedConversations(payload);
expect(storeMock.dispatch).toHaveBeenCalledWith(
'fetchFilteredConversations',
{ queryData: payload, page: 1 }
);
});
});
describe('fetchConversationsOnReconnect', () => {
it('should fetch filtered or saved conversations if query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = {
payload: [
{
attribute_key: 'status',
filter_operator: 'equal_to',
values: ['open'],
},
],
};
const spy = jest.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith(
storeMock.getters.getAppliedConversationFiltersQuery
);
});
it('should fetch all conversations if no query exists', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: null,
};
const spy = jest.spyOn(reconnectService, 'fetchConversations');
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalled();
});
it('should fetch filtered or saved conversations if active folder query exists and no applied query', async () => {
storeMock.getters.getAppliedConversationFiltersQuery = [];
storeMock.getters['customViews/getActiveConversationFolder'] = {
query: { test: 'activeFolderQuery' },
};
const spy = jest.spyOn(
reconnectService,
'fetchFilteredOrSavedConversations'
);
await reconnectService.fetchConversationsOnReconnect();
expect(spy).toHaveBeenCalledWith({ test: 'activeFolderQuery' });
});
});
describe('fetchConversationMessagesOnReconnect', () => {
it('should dispatch syncActiveConversationMessages if conversationId exists', async () => {
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'syncActiveConversationMessages',
{ conversationId: 1 }
);
});
it('should not dispatch syncActiveConversationMessages if conversationId does not exist', async () => {
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.fetchConversationMessagesOnReconnect();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'syncActiveConversationMessages',
expect.anything()
);
});
});
describe('fetchNotificationsOnReconnect', () => {
it('should dispatch notifications/index', async () => {
const filter = { test: 'filter' };
await reconnectService.fetchNotificationsOnReconnect(filter);
expect(storeMock.dispatch).toHaveBeenCalledWith('notifications/index', {
...filter,
page: 1,
});
});
});
describe('revalidateCaches', () => {
it('should dispatch revalidate actions for labels, inboxes, and teams', async () => {
storeMock.dispatch.mockResolvedValueOnce({
label: 'labelKey',
inbox: 'inboxKey',
team: 'teamKey',
});
await reconnectService.revalidateCaches();
expect(storeMock.dispatch).toHaveBeenCalledWith('accounts/getCacheKeys');
expect(storeMock.dispatch).toHaveBeenCalledWith('labels/revalidate', {
newKey: 'labelKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('inboxes/revalidate', {
newKey: 'inboxKey',
});
expect(storeMock.dispatch).toHaveBeenCalledWith('teams/revalidate', {
newKey: 'teamKey',
});
});
});
describe('handleRouteSpecificFetch', () => {
it('should fetch conversations and messages if current route is a conversation route', async () => {
isAConversationRoute.mockReturnValue(true);
const spyConversations = jest.spyOn(
reconnectService,
'fetchConversationsOnReconnect'
);
const spyMessages = jest.spyOn(
reconnectService,
'fetchConversationMessagesOnReconnect'
);
await reconnectService.handleRouteSpecificFetch();
expect(spyConversations).toHaveBeenCalled();
expect(spyMessages).toHaveBeenCalled();
});
it('should fetch notifications if current route is an inbox view route', async () => {
isAInboxViewRoute.mockReturnValue(true);
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
it('should fetch notifications if current route is a notification route', async () => {
isNotificationRoute.mockReturnValue(true);
const spy = jest.spyOn(reconnectService, 'fetchNotificationsOnReconnect');
await reconnectService.handleRouteSpecificFetch();
expect(spy).toHaveBeenCalled();
});
});
describe('setConversationLastMessageId', () => {
it('should dispatch setConversationLastMessageId if conversationId exists', async () => {
routerMock.currentRoute.params.conversation_id = 1;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).toHaveBeenCalledWith(
'setConversationLastMessageId',
{ conversationId: 1 }
);
});
it('should not dispatch setConversationLastMessageId if conversationId does not exist', async () => {
routerMock.currentRoute.params.conversation_id = null;
await reconnectService.setConversationLastMessageId();
expect(storeMock.dispatch).not.toHaveBeenCalledWith(
'setConversationLastMessageId',
expect.anything()
);
});
});
describe('onDisconnect', () => {
it('should set disconnectTime and call setConversationLastMessageId', () => {
reconnectService.setConversationLastMessageId = jest.fn();
reconnectService.onDisconnect();
expect(reconnectService.disconnectTime).toBeInstanceOf(Date);
expect(reconnectService.setConversationLastMessageId).toHaveBeenCalled();
});
});
describe('onReconnect', () => {
it('should handle route-specific fetch, revalidate caches, and emit WEBSOCKET_RECONNECT_COMPLETED event', async () => {
reconnectService.handleRouteSpecificFetch = jest.fn();
reconnectService.revalidateCaches = jest.fn();
await reconnectService.onReconnect();
expect(reconnectService.handleRouteSpecificFetch).toHaveBeenCalled();
expect(reconnectService.revalidateCaches).toHaveBeenCalled();
expect(emitter.emit).toHaveBeenCalledWith(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED
);
});
});
});

View File

@@ -106,6 +106,51 @@ describe('isAConversationRoute', () => {
expect(isAConversationRoute('conversations_through_team')).toBe(true); expect(isAConversationRoute('conversations_through_team')).toBe(true);
expect(isAConversationRoute('dashboard')).toBe(false); expect(isAConversationRoute('dashboard')).toBe(false);
}); });
it('returns true if base conversation route name is provided and includeBase is true', () => {
expect(isAConversationRoute('home', true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true)).toBe(true);
expect(isAConversationRoute('conversation_unattended', true)).toBe(true);
expect(isAConversationRoute('inbox_dashboard', true)).toBe(true);
expect(isAConversationRoute('label_conversations', true)).toBe(true);
expect(isAConversationRoute('team_conversations', true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true)).toBe(true);
});
it('returns false if base conversation route name is provided and includeBase is false', () => {
expect(isAConversationRoute('home', false)).toBe(false);
expect(isAConversationRoute('conversation_mentions', false)).toBe(false);
expect(isAConversationRoute('conversation_unattended', false)).toBe(false);
expect(isAConversationRoute('inbox_dashboard', false)).toBe(false);
expect(isAConversationRoute('label_conversations', false)).toBe(false);
expect(isAConversationRoute('team_conversations', false)).toBe(false);
expect(isAConversationRoute('folder_conversations', false)).toBe(false);
expect(isAConversationRoute('conversation_participating', false)).toBe(
false
);
});
it('returns true if base conversation route name is provided and includeBase and includeExtended is true', () => {
expect(isAConversationRoute('home', true, true)).toBe(true);
expect(isAConversationRoute('conversation_mentions', true, true)).toBe(
true
);
expect(isAConversationRoute('conversation_unattended', true, true)).toBe(
true
);
expect(isAConversationRoute('inbox_dashboard', true, true)).toBe(true);
expect(isAConversationRoute('label_conversations', true, true)).toBe(true);
expect(isAConversationRoute('team_conversations', true, true)).toBe(true);
expect(isAConversationRoute('folder_conversations', true, true)).toBe(true);
expect(isAConversationRoute('conversation_participating', true, true)).toBe(
true
);
});
it('returns false if base conversation route name is not provided', () => {
expect(isAConversationRoute('')).toBe(false);
});
}); });
describe('getConversationDashboardRoute', () => { describe('getConversationDashboardRoute', () => {
@@ -141,4 +186,12 @@ describe('isAInboxViewRoute', () => {
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true); expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
expect(isAInboxViewRoute('inbox_conversation')).toBe(false); expect(isAInboxViewRoute('inbox_conversation')).toBe(false);
}); });
it('returns true if base inbox view route name is provided and includeBase is true', () => {
expect(isAInboxViewRoute('inbox_view', true)).toBe(true);
});
it('returns false if base inbox view route name is provided and includeBase is false', () => {
expect(isAInboxViewRoute('inbox_view')).toBe(false);
});
}); });

View File

@@ -33,7 +33,7 @@
"NONE": "None", "NONE": "None",
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.", "NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
"ASSIGN_SELECTED_TEAMS": "Assign selected team.", "ASSIGN_SELECTED_TEAMS": "Assign selected team.",
"ASSIGN_SUCCESFUL": "Teams assiged successfully.", "ASSIGN_SUCCESFUL": "Teams assigned successfully.",
"ASSIGN_FAILED": "Failed to assign team. Please try again." "ASSIGN_FAILED": "Failed to assign team. Please try again."
} }
} }

View File

@@ -95,7 +95,9 @@
}, },
"NETWORK": { "NETWORK": {
"NOTIFICATION": { "NOTIFICATION": {
"OFFLINE": "Offline" "OFFLINE": "Offline",
"RECONNECTING": "Reconnecting...",
"RECONNECT_SUCCESS": "Reconnected"
}, },
"BUTTON": { "BUTTON": {
"REFRESH": "Refresh" "REFRESH": "Refresh"
@@ -154,7 +156,7 @@
"UNTIL_TOMORROW": "Until tomorrow", "UNTIL_TOMORROW": "Until tomorrow",
"UNTIL_NEXT_MONTH": "Until next month", "UNTIL_NEXT_MONTH": "Until next month",
"AN_HOUR_FROM_NOW": "Until an hour from now", "AN_HOUR_FROM_NOW": "Until an hour from now",
"CUSTOM": "Custom...", "UNTIL_CUSTOM_TIME": "Custom...",
"CHANGE_APPEARANCE": "Change Appearance", "CHANGE_APPEARANCE": "Change Appearance",
"LIGHT_MODE": "Light", "LIGHT_MODE": "Light",
"DARK_MODE": "Dark", "DARK_MODE": "Dark",

View File

@@ -2,6 +2,8 @@
"INBOX_MGMT": { "INBOX_MGMT": {
"HEADER": "Inboxes", "HEADER": "Inboxes",
"SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>", "SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"LIST": { "LIST": {
"404": "There are no inboxes attached to this account." "404": "There are no inboxes attached to this account."
}, },
@@ -364,8 +366,15 @@
"TITLE": "Microsoft Email", "TITLE": "Microsoft Email",
"DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.", "DESCRIPTION": "Click on the Sign in with Microsoft button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"EMAIL_PLACEHOLDER": "Enter email address", "EMAIL_PLACEHOLDER": "Enter email address",
"HELP": "To add your Microsoft account as a channel, you need to authenticate your Microsoft account by clicking on 'Sign in with Microsoft' ", "SIGN_IN": "Sign in with Microsoft",
"ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again" "ERROR_MESSAGE": "There was an error connecting to Microsoft, please try again"
},
"GOOGLE": {
"TITLE": "Google Email",
"DESCRIPTION": "Click on the Sign in with Google button to get started. You will redirected to the email sign in page. Once you accept the requested permissions, you would be redirected back to the inbox creation step.",
"SIGN_IN": "Sign in with Google",
"EMAIL_PLACEHOLDER": "Enter email address",
"ERROR_MESSAGE": "There was an error connecting to Google, please try again"
} }
}, },
"DETAILS": { "DETAILS": {
@@ -733,6 +742,7 @@
}, },
"EMAIL_PROVIDERS": { "EMAIL_PROVIDERS": {
"MICROSOFT": "Microsoft", "MICROSOFT": "Microsoft",
"GOOGLE": "Google",
"OTHER_PROVIDERS": "Other Providers" "OTHER_PROVIDERS": "Other Providers"
} }
} }

View File

@@ -203,6 +203,87 @@
"API_SUCCESS": "Dashboard app deleted successfully", "API_SUCCESS": "Dashboard app deleted successfully",
"API_ERROR": "We couldn't delete the app. Please try again later" "API_ERROR": "We couldn't delete the app. Please try again later"
} }
},
"LINEAR": {
"ADD_OR_LINK_BUTTON": "Create/Link Linear Issue",
"LOADING": "Fetching linear issues...",
"LOADING_ERROR": "There was an error fetching the linear issues, please try again",
"CREATE": "Create",
"LINK": {
"SEARCH": "Search issues",
"SELECT": "Select issue",
"TITLE": "Link",
"EMPTY_LIST": "No linear issues found",
"LOADING": "Loading",
"ERROR": "There was an error fetching the linear issues, please try again",
"LINK_SUCCESS": "Issue linked successfully",
"LINK_ERROR": "There was an error linking the issue, please try again",
"LINK_TITLE": "Conversation (#%{conversationId}) with %{name}"
},
"ADD_OR_LINK": {
"TITLE": "Create/link linear issue",
"DESCRIPTION": "Create Linear issues from conversations, or link existing ones for seamless tracking.",
"FORM": {
"TITLE": {
"LABEL": "Title",
"PLACEHOLDER": "Enter title",
"REQUIRED_ERROR": "Title is required"
},
"DESCRIPTION": {
"LABEL": "Description",
"PLACEHOLDER": "Enter description"
},
"TEAM": {
"LABEL": "Team",
"PLACEHOLDER": "Select team",
"SEARCH": "Search team",
"REQUIRED_ERROR": "Team is required"
},
"ASSIGNEE": {
"LABEL": "Assignee",
"PLACEHOLDER": "Select assignee",
"SEARCH": "Search assignee"
},
"PRIORITY": {
"LABEL": "Priority",
"PLACEHOLDER": "Select priority",
"SEARCH": "Search priority"
},
"LABEL": {
"LABEL": "Label",
"PLACEHOLDER": "Select label",
"SEARCH": "Search label"
},
"STATUS": {
"LABEL": "Status",
"PLACEHOLDER": "Select status",
"SEARCH": "Search status"
},
"PROJECT": {
"LABEL": "Project",
"PLACEHOLDER": "Select project",
"SEARCH": "Search project"
}
},
"CREATE": "Create",
"CANCEL": "Cancel",
"CREATE_SUCCESS": "Issue created successfully",
"CREATE_ERROR": "There was an error creating the issue, please try again",
"LOADING_TEAM_ERROR": "There was an error fetching the teams, please try again",
"LOADING_TEAM_ENTITIES_ERROR": "There was an error fetching the team entities, please try again"
},
"ISSUE": {
"STATUS": "Status",
"PRIORITY": "Priority",
"ASSIGNEE": "Assignee",
"LABELS": "Labels",
"CREATED_AT": "Created at %{createdAt}"
},
"UNLINK": {
"TITLE": "Unlink",
"SUCCESS": "Issue unlinked successfully",
"ERROR": "There was an error unlinking the issue, please try again"
}
} }
} }
} }

View File

@@ -274,7 +274,7 @@
"SLA": "SLA", "SLA": "SLA",
"BETA": "Beta", "BETA": "Beta",
"REPORTS_OVERVIEW": "Overview", "REPORTS_OVERVIEW": "Overview",
"FACEBOOK_REAUTHORIZE": "Your Facebook connection has expired, please reconnect your Facebook page to continue services", "REAUTHORIZE": "Your inbox connection has expired, please reconnect\n to continue receiving and sending messages",
"HELP_CENTER": { "HELP_CENTER": {
"TITLE": "Help Center", "TITLE": "Help Center",
"ALL_ARTICLES": "All Articles", "ALL_ARTICLES": "All Articles",

View File

@@ -15,7 +15,7 @@ export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
]; ];
const slugifyChannel = name => const slugifyChannel = name =>
name.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_'); name?.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
export const isEditorHotKeyEnabled = (uiSettings, key) => { export const isEditorHotKeyEnabled = (uiSettings, key) => {
const { const {
@@ -70,6 +70,8 @@ export default {
this.updateUISettings({ [key]: !this.isContactSidebarItemOpen(key) }); this.updateUISettings({ [key]: !this.isContactSidebarItemOpen(key) });
}, },
setSignatureFlagForInbox(channelType, value) { setSignatureFlagForInbox(channelType, value) {
if (!channelType) return;
channelType = slugifyChannel(channelType); channelType = slugifyChannel(channelType);
this.updateUISettings({ this.updateUISettings({
[`${channelType}_signature_enabled`]: value, [`${channelType}_signature_enabled`]: value,

View File

@@ -110,11 +110,11 @@ export default {
mounted() { mounted() {
this.handleResize(); this.handleResize();
window.addEventListener('resize', this.handleResize); window.addEventListener('resize', this.handleResize);
bus.$on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); this.$emitter.on(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
}, },
beforeDestroy() { beforeDestroy() {
window.removeEventListener('resize', this.handleResize); window.removeEventListener('resize', this.handleResize);
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar); this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
}, },
methods: { methods: {

View File

@@ -11,6 +11,9 @@ import {
ICON_REOPEN_CONVERSATION, ICON_REOPEN_CONVERSATION,
ICON_RESOLVE_CONVERSATION, ICON_RESOLVE_CONVERSATION,
} from './CommandBarIcons'; } from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { createSnoozeHandlers } from './commandBarActions';
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS; const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
@@ -22,79 +25,11 @@ export const SNOOZE_CONVERSATION_BULK_ACTIONS = [
icon: ICON_SNOOZE_CONVERSATION, icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS), children: Object.values(SNOOZE_OPTIONS),
}, },
...createSnoozeHandlers(
{ CMD_BULK_ACTION_SNOOZE_CONVERSATION,
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY, 'bulk_action_snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY', 'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
parent: 'bulk_action_snooze_conversation', ),
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_REPLY
),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'bulk_action_snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.AN_HOUR_FROM_NOW
),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_TOMORROW
),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_WEEK
),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_NEXT_MONTH
),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
parent: 'bulk_action_snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
),
},
]; ];
export const RESOLVED_CONVERSATION_BULK_ACTIONS = [ export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
@@ -103,7 +38,7 @@ export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS', section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_REOPEN_CONVERSATION, icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_REOPEN_CONVERSATION), handler: () => emitter.emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
}, },
]; ];
@@ -113,7 +48,7 @@ export const OPEN_CONVERSATION_BULK_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS', section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
icon: ICON_RESOLVE_CONVERSATION, icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION), handler: () => emitter.emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
}, },
]; ];

View File

@@ -1,4 +1,5 @@
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import { import {
CMD_MUTE_CONVERSATION, CMD_MUTE_CONVERSATION,
@@ -26,10 +27,21 @@ export const OPEN_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION', section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_RESOLVE_CONVERSATION, icon: ICON_RESOLVE_CONVERSATION,
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION), handler: () => emitter.emit(CMD_RESOLVE_CONVERSATION),
}, },
]; ];
export const createSnoozeHandlers = (busEventName, parentId, section) => {
return Object.values(SNOOZE_OPTIONS).map(option => ({
id: option,
title: `COMMAND_BAR.COMMANDS.${option.toUpperCase()}`,
parent: parentId,
section: section,
icon: ICON_SNOOZE_CONVERSATION,
handler: () => emitter.emit(busEventName, option),
}));
};
export const SNOOZE_CONVERSATION_ACTIONS = [ export const SNOOZE_CONVERSATION_ACTIONS = [
{ {
id: 'snooze_conversation', id: 'snooze_conversation',
@@ -37,61 +49,11 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
icon: ICON_SNOOZE_CONVERSATION, icon: ICON_SNOOZE_CONVERSATION,
children: Object.values(SNOOZE_OPTIONS), children: Object.values(SNOOZE_OPTIONS),
}, },
...createSnoozeHandlers(
{ CMD_SNOOZE_CONVERSATION,
id: SNOOZE_OPTIONS.UNTIL_NEXT_REPLY, 'snooze_conversation',
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_REPLY', 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION'
parent: 'snooze_conversation', ),
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_REPLY),
},
{
id: SNOOZE_OPTIONS.AN_HOUR_FROM_NOW,
title: 'COMMAND_BAR.COMMANDS.AN_HOUR_FROM_NOW',
parent: 'snooze_conversation',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
},
{
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
title: 'COMMAND_BAR.COMMANDS.UNTIL_TOMORROW',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_WEEK',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
},
{
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
title: 'COMMAND_BAR.COMMANDS.UNTIL_NEXT_MONTH',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
},
{
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
section: 'COMMAND_BAR.SECTIONS.SNOOZE_CONVERSATION',
parent: 'snooze_conversation',
icon: ICON_SNOOZE_CONVERSATION,
handler: () =>
bus.$emit(CMD_SNOOZE_CONVERSATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
},
]; ];
export const RESOLVED_CONVERSATION_ACTIONS = [ export const RESOLVED_CONVERSATION_ACTIONS = [
@@ -100,7 +62,7 @@ export const RESOLVED_CONVERSATION_ACTIONS = [
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION', section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_REOPEN_CONVERSATION, icon: ICON_REOPEN_CONVERSATION,
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION), handler: () => emitter.emit(CMD_REOPEN_CONVERSATION),
}, },
]; ];
@@ -109,7 +71,7 @@ export const SEND_TRANSCRIPT_ACTION = {
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT', title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION', section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_SEND_TRANSCRIPT, icon: ICON_SEND_TRANSCRIPT,
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT), handler: () => emitter.emit(CMD_SEND_TRANSCRIPT),
}; };
export const UNMUTE_ACTION = { export const UNMUTE_ACTION = {
@@ -117,7 +79,7 @@ export const UNMUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION', section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_UNMUTE_CONVERSATION, icon: ICON_UNMUTE_CONVERSATION,
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION), handler: () => emitter.emit(CMD_UNMUTE_CONVERSATION),
}; };
export const MUTE_ACTION = { export const MUTE_ACTION = {
@@ -125,5 +87,5 @@ export const MUTE_ACTION = {
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION', title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
section: 'COMMAND_BAR.SECTIONS.CONVERSATION', section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
icon: ICON_MUTE_CONVERSATION, icon: ICON_MUTE_CONVERSATION,
handler: () => bus.$emit(CMD_MUTE_CONVERSATION), handler: () => emitter.emit(CMD_MUTE_CONVERSATION),
}; };

View File

@@ -6,11 +6,13 @@
hideBreadcrumbs hideBreadcrumbs
:placeholder="placeholder" :placeholder="placeholder"
@selected="onSelected" @selected="onSelected"
@closed="onClosed"
/> />
</template> </template>
<script> <script>
import 'ninja-keys'; import '@chatwoot/ninja-keys';
import wootConstants from 'dashboard/constants/globals';
import conversationHotKeysMixin from './conversationHotKeys'; import conversationHotKeysMixin from './conversationHotKeys';
import bulkActionsHotKeysMixin from './bulkActionsHotKeys'; import bulkActionsHotKeysMixin from './bulkActionsHotKeys';
import inboxHotKeysMixin from './inboxHotKeys'; import inboxHotKeysMixin from './inboxHotKeys';
@@ -34,6 +36,14 @@ export default {
appearanceHotKeys, appearanceHotKeys,
goToCommandHotKeys, goToCommandHotKeys,
], ],
data() {
return {
// Added selectedSnoozeType to track the selected snooze type
// So if the selected snooze type is "custom snooze" then we set selectedSnoozeType with the CMD action id
// So that we can track the selected snooze type and when we close the command bar
selectedSnoozeType: null,
};
},
computed: { computed: {
placeholder() { placeholder() {
return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER'); return this.$t('COMMAND_BAR.SEARCH_PLACEHOLDER');
@@ -67,14 +77,35 @@ export default {
this.$refs.ninjakeys.data = this.hotKeys; this.$refs.ninjakeys.data = this.hotKeys;
}, },
onSelected(item) { onSelected(item) {
const { detail: { action: { title = null, section = null } = {} } = {} } = const {
item; detail: {
action: { title = null, section = null, id = null } = {},
} = {},
} = item;
// Added this condition to prevent setting the selectedSnoozeType to null
// When we select the "custom snooze" (CMD bar will close and the custom snooze modal will open)
if (id === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.selectedSnoozeType =
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME;
} else {
this.selectedSnoozeType = null;
}
this.$track(GENERAL_EVENTS.COMMAND_BAR, { this.$track(GENERAL_EVENTS.COMMAND_BAR, {
section, section,
action: title, action: title,
}); });
this.setCommandbarData(); this.setCommandbarData();
}, },
onClosed() {
// If the selectedSnoozeType is not "SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME (custom snooze)" then we set the context menu chat id to null
// Else we do nothing and its handled in the ChatList.vue hideCustomSnoozeModal() method
if (
this.selectedSnoozeType !==
wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME
) {
this.$store.dispatch('setContextMenuChatId', null);
}
},
}, },
}; };
</script> </script>

View File

@@ -1,5 +1,6 @@
import { mapGetters } from 'vuex'; import { mapGetters } from 'vuex';
import wootConstants from 'dashboard/constants/globals'; import wootConstants from 'dashboard/constants/globals';
import { emitter } from 'shared/helpers/mitt';
import { CMD_AI_ASSIST } from './commandBarBusEvents'; import { CMD_AI_ASSIST } from './commandBarBusEvents';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants'; import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
@@ -55,11 +56,15 @@ export default {
replyMode() { replyMode() {
this.setCommandbarData(); this.setCommandbarData();
}, },
contextMenuChatId() {
this.setCommandbarData();
},
}, },
computed: { computed: {
...mapGetters({ ...mapGetters({
currentChat: 'getSelectedChat', currentChat: 'getSelectedChat',
replyMode: 'draftMessages/getReplyEditorMode', replyMode: 'draftMessages/getReplyEditorMode',
contextMenuChatId: 'getContextMenuChatId',
}), }),
draftMessage() { draftMessage() {
return this.$store.getters['draftMessages/get'](this.draftKey); return this.$store.getters['draftMessages/get'](this.draftKey);
@@ -93,6 +98,7 @@ export default {
} }
return this.prepareActions(actions); return this.prepareActions(actions);
}, },
priorityOptions() { priorityOptions() {
return [ return [
{ {
@@ -313,7 +319,7 @@ export default {
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'), section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
priority: item, priority: item,
icon: item.icon, icon: item.icon,
handler: () => bus.$emit(CMD_AI_ASSIST, item.key), handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
})); }));
return [ return [
{ {
@@ -327,25 +333,42 @@ export default {
]; ];
}, },
conversationHotKeys() { isConversationOrInboxRoute() {
if ( return (
isAConversationRoute(this.$route.name) || isAConversationRoute(this.$route.name) ||
isAInboxViewRoute(this.$route.name) isAInboxViewRoute(this.$route.name)
) { );
const defaultConversationHotKeys = [ },
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
}
shouldShowSnoozeOption() {
return (
isAConversationRoute(this.$route.name, true, false) &&
this.contextMenuChatId
);
},
getDefaultConversationHotKeys() {
const defaultConversationHotKeys = [
...this.statusActions,
...this.conversationAdditionalActions,
...this.assignAgentActions,
...this.assignTeamActions,
...this.labelActions,
...this.assignPriorityActions,
];
if (this.isAIIntegrationEnabled) {
return [...defaultConversationHotKeys, ...this.AIAssistActions];
}
return defaultConversationHotKeys;
},
conversationHotKeys() {
if (this.shouldShowSnoozeOption) {
return this.prepareActions(SNOOZE_CONVERSATION_ACTIONS);
}
if (this.isConversationOrInboxRoute) {
return this.getDefaultConversationHotKeys;
}
return []; return [];
}, },
}, },

View File

@@ -2,6 +2,7 @@ import wootConstants from 'dashboard/constants/globals';
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents'; import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons'; import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
import { emitter } from 'shared/helpers/mitt';
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers'; import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
@@ -21,7 +22,7 @@ const INBOX_SNOOZE_EVENTS = [
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION', section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
icon: ICON_SNOOZE_NOTIFICATION, icon: ICON_SNOOZE_NOTIFICATION,
handler: () => handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW), emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
}, },
{ {
id: SNOOZE_OPTIONS.UNTIL_TOMORROW, id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
@@ -30,7 +31,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification', parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION, icon: ICON_SNOOZE_NOTIFICATION,
handler: () => handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW), emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
}, },
{ {
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK, id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
@@ -39,7 +40,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification', parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION, icon: ICON_SNOOZE_NOTIFICATION,
handler: () => handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK), emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
}, },
{ {
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH, id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
@@ -48,7 +49,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification', parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION, icon: ICON_SNOOZE_NOTIFICATION,
handler: () => handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH), emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
}, },
{ {
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME, id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
@@ -57,7 +58,7 @@ const INBOX_SNOOZE_EVENTS = [
parent: 'snooze_notification', parent: 'snooze_notification',
icon: ICON_SNOOZE_NOTIFICATION, icon: ICON_SNOOZE_NOTIFICATION,
handler: () => handler: () =>
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME), emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
}, },
]; ];
export default { export default {

View File

@@ -145,7 +145,6 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
import draggable from 'vuedraggable'; import draggable from 'vuedraggable';
import uiSettingsMixin from 'dashboard/mixins/uiSettings'; import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import MacrosList from './Macros/List.vue'; import MacrosList from './Macros/List.vue';
export default { export default {
components: { components: {
AccordionItem, AccordionItem,

View File

@@ -19,12 +19,12 @@ const initiatedAt = computed(
() => props.conversationAttributes.initiated_at?.timestamp () => props.conversationAttributes.initiated_at?.timestamp
); );
const browserInfo = props.conversationAttributes.browser; const browserInfo = computed(() => props.conversationAttributes.browser);
const browserName = computed(() => { const browserName = computed(() => {
if (!browserInfo) return ''; if (!browserInfo.value) return '';
const { browser_name: name = '', browser_version: version = '' } = const { browser_name: name = '', browser_version: version = '' } =
browserInfo; browserInfo.value;
return `${name} ${version}`; return `${name} ${version}`;
}); });
@@ -33,9 +33,9 @@ const browserLanguage = computed(() =>
); );
const platformName = computed(() => { const platformName = computed(() => {
if (!browserInfo) return ''; if (!browserInfo.value) return '';
const { platform_name: name = '', platform_version: version = '' } = const { platform_name: name = '', platform_version: version = '' } =
browserInfo; browserInfo.value;
return `${name} ${version}`; return `${name} ${version}`;
}); });

View File

@@ -169,7 +169,7 @@ export default {
after: messageId, after: messageId,
}) })
.then(() => { .then(() => {
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId }); this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
}); });
} else { } else {
this.$store.dispatch('clearSelectedState'); this.$store.dispatch('clearSelectedState');

View File

@@ -271,7 +271,10 @@ export default {
}, },
toggleConversationModal() { toggleConversationModal() {
this.showConversationModal = !this.showConversationModal; this.showConversationModal = !this.showConversationModal;
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal); this.$emitter.emit(
BUS_EVENTS.NEW_CONVERSATION_MODAL,
this.showConversationModal
);
}, },
toggleDeleteModal() { toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal; this.showDeleteModal = !this.showDeleteModal;

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