Compare commits
65
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25cdb2d265 | ||
|
|
31bcdaa3dd | ||
|
|
6ae606c981 | ||
|
|
aaf47b4c1f | ||
|
|
cc4851b19d | ||
|
|
5520bf68f3 | ||
|
|
46621b0983 | ||
|
|
2c94c89077 | ||
|
|
97de283103 | ||
|
|
7ed7c1b618 | ||
|
|
8f3234cf4b | ||
|
|
b2de6843f6 | ||
|
|
73c4180e64 | ||
|
|
96f4f50d2d | ||
|
|
f7f687ce53 | ||
|
|
5716bb29b8 | ||
|
|
e72a343612 | ||
|
|
ee2844877c | ||
|
|
66c6b8cd4f | ||
|
|
ef606204a2 | ||
|
|
3dee50d2b1 | ||
|
|
1f09af270c | ||
|
|
6b0a5bea43 | ||
|
|
c5979c4575 | ||
|
|
9046730206 | ||
|
|
7968e98529 | ||
|
|
08516e6c43 | ||
|
|
5abf0e960a | ||
|
|
650fee58a6 | ||
|
|
9689bbf0dd | ||
|
|
20b2dc8c6f | ||
|
|
da4b75a3af | ||
|
|
576c58419c | ||
|
|
49c3d376de | ||
|
|
76efe3c5c3 | ||
|
|
75c83dd0d9 | ||
|
|
f452565d13 | ||
|
|
839f7fe719 | ||
|
|
c23e235cea | ||
|
|
88e4d9f06f | ||
|
|
bff19eb2e6 | ||
|
|
00ef9c475f | ||
|
|
af90f21cfd | ||
|
|
00da2ac847 | ||
|
|
f956530b36 | ||
|
|
803471e42f | ||
|
|
b0114dee42 | ||
|
|
ae35b47ae3 | ||
|
|
b474929f5e | ||
|
|
e3eca47c31 | ||
|
|
6c682a6869 | ||
|
|
b53ce3fa79 | ||
|
|
41e27e95b4 | ||
|
|
b31be37dc2 | ||
|
|
a55fffab3a | ||
|
|
59b912f22c | ||
|
|
eb6a343810 | ||
|
|
4a3376e912 | ||
|
|
7c5e67bf28 | ||
|
|
eafd3ae44d | ||
|
|
616e3a8092 | ||
|
|
f83af33b87 | ||
|
|
0c4c561313 | ||
|
|
9f625715ab | ||
|
|
35508feaae |
@@ -7,7 +7,7 @@ defaults: &defaults
|
||||
working_directory: ~/build
|
||||
docker:
|
||||
# specify the version you desire here
|
||||
- image: cimg/ruby:3.2.2-browsers
|
||||
- image: cimg/ruby:3.3.3-browsers
|
||||
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
|
||||
@@ -12,7 +12,7 @@ services:
|
||||
args:
|
||||
VARIANT: "ubuntu-22.04"
|
||||
NODE_VERSION: "20.9.0"
|
||||
RUBY_VERSION: "3.2.2"
|
||||
RUBY_VERSION: "3.3.3"
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
@@ -25,7 +25,7 @@ services:
|
||||
args:
|
||||
VARIANT: "ubuntu-22.04"
|
||||
NODE_VERSION: "20.9.0"
|
||||
RUBY_VERSION: "3.2.2"
|
||||
RUBY_VERSION: "3.3.3"
|
||||
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
|
||||
USER_UID: "1000"
|
||||
USER_GID: "1000"
|
||||
|
||||
+7
-6
@@ -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
|
||||
# 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
|
||||
|
||||
# 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
|
||||
# SMTP_TLS=
|
||||
# SMTP_SSL=
|
||||
# SMTP_OPEN_TIMEOUT
|
||||
# SMTP_READ_TIMEOUT
|
||||
|
||||
# Mail Incoming
|
||||
# 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=
|
||||
|
||||
|
||||
# MICROSOFT CLARITY
|
||||
# MS_CLARITY_TOKEN=xxxxxxxxx
|
||||
|
||||
# GOOGLE_TAG_MANAGER
|
||||
# GOOGLE_TAG = GTM-XXXXXXX
|
||||
|
||||
## Scout
|
||||
## https://scoutapm.com/docs/ruby/configuration
|
||||
# SCOUT_KEY=YOURKEY
|
||||
|
||||
@@ -24,10 +24,12 @@ jobs:
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
uses: docker/setup-qemu-action@v2
|
||||
with:
|
||||
platforms: 'arm64,amd64'
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
uses: docker/setup-buildx-action@v2
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
@@ -54,10 +56,11 @@ jobs:
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
platforms: linux/amd64
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.2.2
|
||||
3.3.3
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
source 'https://rubygems.org'
|
||||
|
||||
ruby '3.2.2'
|
||||
ruby '3.3.3'
|
||||
|
||||
##-- base gems for rails --##
|
||||
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
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -61,7 +61,7 @@ gem 'redis-namespace'
|
||||
gem 'activerecord-import'
|
||||
|
||||
##--- gems for server & infra configuration ---##
|
||||
gem 'dotenv-rails'
|
||||
gem 'dotenv-rails', '>= 3.0.0'
|
||||
gem 'foreman'
|
||||
gem 'puma'
|
||||
gem 'webpacker'
|
||||
@@ -111,18 +111,18 @@ gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.14.0', require: false
|
||||
gem 'sentry-rails', '>= 5.18.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.15.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.18.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.2.4'
|
||||
gem 'sidekiq', '>= 7.3.0'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
gem 'web-push'
|
||||
gem 'web-push', '>= 3.0.1'
|
||||
|
||||
##-- geocoding / parse location from ip --##
|
||||
# http://www.rubygeocoder.com/
|
||||
@@ -228,7 +228,7 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails'
|
||||
gem 'rspec-rails', '>= 6.1.3'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
|
||||
+120
-117
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actioncable (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionmailbox (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionmailer (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionpack (7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
rack (~> 2.0, >= 2.2.4)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actiontext (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
actionview (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
builder (~> 3.1)
|
||||
erubi (~> 1.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.1, >= 1.2.0)
|
||||
active_record_query_trace (1.8)
|
||||
activejob (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activejob (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activerecord (7.0.8.3)
|
||||
activemodel (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activemodel (7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activerecord (7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
activestorage (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.8.3)
|
||||
activesupport (7.0.8.4)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -150,13 +150,13 @@ GEM
|
||||
statsd-ruby (~> 1.1)
|
||||
base64 (0.2.0)
|
||||
bcrypt (3.1.20)
|
||||
bigdecimal (3.1.7)
|
||||
bigdecimal (3.1.8)
|
||||
bindex (0.8.1)
|
||||
bootsnap (1.16.0)
|
||||
msgpack (~> 1.2)
|
||||
brakeman (5.4.1)
|
||||
browser (5.3.1)
|
||||
builder (3.2.4)
|
||||
builder (3.3.0)
|
||||
bullet (7.0.7)
|
||||
activesupport (>= 3.0.0)
|
||||
uniform_notifier (~> 1.11)
|
||||
@@ -169,7 +169,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.2.3)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
@@ -183,13 +183,16 @@ GEM
|
||||
activerecord (>= 5.a)
|
||||
database_cleaner-core (~> 2.0.0)
|
||||
database_cleaner-core (2.0.1)
|
||||
date (3.3.4)
|
||||
ddtrace (1.11.1)
|
||||
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
|
||||
libdatadog (~> 2.0.0.1.0)
|
||||
libddwaf (~> 1.8.2.0.0)
|
||||
datadog-ci (0.8.3)
|
||||
msgpack
|
||||
debase-ruby_core_source (3.2.0)
|
||||
date (3.3.4)
|
||||
ddtrace (1.23.2)
|
||||
datadog-ci (~> 0.8.1)
|
||||
debase-ruby_core_source (= 3.3.1)
|
||||
libdatadog (~> 7.0.0.1.0)
|
||||
libddwaf (~> 1.14.0.0.0)
|
||||
msgpack
|
||||
debase-ruby_core_source (3.3.1)
|
||||
debug (1.8.0)
|
||||
irb (>= 1.5.0)
|
||||
reline (>= 0.3.1)
|
||||
@@ -204,16 +207,16 @@ GEM
|
||||
bcrypt (~> 3.0)
|
||||
devise (> 3.5.2, < 5)
|
||||
rails (>= 4.2.0, < 7.2)
|
||||
diff-lcs (1.5.0)
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.4)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (2.8.1)
|
||||
dotenv-rails (2.8.1)
|
||||
dotenv (= 2.8.1)
|
||||
railties (>= 3.2)
|
||||
dotenv (3.1.2)
|
||||
dotenv-rails (3.1.2)
|
||||
dotenv (= 3.1.2)
|
||||
railties (>= 6.1)
|
||||
down (5.4.0)
|
||||
addressable (~> 2.8)
|
||||
ecma-re-validator (0.4.0)
|
||||
@@ -223,7 +226,7 @@ GEM
|
||||
http (>= 3.0)
|
||||
ruby2_keywords
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.12.0)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.7)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
@@ -355,7 +358,6 @@ GEM
|
||||
hana (1.3.7)
|
||||
hashdiff (1.0.1)
|
||||
hashie (5.0.0)
|
||||
hkdf (1.0.0)
|
||||
http (5.1.1)
|
||||
addressable (~> 2.8)
|
||||
http-cookie (~> 1.0)
|
||||
@@ -417,15 +419,15 @@ GEM
|
||||
addressable (~> 2.8)
|
||||
letter_opener (1.8.1)
|
||||
launchy (>= 2.2, < 3)
|
||||
libdatadog (2.0.0.1.0)
|
||||
libdatadog (2.0.0.1.0-x86_64-linux)
|
||||
libddwaf (1.8.2.0.0)
|
||||
libdatadog (7.0.0.1.0)
|
||||
libdatadog (7.0.0.1.0-x86_64-linux)
|
||||
libddwaf (1.14.0.0.0)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.8.2.0.0-arm64-darwin)
|
||||
libddwaf (1.14.0.0.0-arm64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.8.2.0.0-x86_64-darwin)
|
||||
libddwaf (1.14.0.0.0-x86_64-darwin)
|
||||
ffi (~> 1.0)
|
||||
libddwaf (1.8.2.0.0-x86_64-linux)
|
||||
libddwaf (1.14.0.0.0-x86_64-linux)
|
||||
ffi (~> 1.0)
|
||||
line-bot-api (1.28.0)
|
||||
liquid (5.4.0)
|
||||
@@ -435,6 +437,7 @@ GEM
|
||||
llhttp-ffi (0.4.0)
|
||||
ffi-compiler (~> 1.0)
|
||||
rake (~> 13.0)
|
||||
logger (1.6.0)
|
||||
lograge (0.14.0)
|
||||
actionpack (>= 4)
|
||||
activesupport (>= 4)
|
||||
@@ -460,8 +463,8 @@ GEM
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.6)
|
||||
minitest (5.23.0)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.24.0)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -474,7 +477,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.11)
|
||||
net-imap (0.4.12)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -490,14 +493,14 @@ GEM
|
||||
newrelic_rpm (9.6.0)
|
||||
base64
|
||||
nio4r (2.7.3)
|
||||
nokogiri (1.16.5)
|
||||
nokogiri (1.16.6)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-arm64-darwin)
|
||||
nokogiri (1.16.6-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-darwin)
|
||||
nokogiri (1.16.6-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.16.5-x86_64-linux)
|
||||
nokogiri (1.16.6-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (1.1.0)
|
||||
oauth-tty (~> 1.0, >= 1.0.1)
|
||||
@@ -527,7 +530,7 @@ GEM
|
||||
omniauth-rails_csrf_protection (1.0.2)
|
||||
actionpack (>= 4.2)
|
||||
omniauth (~> 2.0)
|
||||
openssl (3.1.0)
|
||||
openssl (3.2.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
parallel (1.23.0)
|
||||
@@ -551,11 +554,11 @@ GEM
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
raabro (1.4.0)
|
||||
racc (1.7.3)
|
||||
racc (1.8.0)
|
||||
rack (2.2.9)
|
||||
rack-attack (6.7.0)
|
||||
rack (>= 1.0, < 4)
|
||||
rack-contrib (2.4.0)
|
||||
rack-contrib (2.5.0)
|
||||
rack (< 4)
|
||||
rack-cors (2.0.0)
|
||||
rack (>= 2.0.0)
|
||||
@@ -569,20 +572,20 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.8.3)
|
||||
actioncable (= 7.0.8.3)
|
||||
actionmailbox (= 7.0.8.3)
|
||||
actionmailer (= 7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
actiontext (= 7.0.8.3)
|
||||
actionview (= 7.0.8.3)
|
||||
activejob (= 7.0.8.3)
|
||||
activemodel (= 7.0.8.3)
|
||||
activerecord (= 7.0.8.3)
|
||||
activestorage (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
rails (7.0.8.4)
|
||||
actioncable (= 7.0.8.4)
|
||||
actionmailbox (= 7.0.8.4)
|
||||
actionmailer (= 7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
actiontext (= 7.0.8.4)
|
||||
actionview (= 7.0.8.4)
|
||||
activejob (= 7.0.8.4)
|
||||
activemodel (= 7.0.8.4)
|
||||
activerecord (= 7.0.8.4)
|
||||
activestorage (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.8.3)
|
||||
railties (= 7.0.8.4)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
@@ -590,9 +593,9 @@ GEM
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.8.3)
|
||||
actionpack (= 7.0.8.3)
|
||||
activesupport (= 7.0.8.3)
|
||||
railties (7.0.8.4)
|
||||
actionpack (= 7.0.8.4)
|
||||
activesupport (= 7.0.8.4)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -604,7 +607,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.22.1)
|
||||
redis-client (0.22.2)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -630,23 +633,23 @@ GEM
|
||||
nokogiri
|
||||
rexml (3.2.8)
|
||||
strscan (>= 3.0.9)
|
||||
rspec-core (3.12.2)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-expectations (3.12.3)
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-mocks (3.12.5)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.12.0)
|
||||
rspec-rails (6.0.2)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (6.1.3)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
rspec-core (~> 3.12)
|
||||
rspec-expectations (~> 3.12)
|
||||
rspec-mocks (~> 3.12)
|
||||
rspec-support (~> 3.12)
|
||||
rspec-support (3.12.0)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
rspec-support (~> 3.13)
|
||||
rspec-support (3.13.1)
|
||||
rspec_junit_formatter (0.6.0)
|
||||
rspec-core (>= 2, < 4, != 2.12.0)
|
||||
rubocop (1.50.2)
|
||||
@@ -704,23 +707,24 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.17.3)
|
||||
sentry-rails (5.18.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.17.3)
|
||||
sentry-ruby (5.17.3)
|
||||
sentry-ruby (~> 5.18.0)
|
||||
sentry-ruby (5.18.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.17.3)
|
||||
sentry-ruby (~> 5.17.3)
|
||||
sentry-sidekiq (5.18.0)
|
||||
sentry-ruby (~> 5.18.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.2.4)
|
||||
sidekiq (7.3.0)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.19.0)
|
||||
redis-client (>= 0.22.2)
|
||||
sidekiq-cron (1.12.0)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
@@ -800,8 +804,7 @@ GEM
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
railties (>= 6.0.0)
|
||||
web-push (3.0.0)
|
||||
hkdf (~> 1.0)
|
||||
web-push (3.0.1)
|
||||
jwt (~> 2.0)
|
||||
openssl (~> 3.0)
|
||||
webmock (3.18.1)
|
||||
@@ -821,7 +824,7 @@ GEM
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.14)
|
||||
zeitwerk (2.6.16)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -863,7 +866,7 @@ DEPENDENCIES
|
||||
devise (>= 4.9.4)
|
||||
devise-secure_password!
|
||||
devise_token_auth (>= 1.2.3)
|
||||
dotenv-rails
|
||||
dotenv-rails (>= 3.0.0)
|
||||
down
|
||||
elastic-apm
|
||||
email_reply_trimmer
|
||||
@@ -918,13 +921,13 @@ DEPENDENCIES
|
||||
rack-cors (= 2.0.0)
|
||||
rack-mini-profiler (>= 3.2.0)
|
||||
rack-timeout
|
||||
rails (~> 7.0.8.1)
|
||||
rails (~> 7.0.8.4)
|
||||
redis
|
||||
redis-namespace
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
rspec-rails
|
||||
rspec-rails (>= 6.1.3)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-performance
|
||||
@@ -933,11 +936,11 @@ DEPENDENCIES
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
sentry-rails (>= 5.14.0)
|
||||
sentry-rails (>= 5.18.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.15.0)
|
||||
sentry-sidekiq (>= 5.18.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.2.4)
|
||||
sidekiq (>= 7.3.0)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
@@ -955,14 +958,14 @@ DEPENDENCIES
|
||||
uglifier
|
||||
valid_email2
|
||||
web-console (>= 4.2.1)
|
||||
web-push
|
||||
web-push (>= 3.0.1)
|
||||
webmock
|
||||
webpacker
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.2.2p185
|
||||
ruby 3.3.3p89
|
||||
|
||||
BUNDLED WITH
|
||||
2.4.6
|
||||
2.5.14
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
3.3.1
|
||||
3.9.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.7.0
|
||||
2.8.0
|
||||
|
||||
@@ -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
|
||||
@@ -88,6 +88,6 @@ class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::Bas
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:team_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
|
||||
}
|
||||
)
|
||||
if redirect_url
|
||||
email = email.downcase
|
||||
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
|
||||
cache_key = "microsoft::#{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
|
||||
|
||||
@@ -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
|
||||
@@ -15,10 +15,6 @@ module MicrosoftConcern
|
||||
|
||||
private
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
|
||||
end
|
||||
|
||||
def base_url
|
||||
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class DashboardController < ActionController::Base
|
||||
|
||||
before_action :set_application_pack
|
||||
before_action :set_global_config
|
||||
before_action :set_dashboard_scripts
|
||||
around_action :switch_locale
|
||||
before_action :ensure_installation_onboarding, only: [:index]
|
||||
before_action :render_hc_if_custom_domain, only: [:index]
|
||||
@@ -35,6 +36,10 @@ class DashboardController < ActionController::Base
|
||||
).merge(app_config)
|
||||
end
|
||||
|
||||
def set_dashboard_scripts
|
||||
@dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS')
|
||||
end
|
||||
|
||||
def ensure_installation_onboarding
|
||||
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
|
||||
end
|
||||
@@ -70,4 +75,14 @@ class DashboardController < ActionController::Base
|
||||
'application'
|
||||
end
|
||||
end
|
||||
|
||||
def sensitive_path?
|
||||
# dont load dashboard scripts on sensitive paths like password reset
|
||||
sensitive_paths = [edit_user_password_path].freeze
|
||||
|
||||
# remove app prefix
|
||||
current_path = request.path.gsub(%r{^/app}, '')
|
||||
|
||||
sensitive_paths.include?(current_path)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -1,77 +1,17 @@
|
||||
class Microsoft::CallbacksController < ApplicationController
|
||||
class Microsoft::CallbacksController < OauthCallbackController
|
||||
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
|
||||
|
||||
def oauth_code
|
||||
params[:code]
|
||||
def oauth_client
|
||||
microsoft_client
|
||||
end
|
||||
|
||||
def users_data
|
||||
decoded_token = JWT.decode parsed_body[:id_token], nil, false
|
||||
decoded_token[0]
|
||||
def provider_name
|
||||
'microsoft'
|
||||
end
|
||||
|
||||
def parsed_body
|
||||
@parsed_body ||= @response.response.parsed
|
||||
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
|
||||
}
|
||||
})
|
||||
def imap_address
|
||||
'outlook.office365.com'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -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
|
||||
@@ -40,7 +40,7 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
|
||||
when 'email'
|
||||
['MAILER_INBOUND_EMAIL_DOMAIN']
|
||||
else
|
||||
%w[ENABLE_ACCOUNT_SIGNUP]
|
||||
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,6 +27,7 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import router from '../dashboard/routes';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
import ReconnectService from 'dashboard/helper/ReconnectService';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
@@ -64,6 +66,7 @@ export default {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
latestChatwootVersion: null,
|
||||
reconnectService: null,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -102,6 +105,11 @@ export default {
|
||||
this.listenToThemeChanges();
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.reconnectService) {
|
||||
this.reconnectService.disconnect();
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
initializeColorTheme() {
|
||||
setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||
@@ -125,6 +133,7 @@ export default {
|
||||
this.updateRTLDirectionView(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.$store, router);
|
||||
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
|
||||
@@ -9,6 +9,13 @@ class AccountAPI extends ApiClient {
|
||||
createAccount(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();
|
||||
|
||||
@@ -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();
|
||||
@@ -15,6 +15,7 @@ class ConversationApi extends ApiClient {
|
||||
teamId,
|
||||
conversationType,
|
||||
sortBy,
|
||||
updatedWithin,
|
||||
}) {
|
||||
return axios.get(this.url, {
|
||||
params: {
|
||||
@@ -26,6 +27,7 @@ class ConversationApi extends ApiClient {
|
||||
labels,
|
||||
conversation_type: conversationType,
|
||||
sort_by: sortBy,
|
||||
updated_within: updatedWithin,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -46,6 +46,7 @@ describe('#ConversationAPI', () => {
|
||||
page: 1,
|
||||
labels: [],
|
||||
teamId: 1,
|
||||
updatedWithin: 20,
|
||||
});
|
||||
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
|
||||
params: {
|
||||
@@ -55,6 +56,7 @@ describe('#ConversationAPI', () => {
|
||||
assignee_type: 'me',
|
||||
page: 1,
|
||||
labels: [],
|
||||
updated_within: 20,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<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')"
|
||||
>
|
||||
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
|
||||
|
||||
@@ -111,15 +111,6 @@
|
||||
@updateFolder="onUpdateSavedFilter"
|
||||
/>
|
||||
</woot-modal>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -152,10 +143,6 @@ import {
|
||||
isOnUnattendedView,
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
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';
|
||||
|
||||
export default {
|
||||
@@ -170,7 +157,6 @@ export default {
|
||||
ConversationBulkActions,
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [
|
||||
timeMixin,
|
||||
@@ -247,7 +233,6 @@ export default {
|
||||
root: this.$refs.conversationList,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
},
|
||||
showCustomSnoozeModal: false,
|
||||
|
||||
itemComponent: ConversationItem,
|
||||
// virtualListExtraProps is to pass the props to the conversationItem component.
|
||||
@@ -268,6 +253,7 @@ export default {
|
||||
chatLists: 'getAllConversations',
|
||||
mineChatsList: 'getMineChats',
|
||||
allChatList: 'getAllStatusChats',
|
||||
chatListFilters: 'getChatListFilters',
|
||||
unAssignedChatsList: 'getUnAssignedChats',
|
||||
chatListLoading: 'getChatListLoadingStatus',
|
||||
currentUserID: 'getCurrentUserID',
|
||||
@@ -282,7 +268,6 @@ export default {
|
||||
campaigns: 'campaigns/getAllCampaigns',
|
||||
labels: 'labels/getLabels',
|
||||
selectedConversations: 'bulkActions/getSelectedConversationIds',
|
||||
contextMenuChatId: 'getContextMenuChatId',
|
||||
}),
|
||||
hasAppliedFilters() {
|
||||
return this.appliedFilters.length !== 0;
|
||||
@@ -293,13 +278,6 @@ export default {
|
||||
hasAppliedFiltersOrActiveFolders() {
|
||||
return this.hasAppliedFilters || this.hasActiveFolders;
|
||||
},
|
||||
savedFoldersValue() {
|
||||
if (this.hasActiveFolders) {
|
||||
const payload = this.activeFolder.query;
|
||||
this.fetchSavedFilteredConversations(payload);
|
||||
}
|
||||
return {};
|
||||
},
|
||||
showEndOfListMessage() {
|
||||
return (
|
||||
this.conversationList.length &&
|
||||
@@ -375,7 +353,6 @@ export default {
|
||||
labels: this.label ? [this.label] : undefined,
|
||||
teamId: this.teamId || undefined,
|
||||
conversationType: this.conversationType || undefined,
|
||||
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
|
||||
};
|
||||
},
|
||||
conversationListPagination() {
|
||||
@@ -488,7 +465,13 @@ export default {
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('conversationType', this.conversationType);
|
||||
},
|
||||
activeFolder() {
|
||||
activeFolder(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.$store.dispatch(
|
||||
'customViews/setActiveConversationFolder',
|
||||
newVal || null
|
||||
);
|
||||
}
|
||||
this.resetAndFetchData();
|
||||
this.updateVirtualListProps('foldersId', this.foldersId);
|
||||
},
|
||||
@@ -498,8 +481,14 @@ export default {
|
||||
showAssigneeInConversationCard(newVal) {
|
||||
this.updateVirtualListProps('showAssignee', newVal);
|
||||
},
|
||||
conversationFilters(newVal, oldVal) {
|
||||
if (newVal !== oldVal) {
|
||||
this.$store.dispatch('updateChatListFilters', newVal);
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('setChatListFilters', this.conversationFilters);
|
||||
this.setFiltersFromUISettings();
|
||||
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
|
||||
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
|
||||
@@ -509,14 +498,9 @@ export default {
|
||||
this.$store.dispatch('campaigns/get');
|
||||
}
|
||||
|
||||
bus.$on('fetch_conversation_stats', () => {
|
||||
this.$emitter.on('fetch_conversation_stats', () => {
|
||||
this.$store.dispatch('conversationStats/get', this.conversationFilters);
|
||||
});
|
||||
|
||||
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
beforeDestroy() {
|
||||
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
methods: {
|
||||
updateVirtualListProps(key, value) {
|
||||
@@ -695,8 +679,9 @@ export default {
|
||||
this.fetchConversations();
|
||||
},
|
||||
fetchConversations() {
|
||||
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
|
||||
this.$store
|
||||
.dispatch('fetchAllConversations', this.conversationFilters)
|
||||
.dispatch('fetchAllConversations')
|
||||
.then(this.emitConversationLoaded);
|
||||
},
|
||||
loadMoreConversations() {
|
||||
@@ -736,7 +721,7 @@ export default {
|
||||
updateAssigneeTab(selectedTab) {
|
||||
if (this.activeAssigneeTab !== selectedTab) {
|
||||
this.resetBulkActions();
|
||||
bus.$emit('clearSearchInput');
|
||||
this.$emitter.emit('clearSearchInput');
|
||||
this.activeAssigneeTab = selectedTab;
|
||||
if (!this.currentPage) {
|
||||
this.fetchConversations();
|
||||
@@ -993,43 +978,6 @@ export default {
|
||||
onContextMenuToggle(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>
|
||||
|
||||
@@ -25,8 +25,10 @@
|
||||
<script>
|
||||
import 'highlight.js/styles/default.css';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
script: {
|
||||
type: String,
|
||||
@@ -59,7 +61,7 @@ export default {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.script);
|
||||
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
/>
|
||||
</span>
|
||||
<woot-button
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-if="showActions && value"
|
||||
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
|
||||
variant="link"
|
||||
size="medium"
|
||||
@@ -90,7 +90,7 @@
|
||||
</p>
|
||||
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
|
||||
<woot-button
|
||||
v-if="showCopyAndDeleteButton"
|
||||
v-if="showActions && value"
|
||||
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
|
||||
variant="link"
|
||||
size="small"
|
||||
@@ -100,7 +100,7 @@
|
||||
@click="onCopy"
|
||||
/>
|
||||
<woot-button
|
||||
v-if="showEditButton"
|
||||
v-if="showActions"
|
||||
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
|
||||
variant="link"
|
||||
size="small"
|
||||
@@ -174,12 +174,6 @@ export default {
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showCopyAndDeleteButton() {
|
||||
return this.value && this.showActions;
|
||||
},
|
||||
showEditButton() {
|
||||
return !this.value && this.showActions;
|
||||
},
|
||||
displayValue() {
|
||||
if (this.isAttributeTypeDate) {
|
||||
return this.value
|
||||
@@ -276,10 +270,10 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
this.editedValue = this.formattedValue;
|
||||
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
|
||||
},
|
||||
methods: {
|
||||
onFocusAttribute(focusAttributeKey) {
|
||||
|
||||
@@ -18,8 +18,10 @@
|
||||
<script>
|
||||
import 'highlight.js/styles/default.css';
|
||||
import { copyTextToClipboard } from 'shared/helpers/clipboard';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: String,
|
||||
@@ -35,7 +37,7 @@ export default {
|
||||
async onCopy(e) {
|
||||
e.preventDefault();
|
||||
await copyTextToClipboard(this.value);
|
||||
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
this.showAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
|
||||
},
|
||||
toggleMasked() {
|
||||
this.masked = !this.masked;
|
||||
|
||||
@@ -99,7 +99,9 @@ export default {
|
||||
onMouseUp() {
|
||||
if (this.mousedDownOnBackdrop) {
|
||||
this.mousedDownOnBackdrop = false;
|
||||
this.onClose();
|
||||
if (this.closeOnBackdropClick) {
|
||||
this.onClose();
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
<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
|
||||
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
|
||||
icon="wifi-off"
|
||||
:icon="iconName"
|
||||
class="text-yellow-700/50 dark:text-yellow-50"
|
||||
size="18"
|
||||
/>
|
||||
<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>
|
||||
<woot-button
|
||||
v-if="canRefresh"
|
||||
:title="$t('NETWORK.BUTTON.REFRESH')"
|
||||
variant="clear"
|
||||
size="small"
|
||||
color-scheme="warning"
|
||||
icon="arrow-clockwise"
|
||||
class="visible transition-all duration-500 ease-in-out ml-1"
|
||||
@click="refreshPage"
|
||||
/>
|
||||
<woot-button
|
||||
@@ -34,55 +141,3 @@
|
||||
</div>
|
||||
</transition>
|
||||
</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>
|
||||
|
||||
@@ -21,7 +21,7 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
onMenuItemClick() {
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
|
||||
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -15,11 +15,13 @@
|
||||
|
||||
<script>
|
||||
import WootSnackbar from './Snackbar.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootSnackbar,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
duration: {
|
||||
type: Number,
|
||||
@@ -34,10 +36,10 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
bus.$on('newToastMessage', this.onNewToastMessage);
|
||||
this.$emitter.on('newToastMessage', this.onNewToastMessage);
|
||||
},
|
||||
beforeDestroy() {
|
||||
bus.$off('newToastMessage', this.onNewToastMessage);
|
||||
this.$emitter.off('newToastMessage', this.onNewToastMessage);
|
||||
},
|
||||
methods: {
|
||||
onNewToastMessage(message, action) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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">
|
||||
<woot-button
|
||||
v-if="isOpen"
|
||||
@@ -128,12 +128,12 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
:teams="teams"
|
||||
:custom-views="customViews"
|
||||
:menu-config="activeSecondaryMenu"
|
||||
:current-role="currentRole"
|
||||
:current-user="currentUser"
|
||||
:is-on-chatwoot-cloud="isOnChatwootCloud"
|
||||
@add-label="showAddLabelPopup"
|
||||
@toggle-accounts="toggleAccountModal"
|
||||
@@ -37,7 +37,8 @@ import alertMixin from 'shared/mixins/alertMixin';
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import router from '../../routes';
|
||||
import router, { routesWithPermissions } from '../../routes';
|
||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -98,9 +99,13 @@ export default {
|
||||
return getSidebarItems(this.accountId);
|
||||
},
|
||||
primaryMenuItems() {
|
||||
const userPermissions = this.currentUser.permissions;
|
||||
const menuItems = this.sideMenuConfig.primaryMenu;
|
||||
return menuItems.filter(menuItem => {
|
||||
const isAvailableForTheUser = menuItem.roles.includes(this.currentRole);
|
||||
const isAvailableForTheUser = hasPermissions(
|
||||
routesWithPermissions[menuItem.toStateName],
|
||||
userPermissions
|
||||
);
|
||||
|
||||
if (!isAvailableForTheUser) {
|
||||
return false;
|
||||
|
||||
@@ -9,7 +9,6 @@ const primaryMenuItems = accountId => [
|
||||
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
|
||||
toState: frontendURL(`accounts/${accountId}/inbox-view`),
|
||||
toStateName: 'inbox_view',
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
{
|
||||
icon: 'chat',
|
||||
@@ -17,7 +16,6 @@ const primaryMenuItems = accountId => [
|
||||
label: 'CONVERSATIONS',
|
||||
toState: frontendURL(`accounts/${accountId}/dashboard`),
|
||||
toStateName: 'home',
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
{
|
||||
icon: 'book-contacts',
|
||||
@@ -26,7 +24,6 @@ const primaryMenuItems = accountId => [
|
||||
featureFlag: FEATURE_FLAGS.CRM,
|
||||
toState: frontendURL(`accounts/${accountId}/contacts`),
|
||||
toStateName: 'contacts_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
{
|
||||
icon: 'arrow-trending-lines',
|
||||
@@ -34,8 +31,7 @@ const primaryMenuItems = accountId => [
|
||||
label: 'REPORTS',
|
||||
featureFlag: FEATURE_FLAGS.REPORTS,
|
||||
toState: frontendURL(`accounts/${accountId}/reports`),
|
||||
toStateName: 'settings_account_reports',
|
||||
roles: ['administrator'],
|
||||
toStateName: 'account_overview_reports',
|
||||
},
|
||||
{
|
||||
icon: 'megaphone',
|
||||
@@ -44,7 +40,6 @@ const primaryMenuItems = accountId => [
|
||||
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
|
||||
toState: frontendURL(`accounts/${accountId}/campaigns`),
|
||||
toStateName: 'ongoing_campaigns',
|
||||
roles: ['administrator'],
|
||||
},
|
||||
{
|
||||
icon: 'library',
|
||||
@@ -54,7 +49,6 @@ const primaryMenuItems = accountId => [
|
||||
alwaysVisibleOnChatwootInstances: true,
|
||||
toState: frontendURL(`accounts/${accountId}/portals`),
|
||||
toStateName: 'default_portal_articles',
|
||||
roles: ['administrator'],
|
||||
},
|
||||
{
|
||||
icon: 'settings',
|
||||
@@ -62,7 +56,6 @@ const primaryMenuItems = accountId => [
|
||||
label: 'SETTINGS',
|
||||
toState: frontendURL(`accounts/${accountId}/settings`),
|
||||
toStateName: 'settings_home',
|
||||
roles: ['administrator', 'agent'],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -24,7 +24,6 @@ const settings = accountId => ({
|
||||
'settings_inbox_list',
|
||||
'settings_inbox_new',
|
||||
'settings_inbox_show',
|
||||
'settings_inbox',
|
||||
'settings_inboxes_add_agents',
|
||||
'settings_inboxes_page_channel',
|
||||
'settings_integrations_dashboard_apps',
|
||||
@@ -46,6 +45,9 @@ const settings = accountId => ({
|
||||
icon: 'briefcase',
|
||||
label: 'ACCOUNT_SETTINGS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/general`),
|
||||
toStateName: 'general_settings_index',
|
||||
},
|
||||
@@ -53,6 +55,9 @@ const settings = accountId => ({
|
||||
icon: 'people',
|
||||
label: 'AGENTS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
|
||||
toStateName: 'agent_list',
|
||||
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
|
||||
@@ -61,6 +66,9 @@ const settings = accountId => ({
|
||||
icon: 'people-team',
|
||||
label: 'TEAMS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
|
||||
toStateName: 'settings_teams_list',
|
||||
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
|
||||
@@ -69,6 +77,9 @@ const settings = accountId => ({
|
||||
icon: 'mail-inbox-all',
|
||||
label: 'INBOXES',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
|
||||
toStateName: 'settings_inbox_list',
|
||||
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
|
||||
@@ -77,6 +88,9 @@ const settings = accountId => ({
|
||||
icon: 'tag',
|
||||
label: 'LABELS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
|
||||
toStateName: 'labels_list',
|
||||
featureFlag: FEATURE_FLAGS.LABELS,
|
||||
@@ -85,6 +99,9 @@ const settings = accountId => ({
|
||||
icon: 'code',
|
||||
label: 'CUSTOM_ATTRIBUTES',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(
|
||||
`accounts/${accountId}/settings/custom-attributes/list`
|
||||
),
|
||||
@@ -95,6 +112,9 @@ const settings = accountId => ({
|
||||
icon: 'automation',
|
||||
label: 'AUTOMATION',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
|
||||
toStateName: 'automation_list',
|
||||
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
|
||||
@@ -103,6 +123,9 @@ const settings = accountId => ({
|
||||
icon: 'bot',
|
||||
label: 'AGENT_BOTS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
globalConfigFlag: 'csmlEditorHost',
|
||||
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
|
||||
toStateName: 'agent_bots',
|
||||
@@ -112,6 +135,9 @@ const settings = accountId => ({
|
||||
icon: 'flash-settings',
|
||||
label: 'MACROS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/macros`),
|
||||
toStateName: 'macros_wrapper',
|
||||
featureFlag: FEATURE_FLAGS.MACROS,
|
||||
@@ -120,6 +146,9 @@ const settings = accountId => ({
|
||||
icon: 'chat-multiple',
|
||||
label: 'CANNED_RESPONSES',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
toState: frontendURL(
|
||||
`accounts/${accountId}/settings/canned-response/list`
|
||||
),
|
||||
@@ -130,6 +159,9 @@ const settings = accountId => ({
|
||||
icon: 'flash-on',
|
||||
label: 'INTEGRATIONS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
|
||||
toStateName: 'settings_integrations',
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
@@ -138,6 +170,9 @@ const settings = accountId => ({
|
||||
icon: 'star-emphasis',
|
||||
label: 'APPLICATIONS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/applications`),
|
||||
toStateName: 'settings_applications',
|
||||
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
|
||||
@@ -146,6 +181,9 @@ const settings = accountId => ({
|
||||
icon: 'key',
|
||||
label: 'AUDIT_LOGS',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
||||
toStateName: 'auditlogs_list',
|
||||
isEnterpriseOnly: true,
|
||||
@@ -156,6 +194,9 @@ const settings = accountId => ({
|
||||
icon: 'document-list-clock',
|
||||
label: 'SLA',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
|
||||
toStateName: 'sla_list',
|
||||
isEnterpriseOnly: true,
|
||||
@@ -166,6 +207,9 @@ const settings = accountId => ({
|
||||
icon: 'credit-card-person',
|
||||
label: 'BILLING',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/billing`),
|
||||
toStateName: 'billing_settings_index',
|
||||
showOnlyOnCloud: true,
|
||||
|
||||
@@ -29,6 +29,8 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
|
||||
import AccountContext from './AccountContext.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { hasPermissions } from '../../../helper/permissionsHelper';
|
||||
import { routesWithPermissions } from '../../../routes';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -60,9 +62,9 @@ export default {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
currentRole: {
|
||||
type: String,
|
||||
default: '',
|
||||
currentUser: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
isOnChatwootCloud: {
|
||||
type: Boolean,
|
||||
@@ -80,16 +82,16 @@ export default {
|
||||
return this.customViews.filter(view => view.filter_type === 'contact');
|
||||
},
|
||||
accessibleMenuItems() {
|
||||
if (!this.currentRole) {
|
||||
return [];
|
||||
}
|
||||
const menuItemsFilteredByRole = this.menuConfig.menuItems.filter(
|
||||
menuItem =>
|
||||
window.roleWiseRoutes[this.currentRole].indexOf(
|
||||
menuItem.toStateName
|
||||
) > -1
|
||||
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
||||
menuItem => {
|
||||
const { permissions: userPermissions = [] } = this.currentUser;
|
||||
return hasPermissions(
|
||||
routesWithPermissions[menuItem.toStateName],
|
||||
userPermissions
|
||||
);
|
||||
}
|
||||
);
|
||||
return menuItemsFilteredByRole.filter(item => {
|
||||
return menuItemsFilteredByPermissions.filter(item => {
|
||||
if (item.showOnlyOnCloud) {
|
||||
return this.isOnChatwootCloud;
|
||||
}
|
||||
|
||||
+3
-3
@@ -65,11 +65,11 @@
|
||||
</div>
|
||||
<span
|
||||
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
|
||||
v-tooltip.top-end="$t('SIDEBAR.FACEBOOK_REAUTHORIZE')"
|
||||
class="text-xxs"
|
||||
v-tooltip.top-end="$t('SIDEBAR.REAUTHORIZE')"
|
||||
class="text-xxs text-red-500 dark:text-red-300"
|
||||
:icon="warningIcon"
|
||||
size="12"
|
||||
/>
|
||||
|
||||
@@ -65,27 +65,29 @@
|
||||
:show-child-count="showChildCount(child.count)"
|
||||
:child-item-count="child.count"
|
||||
/>
|
||||
<router-link
|
||||
v-if="showItem(menuItem)"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="menuItem.toState"
|
||||
custom
|
||||
>
|
||||
<li class="pl-1">
|
||||
<a :href="href">
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="add"
|
||||
:data-testid="menuItem.dataTestid"
|
||||
@click="e => newLinkClick(e, navigate)"
|
||||
>
|
||||
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
|
||||
</woot-button>
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
<Policy :permissions="['administrator']">
|
||||
<router-link
|
||||
v-if="menuItem.newLink"
|
||||
v-slot="{ href, navigate }"
|
||||
:to="menuItem.toState"
|
||||
custom
|
||||
>
|
||||
<li class="pl-1">
|
||||
<a :href="href">
|
||||
<woot-button
|
||||
size="tiny"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
icon="add"
|
||||
:data-testid="menuItem.dataTestid"
|
||||
@click="e => newLinkClick(e, navigate)"
|
||||
>
|
||||
{{ $t(`SIDEBAR.${menuItem.newLinkTag}`) }}
|
||||
</woot-button>
|
||||
</a>
|
||||
</li>
|
||||
</router-link>
|
||||
</Policy>
|
||||
</ul>
|
||||
</li>
|
||||
</template>
|
||||
@@ -105,9 +107,10 @@ import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
} from '../../../store/modules/conversations/helpers/actionHelpers';
|
||||
import Policy from '../../policy.vue';
|
||||
|
||||
export default {
|
||||
components: { SecondaryChildNavItem },
|
||||
components: { SecondaryChildNavItem, Policy },
|
||||
mixins: [adminMixin, configMixin],
|
||||
props: {
|
||||
menuItem: {
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
<script setup>
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { computed } from 'vue';
|
||||
import { hasPermissions } from '../helper/permissionsHelper';
|
||||
const props = defineProps({
|
||||
permissions: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const user = getters.getCurrentUser.value;
|
||||
const hasPermission = computed(() =>
|
||||
hasPermissions(props.permissions, user.permissions)
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="hasPermission">
|
||||
<slot />
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
setYear,
|
||||
isAfter,
|
||||
} from 'date-fns';
|
||||
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import DatePickerButton from './components/DatePickerButton.vue';
|
||||
import CalendarDateInput from './components/CalendarDateInput.vue';
|
||||
import CalendarDateRange from './components/CalendarDateRange.vue';
|
||||
@@ -185,7 +185,7 @@ const updateManualInput = (newDate, calendarType) => {
|
||||
};
|
||||
|
||||
const handleManualInputError = message => {
|
||||
bus.$emit('newToastMessage', message);
|
||||
useAlert(message);
|
||||
};
|
||||
|
||||
const resetDatePicker = () => {
|
||||
@@ -201,7 +201,7 @@ const resetDatePicker = () => {
|
||||
|
||||
const emitDateRange = () => {
|
||||
if (!isValid(selectedStartDate.value) || !isValid(selectedEndDate.value)) {
|
||||
bus.$emit('newToastMessage', 'Please select a valid time range');
|
||||
useAlert('Please select a valid time range');
|
||||
} else {
|
||||
showDatePicker.value = false;
|
||||
emit('dateRangeChanged', [selectedStartDate.value, selectedEndDate.value]);
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { picoSearch } from '@scmmishra/pico-search';
|
||||
import ListItemButton from './DropdownListItemButton.vue';
|
||||
import DropdownSearch from './DropdownSearch.vue';
|
||||
import DropdownEmptyState from './DropdownEmptyState.vue';
|
||||
import DropdownLoadingState from './DropdownLoadingState.vue';
|
||||
|
||||
const props = defineProps({
|
||||
listItems: {
|
||||
@@ -19,20 +21,31 @@ const props = defineProps({
|
||||
default: '',
|
||||
},
|
||||
activeFilterId: {
|
||||
type: Number,
|
||||
type: [String, Number],
|
||||
default: null,
|
||||
},
|
||||
showClearFilter: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isLoading: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
loadingPlaceholder: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emits = defineEmits(['on-search']);
|
||||
|
||||
const searchTerm = ref('');
|
||||
|
||||
const onSearch = value => {
|
||||
const onSearch = debounce(value => {
|
||||
searchTerm.value = value;
|
||||
};
|
||||
emits('on-search', value);
|
||||
}, 300);
|
||||
|
||||
const filteredListItems = computed(() => {
|
||||
if (!searchTerm.value) return props.listItems;
|
||||
@@ -47,6 +60,16 @@ const isFilterActive = id => {
|
||||
if (!props.activeFilterId) return false;
|
||||
return id === props.activeFilterId;
|
||||
};
|
||||
|
||||
const shouldShowLoadingState = computed(() => {
|
||||
return (
|
||||
props.isLoading && isDropdownListEmpty.value && props.loadingPlaceholder
|
||||
);
|
||||
});
|
||||
|
||||
const shouldShowEmptyState = computed(() => {
|
||||
return !props.isLoading && isDropdownListEmpty.value;
|
||||
});
|
||||
</script>
|
||||
<template>
|
||||
<div
|
||||
@@ -55,7 +78,7 @@ const isFilterActive = id => {
|
||||
>
|
||||
<slot name="search">
|
||||
<dropdown-search
|
||||
v-if="enableSearch && listItems.length"
|
||||
v-if="enableSearch"
|
||||
:input-value="searchTerm"
|
||||
:input-placeholder="inputPlaceholder"
|
||||
:show-clear-filter="showClearFilter"
|
||||
@@ -64,8 +87,12 @@ const isFilterActive = id => {
|
||||
/>
|
||||
</slot>
|
||||
<slot name="listItem">
|
||||
<dropdown-loading-state
|
||||
v-if="shouldShowLoadingState"
|
||||
:message="loadingPlaceholder"
|
||||
/>
|
||||
<dropdown-empty-state
|
||||
v-if="isDropdownListEmpty"
|
||||
v-else-if="shouldShowEmptyState"
|
||||
:message="$t('REPORT.FILTER_ACTIONS.EMPTY_LIST')"
|
||||
/>
|
||||
<list-item-button
|
||||
|
||||
@@ -13,7 +13,7 @@ defineProps({
|
||||
<template>
|
||||
<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"
|
||||
@click.stop="$emit('click')"
|
||||
@click.stop.prevent="$emit('click')"
|
||||
@mouseenter="$emit('mouseenter')"
|
||||
@mouseleave="$emit('mouseleave')"
|
||||
@focus="$emit('focus')"
|
||||
|
||||
@@ -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>
|
||||
@@ -81,7 +81,7 @@ export default {
|
||||
},
|
||||
|
||||
mounted() {
|
||||
bus.$on(CMD_AI_ASSIST, this.onAIAssist);
|
||||
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
|
||||
this.initialMessage = this.draftMessage;
|
||||
},
|
||||
|
||||
|
||||
@@ -352,10 +352,16 @@ export default {
|
||||
// Components using this
|
||||
// 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() {
|
||||
bus.$off(BUS_EVENTS.INSERT_INTO_RICH_EDITOR, this.insertContentIntoEditor);
|
||||
this.$emitter.off(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
this.insertContentIntoEditor
|
||||
);
|
||||
},
|
||||
methods: {
|
||||
reloadState(content = this.value) {
|
||||
|
||||
@@ -71,6 +71,10 @@
|
||||
:class="{ 'justify-end': isContactPanelOpen }"
|
||||
>
|
||||
<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" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -89,6 +93,8 @@ import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { snoozedReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import Linear from './linear/index.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -97,6 +103,7 @@ export default {
|
||||
MoreActions,
|
||||
Thumbnail,
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
@@ -121,6 +128,9 @@ export default {
|
||||
...mapGetters({
|
||||
uiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
currentChat: 'getSelectedChat',
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
appIntegrations: 'integrations/getAppIntegrations',
|
||||
}),
|
||||
chatMetadata() {
|
||||
return this.chat.meta;
|
||||
@@ -178,6 +188,17 @@ export default {
|
||||
hasSlaPolicyId() {
|
||||
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: {
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<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
|
||||
v-if="isFailed && !hasOneDayPassed && !isAnEmailInbox"
|
||||
@@ -121,7 +125,10 @@
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
|
||||
<div
|
||||
v-if="shouldShowContextMenu"
|
||||
class="context-menu-wrap invisible group-hover:visible"
|
||||
>
|
||||
<context-menu
|
||||
v-if="isBubble && !isMessageDeleted"
|
||||
:context-menu-position="contextMenuPosition"
|
||||
@@ -250,7 +257,16 @@ export default {
|
||||
html_content: { full: fullHTMLContent } = {},
|
||||
text_content: { full: fullTextContent } = {},
|
||||
} = this.contentAttributes.email || {};
|
||||
return fullHTMLContent || fullTextContent || '';
|
||||
|
||||
if (fullHTMLContent) {
|
||||
return fullHTMLContent;
|
||||
}
|
||||
|
||||
if (fullTextContent) {
|
||||
return fullTextContent.replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
displayQuotedButton() {
|
||||
if (this.emailMessageContent.includes('<blockquote')) {
|
||||
@@ -473,11 +489,11 @@ export default {
|
||||
},
|
||||
mounted() {
|
||||
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();
|
||||
},
|
||||
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);
|
||||
},
|
||||
methods: {
|
||||
@@ -531,7 +547,7 @@ export default {
|
||||
const { conversation_id: conversationId, id: replyTo } = this.data;
|
||||
|
||||
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() {
|
||||
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
|
||||
|
||||
@@ -324,12 +324,12 @@ export default {
|
||||
},
|
||||
|
||||
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
|
||||
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,
|
||||
// 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;
|
||||
});
|
||||
},
|
||||
@@ -396,7 +396,7 @@ export default {
|
||||
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
||||
},
|
||||
removeBusListeners() {
|
||||
bus.$off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
this.$emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
},
|
||||
onScrollToMessage({ messageId = '' } = {}) {
|
||||
this.$nextTick(() => {
|
||||
@@ -514,7 +514,7 @@ export default {
|
||||
} else {
|
||||
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);
|
||||
},
|
||||
|
||||
|
||||
@@ -61,14 +61,14 @@ export default {
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_MUTE_CONVERSATION, this.mute);
|
||||
bus.$on(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
bus.$on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
this.$emitter.on(CMD_MUTE_CONVERSATION, this.mute);
|
||||
this.$emitter.on(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
this.$emitter.on(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_MUTE_CONVERSATION, this.mute);
|
||||
bus.$off(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
bus.$off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
this.$emitter.off(CMD_MUTE_CONVERSATION, this.mute);
|
||||
this.$emitter.off(CMD_UNMUTE_CONVERSATION, this.unmute);
|
||||
this.$emitter.off(CMD_SEND_TRANSCRIPT, this.toggleEmailActionsModal);
|
||||
},
|
||||
methods: {
|
||||
mute() {
|
||||
|
||||
@@ -596,12 +596,15 @@ export default {
|
||||
);
|
||||
|
||||
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
|
||||
// Is showing on top of new conversation modal drag and drop
|
||||
// TODO need to find a better solution
|
||||
bus.$on(
|
||||
this.$emitter.on(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
this.onNewConversationModalActive
|
||||
);
|
||||
@@ -609,10 +612,13 @@ export default {
|
||||
destroyed() {
|
||||
document.removeEventListener('paste', this.onPaste);
|
||||
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() {
|
||||
bus.$off(
|
||||
this.$emitter.off(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
this.onNewConversationModalActive
|
||||
);
|
||||
@@ -625,7 +631,7 @@ export default {
|
||||
const lines = title.split('\n');
|
||||
const nonEmptyLines = lines.filter(line => line.trim() !== '');
|
||||
const filteredMarkdown = nonEmptyLines.join(' ');
|
||||
bus.$emit(
|
||||
this.$emitter.emit(
|
||||
BUS_EVENTS.INSERT_INTO_RICH_EDITOR,
|
||||
`[${filteredMarkdown}](${url})`
|
||||
);
|
||||
@@ -867,8 +873,8 @@ export default {
|
||||
'createPendingMessageAndSend',
|
||||
messagePayload
|
||||
);
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
bus.$emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
this.$emitter.emit(BUS_EVENTS.MESSAGE_SENT);
|
||||
this.removeFromDraft();
|
||||
this.sendMessageAnalyticsData(messagePayload.private);
|
||||
} catch (error) {
|
||||
@@ -1194,7 +1200,7 @@ export default {
|
||||
resetReplyToMessage() {
|
||||
const replyStorageKey = LOCAL_STORAGE_KEYS.MESSAGE_REPLY_TO;
|
||||
LocalStorage.deleteFromJsonStore(replyStorageKey, this.conversationId);
|
||||
bus.$emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
||||
this.$emitter.emit(BUS_EVENTS.TOGGLE_REPLY_TO_MESSAGE);
|
||||
},
|
||||
onNewConversationModalActive(isActive) {
|
||||
// Issue is if the new conversation modal is open and we drag and drop the file
|
||||
|
||||
@@ -47,7 +47,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
scrollToMessage() {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId: this.message.id });
|
||||
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, {
|
||||
messageId: this.message.id,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ const toggleShowAllNRT = () => {
|
||||
</script>
|
||||
<template>
|
||||
<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">
|
||||
{{ $t('SLA.EVENTS.TITLE') }}
|
||||
|
||||
+18
-6
@@ -167,17 +167,29 @@ export default {
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
bus.$on(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$on(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
|
||||
bus.$on(
|
||||
this.$emitter.on(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
this.$emitter.on(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
this.$emitter.on(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
},
|
||||
destroyed() {
|
||||
bus.$off(CMD_BULK_ACTION_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
bus.$off(CMD_BULK_ACTION_REOPEN_CONVERSATION, this.onCmdReopenConversation);
|
||||
bus.$off(
|
||||
this.$emitter.off(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
this.onCmdSnoozeConversation
|
||||
);
|
||||
this.$emitter.off(
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
this.onCmdReopenConversation
|
||||
);
|
||||
this.$emitter.off(
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
this.onCmdResolveConversation
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
+73
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,10 @@
|
||||
import { required } from '@vuelidate/validators';
|
||||
|
||||
export default {
|
||||
title: {
|
||||
required,
|
||||
},
|
||||
teamId: {
|
||||
required,
|
||||
},
|
||||
};
|
||||
@@ -2,12 +2,21 @@ import { createLocalVue, mount } from '@vue/test-utils';
|
||||
import Vuex from 'vuex';
|
||||
import VueI18n from 'vue-i18n';
|
||||
import VTooltip from 'v-tooltip';
|
||||
|
||||
import Button from 'dashboard/components/buttons/Button';
|
||||
import i18n from 'dashboard/i18n';
|
||||
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
|
||||
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();
|
||||
localVue.use(Vuex);
|
||||
localVue.use(VueI18n);
|
||||
@@ -16,6 +25,12 @@ localVue.use(VTooltip);
|
||||
localVue.component('fluent-icon', FluentIcon);
|
||||
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 });
|
||||
|
||||
describe('MoveActions', () => {
|
||||
@@ -29,12 +44,6 @@ describe('MoveActions', () => {
|
||||
let moreActions = null;
|
||||
|
||||
beforeEach(() => {
|
||||
window.bus = {
|
||||
$emit: jest.fn(),
|
||||
$on: jest.fn(),
|
||||
$off: jest.fn(),
|
||||
};
|
||||
|
||||
state = {
|
||||
authenticated: true,
|
||||
currentChat,
|
||||
@@ -76,7 +85,7 @@ describe('MoveActions', () => {
|
||||
it('shows alert', async () => {
|
||||
await moreActions.find('button:first-child').trigger('click');
|
||||
|
||||
expect(window.bus.$emit).toBeCalledWith(
|
||||
expect(emitter.emit).toBeCalledWith(
|
||||
'newToastMessage',
|
||||
'This contact is blocked successfully. You will not be notified of any future conversations.',
|
||||
undefined
|
||||
@@ -102,7 +111,7 @@ describe('MoveActions', () => {
|
||||
it('shows alert', async () => {
|
||||
await moreActions.find('button:first-child').trigger('click');
|
||||
|
||||
expect(window.bus.$emit).toBeCalledWith(
|
||||
expect(emitter.emit).toBeCalledWith(
|
||||
'newToastMessage',
|
||||
'This contact is unblocked successfully.',
|
||||
undefined
|
||||
|
||||
@@ -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 };
|
||||
@@ -1,4 +1,5 @@
|
||||
import { getCurrentInstance } from 'vue';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export const useTrack = () => {
|
||||
const vm = getCurrentInstance();
|
||||
@@ -8,5 +9,5 @@ export const useTrack = () => {
|
||||
};
|
||||
|
||||
export function useAlert(message, action) {
|
||||
bus.$emit('newToastMessage', message, action);
|
||||
emitter.emit('newToastMessage', message, action);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -30,4 +30,5 @@ export const FEATURE_FLAGS = {
|
||||
EMAIL_CONTINUITY_ON_API_CHANNEL: 'email_continuity_on_api_channel',
|
||||
INBOUND_EMAILS: 'inbound_emails',
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
LINEAR: 'linear_integration',
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -1,6 +1,8 @@
|
||||
import AuthAPI from '../api/auth';
|
||||
import BaseActionCableConnector from '../../shared/helpers/BaseActionCableConnector';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
class ActionCableConnector extends BaseActionCableConnector {
|
||||
constructor(app, pubsubToken) {
|
||||
@@ -32,34 +34,14 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
};
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onReconnect = () => {
|
||||
this.syncActiveConversationMessages();
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_RECONNECT);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onDisconnected = () => {
|
||||
this.setActiveConversationLastMessageId();
|
||||
};
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
emitter.emit(BUS_EVENTS.WEBSOCKET_DISCONNECT);
|
||||
};
|
||||
|
||||
isAValidEvent = data => {
|
||||
@@ -177,8 +159,8 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
fetchConversationStats = () => {
|
||||
bus.$emit('fetch_conversation_stats');
|
||||
bus.$emit('fetch_overview_reports');
|
||||
emitter.emit('fetch_conversation_stats');
|
||||
emitter.emit('fetch_overview_reports');
|
||||
};
|
||||
|
||||
onContactDelete = data => {
|
||||
@@ -207,7 +189,7 @@ class ActionCableConnector extends BaseActionCableConnector {
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
onFirstReplyCreated = () => {
|
||||
bus.$emit('fetch_overview_reports');
|
||||
emitter.emit('fetch_overview_reports');
|
||||
};
|
||||
|
||||
onCacheInvalidate = data => {
|
||||
|
||||
@@ -121,7 +121,7 @@ export const generateConditionOptions = (options, key = 'id') => {
|
||||
};
|
||||
|
||||
// Add the "None" option to the agent list
|
||||
export const agentList = agents => [
|
||||
export const addNoneToList = agents => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
@@ -137,8 +137,8 @@ export const getActionOptions = ({
|
||||
type,
|
||||
}) => {
|
||||
const actionsMap = {
|
||||
assign_agent: agentList(agents),
|
||||
assign_team: teams,
|
||||
assign_agent: addNoneToList(agents),
|
||||
assign_team: addNoneToList(teams),
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
remove_label: generateConditionOptions(labels, 'title'),
|
||||
|
||||
@@ -87,7 +87,8 @@ export const getInboxClassByType = (type, phoneNumber) => {
|
||||
};
|
||||
|
||||
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 '';
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export const hasPermissions = (
|
||||
requiredPermissions = [],
|
||||
availablePermissions = []
|
||||
) => {
|
||||
return requiredPermissions.some(permission =>
|
||||
availablePermissions.includes(permission)
|
||||
);
|
||||
};
|
||||
|
||||
const isPermissionsPresentInRoute = route =>
|
||||
route.meta && route.meta.permissions;
|
||||
|
||||
export const buildPermissionsFromRouter = (routes = []) =>
|
||||
routes.reduce((acc, route) => {
|
||||
if (route.name) {
|
||||
if (!isPermissionsPresentInRoute(route)) {
|
||||
// eslint-disable-next-line
|
||||
console.error(route);
|
||||
throw new Error(
|
||||
"The route doesn't have the required permissions defined"
|
||||
);
|
||||
}
|
||||
acc[route.name] = route.meta.permissions;
|
||||
}
|
||||
|
||||
if (route.children) {
|
||||
acc = {
|
||||
...acc,
|
||||
...buildPermissionsFromRouter(route.children),
|
||||
};
|
||||
}
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
@@ -1,6 +1,7 @@
|
||||
/* eslint-disable no-console */
|
||||
import NotificationSubscriptions from '../api/notificationSubscription';
|
||||
import auth from '../api/auth';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export const verifyServiceWorkerExistence = (callback = () => {}) => {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
@@ -68,7 +69,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
onSuccess();
|
||||
})
|
||||
.catch(() => {
|
||||
window.bus.$emit(
|
||||
emitter.emit(
|
||||
'newToastMessage',
|
||||
'This browser does not support desktop notification'
|
||||
);
|
||||
@@ -77,7 +78,7 @@ export const registerSubscription = (onSuccess = () => {}) => {
|
||||
|
||||
export const requestPushPermissions = ({ onSuccess }) => {
|
||||
if (!('Notification' in window)) {
|
||||
window.bus.$emit(
|
||||
emitter.emit(
|
||||
'newToastMessage',
|
||||
'This browser does not support desktop notification'
|
||||
);
|
||||
|
||||
@@ -1,19 +1,16 @@
|
||||
import { hasPermissions } from './permissionsHelper';
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
|
||||
return accounts.find(account => account.id === accountId);
|
||||
};
|
||||
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getUserRole = ({ accounts } = {}, accountId) => {
|
||||
const currentAccount = getCurrentAccount({ accounts }, accountId) || {};
|
||||
return currentAccount.role || null;
|
||||
export const routeIsAccessibleFor = (route, userPermissions = []) => {
|
||||
const { meta: { permissions: routePermissions = [] } = {} } = route;
|
||||
return hasPermissions(routePermissions, userPermissions);
|
||||
};
|
||||
|
||||
export const routeIsAccessibleFor = (route, role, roleWiseRoutes) => {
|
||||
return roleWiseRoutes[role].includes(route);
|
||||
};
|
||||
|
||||
const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
|
||||
const validateActiveAccountRoutes = (to, user) => {
|
||||
// If the current account is active, then check for the route permissions
|
||||
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
|
||||
|
||||
@@ -22,15 +19,13 @@ const validateActiveAccountRoutes = (to, user, roleWiseRoutes) => {
|
||||
return accountDashboardURL;
|
||||
}
|
||||
|
||||
const userRole = getUserRole(user, Number(to.params.accountId));
|
||||
const isAccessible = routeIsAccessibleFor(to.name, userRole, roleWiseRoutes);
|
||||
const isAccessible = routeIsAccessibleFor(to, user.permissions);
|
||||
// If the route is not accessible for the user, return to dashboard screen
|
||||
return isAccessible ? null : accountDashboardURL;
|
||||
};
|
||||
|
||||
export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
export const validateLoggedInRoutes = (to, user) => {
|
||||
const currentAccount = getCurrentAccount(user, Number(to.params.accountId));
|
||||
|
||||
// If current account is missing, either user does not have
|
||||
// access to the account or the account is deleted, return to login screen
|
||||
if (!currentAccount) {
|
||||
@@ -40,7 +35,7 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
const isCurrentAccountActive = currentAccount.status === 'active';
|
||||
|
||||
if (isCurrentAccountActive) {
|
||||
return validateActiveAccountRoutes(to, user, roleWiseRoutes);
|
||||
return validateActiveAccountRoutes(to, user);
|
||||
}
|
||||
|
||||
// If the current account is not active, then redirect the user to the suspended screen
|
||||
@@ -109,5 +104,14 @@ export const getConversationDashboardRoute = routeName => {
|
||||
}
|
||||
};
|
||||
|
||||
export const isAInboxViewRoute = routeName =>
|
||||
['inbox_view_conversation'].includes(routeName);
|
||||
export const isAInboxViewRoute = (routeName, includeBase = false) => {
|
||||
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';
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AnalyticsHelper from './AnalyticsHelper';
|
||||
import DashboardAudioNotificationHelper from './AudioAlerts/DashboardAudioNotificationHelper';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
export const CHATWOOT_SET_USER = 'CHATWOOT_SET_USER';
|
||||
export const CHATWOOT_RESET = 'CHATWOOT_RESET';
|
||||
@@ -8,7 +9,7 @@ export const ANALYTICS_IDENTITY = 'ANALYTICS_IDENTITY';
|
||||
export const ANALYTICS_RESET = 'ANALYTICS_RESET';
|
||||
|
||||
export const initializeAnalyticsEvents = () => {
|
||||
window.bus.$on(ANALYTICS_IDENTITY, ({ user }) => {
|
||||
emitter.on(ANALYTICS_IDENTITY, ({ user }) => {
|
||||
AnalyticsHelper.identify(user);
|
||||
});
|
||||
};
|
||||
@@ -34,12 +35,12 @@ const initializeAudioAlerts = user => {
|
||||
};
|
||||
|
||||
export const initializeChatwootEvents = () => {
|
||||
window.bus.$on(CHATWOOT_RESET, () => {
|
||||
emitter.on(CHATWOOT_RESET, () => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.reset();
|
||||
}
|
||||
});
|
||||
window.bus.$on(CHATWOOT_SET_USER, ({ user }) => {
|
||||
emitter.on(CHATWOOT_SET_USER, ({ user }) => {
|
||||
if (window.$chatwoot) {
|
||||
window.$chatwoot.setUser(user.email, {
|
||||
avatar_url: user.avatar_url,
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
import {
|
||||
buildPermissionsFromRouter,
|
||||
hasPermissions,
|
||||
} from '../permissionsHelper';
|
||||
|
||||
describe('hasPermissions', () => {
|
||||
it('returns true if permission is present', () => {
|
||||
expect(
|
||||
hasPermissions(['contact_manage'], ['team_manage', 'contact_manage'])
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true if permission is not present', () => {
|
||||
expect(
|
||||
hasPermissions(['contact_manage'], ['team_manage', 'user_manage'])
|
||||
).toBe(false);
|
||||
expect(hasPermissions()).toBe(false);
|
||||
expect(hasPermissions([])).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPermissionsFromRouter', () => {
|
||||
it('returns a valid object when routes have permissions defined', () => {
|
||||
expect(
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: { permissions: ['agent_admin'] },
|
||||
},
|
||||
{
|
||||
path: 'inbox',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
name: 'inbox_list',
|
||||
meta: { permissions: ['inbox_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: 'conversations',
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
children: [
|
||||
{
|
||||
path: 'attachments',
|
||||
name: 'attachments_list',
|
||||
meta: { permissions: ['conversation_admin'] },
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
])
|
||||
).toEqual({
|
||||
agent_list: ['agent_admin'],
|
||||
inbox_list: ['inbox_admin'],
|
||||
attachments_list: ['conversation_admin'],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws an error if a named routed does not have permissions defined', () => {
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
|
||||
expect(() => {
|
||||
buildPermissionsFromRouter([
|
||||
{
|
||||
path: 'agent',
|
||||
name: 'agent_list',
|
||||
meta: {},
|
||||
},
|
||||
]);
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
getConversationDashboardRoute,
|
||||
getCurrentAccount,
|
||||
getUserRole,
|
||||
isAConversationRoute,
|
||||
routeIsAccessibleFor,
|
||||
validateLoggedInRoutes,
|
||||
@@ -15,24 +14,11 @@ describe('#getCurrentAccount', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getUserRole', () => {
|
||||
it('should return the current role', () => {
|
||||
expect(
|
||||
getUserRole({ accounts: [{ id: 1, role: 'administrator' }] }, 1)
|
||||
).toEqual('administrator');
|
||||
expect(getUserRole({ accounts: [] }, 1)).toEqual(null);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#routeIsAccessibleFor', () => {
|
||||
it('should return the correct access', () => {
|
||||
const roleWiseRoutes = { agent: ['conversations'], admin: ['billing'] };
|
||||
expect(routeIsAccessibleFor('billing', 'agent', roleWiseRoutes)).toEqual(
|
||||
false
|
||||
);
|
||||
expect(routeIsAccessibleFor('billing', 'admin', roleWiseRoutes)).toEqual(
|
||||
true
|
||||
);
|
||||
let route = { meta: { permissions: ['administrator'] } };
|
||||
expect(routeIsAccessibleFor(route, ['agent'])).toEqual(false);
|
||||
expect(routeIsAccessibleFor(route, ['administrator'])).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -40,11 +26,7 @@ describe('#validateLoggedInRoutes', () => {
|
||||
describe('when account access is missing', () => {
|
||||
it('should return the login route', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ params: { accountId: 1 } },
|
||||
{ accounts: [] },
|
||||
{}
|
||||
)
|
||||
validateLoggedInRoutes({ params: { accountId: 1 } }, { accounts: [] })
|
||||
).toEqual(`app/login`);
|
||||
});
|
||||
});
|
||||
@@ -53,9 +35,12 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('return suspended route', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'suspended' }] },
|
||||
{ agent: ['conversations'] }
|
||||
{
|
||||
name: 'conversations',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['agent'] },
|
||||
},
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'suspended' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/suspended`);
|
||||
});
|
||||
@@ -65,9 +50,22 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('returns null (no action required)', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ agent: ['conversations'] }
|
||||
{
|
||||
name: 'conversations',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['agent'] },
|
||||
},
|
||||
{
|
||||
permissions: ['agent'],
|
||||
accounts: [
|
||||
{
|
||||
id: 1,
|
||||
role: 'agent',
|
||||
permissions: ['agent'],
|
||||
status: 'active',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
).toEqual(null);
|
||||
});
|
||||
@@ -76,9 +74,12 @@ describe('#validateLoggedInRoutes', () => {
|
||||
it('returns dashboard url', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'conversations', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ admin: ['conversations'], agent: [] }
|
||||
{
|
||||
name: 'billing',
|
||||
params: { accountId: 1 },
|
||||
meta: { permissions: ['administrator'] },
|
||||
},
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/dashboard`);
|
||||
});
|
||||
@@ -88,8 +89,7 @@ describe('#validateLoggedInRoutes', () => {
|
||||
expect(
|
||||
validateLoggedInRoutes(
|
||||
{ name: 'account_suspended', params: { accountId: 1 } },
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] },
|
||||
{ agent: ['account_suspended'] }
|
||||
{ accounts: [{ id: 1, role: 'agent', status: 'active' }] }
|
||||
)
|
||||
).toEqual(`accounts/1/dashboard`);
|
||||
});
|
||||
@@ -186,4 +186,12 @@ describe('isAInboxViewRoute', () => {
|
||||
expect(isAInboxViewRoute('inbox_view_conversation')).toBe(true);
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"NONE": "None",
|
||||
"NO_TEAMS_AVAILABLE": "There are no teams added to this account yet.",
|
||||
"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."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -95,7 +95,9 @@
|
||||
},
|
||||
"NETWORK": {
|
||||
"NOTIFICATION": {
|
||||
"OFFLINE": "Offline"
|
||||
"OFFLINE": "Offline",
|
||||
"RECONNECTING": "Reconnecting...",
|
||||
"RECONNECT_SUCCESS": "Reconnected"
|
||||
},
|
||||
"BUTTON": {
|
||||
"REFRESH": "Refresh"
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
"INBOX_MGMT": {
|
||||
"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>",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected, you will not receive any new messages. <a class=\"underline\" href=\"%{actionUrl}\">Click here</a> to reconnect.",
|
||||
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
|
||||
"CLICK_TO_RECONNECT": "Click here to reconnect.",
|
||||
"LIST": {
|
||||
"404": "There are no inboxes attached to this account."
|
||||
},
|
||||
@@ -365,8 +366,15 @@
|
||||
"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.",
|
||||
"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"
|
||||
},
|
||||
"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": {
|
||||
@@ -734,7 +742,8 @@
|
||||
},
|
||||
"EMAIL_PROVIDERS": {
|
||||
"MICROSOFT": "Microsoft",
|
||||
"GOOGLE": "Google",
|
||||
"OTHER_PROVIDERS": "Other Providers"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,6 +203,87 @@
|
||||
"API_SUCCESS": "Dashboard app deleted successfully",
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,7 +274,7 @@
|
||||
"SLA": "SLA",
|
||||
"BETA": "Beta",
|
||||
"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": {
|
||||
"TITLE": "Help Center",
|
||||
"ALL_ARTICLES": "All Articles",
|
||||
|
||||
@@ -15,7 +15,7 @@ export const DEFAULT_CONTACT_SIDEBAR_ITEMS_ORDER = [
|
||||
];
|
||||
|
||||
const slugifyChannel = name =>
|
||||
name.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
|
||||
name?.toLowerCase().replace(' ', '_').replace('-', '_').replace('::', '_');
|
||||
|
||||
export const isEditorHotKeyEnabled = (uiSettings, key) => {
|
||||
const {
|
||||
@@ -70,6 +70,8 @@ export default {
|
||||
this.updateUISettings({ [key]: !this.isContactSidebarItemOpen(key) });
|
||||
},
|
||||
setSignatureFlagForInbox(channelType, value) {
|
||||
if (!channelType) return;
|
||||
|
||||
channelType = slugifyChannel(channelType);
|
||||
this.updateUISettings({
|
||||
[`${channelType}_signature_enabled`]: value,
|
||||
|
||||
@@ -7,7 +7,9 @@ export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/search'),
|
||||
name: 'search',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: SearchView,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,11 +110,11 @@ export default {
|
||||
mounted() {
|
||||
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() {
|
||||
window.removeEventListener('resize', this.handleResize);
|
||||
bus.$off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
this.$emitter.off(BUS_EVENTS.TOGGLE_SIDEMENU, this.toggleSidebar);
|
||||
},
|
||||
|
||||
methods: {
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
ICON_REOPEN_CONVERSATION,
|
||||
ICON_RESOLVE_CONVERSATION,
|
||||
} from './CommandBarIcons';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import { createSnoozeHandlers } from './commandBarActions';
|
||||
|
||||
@@ -37,7 +38,7 @@ export const RESOLVED_CONVERSATION_BULK_ACTIONS = [
|
||||
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
icon: ICON_REOPEN_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_BULK_ACTION_REOPEN_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -47,7 +48,7 @@ export const OPEN_CONVERSATION_BULK_ACTIONS = [
|
||||
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
icon: ICON_RESOLVE_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import {
|
||||
CMD_MUTE_CONVERSATION,
|
||||
@@ -26,7 +27,7 @@ export const OPEN_CONVERSATION_ACTIONS = [
|
||||
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_RESOLVE_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_RESOLVE_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_RESOLVE_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -37,7 +38,7 @@ export const createSnoozeHandlers = (busEventName, parentId, section) => {
|
||||
parent: parentId,
|
||||
section: section,
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
handler: () => bus.$emit(busEventName, option),
|
||||
handler: () => emitter.emit(busEventName, option),
|
||||
}));
|
||||
};
|
||||
|
||||
@@ -61,7 +62,7 @@ export const RESOLVED_CONVERSATION_ACTIONS = [
|
||||
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_REOPEN_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_REOPEN_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_REOPEN_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,7 +71,7 @@ export const SEND_TRANSCRIPT_ACTION = {
|
||||
title: 'COMMAND_BAR.COMMANDS.SEND_TRANSCRIPT',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_SEND_TRANSCRIPT,
|
||||
handler: () => bus.$emit(CMD_SEND_TRANSCRIPT),
|
||||
handler: () => emitter.emit(CMD_SEND_TRANSCRIPT),
|
||||
};
|
||||
|
||||
export const UNMUTE_ACTION = {
|
||||
@@ -78,7 +79,7 @@ export const UNMUTE_ACTION = {
|
||||
title: 'COMMAND_BAR.COMMANDS.UNMUTE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_UNMUTE_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_UNMUTE_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_UNMUTE_CONVERSATION),
|
||||
};
|
||||
|
||||
export const MUTE_ACTION = {
|
||||
@@ -86,5 +87,5 @@ export const MUTE_ACTION = {
|
||||
title: 'COMMAND_BAR.COMMANDS.MUTE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_MUTE_CONVERSATION,
|
||||
handler: () => bus.$emit(CMD_MUTE_CONVERSATION),
|
||||
handler: () => emitter.emit(CMD_MUTE_CONVERSATION),
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import { CMD_AI_ASSIST } from './commandBarBusEvents';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
@@ -318,7 +319,7 @@ export default {
|
||||
section: this.$t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
|
||||
priority: item,
|
||||
icon: item.icon,
|
||||
handler: () => bus.$emit(CMD_AI_ASSIST, item.key),
|
||||
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
|
||||
}));
|
||||
return [
|
||||
{
|
||||
|
||||
@@ -2,6 +2,7 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
|
||||
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||
|
||||
@@ -21,7 +22,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
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,
|
||||
@@ -30,7 +31,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
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,
|
||||
@@ -39,7 +40,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
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,
|
||||
@@ -48,7 +49,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
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,
|
||||
@@ -57,7 +58,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
bus.$emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
||||
},
|
||||
];
|
||||
export default {
|
||||
|
||||
@@ -7,13 +7,17 @@ export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts'),
|
||||
name: 'contacts_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ContactsView,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts/custom_view/:id'),
|
||||
name: 'contacts_segments_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ContactsView,
|
||||
props: route => {
|
||||
return { segmentsId: route.params.id };
|
||||
@@ -22,7 +26,9 @@ export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/labels/:label/contacts'),
|
||||
name: 'contacts_labels_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ContactsView,
|
||||
props: route => {
|
||||
return { label: route.params.label };
|
||||
@@ -31,7 +37,9 @@ export const routes = [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/contacts/:contactId'),
|
||||
name: 'contact_profile_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ContactManageView,
|
||||
props: route => {
|
||||
return { contactId: route.params.contactId };
|
||||
|
||||
@@ -145,7 +145,6 @@ import CustomAttributes from './customAttributes/CustomAttributes.vue';
|
||||
import draggable from 'vuedraggable';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import MacrosList from './Macros/List.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AccordionItem,
|
||||
|
||||
@@ -19,12 +19,12 @@ const initiatedAt = computed(
|
||||
() => props.conversationAttributes.initiated_at?.timestamp
|
||||
);
|
||||
|
||||
const browserInfo = props.conversationAttributes.browser;
|
||||
const browserInfo = computed(() => props.conversationAttributes.browser);
|
||||
|
||||
const browserName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
if (!browserInfo.value) return '';
|
||||
const { browser_name: name = '', browser_version: version = '' } =
|
||||
browserInfo;
|
||||
browserInfo.value;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
@@ -33,9 +33,9 @@ const browserLanguage = computed(() =>
|
||||
);
|
||||
|
||||
const platformName = computed(() => {
|
||||
if (!browserInfo) return '';
|
||||
if (!browserInfo.value) return '';
|
||||
const { platform_name: name = '', platform_version: version = '' } =
|
||||
browserInfo;
|
||||
browserInfo.value;
|
||||
return `${name} ${version}`;
|
||||
});
|
||||
|
||||
|
||||
@@ -22,25 +22,40 @@
|
||||
:is-on-expanded-layout="isOnExpandedLayout"
|
||||
@contact-panel-toggle="onToggleContactPanel"
|
||||
/>
|
||||
<woot-modal
|
||||
:show.sync="showCustomSnoozeModal"
|
||||
:on-close="hideCustomSnoozeModal"
|
||||
>
|
||||
<custom-snooze-modal
|
||||
@close="hideCustomSnoozeModal"
|
||||
@choose-time="chooseSnoozeTime"
|
||||
/>
|
||||
</woot-modal>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getUnixTime } from 'date-fns';
|
||||
import ChatList from '../../../components/ChatList.vue';
|
||||
import ConversationBox from '../../../components/widgets/conversation/ConversationBox.vue';
|
||||
import PopOverSearch from './search/PopOverSearch.vue';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { CMD_SNOOZE_CONVERSATION } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
ChatList,
|
||||
ConversationBox,
|
||||
PopOverSearch,
|
||||
CustomSnoozeModal,
|
||||
},
|
||||
mixins: [uiSettingsMixin],
|
||||
mixins: [uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
inboxId: {
|
||||
type: [String, Number],
|
||||
@@ -70,12 +85,14 @@ export default {
|
||||
data() {
|
||||
return {
|
||||
showSearchModal: false,
|
||||
showCustomSnoozeModal: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
chatList: 'getAllConversations',
|
||||
currentChat: 'getSelectedChat',
|
||||
contextMenuChatId: 'getContextMenuChatId',
|
||||
}),
|
||||
showConversationList() {
|
||||
return this.isOnExpandedLayout ? !this.conversationId : true;
|
||||
@@ -112,6 +129,10 @@ export default {
|
||||
this.$watch('chatList.length', () => {
|
||||
this.setActiveChat();
|
||||
});
|
||||
this.$emitter.on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.$emitter.off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
|
||||
},
|
||||
|
||||
methods: {
|
||||
@@ -169,7 +190,7 @@ export default {
|
||||
after: messageId,
|
||||
})
|
||||
.then(() => {
|
||||
bus.$emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
|
||||
this.$emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE, { messageId });
|
||||
});
|
||||
} else {
|
||||
this.$store.dispatch('clearSelectedState');
|
||||
@@ -186,6 +207,43 @@ export default {
|
||||
closeSearch() {
|
||||
this.showSearchModal = false;
|
||||
},
|
||||
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 the custom snooze modal is open
|
||||
// Then if the custom snooze modal is closed then set the context menu chat id to null
|
||||
this.$store.dispatch('setContextMenuChatId', null);
|
||||
this.showCustomSnoozeModal = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -271,7 +271,10 @@ export default {
|
||||
},
|
||||
toggleConversationModal() {
|
||||
this.showConversationModal = !this.showConversationModal;
|
||||
bus.$emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, this.showConversationModal);
|
||||
this.$emitter.emit(
|
||||
BUS_EVENTS.NEW_CONVERSATION_MODAL,
|
||||
this.showConversationModal
|
||||
);
|
||||
},
|
||||
toggleDeleteModal() {
|
||||
this.showDeleteModal = !this.showDeleteModal;
|
||||
|
||||
@@ -7,7 +7,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/dashboard'),
|
||||
name: 'home',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: () => {
|
||||
return { inboxId: 0 };
|
||||
@@ -16,7 +18,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/conversations/:conversation_id'),
|
||||
name: 'inbox_conversation',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => {
|
||||
return { inboxId: 0, conversationId: route.params.conversation_id };
|
||||
@@ -25,7 +29,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/inbox/:inbox_id'),
|
||||
name: 'inbox_dashboard',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => {
|
||||
return { inboxId: route.params.inbox_id };
|
||||
@@ -36,7 +42,9 @@ export default {
|
||||
'accounts/:accountId/inbox/:inbox_id/conversations/:conversation_id'
|
||||
),
|
||||
name: 'conversation_through_inbox',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => {
|
||||
return {
|
||||
@@ -48,7 +56,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/label/:label'),
|
||||
name: 'label_conversations',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({ label: route.params.label }),
|
||||
},
|
||||
@@ -57,7 +67,9 @@ export default {
|
||||
'accounts/:accountId/label/:label/conversations/:conversation_id'
|
||||
),
|
||||
name: 'conversations_through_label',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversation_id,
|
||||
@@ -67,7 +79,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/team/:teamId'),
|
||||
name: 'team_conversations',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({ teamId: route.params.teamId }),
|
||||
},
|
||||
@@ -76,7 +90,9 @@ export default {
|
||||
'accounts/:accountId/team/:teamId/conversations/:conversationId'
|
||||
),
|
||||
name: 'conversations_through_team',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversationId,
|
||||
@@ -86,7 +102,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/custom_view/:id'),
|
||||
name: 'folder_conversations',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({ foldersId: route.params.id }),
|
||||
},
|
||||
@@ -95,7 +113,9 @@ export default {
|
||||
'accounts/:accountId/custom_view/:id/conversations/:conversation_id'
|
||||
),
|
||||
name: 'conversations_through_folders',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversation_id,
|
||||
@@ -105,7 +125,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/mentions/conversations'),
|
||||
name: 'conversation_mentions',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: () => ({ conversationType: 'mention' }),
|
||||
},
|
||||
@@ -114,7 +136,9 @@ export default {
|
||||
'accounts/:accountId/mentions/conversations/:conversationId'
|
||||
),
|
||||
name: 'conversation_through_mentions',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversationId,
|
||||
@@ -124,7 +148,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/unattended/conversations'),
|
||||
name: 'conversation_unattended',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: () => ({ conversationType: 'unattended' }),
|
||||
},
|
||||
@@ -133,7 +159,9 @@ export default {
|
||||
'accounts/:accountId/unattended/conversations/:conversationId'
|
||||
),
|
||||
name: 'conversation_through_unattended',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversationId,
|
||||
@@ -143,7 +171,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/participating/conversations'),
|
||||
name: 'conversation_participating',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: () => ({ conversationType: 'participating' }),
|
||||
},
|
||||
@@ -152,7 +182,9 @@ export default {
|
||||
'accounts/:accountId/participating/conversations/:conversationId'
|
||||
),
|
||||
name: 'conversation_through_participating',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: ConversationView,
|
||||
props: route => ({
|
||||
conversationId: route.params.conversationId,
|
||||
|
||||
@@ -28,7 +28,9 @@ export default {
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/suspended'),
|
||||
name: 'account_suspended',
|
||||
roles: ['administrator', 'agent'],
|
||||
meta: {
|
||||
permissions: ['administrator', 'agent'],
|
||||
},
|
||||
component: Suspended,
|
||||
},
|
||||
],
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user