Compare commits
88
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14c2048c1c | ||
|
|
e8b7e791a5 | ||
|
|
a236d4da3a | ||
|
|
b18cac77fb | ||
|
|
647161121e | ||
|
|
cbbe939662 | ||
|
|
30cb141e5e | ||
|
|
b0d5c438ac | ||
|
|
3eefa4daee | ||
|
|
ddce999385 | ||
|
|
ff915dd2ea | ||
|
|
dd0930d75e | ||
|
|
3f0d96c24d | ||
|
|
c16b801562 | ||
|
|
018c468303 | ||
|
|
48bf8d08e5 | ||
|
|
fabb3c8da4 | ||
|
|
bdeb2f9812 | ||
|
|
53dc38e650 | ||
|
|
27fc24375d | ||
|
|
f999777a2d | ||
|
|
71d1e98765 | ||
|
|
2c93d563c1 | ||
|
|
f1b556d4a3 | ||
|
|
73c119cd3d | ||
|
|
0ad2f94151 | ||
|
|
f8cb806548 | ||
|
|
9ba5adfd60 | ||
|
|
0dd5104acb | ||
|
|
0968aead6c | ||
|
|
53d530b815 | ||
|
|
2429daa45c | ||
|
|
86894b27b5 | ||
|
|
05df4d4212 | ||
|
|
47fd0ea2b2 | ||
|
|
30b8a365dc | ||
|
|
29110ffd6b | ||
|
|
e9950afb1a | ||
|
|
f21298aeb9 | ||
|
|
7b6a56714f | ||
|
|
94a20af9db | ||
|
|
616371adbb | ||
|
|
2d4ef0c328 | ||
|
|
5e6e234afe | ||
|
|
18e9e771b5 | ||
|
|
0245e01763 | ||
|
|
fcd40e9636 | ||
|
|
4f5c5e9f85 | ||
|
|
cb07ac16d1 | ||
|
|
4fa299ff15 | ||
|
|
71e9566854 | ||
|
|
272f920811 | ||
|
|
e39d19b1e8 | ||
|
|
6c39aed882 | ||
|
|
8d43101892 | ||
|
|
f05535c5ad | ||
|
|
4503ba018d | ||
|
|
8bc2bc6d40 | ||
|
|
bfd192ebb2 | ||
|
|
6e13d22ff7 | ||
|
|
3e8b6e1aee | ||
|
|
3038aaef67 | ||
|
|
57a68666cb | ||
|
|
4acb824857 | ||
|
|
5c74674c2b | ||
|
|
52e49d5f9d | ||
|
|
e529e1206e | ||
|
|
a8009c7b39 | ||
|
|
99bb074656 | ||
|
|
9322112481 | ||
|
|
ce4cfee8bd | ||
|
|
f31fc2b375 | ||
|
|
e5f7807833 | ||
|
|
2483d99d3c | ||
|
|
336584c95a | ||
|
|
9ebabb9832 | ||
|
|
2acf09b3eb | ||
|
|
d18d3e9e2e | ||
|
|
deec1d213b | ||
|
|
25c3fb3c36 | ||
|
|
e2a6dc3e04 | ||
|
|
57feedbf25 | ||
|
|
71310c21ad | ||
|
|
623cd72897 | ||
|
|
04aa13e8aa | ||
|
|
fe8a67b76a | ||
|
|
e204fe1c1f | ||
|
|
08fa370261 |
@@ -15,9 +15,9 @@ defaults: &defaults
|
||||
- image: cimg/postgres:15.3
|
||||
- image: cimg/redis:6.2.6
|
||||
environment:
|
||||
- RAILS_LOG_TO_STDOUT: false
|
||||
- COVERAGE: true
|
||||
- LOG_LEVEL: warn
|
||||
- RAILS_LOG_TO_STDOUT: false
|
||||
- COVERAGE: true
|
||||
- LOG_LEVEL: warn
|
||||
parallelism: 4
|
||||
resource_class: large
|
||||
|
||||
@@ -46,7 +46,7 @@ jobs:
|
||||
export NVM_DIR="$HOME/.nvm"
|
||||
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
|
||||
[ -s "$NVM_DIR/bash_completion" ] && \. "$NVM_DIR/bash_completion"
|
||||
nvm install v16
|
||||
nvm install v20
|
||||
echo 'export NVM_DIR="$HOME/.nvm"' >> $BASH_ENV
|
||||
echo '[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"' >> $BASH_ENV
|
||||
|
||||
@@ -64,7 +64,6 @@ jobs:
|
||||
- ~/.bundle
|
||||
key: chatwoot-bundle-{{ .Environment.CACHE_VERSION }}-v20220524-{{ checksum "Gemfile.lock" }}
|
||||
|
||||
|
||||
# Only necessary if app uses webpacker or yarn in some other way
|
||||
- restore_cache:
|
||||
keys:
|
||||
@@ -82,7 +81,7 @@ jobs:
|
||||
- ~/.cache/yarn
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
@@ -182,7 +181,7 @@ jobs:
|
||||
- attach_workspace:
|
||||
at: ~/build
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
@@ -204,4 +203,4 @@ workflows:
|
||||
- build
|
||||
- upload-coverage:
|
||||
requires:
|
||||
- build
|
||||
- build
|
||||
|
||||
@@ -199,6 +199,8 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## Rack Attack configuration
|
||||
## To prevent and throttle abusive requests
|
||||
# ENABLE_RACK_ATTACK=true
|
||||
# RACK_ATTACK_IP_LIMIT=3000
|
||||
# ENABLE_RACK_ATTACK_WIDGET_API=true
|
||||
|
||||
## Running chatwoot as an API only server
|
||||
## setting this value to true will disable the frontend dashboard endpoints
|
||||
|
||||
@@ -52,7 +52,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
@@ -75,6 +76,8 @@ jobs:
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec --profile=10 --format documentation
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -51,7 +51,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: yarn
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
@@ -68,7 +69,12 @@ jobs:
|
||||
# Run Response Bot specs
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 --profile=10 --format documentation
|
||||
bundle exec rspec \
|
||||
spec/enterprise/controllers/api/v1/accounts/response_sources_controller_spec.rb \
|
||||
spec/enterprise/services/enterprise/message_templates/response_bot_service_spec.rb \
|
||||
spec/enterprise/controllers/enterprise/api/v1/accounts/inboxes_controller_spec.rb:47 \
|
||||
--profile=10 \
|
||||
--format documentation
|
||||
|
||||
- name: Upload rails log folder
|
||||
uses: actions/upload-artifact@v3
|
||||
|
||||
@@ -21,7 +21,8 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 16
|
||||
node-version: 20
|
||||
cache: 'yarn'
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
@@ -30,9 +31,11 @@ jobs:
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
|
||||
- name: Run asset compile
|
||||
run: bundle exec rake assets:precompile
|
||||
env:
|
||||
NODE_OPTIONS: --openssl-legacy-provider
|
||||
|
||||
- name: Size Check
|
||||
run: yarn run size
|
||||
|
||||
+3
-7
@@ -8,15 +8,11 @@ Layout/LineLength:
|
||||
Max: 150
|
||||
|
||||
Metrics/ClassLength:
|
||||
Max: 125
|
||||
Max: 175
|
||||
Exclude:
|
||||
- 'app/models/conversation.rb'
|
||||
- 'app/models/contact.rb'
|
||||
- 'app/mailers/conversation_reply_mailer.rb'
|
||||
- 'app/models/message.rb'
|
||||
- 'app/builders/messages/facebook/message_builder.rb'
|
||||
- 'app/controllers/api/v1/accounts/contacts_controller.rb'
|
||||
- 'app/listeners/action_cable_listener.rb'
|
||||
- 'app/models/conversation.rb'
|
||||
|
||||
RSpec/ExampleLength:
|
||||
Max: 25
|
||||
Style/Documentation:
|
||||
|
||||
@@ -4,7 +4,7 @@ ruby '3.2.2'
|
||||
|
||||
##-- base gems for rails --##
|
||||
gem 'rack-cors', require: 'rack/cors'
|
||||
gem 'rails', '~> 7.0.7.2'
|
||||
gem 'rails', '~> 7.0.8.0'
|
||||
# Reduces boot times through caching; required in config/boot.rb
|
||||
gem 'bootsnap', require: false
|
||||
|
||||
@@ -76,6 +76,7 @@ gem 'pundit'
|
||||
# super admin
|
||||
gem 'administrate', '>= 0.19.0'
|
||||
gem 'administrate-field-active_storage'
|
||||
gem 'administrate-field-belongs_to_search'
|
||||
|
||||
##--- gems for pubsub service ---##
|
||||
# https://karolgalanciak.com/blog/2019/11/30/from-activerecord-callbacks-to-publish-slash-subscribe-pattern-and-event-driven-design/
|
||||
@@ -108,14 +109,14 @@ gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.10.0', require: false
|
||||
gem 'sentry-rails', '>= 5.11.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.10.0', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.11.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq'
|
||||
gem 'sidekiq', '>= 7.1.3'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron'
|
||||
gem 'sidekiq-cron', '>= 1.10.1'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
@@ -188,7 +189,7 @@ group :development do
|
||||
gem 'bullet'
|
||||
gem 'letter_opener'
|
||||
gem 'scss_lint', require: false
|
||||
gem 'web-console'
|
||||
gem 'web-console', '>= 4.2.1'
|
||||
|
||||
# used in swagger build
|
||||
gem 'json_refs'
|
||||
|
||||
+86
-80
@@ -33,70 +33,70 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actioncable (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actionmailbox (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
activejob (= 7.0.8)
|
||||
activerecord (= 7.0.8)
|
||||
activestorage (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
mail (>= 2.7.1)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
actionmailer (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actionmailer (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
actionview (= 7.0.8)
|
||||
activejob (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
net-imap
|
||||
net-pop
|
||||
net-smtp
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actionpack (7.0.8)
|
||||
actionview (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
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.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actiontext (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
activerecord (= 7.0.8)
|
||||
activestorage (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
globalid (>= 0.6.0)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
actionview (7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
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.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activejob (7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activerecord (7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activemodel (7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
activerecord (7.0.8)
|
||||
activemodel (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
activerecord-import (1.4.1)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
activestorage (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
activejob (= 7.0.8)
|
||||
activerecord (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (7.0.7.2)
|
||||
activesupport (7.0.8)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -116,6 +116,11 @@ GEM
|
||||
administrate-field-active_storage (0.4.2)
|
||||
administrate (>= 0.2.2)
|
||||
rails (>= 7.0)
|
||||
administrate-field-belongs_to_search (0.8.0)
|
||||
administrate (>= 0.3, < 1.0)
|
||||
jbuilder (~> 2)
|
||||
rails (>= 4.2, < 7.1)
|
||||
selectize-rails (~> 0.6)
|
||||
annotate (3.2.0)
|
||||
activerecord (>= 3.2, < 8.0)
|
||||
rake (>= 10.4, < 14.0)
|
||||
@@ -269,8 +274,8 @@ GEM
|
||||
grpc (~> 1.36)
|
||||
geocoder (1.8.1)
|
||||
gli (2.21.0)
|
||||
globalid (1.1.0)
|
||||
activesupport (>= 5.0)
|
||||
globalid (1.2.1)
|
||||
activesupport (>= 6.1)
|
||||
gmail_xoauth (0.4.2)
|
||||
oauth (>= 0.3.6)
|
||||
google-apis-core (0.11.0)
|
||||
@@ -450,9 +455,9 @@ GEM
|
||||
mime-types-data (~> 3.2015)
|
||||
mime-types-data (3.2023.0218.1)
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.2)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.4)
|
||||
minitest (5.19.0)
|
||||
minitest (5.20.0)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -463,7 +468,7 @@ GEM
|
||||
activerecord (>= 5.2)
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.3.6)
|
||||
net-imap (0.3.7)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -478,14 +483,14 @@ GEM
|
||||
sidekiq
|
||||
newrelic_rpm (8.16.0)
|
||||
nio4r (2.5.9)
|
||||
nokogiri (1.15.3)
|
||||
nokogiri (1.15.4)
|
||||
mini_portile2 (~> 2.8.2)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.3-arm64-darwin)
|
||||
nokogiri (1.15.4-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.3-x86_64-darwin)
|
||||
nokogiri (1.15.4-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.15.3-x86_64-linux)
|
||||
nokogiri (1.15.4-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
numo-narray (0.9.2.1)
|
||||
oauth (1.1.0)
|
||||
@@ -563,30 +568,30 @@ GEM
|
||||
rack-test (2.1.0)
|
||||
rack (>= 1.3)
|
||||
rack-timeout (0.6.3)
|
||||
rails (7.0.7.2)
|
||||
actioncable (= 7.0.7.2)
|
||||
actionmailbox (= 7.0.7.2)
|
||||
actionmailer (= 7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
actiontext (= 7.0.7.2)
|
||||
actionview (= 7.0.7.2)
|
||||
activejob (= 7.0.7.2)
|
||||
activemodel (= 7.0.7.2)
|
||||
activerecord (= 7.0.7.2)
|
||||
activestorage (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
rails (7.0.8)
|
||||
actioncable (= 7.0.8)
|
||||
actionmailbox (= 7.0.8)
|
||||
actionmailer (= 7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
actiontext (= 7.0.8)
|
||||
actionview (= 7.0.8)
|
||||
activejob (= 7.0.8)
|
||||
activemodel (= 7.0.8)
|
||||
activerecord (= 7.0.8)
|
||||
activestorage (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 7.0.7.2)
|
||||
rails-dom-testing (2.1.1)
|
||||
railties (= 7.0.8)
|
||||
rails-dom-testing (2.2.0)
|
||||
activesupport (>= 5.0.0)
|
||||
minitest
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.6.0)
|
||||
loofah (~> 2.21)
|
||||
nokogiri (~> 1.14)
|
||||
railties (7.0.7.2)
|
||||
actionpack (= 7.0.7.2)
|
||||
activesupport (= 7.0.7.2)
|
||||
railties (7.0.8)
|
||||
actionpack (= 7.0.8)
|
||||
activesupport (= 7.0.8)
|
||||
method_source
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
@@ -598,7 +603,7 @@ GEM
|
||||
ffi (~> 1.0)
|
||||
redis (5.0.6)
|
||||
redis-client (>= 0.9.0)
|
||||
redis-client (0.14.1)
|
||||
redis-client (0.17.0)
|
||||
connection_pool
|
||||
redis-namespace (1.10.0)
|
||||
redis (>= 4)
|
||||
@@ -697,23 +702,23 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.10.0)
|
||||
sentry-rails (5.11.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.10.0)
|
||||
sentry-ruby (5.10.0)
|
||||
sentry-ruby (~> 5.11.0)
|
||||
sentry-ruby (5.11.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.10.0)
|
||||
sentry-ruby (~> 5.10.0)
|
||||
sentry-sidekiq (5.11.0)
|
||||
sentry-ruby (~> 5.11.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.1.2)
|
||||
sidekiq (7.1.3)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
rack (>= 2.2.4)
|
||||
redis-client (>= 0.14.0)
|
||||
sidekiq-cron (1.10.0)
|
||||
sidekiq-cron (1.10.1)
|
||||
fugit (~> 1.8)
|
||||
globalid (>= 1.0.1)
|
||||
sidekiq (>= 6)
|
||||
@@ -786,7 +791,7 @@ GEM
|
||||
version_gem (1.1.2)
|
||||
warden (1.2.9)
|
||||
rack (>= 2.0.9)
|
||||
web-console (4.2.0)
|
||||
web-console (4.2.1)
|
||||
actionview (>= 6.0.0)
|
||||
activemodel (>= 6.0.0)
|
||||
bindex (>= 0.4.0)
|
||||
@@ -805,14 +810,14 @@ GEM
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.8.1)
|
||||
websocket-driver (0.7.5)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
wisper (2.0.0)
|
||||
working_hours (1.4.1)
|
||||
activesupport (>= 3.2)
|
||||
tzinfo
|
||||
zeitwerk (2.6.9)
|
||||
zeitwerk (2.6.11)
|
||||
|
||||
PLATFORMS
|
||||
arm64-darwin-20
|
||||
@@ -831,6 +836,7 @@ DEPENDENCIES
|
||||
acts-as-taggable-on
|
||||
administrate (>= 0.19.0)
|
||||
administrate-field-active_storage
|
||||
administrate-field-belongs_to_search
|
||||
annotate
|
||||
attr_extras
|
||||
audited (~> 5.3)
|
||||
@@ -907,7 +913,7 @@ DEPENDENCIES
|
||||
rack-cors
|
||||
rack-mini-profiler (>= 3.1.1)
|
||||
rack-timeout
|
||||
rails (~> 7.0.7.2)
|
||||
rails (~> 7.0.8.0)
|
||||
redis
|
||||
redis-namespace
|
||||
responders
|
||||
@@ -922,12 +928,12 @@ DEPENDENCIES
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
sentry-rails (>= 5.10.0)
|
||||
sentry-rails (>= 5.11.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.10.0)
|
||||
sentry-sidekiq (>= 5.11.0)
|
||||
shoulda-matchers
|
||||
sidekiq
|
||||
sidekiq-cron
|
||||
sidekiq (>= 7.1.3)
|
||||
sidekiq-cron (>= 1.10.1)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.0.0)
|
||||
spring
|
||||
@@ -943,7 +949,7 @@ DEPENDENCIES
|
||||
tzinfo-data
|
||||
uglifier
|
||||
valid_email2
|
||||
web-console
|
||||
web-console (>= 4.2.1)
|
||||
web-push
|
||||
webmock
|
||||
webpacker
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
backend: bin/rails s -p 3000
|
||||
frontend: bin/webpack-dev-server
|
||||
frontend: export NODE_OPTIONS=--openssl-legacy-provider && bin/webpack-dev-server
|
||||
# https://github.com/mperham/sidekiq/issues/3090#issuecomment-389748695
|
||||
worker: dotenv bundle exec sidekiq -C config/sidekiq.yml
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.17.0
|
||||
3.1.0
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
2.3.0
|
||||
2.4.0
|
||||
|
||||
@@ -60,6 +60,9 @@
|
||||
],
|
||||
"stack": "heroku-20",
|
||||
"buildpacks": [
|
||||
{
|
||||
"url": "heroku/nodejs"
|
||||
},
|
||||
{
|
||||
"url": "heroku/ruby"
|
||||
}
|
||||
|
||||
@@ -29,3 +29,5 @@
|
||||
@import 'components/pagination';
|
||||
@import 'components/search';
|
||||
@import 'components/reports';
|
||||
|
||||
@import 'custom_styles';
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// custom styles for the dashboard
|
||||
|
||||
.feature-cell {
|
||||
background: $color-extra-light-blue;
|
||||
border-radius: 10px;
|
||||
float: left;
|
||||
margin-left: 8px;
|
||||
margin-top: 6px;
|
||||
padding: 4px 12px;
|
||||
|
||||
.icon-container {
|
||||
margin-right: 4px;
|
||||
|
||||
}
|
||||
|
||||
.value-container {
|
||||
margin-left: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.feature-container {
|
||||
max-width: 100rem;
|
||||
}
|
||||
@@ -78,11 +78,6 @@ $conv-header-height: 4rem;
|
||||
$inbox-thumb-size: 4.8rem;
|
||||
|
||||
|
||||
// Spinner
|
||||
$spinkit-spinner-color: $color-white !default;
|
||||
$spinkit-spinner-margin: 0 0 0 1.6rem !default;
|
||||
$spinkit-size: 1.6rem !default;
|
||||
|
||||
// Snackbar default
|
||||
$woot-snackbar-bg: #323232;
|
||||
$woot-snackbar-button: #ffeb3b;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
before_action :portal
|
||||
before_action :check_authorization
|
||||
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
|
||||
before_action :fetch_article, except: [:index, :create, :reorder]
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
def index
|
||||
@@ -36,17 +36,6 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def attach_file
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:background_image].tempfile,
|
||||
filename: params[:background_image].original_filename,
|
||||
content_type: params[:background_image].content_type
|
||||
)
|
||||
file_blob.save!
|
||||
render json: { file_url: url_for(file_blob) }
|
||||
end
|
||||
|
||||
def reorder
|
||||
Article.update_positions(params[:positions_hash])
|
||||
head :ok
|
||||
|
||||
@@ -20,16 +20,6 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
@automation_rule
|
||||
end
|
||||
|
||||
def attach_file
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def update
|
||||
ActiveRecord::Base.transaction do
|
||||
automation_rule_update
|
||||
|
||||
@@ -18,18 +18,19 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def index
|
||||
@contacts_count = resolved_contacts.count
|
||||
@contacts = fetch_contacts_with_conversation_count(resolved_contacts)
|
||||
@contacts = fetch_contacts(resolved_contacts)
|
||||
end
|
||||
|
||||
def search
|
||||
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
|
||||
|
||||
contacts = resolved_contacts.where(
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search',
|
||||
'name ILIKE :search OR email ILIKE :search OR phone_number ILIKE :search OR contacts.identifier LIKE :search
|
||||
OR contacts.additional_attributes->>\'company_name\' ILIKE :search',
|
||||
search: "%#{params[:q].strip}%"
|
||||
)
|
||||
@contacts_count = contacts.count
|
||||
@contacts = fetch_contacts_with_conversation_count(contacts)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
end
|
||||
|
||||
def import
|
||||
@@ -63,7 +64,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
result = ::Contacts::FilterService.new(params.permit!, current_user).perform
|
||||
contacts = result[:contacts]
|
||||
@contacts_count = result[:count]
|
||||
@contacts = fetch_contacts_with_conversation_count(contacts)
|
||||
@contacts = fetch_contacts(contacts)
|
||||
end
|
||||
|
||||
def contactable_inboxes
|
||||
@@ -125,17 +126,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
|
||||
@current_page = params[:page] || 1
|
||||
end
|
||||
|
||||
def fetch_contacts_with_conversation_count(contacts)
|
||||
conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
|
||||
contacts_with_conversation_count = filtrate(contacts)
|
||||
.select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
|
||||
.group('contacts.id')
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
def fetch_contacts(contacts)
|
||||
contacts_with_avatar = filtrate(contacts)
|
||||
.includes([{ avatar_attachment: [:blob] }])
|
||||
.page(@current_page).per(RESULTS_PER_PAGE)
|
||||
|
||||
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
return contacts_with_avatar.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes
|
||||
|
||||
contacts_with_conversation_count
|
||||
contacts_with_avatar
|
||||
end
|
||||
|
||||
def build_contact_inbox
|
||||
|
||||
@@ -39,16 +39,6 @@ class Api::V1::Accounts::MacrosController < Api::V1::Accounts::BaseController
|
||||
head :ok
|
||||
end
|
||||
|
||||
def attach_file
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def execute
|
||||
::MacrosExecutionJob.perform_later(@macro, conversation_ids: params[:conversation_ids], user: Current.user)
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
include ::FileTypeHelper
|
||||
|
||||
before_action :fetch_portal, except: [:index, :create, :attach_file]
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
before_action :check_authorization
|
||||
before_action :set_current_page, only: [:index]
|
||||
|
||||
@@ -53,16 +53,6 @@ class Api::V1::Accounts::PortalsController < Api::V1::Accounts::BaseController
|
||||
@portal.logo.attach(blob)
|
||||
end
|
||||
|
||||
def attach_file
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:logo].tempfile,
|
||||
filename: params[:logo].original_filename,
|
||||
content_type: params[:logo].content_type
|
||||
)
|
||||
render json: { blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_portal
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
file_blob.save!
|
||||
|
||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
end
|
||||
@@ -8,7 +8,7 @@ module EnsureCurrentAccountHelper
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
ensure_account_is_active?(account)
|
||||
render_unauthorized('Account is suspended') and return unless account.active?
|
||||
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
@@ -27,8 +27,4 @@ module EnsureCurrentAccountHelper
|
||||
def account_accessible_for_bot?(account)
|
||||
render_unauthorized('You are not authorized to access this account') unless @resource.agent_bot_inboxes.find_by(account_id: account.id)
|
||||
end
|
||||
|
||||
def ensure_account_is_active?(account)
|
||||
render_unauthorized('Account is suspended') unless account.active?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,7 +44,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
end
|
||||
|
||||
def list_params
|
||||
params.permit(:query, :locale, :sort)
|
||||
params.permit(:query, :locale, :sort, :status)
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
class Public::Api::V1::Portals::BaseController < PublicController
|
||||
before_action :show_plain_layout
|
||||
around_action :set_locale
|
||||
after_action :allow_iframe_requests
|
||||
|
||||
private
|
||||
|
||||
@@ -39,4 +40,8 @@ class Public::Api::V1::Portals::BaseController < PublicController
|
||||
|
||||
I18n.with_locale(@locale, &)
|
||||
end
|
||||
|
||||
def allow_iframe_requests
|
||||
response.headers.delete('X-Frame-Options') if @is_plain_layout_enabled
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
class SlackUploadsController < ApplicationController
|
||||
include Rails.application.routes.url_helpers
|
||||
before_action :set_blob, only: [:show]
|
||||
|
||||
def show
|
||||
if @blob
|
||||
redirect_to blob_url
|
||||
else
|
||||
redirect_to avatar_url
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def set_blob
|
||||
@blob = ActiveStorage::Blob.find_by(key: params[:blob_key])
|
||||
end
|
||||
|
||||
def blob_url
|
||||
url_for(@blob.representation(resize_to_fill: [250, nil]))
|
||||
end
|
||||
|
||||
def avatar_url
|
||||
base_url = ENV.fetch('FRONTEND_URL', nil)
|
||||
"#{base_url}/integrations/slack/#{params[:sender_type]}.png"
|
||||
end
|
||||
end
|
||||
@@ -6,6 +6,7 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
postgres_status
|
||||
redis_metrics
|
||||
chatwoot_edition
|
||||
instance_meta
|
||||
end
|
||||
|
||||
def chatwoot_edition
|
||||
@@ -18,6 +19,10 @@ class SuperAdmin::InstanceStatusesController < SuperAdmin::ApplicationController
|
||||
end
|
||||
end
|
||||
|
||||
def instance_meta
|
||||
@metrics['Database Migrations'] = ActiveRecord::Base.connection.migration_context.needs_migration? ? 'pending' : 'completed'
|
||||
end
|
||||
|
||||
def chatwoot_version
|
||||
@metrics['Chatwoot version'] = Chatwoot.config[:version]
|
||||
end
|
||||
|
||||
@@ -18,8 +18,8 @@ class AccountDashboard < Administrate::BaseDashboard
|
||||
end
|
||||
|
||||
ATTRIBUTE_TYPES = {
|
||||
id: Field::Number,
|
||||
name: Field::String,
|
||||
id: Field::Number.with_options(searchable: true),
|
||||
name: Field::String.with_options(searchable: true),
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime,
|
||||
users: CountField,
|
||||
|
||||
@@ -8,9 +8,9 @@ class AccountUserDashboard < Administrate::BaseDashboard
|
||||
# which determines how the attribute is displayed
|
||||
# on pages throughout the dashboard.
|
||||
ATTRIBUTE_TYPES = {
|
||||
account: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
|
||||
user: Field::BelongsTo.with_options(searchable: true, searchable_field: 'name', order: 'id DESC'),
|
||||
inviter: Field::BelongsTo.with_options(class_name: 'User', searchable: true, searchable_field: 'name'),
|
||||
account: Field::BelongsToSearch.with_options(class_name: 'Account', searchable: true, searchable_field: [:name, :id], order: 'id DESC'),
|
||||
user: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
|
||||
inviter: Field::BelongsToSearch.with_options(class_name: 'User', searchable: true, searchable_field: [:name, :email, :id], order: 'id DESC'),
|
||||
id: Field::Number,
|
||||
role: Field::Select.with_options(collection: AccountUser.roles.keys),
|
||||
created_at: Field::DateTime,
|
||||
|
||||
@@ -9,7 +9,7 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
# on pages throughout the dashboard.
|
||||
ATTRIBUTE_TYPES = {
|
||||
account_users: Field::HasMany,
|
||||
id: Field::Number,
|
||||
id: Field::Number.with_options(searchable: true),
|
||||
avatar_url: AvatarField,
|
||||
avatar: Field::ActiveStorage.with_options(
|
||||
destroy_url: proc do |_namespace, _resource, attachment|
|
||||
@@ -28,9 +28,9 @@ class UserDashboard < Administrate::BaseDashboard
|
||||
confirmed_at: Field::DateTime,
|
||||
confirmation_sent_at: Field::DateTime,
|
||||
unconfirmed_email: Field::String,
|
||||
name: Field::String,
|
||||
name: Field::String.with_options(searchable: true),
|
||||
display_name: Field::String,
|
||||
email: Field::String,
|
||||
email: Field::String.with_options(searchable: true),
|
||||
tokens: Field::String.with_options(searchable: false),
|
||||
created_at: Field::DateTime,
|
||||
updated_at: Field::DateTime,
|
||||
|
||||
@@ -26,14 +26,14 @@
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification';
|
||||
import NetworkNotification from './components/NetworkNotification.vue';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import UpgradeBanner from './components/app/UpgradeBanner.vue';
|
||||
import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import {
|
||||
|
||||
@@ -9,14 +9,6 @@ class AutomationsAPI extends ApiClient {
|
||||
clone(automationId) {
|
||||
return axios.post(`${this.url}/${automationId}/clone`);
|
||||
}
|
||||
|
||||
attachment(file) {
|
||||
return axios.post(`${this.url}/attach_file`, file, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new AutomationsAPI();
|
||||
|
||||
@@ -47,20 +47,6 @@ class ArticlesAPI extends PortalsAPI {
|
||||
return axios.delete(`${this.url}/${portalSlug}/articles/${articleId}`);
|
||||
}
|
||||
|
||||
uploadImage({ portalSlug, file }) {
|
||||
let formData = new FormData();
|
||||
formData.append('background_image', file);
|
||||
return axios.post(
|
||||
`${this.url}/${portalSlug}/articles/attach_file`,
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
|
||||
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
|
||||
positions_hash: reorderedGroup,
|
||||
|
||||
@@ -81,11 +81,6 @@ $conv-header-height: 2.5rem;
|
||||
$inbox-thumb-size: 3rem;
|
||||
|
||||
|
||||
// Spinner
|
||||
$spinkit-spinner-color: $color-white !default;
|
||||
$spinkit-spinner-margin: 0 0 0 1rem !default;
|
||||
$spinkit-size: 1rem !default;
|
||||
|
||||
// Snackbar default
|
||||
$woot-snackbar-bg: #323232;
|
||||
$woot-snackbar-button: #ffeb3b;
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
@import 'shared/assets/stylesheets/border-radius';
|
||||
@import 'variables';
|
||||
|
||||
@import '~spinkit/scss/spinners/7-three-bounce';
|
||||
@import 'vue-multiselect/dist/vue-multiselect.min.css';
|
||||
@import '~shared/assets/stylesheets/ionicons';
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -193,17 +193,17 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationCard from './widgets/conversation/ConversationCard.vue';
|
||||
import timeMixin from '../mixins/time';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
@@ -61,7 +61,7 @@
|
||||
>
|
||||
<a
|
||||
v-if="isAttributeTypeLink"
|
||||
:href="value"
|
||||
:href="hrefURL"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="value inline-block rounded-sm mb-0 break-all py-0.5 px-1"
|
||||
@@ -188,6 +188,9 @@ export default {
|
||||
urlValue() {
|
||||
return isValidURL(this.value) ? this.value : '---';
|
||||
},
|
||||
hrefURL() {
|
||||
return isValidURL(this.value) ? this.value : '';
|
||||
},
|
||||
notAttributeTypeCheckboxAndList() {
|
||||
return !this.isAttributeTypeCheckbox && !this.isAttributeTypeList;
|
||||
},
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WootSnackbar from './Snackbar';
|
||||
import WootSnackbar from './Snackbar.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -90,7 +90,7 @@ import { getUnixTime } from 'date-fns';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal';
|
||||
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import {
|
||||
hasPressedAltAndEKey,
|
||||
|
||||
@@ -49,11 +49,11 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
|
||||
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
|
||||
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider.vue';
|
||||
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
|
||||
|
||||
@@ -35,8 +35,8 @@ import adminMixin from '../../mixins/isAdmin';
|
||||
import { getSidebarItems } from './config/default-sidebar';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import PrimarySidebar from './sidebarComponents/Primary';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary';
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import {
|
||||
hasPressedAltAndCKey,
|
||||
hasPressedAltAndRKey,
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from '../../widgets/Thumbnail';
|
||||
import Thumbnail from '../../widgets/Thumbnail.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -109,9 +109,9 @@
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { mapGetters } from 'vuex';
|
||||
import Auth from '../../../api/auth';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
|
||||
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus';
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
import AvailabilityStatus from 'dashboard/components/layout/AvailabilityStatus.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -39,11 +39,11 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Logo from './Logo';
|
||||
import PrimaryNavItem from './PrimaryNavItem';
|
||||
import OptionsMenu from './OptionsMenu';
|
||||
import AgentDetails from './AgentDetails';
|
||||
import NotificationBell from './NotificationBell';
|
||||
import Logo from './Logo.vue';
|
||||
import PrimaryNavItem from './PrimaryNavItem.vue';
|
||||
import OptionsMenu from './OptionsMenu.vue';
|
||||
import AgentDetails from './AgentDetails.vue';
|
||||
import NotificationBell from './NotificationBell.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { ACCOUNT_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
|
||||
@@ -97,7 +97,7 @@ import {
|
||||
getInboxWarningIconClass,
|
||||
} from 'dashboard/helper/inbox';
|
||||
|
||||
import SecondaryChildNavItem from './SecondaryChildNavItem';
|
||||
import SecondaryChildNavItem from './SecondaryChildNavItem.vue';
|
||||
import {
|
||||
isOnMentionsView,
|
||||
isOnUnattendedView,
|
||||
|
||||
@@ -28,8 +28,8 @@
|
||||
</button>
|
||||
</template>
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
|
||||
|
||||
export default {
|
||||
name: 'WootButton',
|
||||
|
||||
@@ -108,7 +108,7 @@
|
||||
<script>
|
||||
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
|
||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
export default {
|
||||
components: {
|
||||
AutomationActionTeamMessageInput,
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
export default {
|
||||
components: {
|
||||
@@ -61,11 +61,9 @@ export default {
|
||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADING');
|
||||
try {
|
||||
const file = event.target.files[0];
|
||||
const formData = new FormData();
|
||||
formData.append('attachment', file, file.name);
|
||||
const id = await this.$store.dispatch(
|
||||
'automations/uploadAttachment',
|
||||
formData
|
||||
file
|
||||
);
|
||||
this.$emit('input', [id]);
|
||||
this.uploadState = 'uploaded';
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
/>
|
||||
</template>
|
||||
<script>
|
||||
import ChannelSelector from '../ChannelSelector';
|
||||
import ChannelSelector from '../ChannelSelector.vue';
|
||||
export default {
|
||||
components: { ChannelSelector },
|
||||
props: {
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState';
|
||||
import LoadingState from 'dashboard/components/widgets/LoadingState.vue';
|
||||
export default {
|
||||
components: {
|
||||
LoadingState,
|
||||
|
||||
@@ -30,9 +30,9 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel';
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import {
|
||||
|
||||
@@ -42,7 +42,7 @@
|
||||
* Badge - Chat source indication { fb / telegram }
|
||||
* Username - Username for avatar
|
||||
*/
|
||||
import Avatar from './Avatar';
|
||||
import Avatar from './Avatar.vue';
|
||||
import { removeEmoji } from 'shared/helpers/emoji';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import Thumbnail from './Thumbnail';
|
||||
import Thumbnail from './Thumbnail.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div class="flex items-center text-left">
|
||||
<div class="flex items-center gap-1.5 text-left">
|
||||
<thumbnail
|
||||
:src="user.thumbnail"
|
||||
:size="size"
|
||||
@@ -7,7 +7,7 @@
|
||||
:status="user.availability_status"
|
||||
/>
|
||||
<h6
|
||||
class="my-0 mx-2 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
class="my-0 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis text-capitalize"
|
||||
:class="textClass"
|
||||
>
|
||||
{{ user.name }}
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
:search-key="variableSearchTerm"
|
||||
@click="insertVariable"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
type="file"
|
||||
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
|
||||
hidden
|
||||
@change="onFileChange"
|
||||
/>
|
||||
<div ref="editor" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -22,7 +29,7 @@
|
||||
<script>
|
||||
import {
|
||||
messageSchema,
|
||||
wootMessageWriterSetup,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
@@ -34,11 +41,16 @@ import {
|
||||
triggerCharacters,
|
||||
} from '@chatwoot/prosemirror-schema/src/mentions/plugin';
|
||||
|
||||
import TagAgents from '../conversation/TagAgents';
|
||||
import CannedResponse from '../conversation/CannedResponse';
|
||||
import VariableList from '../conversation/VariableList';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
const MAXIMUM_FILE_UPLOAD_SIZE = 4; // in MB
|
||||
|
||||
import {
|
||||
hasPressedEnterAndNotCmdOrShift,
|
||||
@@ -51,14 +63,27 @@ import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import { findNodeToInsertImage } from 'dashboard/helper/messageEditorHelper';
|
||||
import { MESSAGE_EDITOR_MENU_OPTIONS } from 'dashboard/constants/editor';
|
||||
|
||||
const createState = (content, placeholder, plugins = []) => {
|
||||
const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new MessageMarkdownTransformer(messageSchema).parse(content),
|
||||
plugins: wootMessageWriterSetup({
|
||||
plugins: buildEditor({
|
||||
schema: messageSchema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -66,7 +91,7 @@ const createState = (content, placeholder, plugins = []) => {
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
mixins: [eventListenerMixins, uiSettingsMixin],
|
||||
mixins: [eventListenerMixins, uiSettingsMixin, alertMixin],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
@@ -78,6 +103,11 @@ export default {
|
||||
enableVariables: { type: Boolean, default: false },
|
||||
enableCannedResponses: { type: Boolean, default: true },
|
||||
variables: { type: Object, default: () => ({}) },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
signature: { type: String, default: '' },
|
||||
// allowSignature is a kill switch, ensuring no signature methods
|
||||
// are triggered except when this flag is true
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -104,6 +134,11 @@ export default {
|
||||
this.enableCannedResponses && this.showCannedMenu && !this.isPrivate
|
||||
);
|
||||
},
|
||||
editorMenuOptions() {
|
||||
return this.enabledMenuOptions.length
|
||||
? this.enabledMenuOptions
|
||||
: MESSAGE_EDITOR_MENU_OPTIONS;
|
||||
},
|
||||
plugins() {
|
||||
if (!this.enableSuggestions) {
|
||||
return [];
|
||||
@@ -193,6 +228,12 @@ export default {
|
||||
}),
|
||||
];
|
||||
},
|
||||
sendWithSignature() {
|
||||
// this is considered the source of truth, we watch this property
|
||||
// on change, we toggle the signature in the editor
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
return isEnabled && this.allowSignature && !this.isPrivate;
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
showUserMentions(updatedValue) {
|
||||
@@ -204,20 +245,19 @@ export default {
|
||||
showVariables(updatedValue) {
|
||||
this.$emit('toggle-variables-menu', !this.isPrivate && updatedValue);
|
||||
},
|
||||
value(newValue = '') {
|
||||
if (newValue !== this.contentFromEditor) {
|
||||
this.reloadState();
|
||||
value(newVal = '') {
|
||||
if (newVal !== this.contentFromEditor) {
|
||||
this.reloadState(newVal);
|
||||
}
|
||||
},
|
||||
editorId() {
|
||||
this.showCannedMenu = false;
|
||||
this.cannedSearchTerm = '';
|
||||
this.reloadState();
|
||||
this.reloadState(this.value);
|
||||
},
|
||||
isPrivate() {
|
||||
this.reloadState();
|
||||
this.reloadState(this.value);
|
||||
},
|
||||
|
||||
updateSelectionWith(newValue, oldValue) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
@@ -236,20 +276,106 @@ export default {
|
||||
}
|
||||
return null;
|
||||
},
|
||||
sendWithSignature(newValue) {
|
||||
// see if the allowSignature flag is true
|
||||
if (this.allowSignature) {
|
||||
this.toggleSignatureInEditor(newValue);
|
||||
}
|
||||
},
|
||||
},
|
||||
created() {
|
||||
this.state = createState(this.value, this.placeholder, this.plugins);
|
||||
this.state = createState(
|
||||
this.value,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.editorMenuOptions
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
this.createEditorView();
|
||||
this.editorView.updateState(this.state);
|
||||
this.focusEditorInputField();
|
||||
this.focusEditor(this.value);
|
||||
},
|
||||
methods: {
|
||||
reloadState() {
|
||||
this.state = createState(this.value, this.placeholder, this.plugins);
|
||||
reloadState(content = this.value) {
|
||||
this.state = createState(
|
||||
content,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.editorMenuOptions
|
||||
);
|
||||
this.editorView.updateState(this.state);
|
||||
this.focusEditorInputField();
|
||||
|
||||
this.focusEditor(content);
|
||||
},
|
||||
focusEditor(content) {
|
||||
if (this.isBodyEmpty(content) && this.sendWithSignature) {
|
||||
// reload state can be called when switching between conversations, or when drafts is loaded
|
||||
// these drafts can also have a signature, so we need to check if the body is empty
|
||||
// and handle things accordingly
|
||||
this.handleEmptyBodyWithSignature();
|
||||
} else {
|
||||
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
|
||||
// the position is set to start, because the signature is added at the end of the body
|
||||
this.focusEditorInputField('end');
|
||||
}
|
||||
},
|
||||
toggleSignatureInEditor(signatureEnabled) {
|
||||
// The toggleSignatureInEditor gets the new value from the
|
||||
// watcher, this means that if the value is true, the signature
|
||||
// is supposed to be added, else we remove it.
|
||||
if (signatureEnabled) {
|
||||
this.addSignature();
|
||||
} else {
|
||||
this.removeSignature();
|
||||
}
|
||||
},
|
||||
addSignature() {
|
||||
let content = this.value;
|
||||
// see if the content is empty, if it is before appending the signature
|
||||
// we need to add a paragraph node and move the cursor at the start of the editor
|
||||
const contentWasEmpty = this.isBodyEmpty(content);
|
||||
content = appendSignature(content, this.signature);
|
||||
// need to reload first, ensuring that the editorView is updated
|
||||
this.reloadState(content);
|
||||
|
||||
if (contentWasEmpty) {
|
||||
this.handleEmptyBodyWithSignature();
|
||||
}
|
||||
},
|
||||
removeSignature() {
|
||||
if (!this.signature) return;
|
||||
let content = this.value;
|
||||
content = removeSignature(content, this.signature);
|
||||
// reload the state, ensuring that the editorView is updated
|
||||
this.reloadState(content);
|
||||
},
|
||||
isBodyEmpty(content) {
|
||||
// if content is undefined, we assume that the body is empty
|
||||
if (!content) return true;
|
||||
|
||||
// if the signature is present, we need to remove it before checking
|
||||
// note that we don't update the editorView, so this is safe
|
||||
const bodyWithoutSignature = this.signature
|
||||
? removeSignature(content, this.signature)
|
||||
: content;
|
||||
|
||||
// trimming should remove all the whitespaces, so we can check the length
|
||||
return bodyWithoutSignature.trim().length === 0;
|
||||
},
|
||||
handleEmptyBodyWithSignature() {
|
||||
const { schema, tr } = this.state;
|
||||
|
||||
// create a paragraph node and
|
||||
// start a transaction to append it at the end
|
||||
const paragraph = schema.nodes.paragraph.create();
|
||||
const paragraphTransaction = tr.insert(0, paragraph);
|
||||
this.editorView.dispatch(paragraphTransaction);
|
||||
|
||||
// Set the focus at the start of the input field
|
||||
this.focusEditorInputField('start');
|
||||
},
|
||||
createEditorView() {
|
||||
this.editorView = new EditorView(this.$refs.editor, {
|
||||
@@ -294,9 +420,11 @@ export default {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
},
|
||||
focusEditorInputField() {
|
||||
focusEditorInputField(pos = 'end') {
|
||||
const { tr } = this.editorView.state;
|
||||
const selection = Selection.atEnd(tr.doc);
|
||||
|
||||
const selection =
|
||||
pos === 'end' ? Selection.atEnd(tr.doc) : Selection.atStart(tr.doc);
|
||||
|
||||
this.editorView.dispatch(tr.setSelection(selection));
|
||||
this.editorView.focus();
|
||||
@@ -374,6 +502,57 @@ export default {
|
||||
tr.scrollIntoView();
|
||||
return false;
|
||||
},
|
||||
openFileBrowser() {
|
||||
this.$refs.imageUpload.click();
|
||||
},
|
||||
onFileChange() {
|
||||
const file = this.$refs.imageUpload.files[0];
|
||||
if (checkFileSizeLimit(file, MAXIMUM_FILE_UPLOAD_SIZE)) {
|
||||
this.uploadImageToStorage(file);
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SIZE_ERROR',
|
||||
{
|
||||
size: MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
}
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
this.$refs.imageUpload.value = '';
|
||||
},
|
||||
async uploadImageToStorage(file) {
|
||||
try {
|
||||
const { fileUrl } = await uploadFile(file);
|
||||
if (fileUrl) {
|
||||
this.onImageInsertInEditor(fileUrl);
|
||||
}
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_SUCCESS'
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
this.showAlert(
|
||||
this.$t(
|
||||
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.IMAGE_UPLOAD_ERROR'
|
||||
)
|
||||
);
|
||||
}
|
||||
},
|
||||
onImageInsertInEditor(fileUrl) {
|
||||
const { tr } = this.editorView.state;
|
||||
|
||||
const insertData = findNodeToInsertImage(this.editorView.state, fileUrl);
|
||||
|
||||
if (insertData) {
|
||||
this.editorView.dispatch(
|
||||
tr.insert(insertData.pos, insertData.node).scrollIntoView()
|
||||
);
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
},
|
||||
|
||||
emitOnChange() {
|
||||
this.editorView.updateState(this.state);
|
||||
@@ -491,6 +670,10 @@ export default {
|
||||
|
||||
.ProseMirror-prompt {
|
||||
@apply z-50 bg-slate-25 dark:bg-slate-700 rounded-md border border-solid border-slate-75 dark:border-slate-800;
|
||||
|
||||
h5 {
|
||||
@apply dark:text-slate-25 text-slate-800;
|
||||
}
|
||||
}
|
||||
|
||||
.is-private {
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<script>
|
||||
import {
|
||||
fullSchema,
|
||||
wootArticleWriterSetup,
|
||||
buildEditor,
|
||||
EditorView,
|
||||
ArticleMarkdownSerializer,
|
||||
ArticleMarkdownTransformer,
|
||||
@@ -33,15 +33,17 @@ const createState = (
|
||||
content,
|
||||
placeholder,
|
||||
plugins = [],
|
||||
onImageUpload = () => {}
|
||||
methods = {},
|
||||
enabledMenuOptions
|
||||
) => {
|
||||
return EditorState.create({
|
||||
doc: new ArticleMarkdownTransformer(fullSchema).parse(content),
|
||||
plugins: wootArticleWriterSetup({
|
||||
plugins: buildEditor({
|
||||
schema: fullSchema,
|
||||
placeholder,
|
||||
methods,
|
||||
plugins,
|
||||
onImageUpload,
|
||||
enabledMenuOptions,
|
||||
}),
|
||||
});
|
||||
};
|
||||
@@ -52,6 +54,7 @@ export default {
|
||||
value: { type: String, default: '' },
|
||||
editorId: { type: String, default: '' },
|
||||
placeholder: { type: String, default: '' },
|
||||
enabledMenuOptions: { type: Array, default: () => [] },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -83,7 +86,8 @@ export default {
|
||||
this.value,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
this.openFileBrowser
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.enabledMenuOptions
|
||||
);
|
||||
},
|
||||
mounted() {
|
||||
@@ -152,7 +156,8 @@ export default {
|
||||
this.value,
|
||||
this.placeholder,
|
||||
this.plugins,
|
||||
this.openFileBrowser
|
||||
{ onImageUpload: this.openFileBrowser },
|
||||
this.enabledMenuOptions
|
||||
);
|
||||
this.editorView.updateState(this.state);
|
||||
this.focusEditorInputField();
|
||||
|
||||
@@ -134,7 +134,7 @@ import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
|
||||
} from 'shared/constants/messages';
|
||||
import VideoCallButton from '../VideoCallButton';
|
||||
import VideoCallButton from '../VideoCallButton.vue';
|
||||
import AIAssistanceButton from '../AIAssistanceButton.vue';
|
||||
import { REPLY_EDITOR_MODES } from './constants';
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -288,7 +288,7 @@ export default {
|
||||
}
|
||||
},
|
||||
showMessageSignatureButton() {
|
||||
return !this.isOnPrivateNote && this.isAnEmailChannel;
|
||||
return !this.isOnPrivateNote;
|
||||
},
|
||||
sendWithSignature() {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
|
||||
+1
@@ -358,6 +358,7 @@ export default {
|
||||
applied_filters: this.appliedFilters.map(filter => ({
|
||||
key: filter.attribute_key,
|
||||
operator: filter.filter_operator,
|
||||
query_operator: filter.query_operator,
|
||||
})),
|
||||
});
|
||||
},
|
||||
|
||||
+1
-1
@@ -46,7 +46,7 @@
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import FilterItem from './FilterItem';
|
||||
import FilterItem from './FilterItem.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -58,11 +58,11 @@
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel';
|
||||
import ConversationHeader from './ConversationHeader';
|
||||
import ContactPanel from 'dashboard/routes/dashboard/conversation/ContactPanel.vue';
|
||||
import ConversationHeader from './ConversationHeader.vue';
|
||||
import DashboardAppFrame from '../DashboardApp/Frame.vue';
|
||||
import EmptyState from './EmptyState/EmptyState';
|
||||
import MessagesView from './MessagesView';
|
||||
import EmptyState from './EmptyState/EmptyState.vue';
|
||||
import MessagesView from './MessagesView.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -147,16 +147,16 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import conversationMixin from '../../../mixins/conversations';
|
||||
import timeMixin from '../../../mixins/time';
|
||||
import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
const ATTACHMENT_ICONS = {
|
||||
|
||||
@@ -73,12 +73,12 @@
|
||||
import { hasPressedAltAndOKey } from 'shared/helpers/KeyboardHelpers';
|
||||
import { mapGetters } from 'vuex';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName';
|
||||
import MoreActions from './MoreActions';
|
||||
import Thumbnail from '../Thumbnail';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
|
||||
import { conversationReopenTime } from 'dashboard/helper/snoozeHelpers';
|
||||
|
||||
@@ -37,8 +37,8 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import adminMixin from 'dashboard/mixins/isAdmin';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
import OnboardingView from '../OnboardingView';
|
||||
import EmptyStateMessage from './EmptyStateMessage';
|
||||
import OnboardingView from '../OnboardingView.vue';
|
||||
import EmptyStateMessage from './EmptyStateMessage.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Hotkey from 'dashboard/components/base/Hotkey';
|
||||
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -130,18 +130,18 @@
|
||||
</template>
|
||||
<script>
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import BubbleActions from './bubble/Actions';
|
||||
import BubbleFile from './bubble/File';
|
||||
import BubbleImage from './bubble/Image';
|
||||
import BubbleVideo from './bubble/Video';
|
||||
import BubbleImageAudioVideo from './bubble/ImageAudioVideo';
|
||||
import BubbleActions from './bubble/Actions.vue';
|
||||
import BubbleFile from './bubble/File.vue';
|
||||
import BubbleImage from './bubble/Image.vue';
|
||||
import BubbleVideo from './bubble/Video.vue';
|
||||
import BubbleImageAudioVideo from './bubble/ImageAudioVideo.vue';
|
||||
import BubbleIntegration from './bubble/Integration.vue';
|
||||
import BubbleLocation from './bubble/Location';
|
||||
import BubbleMailHead from './bubble/MailHead';
|
||||
import BubbleText from './bubble/Text';
|
||||
import BubbleContact from './bubble/Contact';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu';
|
||||
import BubbleLocation from './bubble/Location.vue';
|
||||
import BubbleMailHead from './bubble/MailHead.vue';
|
||||
import BubbleText from './bubble/Text.vue';
|
||||
import BubbleContact from './bubble/Contact.vue';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import ContextMenu from 'dashboard/modules/conversations/components/MessageContextMenu.vue';
|
||||
import instagramImageErrorPlaceholder from './instagramImageErrorPlaceholder.vue';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
|
||||
|
||||
@@ -98,9 +98,9 @@
|
||||
|
||||
<script>
|
||||
// components
|
||||
import ReplyBox from './ReplyBox';
|
||||
import Message from './Message';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion';
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import Message from './Message.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
// stores and apis
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
import EmailTranscriptModal from './EmailTranscriptModal';
|
||||
import ResolveAction from '../../buttons/ResolveAction';
|
||||
import EmailTranscriptModal from './EmailTranscriptModal.vue';
|
||||
import ResolveAction from '../../buttons/ResolveAction.vue';
|
||||
import {
|
||||
CMD_MUTE_CONVERSATION,
|
||||
CMD_SEND_TRANSCRIPT,
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
class="input"
|
||||
:placeholder="messagePlaceHolder"
|
||||
:min-height="4"
|
||||
:signature="signatureToApply"
|
||||
:allow-signature="true"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@@ -69,6 +72,8 @@
|
||||
:min-height="4"
|
||||
:enable-variables="true"
|
||||
:variables="messageVariables"
|
||||
:signature="signatureToApply"
|
||||
:allow-signature="true"
|
||||
@typing-off="onTypingOff"
|
||||
@typing-on="onTypingOn"
|
||||
@focus="onFocus"
|
||||
@@ -86,16 +91,10 @@
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-if="isSignatureEnabledForInbox"
|
||||
v-tooltip="$t('CONVERSATION.FOOTER.MESSAGE_SIGN_TOOLTIP')"
|
||||
v-if="isSignatureEnabledForInbox && !isSignatureAvailable"
|
||||
class="message-signature-wrap"
|
||||
>
|
||||
<p
|
||||
v-if="isSignatureAvailable"
|
||||
v-dompurify-html="formatMessage(messageSignature)"
|
||||
class="message-signature"
|
||||
/>
|
||||
<p v-else class="message-signature">
|
||||
<p class="message-signature">
|
||||
{{ $t('CONVERSATION.FOOTER.MESSAGE_SIGNATURE_NOT_CONFIGURED') }}
|
||||
<router-link :to="profilePath">
|
||||
{{ $t('CONVERSATION.FOOTER.CLICK_HERE') }}
|
||||
@@ -149,23 +148,18 @@ import { mapGetters } from 'vuex';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
|
||||
import CannedResponse from './CannedResponse';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel';
|
||||
import ReplyEmailHead from './ReplyEmailHead';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel';
|
||||
import CannedResponse from './CannedResponse.vue';
|
||||
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview.vue';
|
||||
import ReplyTopPanel from 'dashboard/components/widgets/WootWriter/ReplyTopPanel.vue';
|
||||
import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor';
|
||||
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import {
|
||||
MAXIMUM_FILE_UPLOAD_SIZE,
|
||||
MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL,
|
||||
AUDIO_FORMATS,
|
||||
} from 'shared/constants/messages';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
getMessageVariables,
|
||||
@@ -177,13 +171,19 @@ import { buildHotKeys } from 'shared/helpers/KeyboardHelpers';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
|
||||
import { DirectUpload } from 'activestorage';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
const EmojiInput = () => import('shared/components/emoji/EmojiInput');
|
||||
|
||||
@@ -208,6 +208,7 @@ export default {
|
||||
alertMixin,
|
||||
messageFormatterMixin,
|
||||
rtlMixin,
|
||||
fileUploadMixin,
|
||||
],
|
||||
props: {
|
||||
selectedTweet: {
|
||||
@@ -471,10 +472,10 @@ export default {
|
||||
);
|
||||
},
|
||||
isSignatureEnabledForInbox() {
|
||||
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
|
||||
return !this.isPrivate && this.sendWithSignature;
|
||||
},
|
||||
isSignatureAvailable() {
|
||||
return !!this.messageSignature;
|
||||
return !!this.signatureToApply;
|
||||
},
|
||||
sendWithSignature() {
|
||||
const { send_with_signature: isEnabled } = this.uiSettings;
|
||||
@@ -514,6 +515,12 @@ export default {
|
||||
});
|
||||
return variables;
|
||||
},
|
||||
// ensure that the signature is plain text depending on `showRichContentEditor`
|
||||
signatureToApply() {
|
||||
return this.showRichContentEditor
|
||||
? this.messageSignature
|
||||
: extractTextFromMarkdown(this.messageSignature);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
currentChat(conversation) {
|
||||
@@ -581,6 +588,23 @@ export default {
|
||||
this.updateUISettings({
|
||||
display_rich_content_editor: !this.showRichContentEditor,
|
||||
});
|
||||
|
||||
const plainTextSignature = extractTextFromMarkdown(this.messageSignature);
|
||||
|
||||
if (!this.showRichContentEditor && this.messageSignature) {
|
||||
// remove the old signature -> extract text from markdown -> attach new signature
|
||||
let message = removeSignature(this.message, this.messageSignature);
|
||||
message = extractTextFromMarkdown(message);
|
||||
message = appendSignature(message, plainTextSignature);
|
||||
|
||||
this.message = message;
|
||||
} else {
|
||||
this.message = replaceSignature(
|
||||
this.message,
|
||||
plainTextSignature,
|
||||
this.messageSignature
|
||||
);
|
||||
}
|
||||
},
|
||||
saveDraft(conversationId, replyType) {
|
||||
if (this.message || this.message === '') {
|
||||
@@ -600,9 +624,22 @@ export default {
|
||||
getFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
this.message = this.$store.getters['draftMessages/get'](key) || '';
|
||||
const messageFromStore =
|
||||
this.$store.getters['draftMessages/get'](key) || '';
|
||||
|
||||
// ensure that the message has signature set based on the ui setting
|
||||
this.message = this.toggleSignatureForDraft(messageFromStore);
|
||||
}
|
||||
},
|
||||
toggleSignatureForDraft(message) {
|
||||
if (this.isPrivate) {
|
||||
return message;
|
||||
}
|
||||
|
||||
return this.sendWithSignature
|
||||
? appendSignature(message, this.signatureToApply)
|
||||
: removeSignature(message, this.signatureToApply);
|
||||
},
|
||||
removeFromDraft() {
|
||||
if (this.conversationIdByRoute) {
|
||||
const key = `draft-${this.conversationIdByRoute}-${this.replyType}`;
|
||||
@@ -694,19 +731,14 @@ export default {
|
||||
return;
|
||||
}
|
||||
if (!this.showMentions) {
|
||||
let newMessage = this.message;
|
||||
if (this.isSignatureEnabledForInbox && this.messageSignature) {
|
||||
newMessage += '\n\n' + this.messageSignature;
|
||||
}
|
||||
|
||||
const isOnWhatsApp =
|
||||
this.isATwilioWhatsAppChannel ||
|
||||
this.isAWhatsAppCloudChannel ||
|
||||
this.is360DialogWhatsAppChannel;
|
||||
if (isOnWhatsApp && !this.isPrivate) {
|
||||
this.sendMessageAsMultipleMessages(newMessage);
|
||||
this.sendMessageAsMultipleMessages(this.message);
|
||||
} else {
|
||||
const messagePayload = this.getMessagePayload(newMessage);
|
||||
const messagePayload = this.getMessagePayload(this.message);
|
||||
this.sendMessage(messagePayload);
|
||||
}
|
||||
|
||||
@@ -828,6 +860,10 @@ export default {
|
||||
},
|
||||
clearMessage() {
|
||||
this.message = '';
|
||||
if (this.sendWithSignature && !this.isPrivate) {
|
||||
// if signature is enabled, append it to the message
|
||||
this.message = appendSignature(this.message, this.signatureToApply);
|
||||
}
|
||||
this.attachedFiles = [];
|
||||
this.isRecordingAudio = false;
|
||||
},
|
||||
@@ -904,67 +940,6 @@ export default {
|
||||
isPrivate,
|
||||
});
|
||||
},
|
||||
onFileUpload(file) {
|
||||
if (this.globalConfig.directUploadsEnabled) {
|
||||
this.onDirectFileUpload(file);
|
||||
} else {
|
||||
this.onIndirectFileUpload(file);
|
||||
}
|
||||
},
|
||||
onDirectFileUpload(file) {
|
||||
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
|
||||
const upload = new DirectUpload(
|
||||
file.file,
|
||||
`/api/v1/accounts/${this.accountId}/conversations/${this.currentChat.id}/direct_uploads`,
|
||||
{
|
||||
directUploadWillCreateBlobWithXHR: xhr => {
|
||||
xhr.setRequestHeader(
|
||||
'api_access_token',
|
||||
this.currentUser.access_token
|
||||
);
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
upload.create((error, blob) => {
|
||||
if (error) {
|
||||
this.showAlert(error);
|
||||
} else {
|
||||
this.attachFile({ file, blob });
|
||||
}
|
||||
});
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
onIndirectFileUpload(file) {
|
||||
const MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE = this.isATwilioSMSChannel
|
||||
? MAXIMUM_FILE_UPLOAD_SIZE_TWILIO_SMS_CHANNEL
|
||||
: MAXIMUM_FILE_UPLOAD_SIZE;
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (checkFileSizeLimit(file, MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE)) {
|
||||
this.attachFile({ file });
|
||||
} else {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.FILE_SIZE_LIMIT', {
|
||||
MAXIMUM_SUPPORTED_FILE_UPLOAD_SIZE,
|
||||
})
|
||||
);
|
||||
}
|
||||
},
|
||||
attachFile({ blob, file }) {
|
||||
const reader = new FileReader();
|
||||
reader.readAsDataURL(file.file);
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
import GalleryView from '../components/GalleryView';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
|
||||
@@ -150,7 +150,7 @@ import {
|
||||
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
|
||||
import timeMixin from 'dashboard/mixins/time';
|
||||
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
+1
-1
@@ -96,7 +96,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
|
||||
export default {
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<script>
|
||||
import { required } from 'vuelidate/lib/validators';
|
||||
import Modal from '../../Modal';
|
||||
import Modal from '../../Modal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</modal>
|
||||
</template>
|
||||
<script>
|
||||
import Modal from '../../Modal';
|
||||
import Modal from '../../Modal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Modal from '../../Modal';
|
||||
import Modal from '../../Modal.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<script>
|
||||
import { mixin as clickaway } from 'vue-clickaway';
|
||||
import { SHORTCUT_KEYS } from './constants';
|
||||
import Hotkey from 'dashboard/components/base/Hotkey';
|
||||
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
export const MESSAGE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'code',
|
||||
];
|
||||
|
||||
export const MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'imageUpload',
|
||||
];
|
||||
|
||||
export const ARTICLE_EDITOR_MENU_OPTIONS = [
|
||||
'strong',
|
||||
'em',
|
||||
'link',
|
||||
'undo',
|
||||
'redo',
|
||||
'bulletList',
|
||||
'orderedList',
|
||||
'h1',
|
||||
'h2',
|
||||
'h3',
|
||||
'imageUpload',
|
||||
'code',
|
||||
];
|
||||
@@ -120,9 +120,18 @@ export const generateConditionOptions = (options, key = 'id') => {
|
||||
});
|
||||
};
|
||||
|
||||
// Add the "None" option to the agent list
|
||||
export const agentList = agents => [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
},
|
||||
...(agents || []),
|
||||
];
|
||||
|
||||
export const getActionOptions = ({ agents, teams, labels, type }) => {
|
||||
const actionsMap = {
|
||||
assign_agent: agents,
|
||||
assign_agent: agentList(agents),
|
||||
assign_team: teams,
|
||||
send_email_to_team: teams,
|
||||
add_label: generateConditionOptions(labels, 'title'),
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
import {
|
||||
messageSchema,
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
|
||||
/**
|
||||
* The delimiter used to separate the signature from the rest of the body.
|
||||
* @type {string}
|
||||
*/
|
||||
export const SIGNATURE_DELIMITER = '--';
|
||||
|
||||
/**
|
||||
* Parse and Serialize the markdown text to remove any extra spaces or new lines
|
||||
*/
|
||||
export function cleanSignature(signature) {
|
||||
// convert from markdown to common mark format
|
||||
const nodes = new MessageMarkdownTransformer(messageSchema).parse(signature);
|
||||
return MessageMarkdownSerializer.serialize(nodes);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the signature delimiter to the beginning of the signature.
|
||||
*
|
||||
* @param {string} signature - The signature to add the delimiter to.
|
||||
* @returns {string} - The signature with the delimiter added.
|
||||
*/
|
||||
function appendDelimiter(signature) {
|
||||
return `${SIGNATURE_DELIMITER}\n\n${cleanSignature(signature)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if there's an unedited signature at the end of the body
|
||||
* If there is, return the index of the signature, If there isn't, return -1
|
||||
*
|
||||
* @param {string} body - The body to search for the signature.
|
||||
* @param {string} signature - The signature to search for.
|
||||
* @returns {number} - The index of the last occurrence of the signature in the body, or -1 if not found.
|
||||
*/
|
||||
export function findSignatureInBody(body, signature) {
|
||||
const trimmedBody = body.trimEnd();
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
|
||||
// check if body ends with signature
|
||||
if (trimmedBody.endsWith(cleanedSignature)) {
|
||||
return body.lastIndexOf(cleanedSignature);
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends the signature to the body, separated by the signature delimiter.
|
||||
*
|
||||
* @param {string} body - The body to append the signature to.
|
||||
* @param {string} signature - The signature to append.
|
||||
* @returns {string} - The body with the signature appended.
|
||||
*/
|
||||
export function appendSignature(body, signature) {
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
// if signature is already present, return body
|
||||
if (findSignatureInBody(body, cleanedSignature) > -1) {
|
||||
return body;
|
||||
}
|
||||
|
||||
return `${body.trimEnd()}\n\n${appendDelimiter(cleanedSignature)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the signature from the body, along with the signature delimiter.
|
||||
*
|
||||
* @param {string} body - The body to remove the signature from.
|
||||
* @param {string} signature - The signature to remove.
|
||||
* @returns {string} - The body with the signature removed.
|
||||
*/
|
||||
export function removeSignature(body, signature) {
|
||||
// this will find the index of the signature if it exists
|
||||
// Regardless of extra spaces or new lines after the signature, the index will be the same if present
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
const signatureIndex = findSignatureInBody(body, cleanedSignature);
|
||||
|
||||
// no need to trim the ends here, because it will simply be removed in the next method
|
||||
let newBody = body;
|
||||
|
||||
// if signature is present, remove it and trim it
|
||||
// trimming will ensure any spaces or new lines before the signature are removed
|
||||
// This means we will have the delimiter at the end
|
||||
if (signatureIndex > -1) {
|
||||
newBody = newBody.substring(0, signatureIndex).trimEnd();
|
||||
}
|
||||
|
||||
// let's find the delimiter and remove it
|
||||
const delimiterIndex = newBody.lastIndexOf(SIGNATURE_DELIMITER);
|
||||
if (
|
||||
delimiterIndex !== -1 &&
|
||||
delimiterIndex === newBody.length - SIGNATURE_DELIMITER.length // this will ensure the delimiter is at the end
|
||||
) {
|
||||
// if the delimiter is at the end, remove it
|
||||
newBody = newBody.substring(0, delimiterIndex);
|
||||
}
|
||||
|
||||
// return the value
|
||||
return newBody;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replaces the old signature with the new signature.
|
||||
* If the old signature is not present, it will append the new signature.
|
||||
*
|
||||
* @param {string} body - The body to replace the signature in.
|
||||
* @param {string} oldSignature - The signature to replace.
|
||||
* @param {string} newSignature - The signature to replace the old signature with.
|
||||
* @returns {string} - The body with the old signature replaced with the new signature.
|
||||
*
|
||||
*/
|
||||
export function replaceSignature(body, oldSignature, newSignature) {
|
||||
const withoutSignature = removeSignature(body, oldSignature);
|
||||
return appendSignature(withoutSignature, newSignature);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text from markdown, and remove all images, code blocks, links, headers, bold, italic, lists etc.
|
||||
* Links will be converted to text, and not removed.
|
||||
*
|
||||
* @param {string} markdown - markdown text to be extracted
|
||||
* @returns
|
||||
*/
|
||||
export function extractTextFromMarkdown(markdown) {
|
||||
return markdown
|
||||
.replace(/```[\s\S]*?```/g, '') // Remove code blocks
|
||||
.replace(/`.*?`/g, '') // Remove inline code
|
||||
.replace(/!\[.*?\]\(.*?\)/g, '') // Remove images before removing links
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1') // Remove links but keep the text
|
||||
.replace(/#+\s*|[*_-]{1,3}/g, '') // Remove headers, bold, italic, lists etc.
|
||||
.split('\n')
|
||||
.map(line => line.trim())
|
||||
.filter(Boolean)
|
||||
.join('\n') // Trim each line & remove any lines only having spaces
|
||||
.replace(/\n{2,}/g, '\n') // Remove multiple consecutive newlines (blank lines)
|
||||
.trim(); // Trim any extra space
|
||||
}
|
||||
@@ -1,21 +1,34 @@
|
||||
const setArrayValues = item => {
|
||||
return item.values[0]?.id ? item.values.map(val => val.id) : item.values;
|
||||
};
|
||||
|
||||
const generateValues = item => {
|
||||
if (item.attribute_key === 'content') {
|
||||
const values = item.values || '';
|
||||
return values.split(',');
|
||||
}
|
||||
if (Array.isArray(item.values)) {
|
||||
return setArrayValues(item);
|
||||
}
|
||||
if (typeof item.values === 'object') {
|
||||
return [item.values.id];
|
||||
}
|
||||
if (!item.values) {
|
||||
return [];
|
||||
}
|
||||
return [item.values];
|
||||
};
|
||||
|
||||
const generatePayload = data => {
|
||||
// Make a copy of data to avoid vue data reactivity issues
|
||||
const filters = JSON.parse(JSON.stringify(data));
|
||||
let payload = filters.map(item => {
|
||||
if (Array.isArray(item.values)) {
|
||||
item.values = setArrayValues(item);
|
||||
} else if (typeof item.values === 'object') {
|
||||
item.values = [item.values.id];
|
||||
} else if (!item.values) {
|
||||
item.values = [];
|
||||
} else {
|
||||
item.values = [item.values];
|
||||
}
|
||||
// If item key is content, we will split it using comma and return as array
|
||||
// FIX ME: Make this generic option instead of using the key directly here
|
||||
item.values = generateValues(item);
|
||||
return item;
|
||||
});
|
||||
|
||||
// For every query added, the query_operator is set default to and so the
|
||||
// last query will have an extra query_operator, this would break the api.
|
||||
// Setting this to null for all query payload
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Determines the appropriate node and position to insert an image in the editor.
|
||||
*
|
||||
* Based on the current editor state and the provided image URL, this function finds out the correct node (either
|
||||
* a standalone image node or an image wrapped in a paragraph) and its respective position in the editor.
|
||||
*
|
||||
* 1. If the current node is a paragraph and doesn't contain an image or text, the image is inserted directly into it.
|
||||
* 2. If the current node isn't a paragraph or it's a paragraph containing text, the image will be wrapped
|
||||
* in a new paragraph and then inserted.
|
||||
* 3. If the current node is a paragraph containing an image, the new image will be inserted directly into it.
|
||||
*
|
||||
* @param {Object} editorState - The current state of the editor. It provides necessary details like selection, schema, etc.
|
||||
* @param {string} fileUrl - The URL of the image to be inserted into the editor.
|
||||
* @returns {Object|null} An object containing details about the node to be inserted and its position. It returns null if no image node can be created.
|
||||
* @property {Node} node - The ProseMirror node to be inserted (either an image node or a paragraph containing the image).
|
||||
* @property {number} pos - The position where the new node should be inserted in the editor.
|
||||
*/
|
||||
|
||||
export const findNodeToInsertImage = (editorState, fileUrl) => {
|
||||
const { selection, schema } = editorState;
|
||||
const { nodes } = schema;
|
||||
const currentNode = selection.$from.node();
|
||||
const {
|
||||
type: { name: typeName },
|
||||
content: { size, content },
|
||||
} = currentNode;
|
||||
|
||||
const imageNode = nodes.image.create({ src: fileUrl });
|
||||
|
||||
if (!imageNode) return null;
|
||||
|
||||
const isInParagraph = typeName === 'paragraph';
|
||||
const needsNewLine =
|
||||
!content.some(n => n.type.name === 'image') && size !== 0 ? 1 : 0;
|
||||
|
||||
return {
|
||||
node: isInParagraph ? imageNode : nodes.paragraph.create({}, imageNode),
|
||||
pos: selection.from + needsNewLine,
|
||||
};
|
||||
};
|
||||
@@ -49,3 +49,38 @@ export const validateLoggedInRoutes = (to, user, roleWiseRoutes) => {
|
||||
// Proceed to the route if none of the above conditions are met
|
||||
return null;
|
||||
};
|
||||
|
||||
export const isAConversationRoute = routeName =>
|
||||
[
|
||||
'inbox_conversation',
|
||||
'conversation_through_mentions',
|
||||
'conversation_through_unattended',
|
||||
'conversation_through_inbox',
|
||||
'conversations_through_label',
|
||||
'conversations_through_team',
|
||||
'conversations_through_folders',
|
||||
'conversation_through_participating',
|
||||
].includes(routeName);
|
||||
|
||||
export const getConversationDashboardRoute = routeName => {
|
||||
switch (routeName) {
|
||||
case 'inbox_conversation':
|
||||
return 'home';
|
||||
case 'conversation_through_mentions':
|
||||
return 'conversation_mentions';
|
||||
case 'conversation_through_unattended':
|
||||
return 'conversation_unattended';
|
||||
case 'conversations_through_label':
|
||||
return 'label_conversations';
|
||||
case 'conversations_through_team':
|
||||
return 'team_conversations';
|
||||
case 'conversations_through_folders':
|
||||
return 'folder_conversations';
|
||||
case 'conversation_through_participating':
|
||||
return 'conversation_participating';
|
||||
case 'conversation_through_inbox':
|
||||
return 'inbox_dashboard';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
import {
|
||||
findSignatureInBody,
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
replaceSignature,
|
||||
cleanSignature,
|
||||
extractTextFromMarkdown,
|
||||
} from '../editorHelper';
|
||||
|
||||
const NEW_SIGNATURE = 'This is a new signature';
|
||||
|
||||
const DOES_NOT_HAVE_SIGNATURE = {
|
||||
'no signature': {
|
||||
body: 'This is a test',
|
||||
signature: 'This is a signature',
|
||||
},
|
||||
'text after signature': {
|
||||
body: 'This is a test\n\n--\n\nThis is a signature\n\nThis is more text',
|
||||
signature: 'This is a signature',
|
||||
},
|
||||
'signature has images': {
|
||||
body: 'This is a test',
|
||||
signature:
|
||||
'Testing\n',
|
||||
},
|
||||
'signature has non commonmark syntax': {
|
||||
body: 'This is a test',
|
||||
signature: '- Signature',
|
||||
},
|
||||
'signature has trailing spaces': {
|
||||
body: 'This is a test',
|
||||
signature: '**hello** \n**world**',
|
||||
},
|
||||
};
|
||||
|
||||
const HAS_SIGNATURE = {
|
||||
'signature at end': {
|
||||
body: 'This is a test\n\n--\n\nThis is a signature',
|
||||
signature: 'This is a signature',
|
||||
},
|
||||
'signature at end with spaces and new lines': {
|
||||
body: 'This is a test\n\n--\n\nThis is a signature \n\n',
|
||||
signature: 'This is a signature ',
|
||||
},
|
||||
'no text before signature': {
|
||||
body: '\n\n--\n\nThis is a signature',
|
||||
signature: 'This is a signature',
|
||||
},
|
||||
'signature has non-commonmark syntax': {
|
||||
body: '\n\n--\n\n* Signature',
|
||||
signature: '- Signature',
|
||||
},
|
||||
};
|
||||
|
||||
describe('findSignatureInBody', () => {
|
||||
it('returns -1 if there is no signature', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
expect(findSignatureInBody(body, signature)).toBe(-1);
|
||||
});
|
||||
});
|
||||
it('returns the index of the signature if there is one', () => {
|
||||
Object.keys(HAS_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = HAS_SIGNATURE[key];
|
||||
expect(findSignatureInBody(body, signature)).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('appendSignature', () => {
|
||||
it('appends the signature if it is not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
const cleanedSignature = cleanSignature(signature);
|
||||
expect(
|
||||
appendSignature(body, signature).includes(cleanedSignature)
|
||||
).toBeTruthy();
|
||||
});
|
||||
});
|
||||
it('does not append signature if already present', () => {
|
||||
Object.keys(HAS_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = HAS_SIGNATURE[key];
|
||||
expect(appendSignature(body, signature)).toBe(body);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeSignature', () => {
|
||||
it('does not remove signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
expect(removeSignature(body, signature)).toBe(body);
|
||||
});
|
||||
});
|
||||
it('removes signature if present at the end', () => {
|
||||
const { body, signature } = HAS_SIGNATURE['signature at end'];
|
||||
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
|
||||
});
|
||||
it('removes signature if present with spaces and new lines', () => {
|
||||
const { body, signature } = HAS_SIGNATURE[
|
||||
'signature at end with spaces and new lines'
|
||||
];
|
||||
expect(removeSignature(body, signature)).toBe('This is a test\n\n');
|
||||
});
|
||||
it('removes signature if present without text before it', () => {
|
||||
const { body, signature } = HAS_SIGNATURE['no text before signature'];
|
||||
expect(removeSignature(body, signature)).toBe('\n\n');
|
||||
});
|
||||
it('removes just the delimiter if no signature is present', () => {
|
||||
expect(removeSignature('This is a test\n\n--', 'This is a signature')).toBe(
|
||||
'This is a test\n\n'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceSignature', () => {
|
||||
it('appends the new signature if not present', () => {
|
||||
Object.keys(DOES_NOT_HAVE_SIGNATURE).forEach(key => {
|
||||
const { body, signature } = DOES_NOT_HAVE_SIGNATURE[key];
|
||||
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
||||
`${body}\n\n--\n\n${NEW_SIGNATURE}`
|
||||
);
|
||||
});
|
||||
});
|
||||
it('removes signature if present at the end', () => {
|
||||
const { body, signature } = HAS_SIGNATURE['signature at end'];
|
||||
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
||||
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
|
||||
);
|
||||
});
|
||||
it('removes signature if present with spaces and new lines', () => {
|
||||
const { body, signature } = HAS_SIGNATURE[
|
||||
'signature at end with spaces and new lines'
|
||||
];
|
||||
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
||||
`This is a test\n\n--\n\n${NEW_SIGNATURE}`
|
||||
);
|
||||
});
|
||||
it('removes signature if present without text before it', () => {
|
||||
const { body, signature } = HAS_SIGNATURE['no text before signature'];
|
||||
expect(replaceSignature(body, signature, NEW_SIGNATURE)).toBe(
|
||||
`\n\n--\n\n${NEW_SIGNATURE}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractTextFromMarkdown', () => {
|
||||
it('should extract text from markdown and remove all images, code blocks, links, headers, bold, italic, lists etc.', () => {
|
||||
const markdown = `
|
||||
# Hello World
|
||||
|
||||
This is a **bold** text with a [link](https://example.com).
|
||||
|
||||
\`\`\`javascript
|
||||
const foo = 'bar';
|
||||
console.log(foo);
|
||||
\`\`\`
|
||||
|
||||
Here's an image: 
|
||||
|
||||
- List item 1
|
||||
- List item 2
|
||||
|
||||
*Italic text*
|
||||
`;
|
||||
|
||||
const expected =
|
||||
"Hello World\nThis is a bold text with a link.\nHere's an image:\nList item 1\nList item 2\nItalic text";
|
||||
expect(extractTextFromMarkdown(markdown)).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { findNodeToInsertImage } from '../messageEditorHelper';
|
||||
|
||||
describe('findNodeToInsertImage', () => {
|
||||
let mockEditorState;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEditorState = {
|
||||
selection: {
|
||||
$from: {
|
||||
node: jest.fn(() => ({})),
|
||||
},
|
||||
from: 0,
|
||||
},
|
||||
schema: {
|
||||
nodes: {
|
||||
image: {
|
||||
create: jest.fn(attrs => ({ type: { name: 'image' }, attrs })),
|
||||
},
|
||||
paragraph: {
|
||||
create: jest.fn((_, node) => ({
|
||||
type: { name: 'paragraph' },
|
||||
content: [node],
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
it('should insert image directly into an empty paragraph', () => {
|
||||
const mockNode = {
|
||||
type: { name: 'paragraph' },
|
||||
content: { size: 0, content: [] },
|
||||
};
|
||||
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||
|
||||
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||
expect(result).toEqual({
|
||||
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
||||
pos: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('should insert image directly into a paragraph without an image but with other content', () => {
|
||||
const mockNode = {
|
||||
type: { name: 'paragraph' },
|
||||
content: {
|
||||
size: 1,
|
||||
content: [
|
||||
{
|
||||
type: { name: 'text' },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||
mockEditorState.selection.from = 1;
|
||||
|
||||
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||
expect(result).toEqual({
|
||||
node: { type: { name: 'image' }, attrs: { src: 'image-url' } },
|
||||
pos: 2, // Because it should insert after the text, on a new line.
|
||||
});
|
||||
});
|
||||
|
||||
it("should wrap image in a new paragraph when the current node isn't a paragraph", () => {
|
||||
const mockNode = {
|
||||
type: { name: 'not-a-paragraph' },
|
||||
content: { size: 0, content: [] },
|
||||
};
|
||||
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||
|
||||
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||
expect(result.node.type.name).toBe('paragraph');
|
||||
expect(result.node.content[0].type.name).toBe('image');
|
||||
expect(result.node.content[0].attrs.src).toBe('image-url');
|
||||
expect(result.pos).toBe(0);
|
||||
});
|
||||
|
||||
it('should insert a new image directly into the paragraph that already contains an image', () => {
|
||||
const mockNode = {
|
||||
type: { name: 'paragraph' },
|
||||
content: {
|
||||
size: 1,
|
||||
content: [
|
||||
{
|
||||
type: { name: 'image', attrs: { src: 'existing-image-url' } },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockEditorState.selection.$from.node.mockReturnValueOnce(mockNode);
|
||||
mockEditorState.selection.from = 1;
|
||||
|
||||
const result = findNodeToInsertImage(mockEditorState, 'image-url');
|
||||
expect(result.node.type.name).toBe('image');
|
||||
expect(result.node.attrs.src).toBe('image-url');
|
||||
expect(result.pos).toBe(1);
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
getConversationDashboardRoute,
|
||||
getCurrentAccount,
|
||||
getUserRole,
|
||||
isAConversationRoute,
|
||||
routeIsAccessibleFor,
|
||||
validateLoggedInRoutes,
|
||||
} from '../routeHelpers';
|
||||
@@ -94,3 +96,41 @@ describe('#validateLoggedInRoutes', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('isAConversationRoute', () => {
|
||||
it('returns true if conversation route name is provided', () => {
|
||||
expect(isAConversationRoute('inbox_conversation')).toBe(true);
|
||||
expect(isAConversationRoute('conversation_through_inbox')).toBe(true);
|
||||
expect(isAConversationRoute('conversations_through_label')).toBe(true);
|
||||
expect(isAConversationRoute('conversations_through_team')).toBe(true);
|
||||
expect(isAConversationRoute('dashboard')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConversationDashboardRoute', () => {
|
||||
it('returns dashboard route for conversation', () => {
|
||||
expect(getConversationDashboardRoute('inbox_conversation')).toEqual('home');
|
||||
expect(
|
||||
getConversationDashboardRoute('conversation_through_mentions')
|
||||
).toEqual('conversation_mentions');
|
||||
expect(
|
||||
getConversationDashboardRoute('conversation_through_unattended')
|
||||
).toEqual('conversation_unattended');
|
||||
expect(
|
||||
getConversationDashboardRoute('conversations_through_label')
|
||||
).toEqual('label_conversations');
|
||||
expect(getConversationDashboardRoute('conversations_through_team')).toEqual(
|
||||
'team_conversations'
|
||||
);
|
||||
expect(
|
||||
getConversationDashboardRoute('conversations_through_folders')
|
||||
).toEqual('folder_conversations');
|
||||
expect(
|
||||
getConversationDashboardRoute('conversation_through_participating')
|
||||
).toEqual('conversation_participating');
|
||||
expect(getConversationDashboardRoute('conversation_through_inbox')).toEqual(
|
||||
'inbox_dashboard'
|
||||
);
|
||||
expect(getConversationDashboardRoute('non_existent_route')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { uploadFile } from '../uploadHelper';
|
||||
import axios from 'axios';
|
||||
|
||||
// Mocking axios using jest-mock-axios
|
||||
global.axios = axios;
|
||||
jest.mock('axios');
|
||||
|
||||
describe('#Upload Helpers', () => {
|
||||
afterEach(() => {
|
||||
// Cleaning up the mock after each test
|
||||
axios.post.mockReset();
|
||||
});
|
||||
|
||||
it('should send a POST request with correct data', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockResponse = {
|
||||
data: {
|
||||
file_url: 'https://example.com/fileUrl',
|
||||
blob_key: 'blobKey123',
|
||||
blob_id: 'blobId456',
|
||||
},
|
||||
};
|
||||
|
||||
axios.post.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await uploadFile(mockFile, '1602');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1602/upload',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
fileUrl: 'https://example.com/fileUrl',
|
||||
blobKey: 'blobKey123',
|
||||
blobId: 'blobId456',
|
||||
});
|
||||
});
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockError = new Error('Failed to upload');
|
||||
|
||||
axios.post.mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,49 @@
|
||||
/* global axios */
|
||||
|
||||
/**
|
||||
* Constants and Configuration
|
||||
*/
|
||||
|
||||
// Version for the API endpoint.
|
||||
const API_VERSION = 'v1';
|
||||
|
||||
// Default headers to be used in the axios request.
|
||||
const HEADERS = {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
};
|
||||
|
||||
/**
|
||||
* Uploads a file to the server.
|
||||
*
|
||||
* This function sends a POST request to a given API endpoint and uploads the specified file.
|
||||
* The function uses FormData to wrap the file and axios to send the request.
|
||||
*
|
||||
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
|
||||
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
|
||||
*/
|
||||
export async function uploadFile(file, accountId) {
|
||||
// Create a new FormData instance.
|
||||
let formData = new FormData();
|
||||
|
||||
if (!accountId) {
|
||||
accountId = window.location.pathname.split('/')[3];
|
||||
}
|
||||
|
||||
// Append the file to the FormData instance under the key 'attachment'.
|
||||
formData.append('attachment', file);
|
||||
|
||||
// Use axios to send a POST request to the upload endpoint.
|
||||
const { data } = await axios.post(
|
||||
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
||||
formData,
|
||||
{
|
||||
headers: HEADERS,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
fileUrl: data.file_url,
|
||||
blobKey: data.blob_key,
|
||||
blobId: data.blob_id,
|
||||
};
|
||||
}
|
||||
@@ -26,6 +26,7 @@ import pt_BR from './locale/pt_BR';
|
||||
import ro from './locale/ro';
|
||||
import ru from './locale/ru';
|
||||
import sk from './locale/sk';
|
||||
import sr from './locale/sr';
|
||||
import sv from './locale/sv';
|
||||
import ta from './locale/ta';
|
||||
import th from './locale/th';
|
||||
@@ -66,6 +67,7 @@ export default {
|
||||
ro,
|
||||
ru,
|
||||
sk,
|
||||
sr,
|
||||
sv,
|
||||
ta,
|
||||
th,
|
||||
|
||||
@@ -371,6 +371,8 @@
|
||||
"DETAILS": {
|
||||
"LOADING_FB": "Authenticating you with Facebook...",
|
||||
"ERROR_FB_AUTH": "Something went wrong, Please refresh page...",
|
||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
||||
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||
"CREATING_CHANNEL": "Creating your Inbox...",
|
||||
"TITLE": "Configure Inbox Details",
|
||||
"DESC": ""
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "Personal message signature",
|
||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"API_ERROR": "Couldn't save signature! Try again",
|
||||
"API_SUCCESS": "Signature saved successfully"
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@@ -371,6 +371,8 @@
|
||||
"DETAILS": {
|
||||
"LOADING_FB": "جار المصادقة والربط مع الفيسبوك...",
|
||||
"ERROR_FB_AUTH": "حدث خطأ ما، الرجاء تحديث الصفحة...",
|
||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
||||
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||
"CREATING_CHANNEL": "جار إنشاء قناة التواصل...",
|
||||
"TITLE": "تهيئة إعدادات قناة التواصل",
|
||||
"DESC": ""
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "توقيع الرسالة الشخصية",
|
||||
"NOTE": "إنشاء توقيع رسالة شخصية يتم إضافتها إلى جميع الرسائل التي ترسلها من المنصة. استخدم محرر المحتوى الغني لإنشاء توقيع شديد التخصيص.",
|
||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||
"BTN_TEXT": "حفظ توقيع الرسالة",
|
||||
"API_ERROR": "تعذر إرسال الرسالة! حاول مرة أخرى",
|
||||
"API_SUCCESS": "تم حفظ التوقيع بنجاح"
|
||||
"API_SUCCESS": "تم حفظ التوقيع بنجاح",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "توقيع الرسالة",
|
||||
|
||||
@@ -371,6 +371,8 @@
|
||||
"DETAILS": {
|
||||
"LOADING_FB": "Authenticating you with Facebook...",
|
||||
"ERROR_FB_AUTH": "Something went wrong, Please refresh page...",
|
||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
||||
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||
"CREATING_CHANNEL": "Creating your Inbox...",
|
||||
"TITLE": "Configure Inbox Details",
|
||||
"DESC": ""
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "Personal message signature",
|
||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"API_ERROR": "Couldn't save signature! Try again",
|
||||
"API_SUCCESS": "Signature saved successfully"
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@@ -371,6 +371,8 @@
|
||||
"DETAILS": {
|
||||
"LOADING_FB": "S'està autenticant amb Facebook...",
|
||||
"ERROR_FB_AUTH": "Alguna cosa ha anat malament, actualitza la pàgina...",
|
||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
||||
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||
"CREATING_CHANNEL": "S'està creant la safata d'entrada...",
|
||||
"TITLE": "Configura els detalls de la safata d'entrada",
|
||||
"DESC": ""
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "Personal message signature",
|
||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"API_ERROR": "Couldn't save signature! Try again",
|
||||
"API_SUCCESS": "Signature saved successfully"
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
@@ -371,6 +371,8 @@
|
||||
"DETAILS": {
|
||||
"LOADING_FB": "Ověřování pomocí Facebooku...",
|
||||
"ERROR_FB_AUTH": "Něco se pokazilo, prosím obnovte stránku...",
|
||||
"ERROR_FB_UNAUTHORIZED": "You're not authorized to perform this action. ",
|
||||
"ERROR_FB_UNAUTHORIZED_HELP": "Please ensure you have access to the Facebook page with full control. You can read more about Facebook roles <a href=\" https://www.facebook.com/help/187316341316631\">here</a>.",
|
||||
"CREATING_CHANNEL": "Vytvářím vaši doručenou poštu...",
|
||||
"TITLE": "Konfigurace detailů doručené pošty",
|
||||
"DESC": ""
|
||||
|
||||
@@ -36,10 +36,13 @@
|
||||
},
|
||||
"MESSAGE_SIGNATURE_SECTION": {
|
||||
"TITLE": "Personal message signature",
|
||||
"NOTE": "Create a personal message signature that would be added to all the messages you send from your email inbox. Use the rich content editor to create a highly personalised signature.",
|
||||
"NOTE": "Create a unique message signature to appear at the end of every message you send from any inbox. You can also include an inline image, which is supported in live-chat, email, and API inboxes.",
|
||||
"BTN_TEXT": "Save message signature",
|
||||
"API_ERROR": "Couldn't save signature! Try again",
|
||||
"API_SUCCESS": "Signature saved successfully"
|
||||
"API_SUCCESS": "Signature saved successfully",
|
||||
"IMAGE_UPLOAD_ERROR": "Couldn't upload image! Try again",
|
||||
"IMAGE_UPLOAD_SUCCESS": "Image added successfully. Please click on save to save the signature",
|
||||
"IMAGE_UPLOAD_SIZE_ERROR": "Image size should be less than {size}MB"
|
||||
},
|
||||
"MESSAGE_SIGNATURE": {
|
||||
"LABEL": "Message Signature",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user