Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
63b1013b45 | ||
|
|
b8f25d77bf | ||
|
|
f620bdec54 | ||
|
|
45e2cd4903 | ||
|
|
2060f270d3 | ||
|
|
6535624cd6 | ||
|
|
8fbe470bdd | ||
|
|
ab3ee74a86 | ||
|
|
41b8901432 | ||
|
|
329d305e92 | ||
|
|
5c161e0fef | ||
|
|
9cec091716 | ||
|
|
9ed1f5d96b | ||
|
|
81d0405473 | ||
|
|
f64cf85ab2 | ||
|
|
0eeab8c56c | ||
|
|
5ce29a7beb | ||
|
|
04dfb034cc | ||
|
|
360b438a55 | ||
|
|
5b5a6d89c0 | ||
|
|
2e0d43c093 | ||
|
|
ef850eda0f | ||
|
|
80b8f5f915 | ||
|
|
77a6893203 | ||
|
|
8d2b719dc1 | ||
|
|
4f9d41962a | ||
|
|
66d366f0ff | ||
|
|
832a0f24a0 | ||
|
|
c9a8796a36 | ||
|
|
b3c8c83830 | ||
|
|
c1cc94e37c | ||
|
|
45099f40f1 | ||
|
|
b5e0921d0d | ||
|
|
e5136c2691 | ||
|
|
04194e7247 | ||
|
|
8d04894744 | ||
|
|
cb38ec3267 | ||
|
|
8348392d43 | ||
|
|
95c7a24dd1 | ||
|
|
4250c40d1d | ||
|
|
3d824855f0 | ||
|
|
1bf2da180b | ||
|
|
3a5e34fccb | ||
|
|
fa031a0e2d | ||
|
|
676796ddc7 | ||
|
|
899176a793 | ||
|
|
fa51fd1d73 | ||
|
|
1b3011b27b | ||
|
|
2acb48bbe0 | ||
|
|
b082b0e58c | ||
|
|
bd0ed322cc | ||
|
|
3584bbb3af | ||
|
|
f769471439 | ||
|
|
f2815a2c00 | ||
|
|
27ddd77a1b | ||
|
|
2c73df4292 | ||
|
|
2b2252b66e | ||
|
|
f2f0d466f2 | ||
|
|
4f3a271355 | ||
|
|
5b9c4bf7f1 | ||
|
|
26f23a6e21 | ||
|
|
1ccd29140d | ||
|
|
d2d838afd1 | ||
|
|
480eb3043c | ||
|
|
569ae24d8a | ||
|
|
615a575bdd | ||
|
|
17fb6b8d55 | ||
|
|
e010f0c6f0 | ||
|
|
04e890070d | ||
|
|
0319b78eac | ||
|
|
80e5d6d7a0 | ||
|
|
337a74a10c | ||
|
|
d4be268cc3 | ||
|
|
a6b119d187 | ||
|
|
923b4637db | ||
|
|
b6ad468eb4 | ||
|
|
14503b5fe0 | ||
|
|
c64e2e3bc5 | ||
|
|
31cdc63e18 | ||
|
|
3d164271a8 | ||
|
|
1194188f13 | ||
|
|
9b5eb98c59 | ||
|
|
8622740161 | ||
|
|
fb3ed29c90 | ||
|
|
f1ae09d21b | ||
|
|
b1efcde495 | ||
|
|
bc7bcc20b8 | ||
|
|
7e5ec7925c | ||
|
|
727993aa19 | ||
|
|
d5536d65f7 | ||
|
|
57359be37e | ||
|
|
14e6a5d6b0 | ||
|
|
5ea0436051 | ||
|
|
dfb56f6bb8 | ||
|
|
d4a046a21e | ||
|
|
8482ecc1b1 | ||
|
|
5b490bc8cc | ||
|
|
c2afdd5c93 | ||
|
|
75ce5345a9 | ||
|
|
9329f08e14 | ||
|
|
0b03e4b296 | ||
|
|
821b953ee9 | ||
|
|
7a8aa4ca2e | ||
|
|
2595e774e5 | ||
|
|
9a8a0bd865 | ||
|
|
cd5646b4b9 | ||
|
|
76f53d9a11 | ||
|
|
591d186d66 | ||
|
|
73d8073530 | ||
|
|
1dd0c7249c | ||
|
|
3509692055 | ||
|
|
5d85a21287 | ||
|
|
caee9535f1 | ||
|
|
3813b3b372 | ||
|
|
c397fe1964 | ||
|
|
cb23ff53bf | ||
|
|
3cd1616df6 | ||
|
|
eff3a50316 | ||
|
|
0477123f92 | ||
|
|
c9f821cc5b | ||
|
|
baf697f064 | ||
|
|
5be9380547 | ||
|
|
24b20c10ce | ||
|
|
bfe6324d9a | ||
|
|
15fd37b124 | ||
|
|
3f2ac2042f | ||
|
|
3158966241 | ||
|
|
c674393c02 | ||
|
|
cffc984ef6 | ||
|
|
5e8fd689c9 | ||
|
|
ccf52a620b | ||
|
|
ec0ea0b1dc | ||
|
|
8155024b6a | ||
|
|
823c0ab6a7 | ||
|
|
e8bc30e3c6 | ||
|
|
30ac8054ba | ||
|
|
0ba6e772a4 | ||
|
|
ba0188aefc | ||
|
|
e0f29b9d81 | ||
|
|
971755b845 | ||
|
|
0667d9f016 | ||
|
|
48389d69d8 | ||
|
|
ff881fcad9 | ||
|
|
5da0b0667f | ||
|
|
a8cfcbc168 | ||
|
|
6bfe0f2fc1 | ||
|
|
131c0a8668 | ||
|
|
8e153d6350 | ||
|
|
60a0709de6 | ||
|
|
14c53b8b54 | ||
|
|
7577c9c888 | ||
|
|
0cf970dafd | ||
|
|
c2647a1f27 | ||
|
|
8b9aea231c | ||
|
|
b735135431 | ||
|
|
160e8a8ab4 | ||
|
|
2605f24539 | ||
|
|
dd76dd4204 | ||
|
|
86b4183bde | ||
|
|
4a21633a2b | ||
|
|
f8c46341f6 | ||
|
|
771cb5b9fc | ||
|
|
f43018e017 | ||
|
|
467f3b9191 | ||
|
|
715400f7ab | ||
|
|
7b4cac746a | ||
|
|
6169d172d8 | ||
|
|
e06a6a7c55 | ||
|
|
c608ca37e0 | ||
|
|
6097f4c122 | ||
|
|
8372ae418e | ||
|
|
71391d982b | ||
|
|
b77fad52fb | ||
|
|
40f31cb0a3 | ||
|
|
7f8a199b29 | ||
|
|
23fe8847d8 | ||
|
|
430547e026 | ||
|
|
d0702363f4 | ||
|
|
d6d0c79618 | ||
|
|
9f8e442333 | ||
|
|
4a3ddad02f | ||
|
|
207a03155e | ||
|
|
796a7805db |
@@ -12,8 +12,8 @@ defaults: &defaults
|
||||
# Specify service dependencies here if necessary
|
||||
# CircleCI maintains a library of pre-built images
|
||||
# documented at https://circleci.com/docs/2.0/circleci-images/
|
||||
- image: circleci/postgres:alpine
|
||||
- image: circleci/redis:alpine
|
||||
- image: cimg/postgres:14.1
|
||||
- image: cimg/redis:6.2.6
|
||||
environment:
|
||||
- CC_TEST_REPORTER_ID: b1b5c4447bf93f6f0b06a64756e35afd0810ea83649f03971cbf303b4449456f
|
||||
- RAILS_LOG_TO_STDOUT: false
|
||||
@@ -110,7 +110,7 @@ jobs:
|
||||
- run:
|
||||
name: Run backend tests
|
||||
command: |
|
||||
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10
|
||||
bundle exec rspec $(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings) --profile=10 --format documentation
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o ~/tmp/codeclimate.backend.json coverage/backend/.resultset.json
|
||||
- persist_to_workspace:
|
||||
root: ~/tmp
|
||||
|
||||
@@ -50,3 +50,6 @@ exclude_patterns:
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/automation/constants.js'
|
||||
- 'app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js'
|
||||
- 'app/javascript/dashboard/routes/dashboard/settings/reports/constants.js'
|
||||
- 'app/javascript/dashboard/i18n/index.js'
|
||||
- 'app/javascript/widget/i18n/index.js'
|
||||
- 'app/javascript/survey/i18n/index.js'
|
||||
|
||||
+8
-1
@@ -32,6 +32,11 @@ REDIS_SENTINELS=
|
||||
# You can find list of master using "SENTINEL masters" command
|
||||
REDIS_SENTINEL_MASTER_NAME=
|
||||
|
||||
# Redis premium breakage in heroku fix
|
||||
# enable the following configuration
|
||||
# ref: https://github.com/chatwoot/chatwoot/issues/2420
|
||||
# REDIS_OPENSSL_VERIFY_MODE=none
|
||||
|
||||
# Postgres Database config variables
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_USERNAME=postgres
|
||||
@@ -156,13 +161,15 @@ USE_INBOX_AVATAR_FOR_BOT=true
|
||||
## NewRelic
|
||||
# https://docs.newrelic.com/docs/agents/ruby-agent/configuration/ruby-agent-configuration/
|
||||
# NEW_RELIC_LICENSE_KEY=
|
||||
# Set this to true to allow newrelic apm to send logs.
|
||||
# This is turned off by default.
|
||||
# NEW_RELIC_APPLICATION_LOGGING_ENABLED=
|
||||
|
||||
## Datadog
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
|
||||
|
||||
## IP look up configuration
|
||||
## ref https://github.com/alexreisner/geocoder/blob/master/README_API_GUIDE.md
|
||||
## works only on accounts with ip look up feature enabled
|
||||
|
||||
+2
-2
@@ -29,8 +29,8 @@ module.exports = {
|
||||
'vue/html-self-closing': 'off',
|
||||
"vue/no-v-html": 'off',
|
||||
'vue/singleline-html-element-content-newline': 'off',
|
||||
'import/extensions': ['off']
|
||||
|
||||
'import/extensions': ['off'],
|
||||
'no-console': 'error'
|
||||
},
|
||||
settings: {
|
||||
'import/resolver': {
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
# #
|
||||
# # This action will publish Chatwoot CE docker image.
|
||||
# # This is set to run against merges to develop, master
|
||||
# # and when tags are created.
|
||||
# #
|
||||
|
||||
name: Publish Chatwoot CE docker images
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
tags:
|
||||
- v*
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
GIT_REF: ${{ github.head_ref || github.ref_name }} # ref_name to get tags/branches
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v2
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v1
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v1
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Set Chatwoot edition
|
||||
run: |
|
||||
echo -en '\nENV CW_EDITION="ce"' >> docker/Dockerfile
|
||||
|
||||
- name: set docker tag
|
||||
run: |
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:$GIT_REF-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: replace docker tag if master
|
||||
if: github.ref_name == 'master'
|
||||
run: |
|
||||
echo "DOCKER_TAG=chatwoot/chatwoot:latest-ce" >> $GITHUB_ENV
|
||||
|
||||
- name: Login to DockerHub
|
||||
uses: docker/login-action@v1
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v2
|
||||
with:
|
||||
context: .
|
||||
file: docker/Dockerfile
|
||||
push: true
|
||||
tags: ${{ env.DOCKER_TAG }}
|
||||
@@ -0,0 +1,73 @@
|
||||
# #
|
||||
# # This action will strip the enterprise folder
|
||||
# # and run the spec.
|
||||
# # This is set to run against every PR.
|
||||
# #
|
||||
|
||||
name: Run Chatwoot CE spec
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
- master
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
test:
|
||||
runs-on: ubuntu-latest
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:10.8
|
||||
env:
|
||||
POSTGRES_USER: postgres
|
||||
POSTGRES_PASSWORD: ""
|
||||
POSTGRES_DB: postgres
|
||||
ports:
|
||||
- 5432:5432
|
||||
# needed because the postgres container does not provide a healthcheck
|
||||
# tmpfs makes DB faster by using RAM
|
||||
options: >-
|
||||
--mount type=tmpfs,destination=/var/lib/postgresql/data
|
||||
--health-cmd pg_isready
|
||||
--health-interval 10s
|
||||
--health-timeout 5s
|
||||
--health-retries 5
|
||||
redis:
|
||||
image: redis
|
||||
ports:
|
||||
- 6379:6379
|
||||
options: --entrypoint redis-server
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
ref: ${{ github.event.pull_request.head.ref }}
|
||||
repository: ${{ github.event.pull_request.head.repo.full_name }}
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.0.2 # Not needed with a .ruby-version file
|
||||
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
|
||||
|
||||
- name: yarn
|
||||
run: yarn install
|
||||
|
||||
- name: Strip enterprise code
|
||||
run: |
|
||||
rm -rf enterprise
|
||||
rm -rf spec/enterprise
|
||||
|
||||
- name: Create database
|
||||
run: bundle exec rake db:create
|
||||
|
||||
- name: Seed database
|
||||
run: bundle exec rake db:schema:load
|
||||
|
||||
- name: yarn check-files
|
||||
run: yarn install --check-files
|
||||
|
||||
# Run rails tests
|
||||
- name: Run backend tests
|
||||
run: |
|
||||
bundle exec rspec --profile=10 --format documentation
|
||||
@@ -16,6 +16,8 @@ Metrics/ClassLength:
|
||||
- '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:
|
||||
|
||||
@@ -42,7 +42,7 @@ gem 'down', '~> 5.0'
|
||||
gem 'aws-sdk-s3', require: false
|
||||
gem 'azure-storage-blob', require: false
|
||||
gem 'google-cloud-storage', require: false
|
||||
gem 'image_processing'
|
||||
gem 'image_processing', '~> 1.12.2'
|
||||
|
||||
##-- gems for database --#
|
||||
gem 'groupdate'
|
||||
@@ -97,14 +97,14 @@ gem 'brakeman'
|
||||
gem 'ddtrace'
|
||||
gem 'newrelic_rpm'
|
||||
gem 'scout_apm'
|
||||
gem 'sentry-rails'
|
||||
gem 'sentry-ruby'
|
||||
gem 'sentry-sidekiq'
|
||||
gem 'sentry-rails', '~> 5.3'
|
||||
gem 'sentry-ruby', '~> 5.3'
|
||||
gem 'sentry-sidekiq', '~> 5.3'
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '~> 6.4.0'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron'
|
||||
gem 'sidekiq-cron', '~> 1.3'
|
||||
|
||||
##-- Push notification service --##
|
||||
gem 'fcm'
|
||||
@@ -125,6 +125,9 @@ gem 'procore-sift'
|
||||
gem 'email_reply_trimmer'
|
||||
gem 'html2text'
|
||||
|
||||
# to calculate working hours
|
||||
gem 'working_hours'
|
||||
|
||||
group :production, :staging do
|
||||
# we dont want request timing out in development while using byebug
|
||||
gem 'rack-timeout'
|
||||
|
||||
+85
-81
@@ -9,63 +9,63 @@ GIT
|
||||
GEM
|
||||
remote: https://rubygems.org/
|
||||
specs:
|
||||
actioncable (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actioncable (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
nio4r (~> 2.0)
|
||||
websocket-driver (>= 0.6.1)
|
||||
actionmailbox (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actionmailbox (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
mail (>= 2.7.1)
|
||||
actionmailer (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actionmailer (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
mail (~> 2.5, >= 2.5.4)
|
||||
rails-dom-testing (~> 2.0)
|
||||
actionpack (6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actionpack (6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
rack (~> 2.0, >= 2.0.9)
|
||||
rack-test (>= 0.6.3)
|
||||
rails-dom-testing (~> 2.0)
|
||||
rails-html-sanitizer (~> 1.0, >= 1.2.0)
|
||||
actiontext (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actiontext (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
nokogiri (>= 1.8.5)
|
||||
actionview (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
actionview (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
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 (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
activejob (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
globalid (>= 0.3.6)
|
||||
activemodel (6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
activerecord (6.1.4.7)
|
||||
activemodel (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
activemodel (6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activerecord (6.1.5.1)
|
||||
activemodel (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
activerecord-import (1.3.0)
|
||||
activerecord (>= 4.2)
|
||||
activestorage (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
marcel (~> 1.0.0)
|
||||
activestorage (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
marcel (~> 1.0)
|
||||
mini_mime (>= 1.1.0)
|
||||
activesupport (6.1.4.7)
|
||||
activesupport (6.1.5.1)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
i18n (>= 1.6, < 2)
|
||||
minitest (>= 5.1)
|
||||
@@ -136,7 +136,7 @@ GEM
|
||||
climate_control (1.0.1)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.4)
|
||||
concurrent-ruby (1.1.9)
|
||||
concurrent-ruby (1.1.10)
|
||||
connection_pool (2.2.5)
|
||||
crack (0.4.5)
|
||||
rexml
|
||||
@@ -183,7 +183,7 @@ GEM
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.10.0)
|
||||
erubis (2.7.0)
|
||||
et-orbi (1.2.6)
|
||||
et-orbi (1.2.7)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
@@ -210,8 +210,8 @@ GEM
|
||||
ruby_parser (~> 3.0)
|
||||
sexp_processor (~> 4.0)
|
||||
foreman (0.87.2)
|
||||
fugit (1.5.2)
|
||||
et-orbi (~> 1.1, >= 1.1.8)
|
||||
fugit (1.5.3)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.3.4)
|
||||
google-protobuf (~> 3.12, >= 3.12.2)
|
||||
@@ -349,7 +349,7 @@ GEM
|
||||
listen (3.7.1)
|
||||
rb-fsevent (~> 0.10, >= 0.10.3)
|
||||
rb-inotify (~> 0.9, >= 0.9.10)
|
||||
loofah (2.14.0)
|
||||
loofah (2.17.0)
|
||||
crass (~> 1.0.2)
|
||||
nokogiri (>= 1.5.9)
|
||||
mail (2.7.1)
|
||||
@@ -376,16 +376,16 @@ GEM
|
||||
net-http-persistent (4.0.1)
|
||||
connection_pool (~> 2.2)
|
||||
netrc (0.11.0)
|
||||
newrelic_rpm (8.4.0)
|
||||
newrelic_rpm (8.7.0)
|
||||
nio4r (2.5.8)
|
||||
nokogiri (1.13.3)
|
||||
nokogiri (1.13.5)
|
||||
mini_portile2 (~> 2.8.0)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.3-arm64-darwin)
|
||||
nokogiri (1.13.5-arm64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.3-x86_64-darwin)
|
||||
nokogiri (1.13.5-x86_64-darwin)
|
||||
racc (~> 1.4)
|
||||
nokogiri (1.13.3-x86_64-linux)
|
||||
nokogiri (1.13.5-x86_64-linux)
|
||||
racc (~> 1.4)
|
||||
oauth (0.5.8)
|
||||
orm_adapter (0.5.0)
|
||||
@@ -403,7 +403,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (4.0.6)
|
||||
puma (5.6.2)
|
||||
puma (5.6.4)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.2.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -419,31 +419,31 @@ GEM
|
||||
rack-test (1.1.0)
|
||||
rack (>= 1.0, < 3)
|
||||
rack-timeout (0.6.0)
|
||||
rails (6.1.4.7)
|
||||
actioncable (= 6.1.4.7)
|
||||
actionmailbox (= 6.1.4.7)
|
||||
actionmailer (= 6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
actiontext (= 6.1.4.7)
|
||||
actionview (= 6.1.4.7)
|
||||
activejob (= 6.1.4.7)
|
||||
activemodel (= 6.1.4.7)
|
||||
activerecord (= 6.1.4.7)
|
||||
activestorage (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
rails (6.1.5.1)
|
||||
actioncable (= 6.1.5.1)
|
||||
actionmailbox (= 6.1.5.1)
|
||||
actionmailer (= 6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
actiontext (= 6.1.5.1)
|
||||
actionview (= 6.1.5.1)
|
||||
activejob (= 6.1.5.1)
|
||||
activemodel (= 6.1.5.1)
|
||||
activerecord (= 6.1.5.1)
|
||||
activestorage (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
bundler (>= 1.15.0)
|
||||
railties (= 6.1.4.7)
|
||||
railties (= 6.1.5.1)
|
||||
sprockets-rails (>= 2.0.0)
|
||||
rails-dom-testing (2.0.3)
|
||||
activesupport (>= 4.2.0)
|
||||
nokogiri (>= 1.6)
|
||||
rails-html-sanitizer (1.4.2)
|
||||
loofah (~> 2.3)
|
||||
railties (6.1.4.7)
|
||||
actionpack (= 6.1.4.7)
|
||||
activesupport (= 6.1.4.7)
|
||||
railties (6.1.5.1)
|
||||
actionpack (= 6.1.5.1)
|
||||
activesupport (= 6.1.5.1)
|
||||
method_source
|
||||
rake (>= 0.13)
|
||||
rake (>= 12.2)
|
||||
thor (~> 1.0)
|
||||
rainbow (3.1.1)
|
||||
rake (13.0.6)
|
||||
@@ -533,16 +533,16 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.1.0)
|
||||
sentry-rails (5.3.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby-core (~> 5.1.0)
|
||||
sentry-ruby (5.1.0)
|
||||
sentry-ruby-core (~> 5.3.0)
|
||||
sentry-ruby (5.3.0)
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-ruby-core (= 5.1.0)
|
||||
sentry-ruby-core (5.1.0)
|
||||
sentry-ruby-core (= 5.3.0)
|
||||
sentry-ruby-core (5.3.0)
|
||||
concurrent-ruby
|
||||
sentry-sidekiq (5.1.0)
|
||||
sentry-ruby-core (~> 5.1.0)
|
||||
sentry-sidekiq (5.3.0)
|
||||
sentry-ruby-core (~> 5.3.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.16.0)
|
||||
shoulda-matchers (5.1.0)
|
||||
@@ -551,8 +551,8 @@ GEM
|
||||
connection_pool (>= 2.2.2)
|
||||
rack (~> 2.0)
|
||||
redis (>= 4.2.0)
|
||||
sidekiq-cron (1.2.0)
|
||||
fugit (~> 1.1)
|
||||
sidekiq-cron (1.4.0)
|
||||
fugit (~> 1)
|
||||
sidekiq (>= 4.2.1)
|
||||
signet (0.16.0)
|
||||
addressable (~> 2.8)
|
||||
@@ -636,6 +636,9 @@ GEM
|
||||
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.5.4)
|
||||
|
||||
PLATFORMS
|
||||
@@ -688,7 +691,7 @@ DEPENDENCIES
|
||||
hairtrigger
|
||||
hashie
|
||||
html2text
|
||||
image_processing
|
||||
image_processing (~> 1.12.2)
|
||||
jbuilder
|
||||
json_refs
|
||||
json_schemer
|
||||
@@ -723,12 +726,12 @@ DEPENDENCIES
|
||||
rubocop-rspec
|
||||
scout_apm
|
||||
seed_dump
|
||||
sentry-rails
|
||||
sentry-ruby
|
||||
sentry-sidekiq
|
||||
sentry-rails (~> 5.3)
|
||||
sentry-ruby (~> 5.3)
|
||||
sentry-sidekiq (~> 5.3)
|
||||
shoulda-matchers
|
||||
sidekiq (~> 6.4.0)
|
||||
sidekiq-cron
|
||||
sidekiq-cron (~> 1.3)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client
|
||||
spring
|
||||
@@ -746,9 +749,10 @@ DEPENDENCIES
|
||||
webpacker (~> 5.x)
|
||||
webpush
|
||||
wisper (= 2.0.0)
|
||||
working_hours
|
||||
|
||||
RUBY VERSION
|
||||
ruby 3.0.2p107
|
||||
|
||||
BUNDLED WITH
|
||||
2.3.8
|
||||
2.3.10
|
||||
|
||||
@@ -32,6 +32,10 @@
|
||||
"INSTALLATION_ENV": {
|
||||
"description": "Installation method used for Chatwoot.",
|
||||
"value": "heroku"
|
||||
},
|
||||
"REDIS_OPENSSL_VERIFY_MODE":{
|
||||
"description": "OpenSSL verification mode for Redis connections. ref https://help.heroku.com/HC0F8CUS/redis-connection-issues",
|
||||
"value": "none"
|
||||
}
|
||||
},
|
||||
"formation": {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
class Campaigns::CampaignConversationBuilder
|
||||
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes]
|
||||
pattr_initialize [:contact_inbox_id!, :campaign_display_id!, :conversation_additional_attributes, :custom_attributes]
|
||||
|
||||
def perform
|
||||
@contact_inbox = ContactInbox.find(@contact_inbox_id)
|
||||
@@ -21,7 +21,8 @@ class Campaigns::CampaignConversationBuilder
|
||||
|
||||
def message_params
|
||||
ActionController::Parameters.new({
|
||||
content: @campaign.message
|
||||
content: @campaign.message,
|
||||
campaign_id: @campaign.id
|
||||
})
|
||||
end
|
||||
|
||||
@@ -32,7 +33,8 @@ class Campaigns::CampaignConversationBuilder
|
||||
contact_id: @contact_inbox.contact_id,
|
||||
contact_inbox_id: @contact_inbox.id,
|
||||
campaign_id: @campaign.id,
|
||||
additional_attributes: conversation_additional_attributes
|
||||
additional_attributes: conversation_additional_attributes,
|
||||
custom_attributes: custom_attributes || {}
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -15,11 +15,10 @@ class ContactBuilder
|
||||
end
|
||||
|
||||
def create_contact_inbox(contact)
|
||||
::ContactInbox.create!(
|
||||
::ContactInbox.create_with(hmac_verified: hmac_verified || false).find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id,
|
||||
source_id: source_id,
|
||||
hmac_verified: hmac_verified || false
|
||||
source_id: source_id
|
||||
)
|
||||
end
|
||||
|
||||
@@ -70,7 +69,7 @@ class ContactBuilder
|
||||
update_contact_avatar(contact)
|
||||
contact_inbox
|
||||
rescue StandardError => e
|
||||
Rails.logger.info e
|
||||
Rails.logger.error e
|
||||
raise e
|
||||
end
|
||||
end
|
||||
|
||||
@@ -27,9 +27,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
end
|
||||
ensure_contact_avatar
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
Rails.logger.info "Facebook Authorization expired for Inbox #{@inbox.id}"
|
||||
Rails.logger.error "Facebook Authorization expired for Inbox #{@inbox.id}"
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
@@ -43,7 +43,7 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
return if contact.present?
|
||||
|
||||
@contact = Contact.create!(contact_params.except(:remote_avatar_url))
|
||||
@contact_inbox = ContactInbox.create(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||
@contact_inbox = ContactInbox.find_or_create_by!(contact: contact, inbox: @inbox, source_id: @sender_id)
|
||||
end
|
||||
|
||||
def build_message
|
||||
@@ -128,10 +128,10 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
result = {}
|
||||
# OAuthException, code: 100, error_subcode: 2018218, message: (#100) No profile available for this user
|
||||
# We don't need to capture this error as we don't care about contact params in case of echo messages
|
||||
Sentry.capture_exception(e) unless @outgoing_echo
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception unless @outgoing_echo
|
||||
rescue StandardError => e
|
||||
result = {}
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
end
|
||||
process_contact_params_result(result)
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
true
|
||||
end
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ class Messages::MessageBuilder
|
||||
@user = user
|
||||
@message_type = params[:message_type] || 'outgoing'
|
||||
@attachments = params[:attachments]
|
||||
@automation_rule = @params&.dig(:content_attributes, :automation_rule_id)
|
||||
return unless params.instance_of?(ActionController::Parameters)
|
||||
|
||||
@in_reply_to = params.to_unsafe_h&.dig(:content_attributes, :in_reply_to)
|
||||
@@ -64,6 +65,14 @@ class Messages::MessageBuilder
|
||||
@params[:external_created_at].present? ? { external_created_at: @params[:external_created_at] } : {}
|
||||
end
|
||||
|
||||
def automation_rule_id
|
||||
@automation_rule.present? ? { content_attributes: { automation_rule_id: @automation_rule } } : {}
|
||||
end
|
||||
|
||||
def campaign_id
|
||||
@params[:campaign_id].present? ? { additional_attributes: { campaign_id: @params[:campaign_id] } } : {}
|
||||
end
|
||||
|
||||
def message_sender
|
||||
return if @params[:sender_type] != 'AgentBot'
|
||||
|
||||
@@ -82,6 +91,6 @@ class Messages::MessageBuilder
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id]
|
||||
}.merge(external_created_at)
|
||||
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,16 +53,7 @@ class Messages::Messenger::MessageBuilder
|
||||
|
||||
def fetch_story_link(attachment)
|
||||
message = attachment.message
|
||||
begin
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
result = k.get_object(message.source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
result = {}
|
||||
Sentry.capture_exception(e)
|
||||
end
|
||||
result = get_story_object_from_source_id(message.source_id)
|
||||
story_id = result['story']['mention']['id']
|
||||
story_sender = result['from']['username']
|
||||
message.content_attributes[:story_sender] = story_sender
|
||||
@@ -70,4 +61,15 @@ class Messages::Messenger::MessageBuilder
|
||||
message.content = I18n.t('conversations.messages.instagram_story_content', story_sender: story_sender)
|
||||
message.save!
|
||||
end
|
||||
|
||||
def get_story_object_from_source_id(source_id)
|
||||
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
|
||||
k.get_object(source_id, fields: %w[story from]) || {}
|
||||
rescue Koala::Facebook::AuthenticationError
|
||||
@inbox.channel.authorization_error!
|
||||
raise
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
|
||||
{}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
class V2::ReportBuilder
|
||||
include DateRangeHelper
|
||||
include ReportHelper
|
||||
attr_reader :account, :params
|
||||
|
||||
DEFAULT_GROUP_BY = 'day'.freeze
|
||||
AGENT_RESULTS_PER_PAGE = 25
|
||||
|
||||
def initialize(account, params)
|
||||
@account = account
|
||||
@@ -18,8 +20,14 @@ class V2::ReportBuilder
|
||||
|
||||
# For backward compatible with old report
|
||||
def build
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].to_time.to_i }
|
||||
if %w[avg_first_response_time avg_resolution_time].include?(params[:metric])
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i, count: @grouped_values.count[p[0]] }
|
||||
end
|
||||
else
|
||||
timeseries.each_with_object([]) do |p, arr|
|
||||
arr << { value: p[1], timestamp: p[0].in_time_zone(@timezone).to_i }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -34,23 +42,16 @@ class V2::ReportBuilder
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def scope
|
||||
case params[:type]
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
inbox
|
||||
when :agent
|
||||
user
|
||||
when :label
|
||||
label
|
||||
when :team
|
||||
team
|
||||
def conversation_metrics
|
||||
if params[:type].equal?(:account)
|
||||
conversations
|
||||
else
|
||||
agent_metrics.sort_by { |hash| hash[:metric][:open] }.reverse
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def inbox
|
||||
@inbox ||= account.inboxes.find(params[:id])
|
||||
end
|
||||
@@ -68,7 +69,7 @@ class V2::ReportBuilder
|
||||
end
|
||||
|
||||
def get_grouped_values(object_scope)
|
||||
object_scope.group_by_period(
|
||||
@grouped_values = object_scope.group_by_period(
|
||||
params[:group_by] || DEFAULT_GROUP_BY,
|
||||
:created_at,
|
||||
default_value: 0,
|
||||
@@ -78,47 +79,29 @@ class V2::ReportBuilder
|
||||
)
|
||||
end
|
||||
|
||||
def conversations_count
|
||||
(get_grouped_values scope.conversations).count
|
||||
def agent_metrics
|
||||
account_users = @account.account_users.page(params[:page]).per(AGENT_RESULTS_PER_PAGE)
|
||||
account_users.each_with_object([]) do |account_user, arr|
|
||||
@user = account_user.user
|
||||
arr << {
|
||||
id: @user.id,
|
||||
name: @user.name,
|
||||
email: @user.email,
|
||||
thumbnail: @user.avatar_url,
|
||||
availability: account_user.availability_status,
|
||||
metric: conversations
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
def incoming_messages_count
|
||||
(get_grouped_values scope.messages.incoming.unscope(:order)).count
|
||||
end
|
||||
|
||||
def outgoing_messages_count
|
||||
(get_grouped_values scope.messages.outgoing.unscope(:order)).count
|
||||
end
|
||||
|
||||
def resolutions_count
|
||||
(get_grouped_values scope.conversations.resolved).count
|
||||
end
|
||||
|
||||
def avg_first_response_time
|
||||
(get_grouped_values scope.reporting_events.where(name: 'first_response')).average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time
|
||||
(get_grouped_values scope.reporting_events.where(name: 'conversation_resolved')).average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time_summary
|
||||
avg_rt = scope.reporting_events
|
||||
.where(name: 'conversation_resolved', created_at: range)
|
||||
.average(:value)
|
||||
|
||||
return 0 if avg_rt.blank?
|
||||
|
||||
avg_rt
|
||||
end
|
||||
|
||||
def avg_first_response_time_summary
|
||||
avg_frt = scope.reporting_events
|
||||
.where(name: 'first_response', created_at: range)
|
||||
.average(:value)
|
||||
|
||||
return 0 if avg_frt.blank?
|
||||
|
||||
avg_frt
|
||||
def conversations
|
||||
@open_conversations = scope.conversations.where(account_id: @account.id).open
|
||||
first_response_count = @account.reporting_events.where(name: 'first_response', conversation_id: @open_conversations.pluck('id')).count
|
||||
metric = {
|
||||
open: @open_conversations.count,
|
||||
unattended: @open_conversations.count - first_response_count
|
||||
}
|
||||
metric[:unassigned] = @open_conversations.unassigned.count if params[:type].equal?(:account)
|
||||
metric
|
||||
end
|
||||
end
|
||||
|
||||
@@ -45,6 +45,8 @@ class RoomChannel < ApplicationCable::Channel
|
||||
end
|
||||
|
||||
def current_account
|
||||
return if current_user.blank?
|
||||
|
||||
@current_account ||= if @current_user.is_a? Contact
|
||||
@current_user.account
|
||||
else
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@agent_bot.destroy
|
||||
@agent_bot.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@agent.current_account_user.destroy
|
||||
@agent.current_account_user.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -68,7 +68,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def agents
|
||||
@agents ||= Current.account.users.order_by_full_name.includes({ avatar_attachment: [:blob] })
|
||||
@agents ||= Current.account.users.order_by_full_name.includes(:account_users, { avatar_attachment: [:blob] })
|
||||
end
|
||||
|
||||
def validate_limit
|
||||
|
||||
@@ -7,13 +7,39 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
end
|
||||
|
||||
def create
|
||||
@automation_rule = Current.account.automation_rules.create(automation_rules_permit)
|
||||
@automation_rule = Current.account.automation_rules.new(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions]
|
||||
|
||||
render json: { error: @automation_rule.errors.messages }, status: :unprocessable_entity and return unless @automation_rule.valid?
|
||||
|
||||
@automation_rule.save!
|
||||
process_attachments
|
||||
@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 show; end
|
||||
|
||||
def update
|
||||
@automation_rule.update(automation_rules_permit)
|
||||
ActiveRecord::Base.transaction do
|
||||
@automation_rule.update!(automation_rules_permit)
|
||||
@automation_rule.actions = params[:actions] if params[:actions]
|
||||
@automation_rule.save!
|
||||
process_attachments
|
||||
|
||||
rescue StandardError => e
|
||||
Rails.logger.error e
|
||||
render json: { error: @automation_rule.errors.messages }.to_json, status: :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -28,6 +54,17 @@ class Api::V1::Accounts::AutomationRulesController < Api::V1::Accounts::BaseCont
|
||||
@automation_rule = new_rule
|
||||
end
|
||||
|
||||
def process_attachments
|
||||
actions = @automation_rule.actions.filter_map { |k, _v| k if k['action_name'] == 'send_attachment' }
|
||||
return if actions.blank?
|
||||
|
||||
actions.each do |action|
|
||||
blob_id = action['action_params']
|
||||
blob = ActiveStorage::Blob.find_by(id: blob_id)
|
||||
@automation_rule.files.attach(blob)
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def automation_rules_permit
|
||||
|
||||
@@ -1,32 +1,6 @@
|
||||
class Api::V1::Accounts::BaseController < Api::BaseController
|
||||
include SwitchLocale
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
around_action :switch_locale_using_account_locale
|
||||
|
||||
private
|
||||
|
||||
def current_account
|
||||
@current_account ||= ensure_current_account
|
||||
Current.account = @current_account
|
||||
end
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
end
|
||||
account
|
||||
end
|
||||
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
|
||||
@@ -15,7 +15,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
set_instagram_id(page_access_token, facebook_channel)
|
||||
set_avatar(@facebook_inbox, page_id)
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
@@ -60,7 +60,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
set_instagram_id(access_token, fb_page)
|
||||
fb_page&.reauthorized!
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
end
|
||||
end
|
||||
|
||||
@@ -77,7 +77,7 @@ class Api::V1::Accounts::CallbacksController < Api::V1::Accounts::BaseController
|
||||
koala = Koala::Facebook::OAuth.new(GlobalConfigService.load('FB_APP_ID', ''), GlobalConfigService.load('FB_APP_SECRET', ''))
|
||||
koala.exchange_access_token_info(omniauth_token)['access_token']
|
||||
rescue StandardError => e
|
||||
Rails.logger.info e
|
||||
Rails.logger.error e
|
||||
end
|
||||
|
||||
def mark_already_existing_facebook_pages(data)
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::CampaignsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@campaign.destroy
|
||||
@campaign.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Api::V1::Accounts::CannedResponsesController < Api::V1::Accounts::BaseCont
|
||||
end
|
||||
|
||||
def destroy
|
||||
@canned_response.destroy
|
||||
@canned_response.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -7,7 +7,6 @@ class Api::V1::Accounts::Channels::TwilioChannelsController < Api::V1::Accounts:
|
||||
build_inbox
|
||||
setup_webhooks if @twilio_channel.sms?
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
render_could_not_create_error(e.message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::Accounts::Contacts::NotesController < Api::V1::Accounts::Contacts
|
||||
end
|
||||
|
||||
def destroy
|
||||
@note.destroy
|
||||
@note.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
class Api::V1::Accounts::Conversations::BaseController < Api::V1::Accounts::BaseController
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :conversation
|
||||
|
||||
private
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
@conversation ||= Current.account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
authorize @conversation.inbox, :show?
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
class Api::V1::Accounts::Conversations::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include EnsureCurrentAccountHelper
|
||||
before_action :current_account
|
||||
before_action :conversation
|
||||
|
||||
def create
|
||||
return if @conversation.nil? || @current_account.nil?
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def conversation
|
||||
@conversation ||= Current.account.conversations.find_by(display_id: params[:conversation_id])
|
||||
end
|
||||
end
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomAttributeDefinitionsController < Api::V1::Account
|
||||
end
|
||||
|
||||
def destroy
|
||||
@custom_attribute_definition.destroy
|
||||
@custom_attribute_definition.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::CustomFiltersController < Api::V1::Accounts::BaseContro
|
||||
end
|
||||
|
||||
def destroy
|
||||
@custom_filter.destroy
|
||||
@custom_filter.destroy!
|
||||
head :no_content
|
||||
end
|
||||
|
||||
|
||||
@@ -48,7 +48,10 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
# Inbox update doesn't necessarily need channel attributes
|
||||
return if permitted_params(channel_attributes)[:channel].blank?
|
||||
|
||||
validate_email_channel(channel_attributes) if @inbox.inbox_type == 'Email'
|
||||
if @inbox.inbox_type == 'Email'
|
||||
validate_email_channel(channel_attributes)
|
||||
@inbox.channel.reauthorized!
|
||||
end
|
||||
|
||||
@inbox.channel.update!(permitted_params(channel_attributes)[:channel])
|
||||
update_channel_feature_flags
|
||||
@@ -70,7 +73,7 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@inbox.destroy
|
||||
@inbox.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy
|
||||
@hook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ class Api::V1::Accounts::Integrations::SlackController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def destroy
|
||||
@hook.destroy
|
||||
@hook.destroy!
|
||||
|
||||
head :ok
|
||||
end
|
||||
|
||||
@@ -4,6 +4,6 @@ class Api::V1::Accounts::Kbase::BaseController < Api::V1::Accounts::BaseControll
|
||||
private
|
||||
|
||||
def portal
|
||||
@portal ||= Current.account.kbase_portals.find_by(id: params[:portal_id])
|
||||
@portal ||= Current.account.kbase_portals.find_by(slug: params[:portal_id])
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class Api::V1::Accounts::Kbase::CategoriesController < Api::V1::Accounts::Kbase:
|
||||
end
|
||||
|
||||
def destroy
|
||||
@category.destroy
|
||||
@category.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::BaseController
|
||||
class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::BaseController
|
||||
before_action :fetch_portal, except: [:index, :create]
|
||||
|
||||
def index
|
||||
@portals = Current.account.kbase_portals
|
||||
end
|
||||
|
||||
def show; end
|
||||
|
||||
def create
|
||||
@portal = Current.account.kbase_portals.create!(portal_params)
|
||||
end
|
||||
@@ -14,14 +16,18 @@ class Api::V1::Accounts::Kbase::PortalsController < Api::V1::Accounts::Kbase::Ba
|
||||
end
|
||||
|
||||
def destroy
|
||||
@portal.destroy
|
||||
@portal.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_portal
|
||||
@portal = current_account.kbase_portals.find(params[:id])
|
||||
@portal = Current.account.kbase_portals.find_by(slug: permitted_params[:id])
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id)
|
||||
end
|
||||
|
||||
def portal_params
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::LabelsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@label.destroy
|
||||
@label.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ class Api::V1::Accounts::TeamsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@team.destroy
|
||||
@team.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -16,14 +16,14 @@ class Api::V1::Accounts::WebhooksController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@webhook.destroy
|
||||
@webhook.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def webhook_params
|
||||
params.require(:webhook).permit(:inbox_id, :url)
|
||||
params.require(:webhook).permit(:inbox_id, :url, subscriptions: [])
|
||||
end
|
||||
|
||||
def fetch_webhook
|
||||
|
||||
@@ -9,7 +9,7 @@ class Api::V1::NotificationSubscriptionsController < Api::BaseController
|
||||
|
||||
def destroy
|
||||
notification_subscription = NotificationSubscription.where(["subscription_attributes->>'push_token' = ?", params[:push_token]]).first
|
||||
notification_subscription.destroy
|
||||
notification_subscription.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ class Api::V1::WebhooksController < ApplicationController
|
||||
twitter_consumer.consume
|
||||
head :ok
|
||||
rescue StandardError => e
|
||||
Sentry.capture_exception(e)
|
||||
ChatwootExceptionTracker.new(e).capture_exception
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
class Api::V1::Widget::BaseController < ApplicationController
|
||||
include SwitchLocale
|
||||
include WebsiteTokenHelper
|
||||
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
@@ -19,25 +20,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
@conversation ||= conversations.last
|
||||
end
|
||||
|
||||
def auth_token_params
|
||||
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@current_account = @web_widget.account
|
||||
end
|
||||
|
||||
def set_contact
|
||||
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||
source_id: auth_token_params[:source_id]
|
||||
)
|
||||
@contact = @contact_inbox&.contact
|
||||
raise ActiveRecord::RecordNotFound unless @contact
|
||||
|
||||
Current.contact = @contact
|
||||
end
|
||||
|
||||
def create_conversation
|
||||
::Conversation.create!(conversation_params)
|
||||
end
|
||||
@@ -57,7 +39,8 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
browser: browser_params,
|
||||
referer: permitted_params[:message][:referer_url],
|
||||
initiated_at: timestamp_params
|
||||
}
|
||||
},
|
||||
custom_attributes: permitted_params[:custom_attributes].presence || {}
|
||||
}
|
||||
end
|
||||
|
||||
@@ -70,16 +53,33 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
mergee_contact: @contact
|
||||
).perform
|
||||
else
|
||||
@contact.update!(email: email, name: contact_name)
|
||||
@contact.update!(email: email)
|
||||
end
|
||||
end
|
||||
|
||||
def update_contact_phone_number(phone_number)
|
||||
contact_with_phone_number = @current_account.contacts.find_by(phone_number: phone_number)
|
||||
if contact_with_phone_number
|
||||
@contact = ::ContactMergeAction.new(
|
||||
account: @current_account,
|
||||
base_contact: contact_with_phone_number,
|
||||
mergee_contact: @contact
|
||||
).perform
|
||||
else
|
||||
@contact.update!(phone_number: phone_number)
|
||||
end
|
||||
end
|
||||
|
||||
def contact_email
|
||||
permitted_params[:contact][:email].downcase
|
||||
permitted_params.dig(:contact, :email)&.downcase
|
||||
end
|
||||
|
||||
def contact_name
|
||||
params[:contact][:name] || contact_email.split('@')[0]
|
||||
params[:contact][:name] || contact_email.split('@')[0] if contact_email.present?
|
||||
end
|
||||
|
||||
def contact_phone_number
|
||||
permitted_params.dig(:contact, :phone_number)
|
||||
end
|
||||
|
||||
def browser_params
|
||||
@@ -96,10 +96,6 @@ class Api::V1::Widget::BaseController < ApplicationController
|
||||
{ timestamp: permitted_params[:message][:timestamp] }
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token)
|
||||
end
|
||||
|
||||
def message_params
|
||||
{
|
||||
account_id: conversation.account_id,
|
||||
|
||||
@@ -7,12 +7,18 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
|
||||
def create
|
||||
ActiveRecord::Base.transaction do
|
||||
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
|
||||
process_update_contact
|
||||
@conversation = create_conversation
|
||||
conversation.messages.create(message_params)
|
||||
end
|
||||
end
|
||||
|
||||
def process_update_contact
|
||||
update_contact(contact_email) if @contact.email.blank? && contact_email.present?
|
||||
update_contact_phone_number(contact_phone_number) if @contact.phone_number.blank? && contact_phone_number.present?
|
||||
@contact.update!(name: contact_name) if contact_name.present?
|
||||
end
|
||||
|
||||
def update_last_seen
|
||||
head :ok && return if conversation.nil?
|
||||
|
||||
@@ -45,9 +51,12 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def toggle_status
|
||||
head :not_found && return if conversation.nil?
|
||||
return head :not_found if conversation.nil?
|
||||
|
||||
return head :forbidden unless @web_widget.end_conversation?
|
||||
|
||||
unless conversation.resolved?
|
||||
conversation.status = 'resolved'
|
||||
conversation.status = :resolved
|
||||
conversation.save
|
||||
end
|
||||
head :ok
|
||||
@@ -60,6 +69,8 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email], message: [:content, :referer_url, :timestamp, :echo_id])
|
||||
params.permit(:id, :typing_status, :website_token, :email, contact: [:name, :email, :phone_number],
|
||||
message: [:content, :referer_url, :timestamp, :echo_id],
|
||||
custom_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
class Api::V1::Widget::DirectUploadsController < ActiveStorage::DirectUploadsController
|
||||
include WebsiteTokenHelper
|
||||
before_action :set_web_widget
|
||||
before_action :set_contact
|
||||
|
||||
def create
|
||||
return if @contact.nil? || @current_account.nil?
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -35,41 +35,55 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
render layout: false, template: 'api/v2/accounts/reports/teams.csv.erb', format: 'csv'
|
||||
end
|
||||
|
||||
def conversations
|
||||
return head :unprocessable_entity if params[:type].blank?
|
||||
|
||||
render json: conversation_metrics
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def check_authorization
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
def current_summary_params
|
||||
def common_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
id: params[:id],
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until],
|
||||
group_by: params[:group_by]
|
||||
group_by: params[:group_by],
|
||||
business_hours: ActiveModel::Type::Boolean.new.cast(params[:business_hours])
|
||||
}
|
||||
end
|
||||
|
||||
def current_summary_params
|
||||
common_params.merge({
|
||||
since: range[:current][:since],
|
||||
until: range[:current][:until]
|
||||
})
|
||||
end
|
||||
|
||||
def previous_summary_params
|
||||
{
|
||||
type: params[:type].to_sym,
|
||||
id: params[:id],
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until],
|
||||
group_by: params[:group_by]
|
||||
}
|
||||
common_params.merge({
|
||||
since: range[:previous][:since],
|
||||
until: range[:previous][:until]
|
||||
})
|
||||
end
|
||||
|
||||
def report_params
|
||||
common_params.merge({
|
||||
metric: params[:metric],
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
})
|
||||
end
|
||||
|
||||
def conversation_params
|
||||
{
|
||||
metric: params[:metric],
|
||||
type: params[:type].to_sym,
|
||||
since: params[:since],
|
||||
until: params[:until],
|
||||
id: params[:id],
|
||||
group_by: params[:group_by],
|
||||
timezone_offset: params[:timezone_offset]
|
||||
user_id: params[:user_id],
|
||||
page: params[:page].presence || 1
|
||||
}
|
||||
end
|
||||
|
||||
@@ -91,4 +105,8 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
|
||||
summary
|
||||
end
|
||||
|
||||
def conversation_metrics
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
module EnsureCurrentAccountHelper
|
||||
private
|
||||
|
||||
def current_account
|
||||
@current_account ||= ensure_current_account
|
||||
Current.account = @current_account
|
||||
end
|
||||
|
||||
def ensure_current_account
|
||||
account = Account.find(params[:account_id])
|
||||
if current_user
|
||||
account_accessible_for_user?(account)
|
||||
elsif @resource.is_a?(AgentBot)
|
||||
account_accessible_for_bot?(account)
|
||||
end
|
||||
account
|
||||
end
|
||||
|
||||
def account_accessible_for_user?(account)
|
||||
@current_account_user = account.account_users.find_by(user_id: current_user.id)
|
||||
Current.account_user = @current_account_user
|
||||
render_unauthorized('You are not authorized to access this account') unless @current_account_user
|
||||
end
|
||||
|
||||
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
|
||||
end
|
||||
@@ -9,8 +9,7 @@ module RequestExceptionHandler
|
||||
|
||||
def handle_with_exception
|
||||
yield
|
||||
rescue ActiveRecord::RecordNotFound => e
|
||||
Sentry.capture_exception(e)
|
||||
rescue ActiveRecord::RecordNotFound
|
||||
render_not_found_error('Resource could not be found')
|
||||
rescue Pundit::NotAuthorizedError
|
||||
render_unauthorized('You are not authorized to do this action')
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
module WebsiteTokenHelper
|
||||
def auth_token_params
|
||||
@auth_token_params ||= ::Widget::TokenService.new(token: request.headers['X-Auth-Token']).decode_token
|
||||
end
|
||||
|
||||
def set_web_widget
|
||||
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
||||
@current_account = @web_widget.account
|
||||
end
|
||||
|
||||
def set_contact
|
||||
@contact_inbox = @web_widget.inbox.contact_inboxes.find_by(
|
||||
source_id: auth_token_params[:source_id]
|
||||
)
|
||||
@contact = @contact_inbox&.contact
|
||||
raise ActiveRecord::RecordNotFound unless @contact
|
||||
|
||||
Current.contact = @contact
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:website_token)
|
||||
end
|
||||
end
|
||||
@@ -38,9 +38,12 @@ class DashboardController < ActionController::Base
|
||||
end
|
||||
|
||||
def app_config
|
||||
{ APP_VERSION: Chatwoot.config[:version],
|
||||
{
|
||||
APP_VERSION: Chatwoot.config[:version],
|
||||
VAPID_PUBLIC_KEY: VapidService.public_key,
|
||||
ENABLE_ACCOUNT_SIGNUP: GlobalConfigService.load('ENABLE_ACCOUNT_SIGNUP', 'false'),
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', '') }
|
||||
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
|
||||
FACEBOOK_API_VERSION: 'v13.0'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -13,7 +13,7 @@ class Platform::Api::V1::AccountUsersController < PlatformController
|
||||
end
|
||||
|
||||
def destroy
|
||||
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy
|
||||
@resource.account_users.find_by(user_id: account_user_params[:user_id])&.destroy!
|
||||
head :ok
|
||||
end
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
|
||||
def create
|
||||
@resource = (User.find_by(email: user_params[:email]) || User.new(user_params))
|
||||
@resource.skip_confirmation!
|
||||
@resource.save!
|
||||
@resource.confirm
|
||||
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
|
||||
@platform_app.platform_app_permissibles.find_or_create_by!(permissible: @resource)
|
||||
end
|
||||
|
||||
def login
|
||||
@@ -21,6 +21,10 @@ class Platform::Api::V1::UsersController < PlatformController
|
||||
|
||||
def update
|
||||
@resource.assign_attributes(user_update_params)
|
||||
|
||||
# We are using devise's reconfirmable flow for changing emails
|
||||
# But in case of platform APIs we don't want user to go through this extra step
|
||||
@resource.skip_reconfirmation! if user_update_params[:email].present?
|
||||
@resource.save!
|
||||
end
|
||||
|
||||
|
||||
@@ -43,6 +43,6 @@ class Public::Api::V1::Inboxes::ContactsController < Public::Api::V1::InboxesCon
|
||||
end
|
||||
|
||||
def permitted_params
|
||||
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, custom_attributes: {})
|
||||
params.permit(:identifier, :identifier_hash, :email, :name, :avatar_url, :phone_number, custom_attributes: {})
|
||||
end
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
|
||||
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.info e
|
||||
Rails.logger.error e
|
||||
redirect_to twitter_app_redirect_url
|
||||
end
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ class Webhooks::InstagramController < ApplicationController
|
||||
::Webhooks::InstagramEventsJob.perform_later(params.to_unsafe_hash[:entry])
|
||||
render json: :ok
|
||||
else
|
||||
Rails.logger.info("Message is not received from the instagram webhook event: #{params['object']}")
|
||||
Rails.logger.warn("Message is not received from the instagram webhook event: #{params['object']}")
|
||||
head :unprocessable_entity
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,6 +3,7 @@ class WidgetTestsController < ActionController::Base
|
||||
before_action :ensure_widget_position
|
||||
before_action :ensure_widget_type
|
||||
before_action :ensure_widget_style
|
||||
before_action :ensure_dark_mode
|
||||
|
||||
def index
|
||||
render
|
||||
@@ -14,6 +15,10 @@ class WidgetTestsController < ActionController::Base
|
||||
@widget_style = params[:widget_style] || 'standard'
|
||||
end
|
||||
|
||||
def ensure_dark_mode
|
||||
@dark_mode = params[:dark_mode] || 'light'
|
||||
end
|
||||
|
||||
def ensure_widget_position
|
||||
@widget_position = params[:position] || 'left'
|
||||
end
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
class EmailChannelFinder
|
||||
def initialize(email_object)
|
||||
@email_object = email_object
|
||||
end
|
||||
|
||||
def perform
|
||||
channel = nil
|
||||
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
|
||||
recipient_mails.each do |email|
|
||||
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', email.downcase, email.downcase)
|
||||
break if channel.present?
|
||||
end
|
||||
channel
|
||||
end
|
||||
end
|
||||
@@ -22,7 +22,7 @@ class MessageFinder
|
||||
|
||||
def current_messages
|
||||
if @params[:before].present?
|
||||
messages.reorder('created_at desc').where('id < ?', @params[:before]).limit(20).reverse
|
||||
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
|
||||
else
|
||||
messages.reorder('created_at desc').limit(20).reverse
|
||||
end
|
||||
|
||||
@@ -14,7 +14,7 @@ module Api::V1::InboxesHelper
|
||||
Mail.defaults do
|
||||
retriever_method :imap, { address: channel_data[:imap_address],
|
||||
port: channel_data[:imap_port],
|
||||
user_name: channel_data[:imap_email],
|
||||
user_name: channel_data[:imap_login],
|
||||
password: channel_data[:imap_password],
|
||||
enable_ssl: channel_data[:imap_enable_ssl] }
|
||||
end
|
||||
@@ -29,8 +29,12 @@ module Api::V1::InboxesHelper
|
||||
smtp = Net::SMTP.new(channel_data[:smtp_address], channel_data[:smtp_port])
|
||||
|
||||
set_smtp_encryption(channel_data, smtp)
|
||||
check_smtp_connection(channel_data, smtp)
|
||||
end
|
||||
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_email], channel_data[:smtp_password], :login)
|
||||
def check_smtp_connection(channel_data, smtp)
|
||||
smtp.start(channel_data[:smtp_domain], channel_data[:smtp_login], channel_data[:smtp_password],
|
||||
channel_data[:smtp_authentication]&.to_sym || :login)
|
||||
smtp.finish unless smtp&.nil?
|
||||
end
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
module ReportHelper
|
||||
private
|
||||
|
||||
def scope
|
||||
case params[:type]
|
||||
when :account
|
||||
account
|
||||
when :inbox
|
||||
inbox
|
||||
when :agent
|
||||
user
|
||||
when :label
|
||||
label
|
||||
when :team
|
||||
team
|
||||
end
|
||||
end
|
||||
|
||||
def conversations_count
|
||||
(get_grouped_values scope.conversations.where(account_id: account.id)).count
|
||||
end
|
||||
|
||||
def incoming_messages_count
|
||||
(get_grouped_values scope.messages.where(account_id: account.id).incoming.unscope(:order)).count
|
||||
end
|
||||
|
||||
def outgoing_messages_count
|
||||
(get_grouped_values scope.messages.where(account_id: account.id).outgoing.unscope(:order)).count
|
||||
end
|
||||
|
||||
def resolutions_count
|
||||
(get_grouped_values scope.conversations.where(account_id: account.id).resolved).count
|
||||
end
|
||||
|
||||
def avg_first_response_time
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'first_response', account_id: account.id))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
grouped_reporting_events.average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time
|
||||
grouped_reporting_events = (get_grouped_values scope.reporting_events.where(name: 'conversation_resolved', account_id: account.id))
|
||||
return grouped_reporting_events.average(:value_in_business_hours) if params[:business_hours]
|
||||
|
||||
grouped_reporting_events.average(:value)
|
||||
end
|
||||
|
||||
def avg_resolution_time_summary
|
||||
reporting_events = scope.reporting_events
|
||||
.where(name: 'conversation_resolved', account_id: account.id, created_at: range)
|
||||
avg_rt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
|
||||
|
||||
return 0 if avg_rt.blank?
|
||||
|
||||
avg_rt
|
||||
end
|
||||
|
||||
def avg_first_response_time_summary
|
||||
reporting_events = scope.reporting_events
|
||||
.where(name: 'first_response', account_id: account.id, created_at: range)
|
||||
avg_frt = params[:business_hours] ? reporting_events.average(:value_in_business_hours) : reporting_events.average(:value)
|
||||
|
||||
return 0 if avg_frt.blank?
|
||||
|
||||
avg_frt
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,50 @@
|
||||
module ReportingEventHelper
|
||||
def business_hours(inbox, from, to)
|
||||
return 0 unless inbox.working_hours_enabled?
|
||||
|
||||
inbox_working_hours = configure_working_hours(inbox.working_hours)
|
||||
return 0 if inbox_working_hours.blank?
|
||||
|
||||
# Configure working hours
|
||||
WorkingHours::Config.working_hours = inbox_working_hours
|
||||
|
||||
# Configure timezone
|
||||
WorkingHours::Config.time_zone = inbox.timezone
|
||||
|
||||
# Use inbox timezone to change from & to values.
|
||||
from_in_inbox_timezone = from.in_time_zone(inbox.timezone).to_time
|
||||
to_in_inbox_timezone = to.in_time_zone(inbox.timezone).to_time
|
||||
from_in_inbox_timezone.working_time_until(to_in_inbox_timezone)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def configure_working_hours(working_hours)
|
||||
working_hours.each_with_object({}) do |working_hour, object|
|
||||
object[day(working_hour.day_of_week)] = working_hour_range(working_hour) unless working_hour.closed_all_day?
|
||||
end
|
||||
end
|
||||
|
||||
def day(day_of_week)
|
||||
week_days = {
|
||||
0 => :sun,
|
||||
1 => :mon,
|
||||
2 => :tue,
|
||||
3 => :wed,
|
||||
4 => :thu,
|
||||
5 => :fri,
|
||||
6 => :sat
|
||||
}
|
||||
week_days[day_of_week]
|
||||
end
|
||||
|
||||
def working_hour_range(working_hour)
|
||||
{ format_time(working_hour.open_hour, working_hour.open_minutes) => format_time(working_hour.close_hour, working_hour.close_minutes) }
|
||||
end
|
||||
|
||||
def format_time(hour, minute)
|
||||
hour = hour < 10 ? "0#{hour}" : hour
|
||||
minute = minute < 10 ? "0#{minute}" : minute
|
||||
"#{hour}:#{minute}"
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div id="app" class="app-wrapper app-root">
|
||||
<div v-if="!authUIFlags.isFetching" id="app" class="app-wrapper app-root">
|
||||
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<transition name="fade" mode="out-in">
|
||||
<router-view></router-view>
|
||||
@@ -11,21 +11,28 @@
|
||||
<woot-snackbar-box />
|
||||
<network-notification />
|
||||
</div>
|
||||
<loading-state v-else />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { accountIdFromPathname } from './helper/URLHelper';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal';
|
||||
import LoadingState from './components/widgets/LoadingState.vue';
|
||||
import NetworkNotification from './components/NetworkNotification';
|
||||
import UpdateBanner from './components/app/UpdateBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer';
|
||||
import {
|
||||
registerSubscription,
|
||||
verifyServiceWorkerExistence,
|
||||
} from './helper/pushHelper';
|
||||
|
||||
export default {
|
||||
name: 'App',
|
||||
|
||||
components: {
|
||||
AddAccountModal,
|
||||
LoadingState,
|
||||
NetworkNotification,
|
||||
UpdateBanner,
|
||||
WootSnackbarBox,
|
||||
@@ -43,13 +50,12 @@ export default {
|
||||
getAccount: 'accounts/getAccount',
|
||||
currentUser: 'getCurrentUser',
|
||||
globalConfig: 'globalConfig/get',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
hasAccounts() {
|
||||
return (
|
||||
this.currentUser &&
|
||||
this.currentUser.accounts &&
|
||||
this.currentUser.accounts.length !== 0
|
||||
);
|
||||
const { accounts = [] } = this.currentUser || {};
|
||||
return accounts.length > 0;
|
||||
},
|
||||
},
|
||||
|
||||
@@ -58,32 +64,37 @@ export default {
|
||||
if (!this.hasAccounts) {
|
||||
this.showAddAccountModal = true;
|
||||
}
|
||||
verifyServiceWorkerExistence(registration =>
|
||||
registration.pushManager.getSubscription().then(subscription => {
|
||||
if (subscription) {
|
||||
registerSubscription();
|
||||
}
|
||||
})
|
||||
);
|
||||
},
|
||||
currentAccountId() {
|
||||
if (this.currentAccountId) {
|
||||
this.initializeAccount();
|
||||
}
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('setUser');
|
||||
this.setLocale(window.chatwootConfig.selectedLocale);
|
||||
this.initializeAccount();
|
||||
},
|
||||
|
||||
methods: {
|
||||
setLocale(locale) {
|
||||
this.$root.$i18n.locale = locale;
|
||||
},
|
||||
|
||||
async initializeAccount() {
|
||||
const { pathname } = window.location;
|
||||
const accountId = accountIdFromPathname(pathname);
|
||||
|
||||
if (accountId) {
|
||||
await this.$store.dispatch('accounts/get');
|
||||
const {
|
||||
locale,
|
||||
latest_chatwoot_version: latestChatwootVersion,
|
||||
} = this.getAccount(accountId);
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
}
|
||||
await this.$store.dispatch('accounts/get');
|
||||
const {
|
||||
locale,
|
||||
latest_chatwoot_version: latestChatwootVersion,
|
||||
} = this.getAccount(this.currentAccountId);
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
this.setLocale(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
/* eslint no-console: 0 */
|
||||
/* global axios */
|
||||
/* eslint no-undef: "error" */
|
||||
|
||||
import Cookies from 'js-cookie';
|
||||
import endPoints from './endPoints';
|
||||
@@ -61,41 +59,15 @@ export default {
|
||||
});
|
||||
return fetchPromise;
|
||||
},
|
||||
|
||||
isLoggedIn() {
|
||||
const hasAuthCookie = !!Cookies.getJSON('auth_data');
|
||||
const hasUserCookie = !!Cookies.getJSON('user');
|
||||
return hasAuthCookie && hasUserCookie;
|
||||
hasAuthCookie() {
|
||||
return !!Cookies.getJSON('cw_d_session_info');
|
||||
},
|
||||
|
||||
isAdmin() {
|
||||
if (this.isLoggedIn()) {
|
||||
return Cookies.getJSON('user').role === 'administrator';
|
||||
}
|
||||
return false;
|
||||
},
|
||||
|
||||
getAuthData() {
|
||||
if (this.isLoggedIn()) {
|
||||
return Cookies.getJSON('auth_data');
|
||||
if (this.hasAuthCookie()) {
|
||||
return Cookies.getJSON('cw_d_session_info');
|
||||
}
|
||||
return false;
|
||||
},
|
||||
getPubSubToken() {
|
||||
if (this.isLoggedIn()) {
|
||||
const user = Cookies.getJSON('user') || {};
|
||||
const { pubsub_token: pubsubToken } = user;
|
||||
return pubsubToken;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
getCurrentUser() {
|
||||
if (this.isLoggedIn()) {
|
||||
return Cookies.getJSON('user');
|
||||
}
|
||||
return null;
|
||||
},
|
||||
|
||||
verifyPasswordToken({ confirmationToken }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
axios
|
||||
|
||||
@@ -9,6 +9,14 @@ 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();
|
||||
|
||||
@@ -8,7 +8,15 @@ class ReportsAPI extends ApiClient {
|
||||
super('reports', { accountScoped: true, apiVersion: 'v2' });
|
||||
}
|
||||
|
||||
getReports(metric, since, until, type = 'account', id, group_by) {
|
||||
getReports(
|
||||
metric,
|
||||
since,
|
||||
until,
|
||||
type = 'account',
|
||||
id,
|
||||
group_by,
|
||||
business_hours
|
||||
) {
|
||||
return axios.get(`${this.url}`, {
|
||||
params: {
|
||||
metric,
|
||||
@@ -17,12 +25,13 @@ class ReportsAPI extends ApiClient {
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
timezone_offset: getTimeOffset(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getSummary(since, until, type = 'account', id, group_by) {
|
||||
getSummary(since, until, type = 'account', id, group_by, business_hours) {
|
||||
return axios.get(`${this.url}/summary`, {
|
||||
params: {
|
||||
since,
|
||||
@@ -30,6 +39,16 @@ class ReportsAPI extends ApiClient {
|
||||
type,
|
||||
id,
|
||||
group_by,
|
||||
business_hours,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getConversationMetric(type = 'account', page = 1) {
|
||||
return axios.get(`${this.url}/conversations`, {
|
||||
params: {
|
||||
type,
|
||||
page,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -97,5 +97,18 @@ describe('#Reports API', () => {
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('#getConversationMetric', () => {
|
||||
reportsAPI.getConversationMetric('account');
|
||||
expect(context.axiosMock.get).toHaveBeenCalledWith(
|
||||
'/api/v2/reports/conversations',
|
||||
{
|
||||
params: {
|
||||
type: 'account',
|
||||
page: 1,
|
||||
},
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
|
||||
code {
|
||||
border: 0;
|
||||
font-family: 'Monaco', Verdana;
|
||||
font-family: 'ui-monospace', 'SFMono-Regular', 'Menlo', 'Monaco', 'Consolas',
|
||||
'"Liberation Mono"', '"Courier New"', 'monospace';
|
||||
font-size: $font-size-mini;
|
||||
|
||||
&.hljs {
|
||||
@@ -55,7 +56,6 @@ code {
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
|
||||
.badge {
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
margin-right: var(--space-small);
|
||||
}
|
||||
|
||||
.margin-bottom-small {
|
||||
margin-bottom: var(--space-small);
|
||||
}
|
||||
|
||||
.margin-right-smaller {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
@@ -51,10 +55,6 @@
|
||||
background-color: var(--white);
|
||||
}
|
||||
|
||||
.text-y-800 {
|
||||
color: var(--y-800);
|
||||
}
|
||||
|
||||
.text-ellipsis {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
@@ -62,13 +62,13 @@ $default-button-height: 4.0rem;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
@include button-style(var(--y-100), var(--y-200), var(--y-900));
|
||||
@include button-style(var(--y-100), var(--y-200), var(--y-700));
|
||||
}
|
||||
}
|
||||
|
||||
&.clear {
|
||||
&.warning {
|
||||
color: var(--y-800);
|
||||
color: var(--y-600);
|
||||
}
|
||||
|
||||
&.button--only-icon:hover {
|
||||
@@ -87,7 +87,7 @@ $default-button-height: 4.0rem;
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--y-100);
|
||||
background: var(--y-50);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,16 @@
|
||||
padding: 0 $space-small;
|
||||
}
|
||||
|
||||
.video-js {
|
||||
background: transparent;
|
||||
// Override min-height : 50px in foundation
|
||||
//
|
||||
max-height: $space-mega * 2.4;
|
||||
min-height: 4.8rem;
|
||||
padding: var(--space-normal) 0 0;
|
||||
resize: none;
|
||||
}
|
||||
|
||||
>textarea {
|
||||
@include ghost-input();
|
||||
@include margin(0);
|
||||
|
||||
@@ -18,6 +18,13 @@
|
||||
font-size: $font-size-small;
|
||||
font-weight: $font-weight-bold;
|
||||
color: $color-heading;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.info-icon {
|
||||
color: var(--b-400);
|
||||
margin-left: var(--space-micro);
|
||||
}
|
||||
|
||||
.metric-wrap {
|
||||
@@ -71,5 +78,10 @@
|
||||
font-size: $font-size-default;
|
||||
color: $color-gray;
|
||||
}
|
||||
|
||||
.business-hours {
|
||||
margin: $space-normal;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,3 +25,21 @@
|
||||
align-items: center;
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.business-hours {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: end;
|
||||
margin-bottom: var(--space-normal);
|
||||
margin-left: auto;
|
||||
padding-right: var(--space-normal);
|
||||
}
|
||||
|
||||
.business-hours-text {
|
||||
font-size: var(--font-size-small);
|
||||
}
|
||||
|
||||
.switch {
|
||||
margin-bottom: var(--space-zero);
|
||||
margin-left: var(--space-small);
|
||||
}
|
||||
|
||||
@@ -15,9 +15,9 @@
|
||||
{{ $t('NETWORK.BUTTON.REFRESH') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
variant="clear"
|
||||
variant="smooth"
|
||||
size="small"
|
||||
color-scheme="secondary"
|
||||
color-scheme="warning"
|
||||
icon="dismiss-circle"
|
||||
@click="closeNotification"
|
||||
>
|
||||
|
||||
@@ -7,6 +7,10 @@
|
||||
<p class="sub-head">
|
||||
{{ subTitle }}
|
||||
</p>
|
||||
<p v-if="note">
|
||||
<span class="note">{{ $t('INBOX_MGMT.NOTE') }}</span>
|
||||
{{ note }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="medium-6 small-12">
|
||||
<slot></slot>
|
||||
@@ -25,6 +29,10 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
note: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -46,5 +54,9 @@ export default {
|
||||
.title--section {
|
||||
padding-right: var(--space-large);
|
||||
}
|
||||
|
||||
.note {
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -57,3 +57,13 @@ export default {
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
button:disabled {
|
||||
opacity: 1;
|
||||
background-color: var(--w-100);
|
||||
|
||||
&:hover {
|
||||
background-color: var(--w-100);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -53,7 +53,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getCurrentUserAvailability: 'getCurrentUserAvailability',
|
||||
getCurrentAccountId: 'getCurrentAccountId',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
availabilityDisplayLabel() {
|
||||
const availabilityIndex = AVAILABILITY_STATUS_KEYS.findIndex(
|
||||
@@ -63,9 +63,6 @@ export default {
|
||||
availabilityIndex
|
||||
];
|
||||
},
|
||||
currentAccountId() {
|
||||
return this.getCurrentAccountId;
|
||||
},
|
||||
currentUserAvailability() {
|
||||
return this.getCurrentUserAvailability;
|
||||
},
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
:active-menu-item="activePrimaryMenu.key"
|
||||
@toggle-accounts="toggleAccountModal"
|
||||
@key-shortcut-modal="toggleKeyShortcutModal"
|
||||
@open-notification-panel="openNotificationPanel"
|
||||
/>
|
||||
<secondary-sidebar
|
||||
:account-id="accountId"
|
||||
@@ -18,6 +19,7 @@
|
||||
:menu-config="activeSecondaryMenu"
|
||||
:current-role="currentRole"
|
||||
@add-label="showAddLabelPopup"
|
||||
@toggle-accounts="toggleAccountModal"
|
||||
/>
|
||||
</aside>
|
||||
</template>
|
||||
@@ -176,6 +178,9 @@ export default {
|
||||
showAddLabelPopup() {
|
||||
this.$emit('show-add-label-popup');
|
||||
},
|
||||
openNotificationPanel() {
|
||||
this.$emit('open-notification-panel');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -3,7 +3,8 @@ import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const reports = accountId => ({
|
||||
parentNav: 'reports',
|
||||
routes: [
|
||||
'settings_account_reports',
|
||||
'account_overview_reports',
|
||||
'conversation_reports',
|
||||
'csat_reports',
|
||||
'agent_reports',
|
||||
'label_reports',
|
||||
@@ -16,7 +17,14 @@ const reports = accountId => ({
|
||||
label: 'REPORTS_OVERVIEW',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/overview`),
|
||||
toStateName: 'settings_account_reports',
|
||||
toStateName: 'account_overview_reports',
|
||||
},
|
||||
{
|
||||
icon: 'chat',
|
||||
label: 'REPORTS_CONVERSATION',
|
||||
hasSubMenu: false,
|
||||
toState: frontendURL(`accounts/${accountId}/reports/conversation`),
|
||||
toStateName: 'conversation_reports',
|
||||
},
|
||||
{
|
||||
icon: 'emoji',
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
<template>
|
||||
<div v-if="showShowCurrentAccountContext" class="account-context--group">
|
||||
<div
|
||||
v-if="showShowCurrentAccountContext"
|
||||
class="account-context--group"
|
||||
@mouseover="setShowSwitch"
|
||||
@mouseleave="resetShowSwitch"
|
||||
>
|
||||
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
|
||||
<p class="account-context--name text-ellipsis">
|
||||
{{ account.name }}
|
||||
</p>
|
||||
<transition name="fade">
|
||||
<div v-if="showSwitchButton" class="account-context--switch-group">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
icon="arrow-swap"
|
||||
class="cursor-pointer"
|
||||
@click="$emit('toggle-accounts')"
|
||||
>
|
||||
{{ $t('SIDEBAR.SWITCH') }}
|
||||
</woot-button>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
data() {
|
||||
return { showSwitchButton: false };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
account: 'getCurrentAccount',
|
||||
@@ -18,6 +38,14 @@ export default {
|
||||
return this.userAccounts.length > 1 && this.account.name;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
setShowSwitch() {
|
||||
this.showSwitchButton = true;
|
||||
},
|
||||
resetShowSwitch() {
|
||||
this.showSwitchButton = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped lang="scss">
|
||||
@@ -27,10 +55,49 @@ export default {
|
||||
font-size: var(--font-size-mini);
|
||||
padding: var(--space-small);
|
||||
margin-bottom: var(--space-small);
|
||||
width: 100%;
|
||||
position: relative;
|
||||
|
||||
&:hover {
|
||||
background: var(--b-100);
|
||||
}
|
||||
|
||||
.account-context--name {
|
||||
font-weight: var(--font-weight-medium);
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.account-context--switch-group {
|
||||
--overlay-shadow: linear-gradient(
|
||||
to right,
|
||||
rgba(255, 255, 255, 0) 0%,
|
||||
rgba(255, 255, 255, 1) 50%
|
||||
);
|
||||
|
||||
align-items: center;
|
||||
background-image: var(--overlay-shadow);
|
||||
border-top-left-radius: 0;
|
||||
border-top-right-radius: var(--border-radius-normal);
|
||||
border-bottom-left-radius: 0;
|
||||
border-bottom-right-radius: var(--border-radius-normal);
|
||||
display: flex;
|
||||
height: 100%;
|
||||
justify-content: end;
|
||||
opacity: 1;
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 300ms ease;
|
||||
}
|
||||
|
||||
.fade-enter,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,19 +1,21 @@
|
||||
<template>
|
||||
<div class="notifications-link">
|
||||
<primary-nav-item
|
||||
name="NOTIFICATIONS"
|
||||
icon="alert"
|
||||
:to="`/app/accounts/${accountId}/notifications`"
|
||||
:count="unreadCount"
|
||||
/>
|
||||
<woot-button
|
||||
class-names="notifications-link--button"
|
||||
variant="clear"
|
||||
color-scheme="secondary"
|
||||
:class="{ 'is-active': isNotificationPanelActive }"
|
||||
@click="openNotificationPanel"
|
||||
>
|
||||
<fluent-icon icon="alert" />
|
||||
<span v-if="unreadCount" class="badge warning">{{ unreadCount }}</span>
|
||||
</woot-button>
|
||||
</div>
|
||||
</template>
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import PrimaryNavItem from './PrimaryNavItem';
|
||||
|
||||
export default {
|
||||
components: { PrimaryNavItem },
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
@@ -28,8 +30,17 @@ export default {
|
||||
? `${this.notificationMetadata.unreadCount}`
|
||||
: '99+';
|
||||
},
|
||||
isNotificationPanelActive() {
|
||||
return this.$route.name === 'notifications_index';
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openNotificationPanel() {
|
||||
if (this.$route.name !== 'notifications_index') {
|
||||
this.$emit('open-notification-panel');
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,4 +48,32 @@ export default {
|
||||
.notifications-link {
|
||||
margin-bottom: var(--space-small);
|
||||
}
|
||||
|
||||
.badge {
|
||||
position: absolute;
|
||||
right: var(--space-minus-smaller);
|
||||
top: var(--space-minus-smaller);
|
||||
}
|
||||
.notifications-link--button {
|
||||
display: flex;
|
||||
position: relative;
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 1px solid transparent;
|
||||
color: var(--s-600);
|
||||
margin: var(--space-small) 0;
|
||||
|
||||
&:hover {
|
||||
background: var(--w-50);
|
||||
color: var(--s-600);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--w-500);
|
||||
}
|
||||
|
||||
&.is-active {
|
||||
background: var(--w-50);
|
||||
color: var(--w-500);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
/>
|
||||
</nav>
|
||||
<div class="menu vertical user-menu">
|
||||
<notification-bell />
|
||||
<notification-bell @open-notification-panel="openNotificationPanel" />
|
||||
<agent-details @toggle-menu="toggleOptions" />
|
||||
<options-menu
|
||||
:show="showOptionsMenu"
|
||||
@@ -83,6 +83,9 @@ export default {
|
||||
toggleSupportChatWindow() {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
openNotificationPanel() {
|
||||
this.$emit('open-notification-panel');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<template>
|
||||
<div v-if="hasSecondaryMenu" class="main-nav secondary-menu">
|
||||
<account-context />
|
||||
<account-context @toggle-accounts="toggleAccountModal" />
|
||||
<transition-group name="menu-list" tag="ul" class="menu vertical">
|
||||
<secondary-nav-item
|
||||
v-for="menuItem in accessibleMenuItems"
|
||||
@@ -85,14 +85,20 @@ export default {
|
||||
toState: frontendURL(`accounts/${this.accountId}/settings/inboxes/new`),
|
||||
toStateName: 'settings_inbox_new',
|
||||
newLinkRouteName: 'settings_inbox_new',
|
||||
children: this.inboxes.map(inbox => ({
|
||||
id: inbox.id,
|
||||
label: inbox.name,
|
||||
truncateLabel: true,
|
||||
toState: frontendURL(`accounts/${this.accountId}/inbox/${inbox.id}`),
|
||||
type: inbox.channel_type,
|
||||
phoneNumber: inbox.phone_number,
|
||||
})),
|
||||
children: this.inboxes
|
||||
.map(inbox => ({
|
||||
id: inbox.id,
|
||||
label: inbox.name,
|
||||
truncateLabel: true,
|
||||
toState: frontendURL(
|
||||
`accounts/${this.accountId}/inbox/${inbox.id}`
|
||||
),
|
||||
type: inbox.channel_type,
|
||||
phoneNumber: inbox.phone_number,
|
||||
}))
|
||||
.sort((a, b) =>
|
||||
a.label.toLowerCase() > b.label.toLowerCase() ? 1 : -1
|
||||
),
|
||||
};
|
||||
},
|
||||
labelSection() {
|
||||
@@ -218,6 +224,9 @@ export default {
|
||||
showAddLabelPopup() {
|
||||
this.$emit('add-label');
|
||||
},
|
||||
toggleAccountModal() {
|
||||
this.$emit('toggle-accounts');
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
+28
-8
@@ -14,6 +14,10 @@ const i18nConfig = new VueI18n({
|
||||
messages: i18n,
|
||||
});
|
||||
|
||||
const $route = {
|
||||
name: 'notifications_index',
|
||||
};
|
||||
|
||||
describe('notificationBell', () => {
|
||||
const accountId = 1;
|
||||
const notificationMetadata = { unreadCount: 19 };
|
||||
@@ -45,24 +49,40 @@ describe('notificationBell', () => {
|
||||
});
|
||||
|
||||
it('it should return unread count 19 ', () => {
|
||||
const notificationBell = shallowMount(NotificationBell, {
|
||||
store,
|
||||
const wrapper = shallowMount(NotificationBell, {
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
store,
|
||||
mocks: {
|
||||
$route,
|
||||
},
|
||||
});
|
||||
|
||||
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
|
||||
expect(statusViewTitle.vm.count).toBe('19');
|
||||
expect(wrapper.vm.unreadCount).toBe('19');
|
||||
});
|
||||
|
||||
it('it should return unread count 99+ ', async () => {
|
||||
notificationMetadata.unreadCount = 101;
|
||||
notificationMetadata.unreadCount = 100;
|
||||
const wrapper = shallowMount(NotificationBell, {
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
store,
|
||||
mocks: {
|
||||
$route,
|
||||
},
|
||||
});
|
||||
expect(wrapper.vm.unreadCount).toBe('99+');
|
||||
});
|
||||
|
||||
it('isNotificationPanelActive', async () => {
|
||||
const notificationBell = shallowMount(NotificationBell, {
|
||||
store,
|
||||
localVue,
|
||||
i18n: i18nConfig,
|
||||
mocks: {
|
||||
$route,
|
||||
},
|
||||
});
|
||||
const statusViewTitle = notificationBell.find('primary-nav-item-stub');
|
||||
expect(statusViewTitle.vm.count).toBe('99+');
|
||||
|
||||
expect(notificationBell.vm.isNotificationPanelActive).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -112,10 +112,10 @@ export default {
|
||||
}
|
||||
|
||||
&.warning {
|
||||
background: var(--y-800);
|
||||
color: var(--s-600);
|
||||
background: var(--y-600);
|
||||
color: var(--y-500);
|
||||
a {
|
||||
color: var(--s-600);
|
||||
color: var(--y-500);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -157,7 +157,7 @@ export default {
|
||||
&.warning {
|
||||
background: var(--y-100);
|
||||
color: var(--y-900);
|
||||
border: 1px solid var(--y-300);
|
||||
border: 1px solid var(--y-200);
|
||||
a {
|
||||
color: var(--y-900);
|
||||
}
|
||||
|
||||
@@ -1,54 +1,66 @@
|
||||
<template>
|
||||
<label class="switch" :class="classObject">
|
||||
<input
|
||||
:id="id"
|
||||
v-model="value"
|
||||
class="switch-input"
|
||||
:name="name"
|
||||
:disabled="disabled"
|
||||
type="checkbox"
|
||||
/>
|
||||
<div class="switch-paddle" :for="name">
|
||||
<span class="show-for-sr">on off</span>
|
||||
</div>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
class="toggle-button"
|
||||
:class="{ active: value }"
|
||||
role="switch"
|
||||
:aria-checked="value.toString()"
|
||||
@click="onClick"
|
||||
>
|
||||
<span aria-hidden="true" :class="{ active: value }"></span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
disabled: Boolean,
|
||||
type: { type: String, default: '' },
|
||||
size: { type: String, default: '' },
|
||||
checked: Boolean,
|
||||
name: { type: String, default: '' },
|
||||
id: { type: String, default: '' },
|
||||
value: { type: Boolean, default: false },
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
value: null,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
classObject() {
|
||||
const { type, size, value } = this;
|
||||
return {
|
||||
[`is-${type}`]: type,
|
||||
[`${size}`]: size,
|
||||
checked: value,
|
||||
};
|
||||
methods: {
|
||||
onClick() {
|
||||
this.$emit('input', !this.value);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
value(val) {
|
||||
this.$emit('input', val);
|
||||
},
|
||||
},
|
||||
beforeMount() {
|
||||
this.value = this.checked;
|
||||
},
|
||||
mounted() {
|
||||
this.$emit('input', (this.value = !!this.checked));
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.toggle-button {
|
||||
--toggle-button-box-shadow: rgb(255, 255, 255) 0px 0px 0px 0px,
|
||||
rgba(59, 130, 246, 0.5) 0px 0px 0px 0px, rgba(0, 0, 0, 0.1) 0px 1px 3px 0px,
|
||||
rgba(0, 0, 0, 0.06) 0px 1px 2px 0px;
|
||||
background-color: var(--s-200);
|
||||
border-radius: var(--border-radius-large);
|
||||
border: 2px solid transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
height: 19px;
|
||||
position: relative;
|
||||
transition-duration: 200ms;
|
||||
transition-property: background-color;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: 34px;
|
||||
|
||||
&.active {
|
||||
background-color: var(--w-500);
|
||||
}
|
||||
|
||||
span {
|
||||
--space-one-point-five: 1.5rem;
|
||||
background-color: var(--white);
|
||||
border-radius: 100%;
|
||||
box-shadow: var(--toggle-button-box-shadow);
|
||||
display: inline-block;
|
||||
height: var(--space-one-point-five);
|
||||
transform: translate(0, 0);
|
||||
transition-duration: 200ms;
|
||||
transition-property: transform;
|
||||
transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
width: var(--space-one-point-five);
|
||||
|
||||
&.active {
|
||||
transform: translate(var(--space-one-point-five), var(--space-zero));
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="preview-item__wrap">
|
||||
<div
|
||||
v-for="(attachment, index) in attachments"
|
||||
:key="attachment.id"
|
||||
@@ -19,7 +19,7 @@
|
||||
</span>
|
||||
</div>
|
||||
<div class="file-size-wrap">
|
||||
<span class="item">
|
||||
<span class="item text-truncate">
|
||||
{{ formatFileSize(attachment.resource) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -70,15 +70,23 @@ export default {
|
||||
};
|
||||
</script>
|
||||
<style lang="scss" scoped>
|
||||
.preview-item__wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
margin-top: var(--space-normal);
|
||||
max-height: 20rem;
|
||||
}
|
||||
|
||||
.preview-item {
|
||||
display: flex;
|
||||
padding: var(--space-slab) 0 0;
|
||||
background: var(--color-background-light);
|
||||
background: var(--b-50);
|
||||
border-radius: var(--border-radius-normal);
|
||||
width: fit-content;
|
||||
width: 24rem;
|
||||
padding: var(--space-smaller);
|
||||
margin-top: var(--space-normal);
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.thumb-wrap {
|
||||
@@ -114,6 +122,7 @@ export default {
|
||||
|
||||
> .item {
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
font-size: var(--font-size-mini);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
@@ -124,7 +133,8 @@ export default {
|
||||
}
|
||||
|
||||
.file-name-wrap {
|
||||
max-width: 50%;
|
||||
max-width: 60%;
|
||||
min-width: 50%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
margin-left: var(--space-small);
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
<select
|
||||
v-model="action_name"
|
||||
class="action__question"
|
||||
@change="resetFilter()"
|
||||
:class="{ 'full-width': !showActionInput }"
|
||||
@change="resetAction()"
|
||||
>
|
||||
<option
|
||||
v-for="attribute in actionTypes"
|
||||
@@ -17,20 +18,44 @@
|
||||
{{ attribute.label }}
|
||||
</option>
|
||||
</select>
|
||||
<div class="filter__answer--wrap">
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
<div v-if="showActionInput" class="filter__answer--wrap">
|
||||
<div v-if="inputType">
|
||||
<div
|
||||
v-if="inputType === 'multi_select'"
|
||||
class="multiselect-wrap--small"
|
||||
>
|
||||
<multiselect
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="'Select'"
|
||||
:multiple="true"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
v-else-if="inputType === 'email'"
|
||||
v-model="action_params"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="'Select'"
|
||||
:multiple="true"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="dropdownValues"
|
||||
:allow-empty="false"
|
||||
type="email"
|
||||
class="answer--text-input"
|
||||
placeholder="Enter email"
|
||||
/>
|
||||
<input
|
||||
v-else-if="inputType === 'url'"
|
||||
v-model="action_params"
|
||||
type="url"
|
||||
class="answer--text-input"
|
||||
placeholder="Enter url"
|
||||
/>
|
||||
<automation-action-file-input
|
||||
v-if="inputType === 'attachment'"
|
||||
v-model="action_params"
|
||||
:initial-file-name="initialFileName"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,6 +66,18 @@
|
||||
@click="removeAction"
|
||||
/>
|
||||
</div>
|
||||
<automation-action-team-message-input
|
||||
v-if="inputType === 'team_message'"
|
||||
v-model="action_params"
|
||||
:teams="dropdownValues"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inputType === 'textarea'"
|
||||
v-model="action_params"
|
||||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
class="action-message"
|
||||
></textarea>
|
||||
<p
|
||||
v-if="v.action_params.$dirty && v.action_params.$error"
|
||||
class="filter-error"
|
||||
@@ -51,7 +88,13 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AutomationActionTeamMessageInput from './AutomationActionTeamMessageInput.vue';
|
||||
import AutomationActionFileInput from './AutomationFileInput.vue';
|
||||
export default {
|
||||
components: {
|
||||
AutomationActionTeamMessageInput,
|
||||
AutomationActionFileInput,
|
||||
},
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
@@ -69,6 +112,14 @@ export default {
|
||||
type: Object,
|
||||
default: () => null,
|
||||
},
|
||||
showActionInput: {
|
||||
type: Boolean,
|
||||
default: true,
|
||||
},
|
||||
initialFileName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
action_name: {
|
||||
@@ -91,13 +142,17 @@ export default {
|
||||
this.$emit('input', { ...payload, action_params: value });
|
||||
},
|
||||
},
|
||||
inputType() {
|
||||
return this.actionTypes.find(action => action.key === this.action_name)
|
||||
.inputType;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
removeAction() {
|
||||
this.$emit('removeAction');
|
||||
},
|
||||
resetFilter() {
|
||||
this.$emit('resetFilter');
|
||||
resetAction() {
|
||||
this.$emit('resetAction');
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -136,9 +191,14 @@ export default {
|
||||
max-width: 50%;
|
||||
}
|
||||
|
||||
.action__question.full-width {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.filter__answer--wrap {
|
||||
margin-right: var(--space-smaller);
|
||||
flex-grow: 1;
|
||||
max-width: 50%;
|
||||
|
||||
input {
|
||||
margin-bottom: 0;
|
||||
@@ -179,4 +239,7 @@ export default {
|
||||
.multiselect {
|
||||
margin-bottom: var(--space-zero);
|
||||
}
|
||||
.action-message {
|
||||
margin: var(--space-small) 0 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
<template>
|
||||
<div>
|
||||
<div class="multiselect-wrap--small">
|
||||
<multiselect
|
||||
v-model="selectedTeams"
|
||||
track-by="id"
|
||||
label="name"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_DROPDOWN_PLACEHOLDER')"
|
||||
:multiple="true"
|
||||
selected-label
|
||||
:select-label="$t('FORMS.MULTISELECT.ENTER_TO_SELECT')"
|
||||
deselect-label=""
|
||||
:max-height="160"
|
||||
:options="teams"
|
||||
:allow-empty="false"
|
||||
@input="updateValue"
|
||||
/>
|
||||
<textarea
|
||||
v-model="message"
|
||||
rows="4"
|
||||
:placeholder="$t('AUTOMATION.ACTION.TEAM_MESSAGE_INPUT_PLACEHOLDER')"
|
||||
@input="updateValue"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
// The value types are dynamic, hence prop validation removed to work with our action schema
|
||||
// eslint-disable-next-line vue/require-prop-types
|
||||
props: ['teams', 'value'],
|
||||
data() {
|
||||
return {
|
||||
selectedTeams: [],
|
||||
message: '',
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
const { team_ids: teamIds } = this.value;
|
||||
this.selectedTeams = teamIds;
|
||||
this.message = this.value.message;
|
||||
},
|
||||
methods: {
|
||||
updateValue() {
|
||||
this.$emit('input', {
|
||||
team_ids: this.selectedTeams.map(team => team.id),
|
||||
message: this.message,
|
||||
});
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.multiselect {
|
||||
margin: var(--space-smaller) var(--space-zero);
|
||||
}
|
||||
textarea {
|
||||
margin-bottom: var(--space-zero);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,117 @@
|
||||
<template>
|
||||
<label class="input-wrapper" :class="uploadState">
|
||||
<input
|
||||
v-if="uploadState !== 'processing'"
|
||||
type="file"
|
||||
name="attachment"
|
||||
:class="uploadState === 'processing' ? 'disabled' : ''"
|
||||
@change="onChangeFile"
|
||||
/>
|
||||
<spinner v-if="uploadState === 'processing'" />
|
||||
<fluent-icon v-if="uploadState === 'idle'" icon="file-upload" />
|
||||
<fluent-icon
|
||||
v-if="uploadState === 'uploaded'"
|
||||
icon="checkmark-circle"
|
||||
type="outline"
|
||||
class="success-icon"
|
||||
/>
|
||||
<fluent-icon
|
||||
v-if="uploadState === 'failed'"
|
||||
icon="dismiss-circle"
|
||||
type="outline"
|
||||
class="error-icon"
|
||||
/>
|
||||
<p class="file-button">{{ label }}</p>
|
||||
</label>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import Spinner from 'shared/components/Spinner';
|
||||
import alertMixin from 'shared/mixins/alertMixin';
|
||||
export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
mixins: [alertMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
initialFileName: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
uploadState: 'idle',
|
||||
label: this.$t('AUTOMATION.ATTACHMENT.LABEL_IDLE'),
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
if (this.initialFileName) {
|
||||
this.label = this.initialFileName;
|
||||
this.uploadState = 'uploaded';
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
async onChangeFile(event) {
|
||||
this.uploadState = 'processing';
|
||||
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
|
||||
);
|
||||
this.$emit('input', [id]);
|
||||
this.uploadState = 'uploaded';
|
||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOADED');
|
||||
} catch (error) {
|
||||
this.uploadState = 'failed';
|
||||
this.label = this.$t('AUTOMATION.ATTACHMENT.LABEL_UPLOAD_FAILED');
|
||||
this.showAlert(this.$t('AUTOMATION.ATTACHMENT.UPLOAD_ERROR'));
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='file'] {
|
||||
display: none;
|
||||
}
|
||||
.input-wrapper {
|
||||
display: flex;
|
||||
height: 39px;
|
||||
background-color: var(--white);
|
||||
border-radius: var(--border-radius-small);
|
||||
border: 1px dashed var(--w-100);
|
||||
padding: var(--space-smaller) var(--space-small);
|
||||
align-items: center;
|
||||
font-size: var(--font-size-mini);
|
||||
cursor: pointer;
|
||||
}
|
||||
.success-icon {
|
||||
margin-right: var(--space-small);
|
||||
color: var(--g-500);
|
||||
}
|
||||
.error-icon {
|
||||
margin-right: var(--space-small);
|
||||
color: var(--r-500);
|
||||
}
|
||||
|
||||
.processing {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.file-button {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -5,7 +5,14 @@
|
||||
@click="onClick(index)"
|
||||
>
|
||||
<h3 class="heading">
|
||||
{{ heading }}
|
||||
<span>{{ heading }}</span>
|
||||
<fluent-icon
|
||||
v-if="infoText"
|
||||
v-tooltip="infoText"
|
||||
size="14"
|
||||
icon="info"
|
||||
class="info-icon"
|
||||
/>
|
||||
</h3>
|
||||
<div class="metric-wrap">
|
||||
<h4 class="metric">
|
||||
@@ -22,6 +29,7 @@
|
||||
export default {
|
||||
props: {
|
||||
heading: { type: String, default: '' },
|
||||
infoText: { type: String, default: '' },
|
||||
point: { type: [Number, String], default: '' },
|
||||
trend: { type: Number, default: null },
|
||||
index: { type: Number, default: null },
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
<template>
|
||||
<span>
|
||||
{{ textToBeDisplayed }}
|
||||
<button class="show-more--button" @click="toggleShowMore">
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
textToBeDisplayed() {
|
||||
if (this.showMore) {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
return this.text.slice(0, this.limit) + '...';
|
||||
},
|
||||
buttonLabel() {
|
||||
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleShowMore() {
|
||||
this.showMore = !this.showMore;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
<style scoped>
|
||||
.show-more--button {
|
||||
color: var(--w-500);
|
||||
}
|
||||
</style>
|
||||
@@ -209,7 +209,7 @@ export default {
|
||||
}
|
||||
|
||||
.user-online-status--busy {
|
||||
background: var(--y-700);
|
||||
background: var(--y-500);
|
||||
}
|
||||
|
||||
.user-online-status--offline {
|
||||
|
||||
@@ -1,16 +1,29 @@
|
||||
<template>
|
||||
<div class="audio-wave-wrapper">
|
||||
<div id="audio-wave"></div>
|
||||
<audio id="audio-wave" class="video-js vjs-fill vjs-default-skin"></audio>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||
import RecordRTC from 'recordrtc';
|
||||
import 'video.js/dist/video-js.css';
|
||||
import 'videojs-record/dist/css/videojs.record.css';
|
||||
|
||||
import videojs from 'video.js';
|
||||
|
||||
import inboxMixin from '../../../../shared/mixins/inboxMixin';
|
||||
import alertMixin from '../../../../shared/mixins/alertMixin';
|
||||
|
||||
import Recorder from 'opus-recorder';
|
||||
import encoderWorker from 'opus-recorder/dist/encoderWorker.min';
|
||||
|
||||
import WaveSurfer from 'wavesurfer.js';
|
||||
import MicrophonePlugin from 'wavesurfer.js/dist/plugin/wavesurfer.microphone.js';
|
||||
import 'videojs-wavesurfer/dist/videojs.wavesurfer.js';
|
||||
|
||||
import 'videojs-record/dist/videojs.record.js';
|
||||
import 'videojs-record/dist/plugins/videojs.record.opus-recorder.js';
|
||||
import { format, addSeconds } from 'date-fns';
|
||||
|
||||
WaveSurfer.microphone = MicrophonePlugin;
|
||||
|
||||
export default {
|
||||
@@ -18,104 +31,116 @@ export default {
|
||||
mixins: [inboxMixin, alertMixin],
|
||||
data() {
|
||||
return {
|
||||
wavesurfer: false,
|
||||
recorder: false,
|
||||
recordingInterval: false,
|
||||
recordingDateStarted: new Date().getTime(),
|
||||
timeDuration: '00:00',
|
||||
player: false,
|
||||
recordingDateStarted: new Date(0),
|
||||
initialTimeDuration: '00:00',
|
||||
options: {
|
||||
container: '#audio-wave',
|
||||
backend: 'WebAudio',
|
||||
interact: true,
|
||||
cursorWidth: 1,
|
||||
plugins: [
|
||||
WaveSurfer.microphone.create({
|
||||
bufferSize: 4096,
|
||||
numberOfInputChannels: 1,
|
||||
numberOfOutputChannels: 1,
|
||||
constraints: {
|
||||
video: false,
|
||||
audio: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
optionsRecorder: {
|
||||
type: 'audio',
|
||||
mimeType: 'audio/wav',
|
||||
disableLogs: true,
|
||||
recorderType: RecordRTC.StereoAudioRecorder,
|
||||
sampleRate: 44100,
|
||||
numberOfAudioChannels: 2,
|
||||
checkForInactiveTracks: true,
|
||||
bufferSize: 4096,
|
||||
recorderOptions: {
|
||||
debug: true,
|
||||
controls: true,
|
||||
bigPlayButton: false,
|
||||
fluid: false,
|
||||
controlBar: {
|
||||
deviceButton: false,
|
||||
fullscreenToggle: false,
|
||||
cameraButton: false,
|
||||
volumePanel: false,
|
||||
},
|
||||
plugins: {
|
||||
wavesurfer: {
|
||||
backend: 'WebAudio',
|
||||
waveColor: '#1f93ff',
|
||||
progressColor: 'rgb(25, 118, 204)',
|
||||
cursorColor: 'rgba(43, 51, 63, 0.7)',
|
||||
backgroundColor: 'none',
|
||||
barWidth: 1,
|
||||
cursorWidth: 1,
|
||||
hideScrollbar: true,
|
||||
plugins: [
|
||||
WaveSurfer.microphone.create({
|
||||
bufferSize: 4096,
|
||||
numberOfInputChannels: 1,
|
||||
numberOfOutputChannels: 1,
|
||||
constraints: {
|
||||
video: false,
|
||||
audio: true,
|
||||
},
|
||||
}),
|
||||
],
|
||||
},
|
||||
record: {
|
||||
audio: true,
|
||||
video: false,
|
||||
displayMilliseconds: false,
|
||||
maxLength: 300,
|
||||
audioEngine: 'opus-recorder',
|
||||
audioWorkerURL: encoderWorker,
|
||||
audioChannels: 1,
|
||||
audioSampleRate: 48000,
|
||||
audioBitRate: 128,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
isRecording() {
|
||||
if (this.recorder) {
|
||||
return this.recorder.getState() === 'recording';
|
||||
}
|
||||
return false;
|
||||
return this.player && this.player.record().isRecording();
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.wavesurfer = WaveSurfer.create(this.options);
|
||||
this.wavesurfer.on('play', this.playingRecorder);
|
||||
this.wavesurfer.on('pause', this.pausedRecorder);
|
||||
this.wavesurfer.microphone.on('deviceReady', this.startRecording);
|
||||
this.wavesurfer.microphone.on('deviceError', this.deviceError);
|
||||
this.wavesurfer.microphone.start();
|
||||
this.fireStateRecorderTimerChanged(this.initialTimeDuration);
|
||||
window.Recorder = Recorder;
|
||||
this.fireProgressRecord(this.initialTimeDuration);
|
||||
this.player = videojs('#audio-wave', this.recorderOptions, () => {
|
||||
this.$nextTick(() => {
|
||||
this.player.record().getDevice();
|
||||
});
|
||||
});
|
||||
this.player.on('deviceReady', this.deviceReady);
|
||||
this.player.on('deviceError', this.deviceError);
|
||||
this.player.on('startRecord', this.startRecord);
|
||||
this.player.on('stopRecord', this.stopRecord);
|
||||
this.player.on('progressRecord', this.progressRecord);
|
||||
this.player.on('finishRecord', this.finishRecord);
|
||||
this.player.on('playbackFinish', this.playbackFinish);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.recorder) {
|
||||
this.recorder.destroy();
|
||||
if (this.player) {
|
||||
this.player.dispose();
|
||||
}
|
||||
if (this.wavesurfer) {
|
||||
this.wavesurfer.destroy();
|
||||
if (window.Recorder) {
|
||||
window.Recorder = undefined;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
startRecording(stream) {
|
||||
this.recorder = RecordRTC(stream, this.optionsRecorder);
|
||||
this.recorder.onStateChanged = this.onStateRecorderChanged;
|
||||
this.recorder.startRecording();
|
||||
deviceReady() {
|
||||
this.player.record().start();
|
||||
},
|
||||
startRecord() {
|
||||
this.fireStateRecorderChanged('recording');
|
||||
},
|
||||
stopRecord() {
|
||||
this.fireStateRecorderChanged('stopped');
|
||||
},
|
||||
finishRecord() {
|
||||
const file = new File(
|
||||
[this.player.recordedData],
|
||||
this.player.recordedData.name,
|
||||
{ type: this.player.recordedData.type }
|
||||
);
|
||||
this.fireRecorderBlob(file);
|
||||
},
|
||||
progressRecord() {
|
||||
this.fireProgressRecord(this.formatTimeProgress());
|
||||
},
|
||||
stopAudioRecording() {
|
||||
if (this.isRecording) {
|
||||
this.recorder.stopRecording(() => {
|
||||
this.wavesurfer.microphone.stopDevice();
|
||||
this.wavesurfer.loadBlob(this.recorder.getBlob());
|
||||
this.wavesurfer.stop();
|
||||
this.fireRecorderBlob(this.getAudioFile());
|
||||
});
|
||||
}
|
||||
this.player.record().stop();
|
||||
},
|
||||
getAudioFile() {
|
||||
if (this.hasAudio()) {
|
||||
return new File([this.recorder.getBlob()], this.getAudioFileName(), {
|
||||
type: 'audio/wav',
|
||||
});
|
||||
}
|
||||
return false;
|
||||
},
|
||||
hasAudio() {
|
||||
return !(this.isRecording || this.wavesurfer.isPlaying());
|
||||
},
|
||||
playingRecorder() {
|
||||
this.fireStateRecorderChanged('playing');
|
||||
},
|
||||
pausedRecorder() {
|
||||
this.fireStateRecorderChanged('paused');
|
||||
},
|
||||
deviceError(err) {
|
||||
deviceError() {
|
||||
const deviceError = this.player.deviceErrorCode;
|
||||
const deviceErrorName = deviceError?.name.toLowerCase();
|
||||
if (
|
||||
err?.name &&
|
||||
(err.name.toLowerCase().includes('notallowederror') ||
|
||||
err.name.toLowerCase().includes('permissiondeniederror'))
|
||||
deviceErrorName?.includes('notallowederror') ||
|
||||
deviceErrorName?.includes('permissiondeniederror')
|
||||
) {
|
||||
this.showAlert(
|
||||
this.$t('CONVERSATION.REPLYBOX.TIP_AUDIORECORDER_PERMISSION')
|
||||
@@ -127,56 +152,37 @@ export default {
|
||||
);
|
||||
}
|
||||
},
|
||||
onStateRecorderChanged(state) {
|
||||
// recording stopped inactive destroyed
|
||||
switch (state) {
|
||||
case 'recording':
|
||||
this.timerDurationChanged();
|
||||
break;
|
||||
case 'stopped':
|
||||
this.timerDurationChanged();
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
this.fireStateRecorderChanged(state);
|
||||
},
|
||||
timerDurationChanged() {
|
||||
if (this.isRecording) {
|
||||
this.recordingInterval = setInterval(() => {
|
||||
this.calculateTimeDuration(
|
||||
(new Date().getTime() - this.recordingDateStarted) / 1000
|
||||
);
|
||||
this.fireStateRecorderTimerChanged(this.timeDuration);
|
||||
}, 1000);
|
||||
} else {
|
||||
clearInterval(this.recordingInterval);
|
||||
}
|
||||
},
|
||||
calculateTimeDuration(secs) {
|
||||
let hr = Math.floor(secs / 3600);
|
||||
let min = Math.floor((secs - hr * 3600) / 60);
|
||||
let sec = Math.floor(secs - hr * 3600 - min * 60);
|
||||
if (min < 10) {
|
||||
min = '0' + min;
|
||||
}
|
||||
if (sec < 10) {
|
||||
sec = '0' + sec;
|
||||
}
|
||||
if (hr <= 0) {
|
||||
this.timeDuration = min + ':' + sec;
|
||||
} else {
|
||||
if (hr < 10) {
|
||||
hr = '0' + hr;
|
||||
}
|
||||
this.timeDuration = hr + ':' + min + ':' + sec;
|
||||
}
|
||||
formatTimeProgress() {
|
||||
return format(
|
||||
addSeconds(
|
||||
new Date(this.recordingDateStarted.getTimezoneOffset() * 1000 * 60),
|
||||
this.player.record().getDuration()
|
||||
),
|
||||
'mm:ss'
|
||||
);
|
||||
},
|
||||
playPause() {
|
||||
this.wavesurfer.playPause();
|
||||
if (this.player.wavesurfer().surfer.isPlaying()) {
|
||||
this.fireStateRecorderChanged('paused');
|
||||
} else {
|
||||
this.fireStateRecorderChanged('playing');
|
||||
}
|
||||
this.player.wavesurfer().surfer.playPause();
|
||||
},
|
||||
play() {
|
||||
this.fireStateRecorderChanged('playing');
|
||||
this.player.wavesurfer().play();
|
||||
},
|
||||
pause() {
|
||||
this.fireStateRecorderChanged('paused');
|
||||
this.player.wavesurfer().pause();
|
||||
},
|
||||
playbackFinish() {
|
||||
this.fireStateRecorderChanged('paused');
|
||||
this.player.wavesurfer().pause();
|
||||
},
|
||||
fireRecorderBlob(blob) {
|
||||
this.$emit('recorder-blob', {
|
||||
this.$emit('finish-record', {
|
||||
name: blob.name,
|
||||
type: blob.type,
|
||||
size: blob.size,
|
||||
@@ -186,29 +192,8 @@ export default {
|
||||
fireStateRecorderChanged(state) {
|
||||
this.$emit('state-recorder-changed', state);
|
||||
},
|
||||
fireStateRecorderTimerChanged(duration) {
|
||||
this.$emit('state-recorder-timer-changed', duration);
|
||||
},
|
||||
getAudioFileName() {
|
||||
const d = new Date();
|
||||
return `audio-${d.getFullYear()}-${d.getMonth()}-${d.getDate()}-${this.getRandomString()}.wav`;
|
||||
},
|
||||
getRandomString() {
|
||||
if (
|
||||
window.crypto &&
|
||||
window.crypto.getRandomValues &&
|
||||
navigator.userAgent.indexOf('Safari') === -1
|
||||
) {
|
||||
let a = window.crypto.getRandomValues(new Uint32Array(3));
|
||||
let token = '';
|
||||
for (let i = 0, l = a.length; i < l; i += 1) {
|
||||
token += a[i].toString(36);
|
||||
}
|
||||
return token.toLowerCase();
|
||||
}
|
||||
return (Math.random() * new Date().getTime())
|
||||
.toString(36)
|
||||
.replace(/\./g, '');
|
||||
fireProgressRecord(duration) {
|
||||
this.$emit('state-recorder-progress-changed', duration);
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -217,7 +202,9 @@ export default {
|
||||
<style lang="scss">
|
||||
.audio-wave-wrapper {
|
||||
min-height: 8rem;
|
||||
max-height: 12rem;
|
||||
overflow: hidden;
|
||||
height: 8rem;
|
||||
}
|
||||
.video-js .vjs-control-bar {
|
||||
background-color: transparent;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -186,6 +186,12 @@ export default {
|
||||
blur: () => {
|
||||
this.onBlur();
|
||||
},
|
||||
paste: (view, event) => {
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
this.focusEditorInputField();
|
||||
|
||||
@@ -163,13 +163,13 @@ export default {
|
||||
|
||||
&:hover,
|
||||
&:active {
|
||||
color: var(--y-800);
|
||||
color: var(--y-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.button--note {
|
||||
color: var(--y-900);
|
||||
color: var(--y-600);
|
||||
}
|
||||
|
||||
.action-wrap {
|
||||
|
||||
@@ -3,7 +3,7 @@ import { Bar } from 'vue-chartjs';
|
||||
const fontFamily =
|
||||
'-apple-system,system-ui,BlinkMacSystemFont,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif';
|
||||
|
||||
const chartOptions = {
|
||||
const defaultChartOptions = {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
legend: {
|
||||
@@ -11,10 +11,14 @@ const chartOptions = {
|
||||
fontFamily,
|
||||
},
|
||||
},
|
||||
datasets: {
|
||||
bar: {
|
||||
barPercentage: 1.0,
|
||||
},
|
||||
},
|
||||
scales: {
|
||||
xAxes: [
|
||||
{
|
||||
barPercentage: 1.1,
|
||||
ticks: {
|
||||
fontFamily,
|
||||
},
|
||||
@@ -39,8 +43,20 @@ const chartOptions = {
|
||||
|
||||
export default {
|
||||
extends: Bar,
|
||||
props: ['collection'],
|
||||
props: {
|
||||
collection: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
chartOptions: {
|
||||
type: Object,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.renderChart(this.collection, chartOptions);
|
||||
this.renderChart(this.collection, {
|
||||
...defaultChartOptions,
|
||||
...this.chartOptions,
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
+1
-1
@@ -23,7 +23,7 @@ export default {
|
||||
background: var(--s-500);
|
||||
}
|
||||
&__busy {
|
||||
background: var(--y-700);
|
||||
background: var(--y-500);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -14,8 +14,8 @@
|
||||
<fluent-icon
|
||||
v-if="!isHMACVerified"
|
||||
v-tooltip="$t('CONVERSATION.UNVERIFIED_SESSION')"
|
||||
class="text-y-800"
|
||||
size="14"
|
||||
class="hmac-warning__icon"
|
||||
icon="warning"
|
||||
/>
|
||||
</h3>
|
||||
@@ -181,7 +181,11 @@ export default {
|
||||
|
||||
.snoozed--display-text {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--y-900);
|
||||
color: var(--y-600);
|
||||
}
|
||||
}
|
||||
|
||||
.hmac-warning__icon {
|
||||
color: var(--y-600);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -60,6 +60,7 @@
|
||||
:readable-time="readableTime"
|
||||
:source-id="data.source_id"
|
||||
:inbox-id="data.inbox_id"
|
||||
:message-read="showReadTicks"
|
||||
/>
|
||||
</div>
|
||||
<spinner v-if="isPending" size="tiny" />
|
||||
@@ -153,6 +154,14 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
hasUserReadMessage: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isWebWidgetInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -207,7 +216,11 @@ export default {
|
||||
}
|
||||
}
|
||||
return (
|
||||
this.formatMessage(this.data.content, this.isATweet) + botMessageContent
|
||||
this.formatMessage(
|
||||
this.data.content,
|
||||
this.isATweet,
|
||||
this.data.private
|
||||
) + botMessageContent
|
||||
);
|
||||
},
|
||||
contentAttributes() {
|
||||
@@ -264,6 +277,14 @@ export default {
|
||||
isOutgoing() {
|
||||
return this.data.message_type === MESSAGE_TYPE.OUTGOING;
|
||||
},
|
||||
showReadTicks() {
|
||||
return (
|
||||
(this.isOutgoing || this.isTemplate) &&
|
||||
this.hasUserReadMessage &&
|
||||
this.isWebWidgetInbox &&
|
||||
!this.data.private
|
||||
);
|
||||
},
|
||||
isTemplate() {
|
||||
return this.data.message_type === MESSAGE_TYPE.TEMPLATE;
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user