Compare commits

..
Author SHA1 Message Date
Vishnu Narayanan 0ef3b3b5b5 fix: force build_contact and increment_view_count methods to use writing role
These methods are used by the widget to create a contact and helpcenter to
increment the view count. Since these methods are triggered by GET
request, Rails will use the reading role which will fail if the table
is readonly.
2024-05-24 17:31:35 +05:30
Vishnu NarayananandGitHub af084af774 Merge branch 'develop' into feat/add_read_replica_support 2024-05-16 08:41:13 +05:30
Vishnu Narayanan 5a682135f2 feat: configure auto db switching 2024-05-07 16:37:41 +10:00
Vishnu NarayananandGitHub a72dba1769 Merge branch 'develop' into feat/add_read_replica_support 2024-05-07 12:33:32 +10:00
Vishnu NarayananandGitHub cc653e072a Merge branch 'develop' into feat/add_read_replica_support 2024-05-03 14:39:38 +10:00
Vishnu Narayanan 7e64671a27 chore: add replica settings for test&dev 2024-05-03 14:37:37 +10:00
Vishnu Narayanan 43bca252f9 chore: revert db fallback initializer 2024-05-03 13:36:37 +10:00
Vishnu Narayanan 5dc74ff40f chore: debug gh action 2024-05-03 12:34:12 +10:00
Vishnu Narayanan 8dd735f3ea chore: debug gh action 2024-05-03 12:29:33 +10:00
Vishnu Narayanan ce69614662 Merge branch 'feat/add_read_replica_support' of github.com:chatwoot/chatwoot into feat/add_read_replica_support 2024-05-03 12:26:13 +10:00
Vishnu Narayanan 8a6b13fd85 chore: debug gh action 2024-05-03 12:25:47 +10:00
Vishnu NarayananandGitHub 2692dba5bd Merge branch 'develop' into feat/add_read_replica_support 2024-05-03 11:54:54 +10:00
Vishnu Narayanan 0095254e65 feat: handle if read_replica is not available 2024-05-02 18:59:42 +10:00
Vishnu Narayanan 3b34be5740 feat: add read replica support 2024-05-02 18:46:46 +10:00
1583 changed files with 35337 additions and 54197 deletions
+5 -2
View File
@@ -7,7 +7,7 @@ defaults: &defaults
working_directory: ~/build
docker:
# specify the version you desire here
- image: cimg/ruby:3.3.3-browsers
- image: cimg/ruby:3.2.2-browsers
# Specify service dependencies here if necessary
# CircleCI maintains a library of pre-built images
@@ -130,7 +130,10 @@ jobs:
command: |
mkdir -p ~/tmp/test-results/frontend_specs
~/tmp/cc-test-reporter before-build
yarn test:coverage
TESTFILES=$(circleci tests glob **/specs/*.spec.js | circleci tests split --split-by=timings)
yarn test:coverage --profile 10 \
--out ~/tmp/test-results/yarn.xml \
-- ${TESTFILES}
- run:
name: Code Climate Test Coverage
command: |
+2 -2
View File
@@ -12,7 +12,7 @@ services:
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
RUBY_VERSION: "3.2.2"
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
@@ -25,7 +25,7 @@ services:
args:
VARIANT: "ubuntu-22.04"
NODE_VERSION: "20.9.0"
RUBY_VERSION: "3.3.3"
RUBY_VERSION: "3.2.2"
# On Linux, you may need to update USER_UID and USER_GID below if not your local UID is not 1000.
USER_UID: "1000"
USER_GID: "1000"
+13 -8
View File
@@ -1,9 +1,4 @@
# Learn about the various environment variables at
# https://www.chatwoot.com/docs/self-hosted/configuration/environment-variables/#rails-production-variables
# Used to verify the integrity of signed cookies. so ensure a secure value is set
# SECRET_KEY_BASE should be alphanumeric. Avoid special characters or symbols.
# Use `rake secret` to generate this variable
SECRET_KEY_BASE=replace_with_lengthy_secure_hex
# Replace with the URL you are planning to use for your app
@@ -62,6 +57,13 @@ POSTGRES_PASSWORD=
RAILS_ENV=development
# Changes the Postgres query timeout limit. The default is 14 seconds. Modify only when required.
# POSTGRES_STATEMENT_TIMEOUT=14s
# read replica configuration for production
# POSTGRES_HOST_RR=postgres_rr
# create a new user with readonly permissions
# POSTGRES_USERNAME_RR=postgres_readonly
# POSTGRES_PASSWORD_RR=
RAILS_MAX_THREADS=5
# The email from which all outgoing emails are sent
@@ -85,8 +87,6 @@ SMTP_OPENSSL_VERIFY_MODE=peer
# Comment out the following environment variables if required by your SMTP server
# SMTP_TLS=
# SMTP_SSL=
# SMTP_OPEN_TIMEOUT
# SMTP_READ_TIMEOUT
# Mail Incoming
# This is the domain set for the reply emails when conversation continuity is enabled
@@ -191,6 +191,12 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# SENTRY_DSN=
# MICROSOFT CLARITY
# MS_CLARITY_TOKEN=xxxxxxxxx
# GOOGLE_TAG_MANAGER
# GOOGLE_TAG = GTM-XXXXXXX
## Scout
## https://scoutapm.com/docs/ruby/configuration
# SCOUT_KEY=YOURKEY
@@ -257,4 +263,3 @@ AZURE_APP_SECRET=
# Set to true if you want to remove stale contact inboxes
# contact_inboxes with no conversation older than 90 days will be removed
# REMOVE_STALE_CONTACT_INBOX_JOB_STATUS=false
+1 -155
View File
@@ -27,160 +27,6 @@ module.exports = {
'import/no-unresolved': 'off',
'vue/html-indent': 'off',
'vue/multi-word-component-names': 'off',
'vue/next-tick-style': ['error', 'callback'],
'vue/block-order': [
'error',
{
order: ['script', 'template', 'style'],
},
],
'vue/component-name-in-template-casing': [
'error',
'PascalCase',
{
registeredComponentsOnly: true,
},
],
'vue/component-options-name-casing': ['error', 'PascalCase'],
'vue/custom-event-name-casing': ['error', 'camelCase'],
'vue/define-emits-declaration': ['error'],
'vue/define-macros-order': [
'error',
{
order: ['defineProps', 'defineEmits'],
defineExposeLast: false,
},
],
'vue/define-props-declaration': ['error', 'runtime'],
'vue/match-component-import-name': ['error'],
'vue/no-bare-strings-in-template': [
'error',
{
allowlist: [
'(',
')',
',',
'.',
'&',
'+',
'-',
'=',
'*',
'/',
'#',
'%',
'!',
'?',
':',
'[',
']',
'{',
'}',
'<',
'>',
'⌘',
'📄',
'🎉',
'💬',
'👥',
'📥',
'🔖',
'❌',
'✅',
'\u00b7',
'\u2022',
'\u2010',
'\u2013',
'\u2014',
'\u2212',
'|',
],
attributes: {
'/.+/': [
'title',
'aria-label',
'aria-placeholder',
'aria-roledescription',
'aria-valuetext',
],
input: ['placeholder'],
},
directives: ['v-text'],
},
],
'vue/no-empty-component-block': 'error',
'vue/no-multiple-objects-in-class': 'error',
'vue/no-root-v-if': 'warn',
'vue/no-static-inline-styles': [
'error',
{
allowBinding: false,
},
],
'vue/no-template-target-blank': [
'error',
{
allowReferrer: false,
enforceDynamicLinks: 'always',
},
],
'vue/no-required-prop-with-default': [
'error',
{
autofix: false,
},
],
'vue/no-this-in-before-route-enter': 'error',
'vue/no-undef-components': [
'error',
{
ignorePatterns: [
'^woot-',
'^fluent-',
'^multiselect',
'^router-link',
'^router-view',
'^ninja-keys',
'^FormulateForm',
'^FormulateInput',
'^highlightjs',
],
},
],
'vue/no-unused-emit-declarations': 'error',
'vue/no-unused-refs': 'error',
'vue/no-use-v-else-with-v-for': 'error',
'vue/prefer-true-attribute-shorthand': 'error',
'vue/no-useless-v-bind': [
'error',
{
ignoreIncludesComment: false,
ignoreStringEscape: false,
},
],
'vue/no-v-text': 'error',
'vue/padding-line-between-blocks': ['error', 'always'],
'vue/prefer-separate-static-class': 'error',
'vue/require-explicit-slots': 'error',
'vue/require-macro-variable-name': [
'error',
{
defineProps: 'props',
defineEmits: 'emit',
defineSlots: 'slots',
useSlots: 'slots',
useAttrs: 'attrs',
},
],
'vue/no-unused-properties': [
'error',
{
groups: ['props'],
deepData: false,
ignorePublicMembers: false,
unreferencedOptions: [],
},
],
'vue/max-attributes-per-line': [
'error',
{
@@ -219,10 +65,10 @@ module.exports = {
},
env: {
browser: true,
jest: true,
node: true,
},
globals: {
bus: true,
vi: true,
},
};
-1
View File
@@ -45,7 +45,6 @@ jobs:
with:
ref: ${{ github.event.pull_request.head.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
- uses: ruby/setup-ruby@v1
with:
bundler-cache: true # runs 'bundle install' and caches installed gems automatically
+1 -1
View File
@@ -1 +1 @@
3.3.3
3.2.2
+3 -1
View File
@@ -2,6 +2,7 @@ import { addDecorator } from '@storybook/vue';
import Vue from 'vue';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import Vuelidate from 'vuelidate';
import Multiselect from 'vue-multiselect';
import VueDOMPurifyHTML from 'vue-dompurify-html';
import FluentIcon from 'shared/components/FluentIcon/DashboardIcon';
@@ -13,6 +14,7 @@ import { domPurifyConfig } from 'shared/helpers/HTMLSanitizer';
import '../app/javascript/dashboard/assets/scss/storybook.scss';
Vue.use(VueI18n);
Vue.use(Vuelidate);
Vue.use(WootUiKit);
Vue.use(Vuex);
Vue.use(VueDOMPurifyHTML, domPurifyConfig);
@@ -30,7 +32,7 @@ addDecorator(() => ({
template: '<story/>',
i18n: i18nConfig,
store,
beforeCreate: function () {
beforeCreate: function() {
this.$root._i18n = this.$i18n;
},
}));
+9 -9
View File
@@ -1,10 +1,10 @@
source 'https://rubygems.org'
ruby '3.3.3'
ruby '3.2.2'
##-- base gems for rails --##
gem 'rack-cors', '2.0.0', require: 'rack/cors'
gem 'rails', '~> 7.0.8.4'
gem 'rails', '~> 7.0.8.1'
# Reduces boot times through caching; required in config/boot.rb
gem 'bootsnap', require: false
@@ -61,7 +61,7 @@ gem 'redis-namespace'
gem 'activerecord-import'
##--- gems for server & infra configuration ---##
gem 'dotenv-rails', '>= 3.0.0'
gem 'dotenv-rails'
gem 'foreman'
gem 'puma'
gem 'webpacker'
@@ -77,7 +77,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.20.1'
gem 'administrate-field-active_storage', '>= 1.0.3'
gem 'administrate-field-active_storage', '>= 1.0.2'
gem 'administrate-field-belongs_to_search', '>= 0.9.0'
##--- gems for pubsub service ---##
@@ -111,18 +111,18 @@ gem 'elastic-apm', require: false
gem 'newrelic_rpm', require: false
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
gem 'scout_apm', require: false
gem 'sentry-rails', '>= 5.18.2', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.18.2', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.0'
gem 'sidekiq', '>= 7.2.4'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.12.0'
##-- Push notification service --##
gem 'fcm'
gem 'web-push', '>= 3.0.1'
gem 'web-push'
##-- geocoding / parse location from ip --##
# http://www.rubygeocoder.com/
@@ -228,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.3'
gem 'rspec-rails'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+126 -134
View File
@@ -33,78 +33,78 @@ GIT
GEM
remote: https://rubygems.org/
specs:
actioncable (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
actioncable (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
nio4r (~> 2.0)
websocket-driver (>= 0.6.1)
actionmailbox (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
actionmailbox (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (>= 2.7.1)
net-imap
net-pop
net-smtp
actionmailer (7.0.8.4)
actionpack (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activesupport (= 7.0.8.4)
actionmailer (7.0.8.1)
actionpack (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activesupport (= 7.0.8.1)
mail (~> 2.5, >= 2.5.4)
net-imap
net-pop
net-smtp
rails-dom-testing (~> 2.0)
actionpack (7.0.8.4)
actionview (= 7.0.8.4)
activesupport (= 7.0.8.4)
actionpack (7.0.8.1)
actionview (= 7.0.8.1)
activesupport (= 7.0.8.1)
rack (~> 2.0, >= 2.2.4)
rack-test (>= 0.6.3)
rails-dom-testing (~> 2.0)
rails-html-sanitizer (~> 1.0, >= 1.2.0)
actiontext (7.0.8.4)
actionpack (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
actiontext (7.0.8.1)
actionpack (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.6.0)
nokogiri (>= 1.8.5)
actionview (7.0.8.4)
activesupport (= 7.0.8.4)
actionview (7.0.8.1)
activesupport (= 7.0.8.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 (7.0.8.4)
activesupport (= 7.0.8.4)
activejob (7.0.8.1)
activesupport (= 7.0.8.1)
globalid (>= 0.3.6)
activemodel (7.0.8.4)
activesupport (= 7.0.8.4)
activerecord (7.0.8.4)
activemodel (= 7.0.8.4)
activesupport (= 7.0.8.4)
activemodel (7.0.8.1)
activesupport (= 7.0.8.1)
activerecord (7.0.8.1)
activemodel (= 7.0.8.1)
activesupport (= 7.0.8.1)
activerecord-import (1.4.1)
activerecord (>= 4.2)
activestorage (7.0.8.4)
actionpack (= 7.0.8.4)
activejob (= 7.0.8.4)
activerecord (= 7.0.8.4)
activesupport (= 7.0.8.4)
activestorage (7.0.8.1)
actionpack (= 7.0.8.1)
activejob (= 7.0.8.1)
activerecord (= 7.0.8.1)
activesupport (= 7.0.8.1)
marcel (~> 1.0)
mini_mime (>= 1.1.0)
activesupport (7.0.8.4)
activesupport (7.0.8.1)
concurrent-ruby (~> 1.0, >= 1.0.2)
i18n (>= 1.6, < 2)
minitest (>= 5.1)
tzinfo (~> 2.0)
acts-as-taggable-on (9.0.1)
activerecord (>= 6.0, < 7.1)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
addressable (2.8.4)
public_suffix (>= 2.0.2, < 6.0)
administrate (0.20.1)
actionpack (>= 6.0, < 8.0)
actionview (>= 6.0, < 8.0)
@@ -113,7 +113,7 @@ GEM
kaminari (~> 1.2.2)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (1.0.3)
administrate-field-active_storage (1.0.2)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.9.0)
@@ -150,13 +150,13 @@ GEM
statsd-ruby (~> 1.1)
base64 (0.2.0)
bcrypt (3.1.20)
bigdecimal (3.1.8)
bigdecimal (3.1.7)
bindex (0.8.1)
bootsnap (1.16.0)
msgpack (~> 1.2)
brakeman (5.4.1)
browser (5.3.1)
builder (3.3.0)
builder (3.2.4)
bullet (7.0.7)
activesupport (>= 3.0.0)
uniform_notifier (~> 1.11)
@@ -169,15 +169,12 @@ GEM
climate_control (1.2.0)
coderay (1.1.3)
commonmarker (0.23.10)
concurrent-ruby (1.3.3)
concurrent-ruby (1.2.3)
connection_pool (2.4.1)
crack (1.0.0)
bigdecimal
crack (0.4.5)
rexml
crass (1.0.6)
csv (3.3.0)
csv-safe (3.3.1)
csv (~> 3.0)
csv-safe (3.2.1)
cypress-on-rails (1.16.0)
rack
database_cleaner (2.0.2)
@@ -186,16 +183,13 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
datadog-ci (0.8.3)
msgpack
date (3.3.4)
ddtrace (1.23.2)
datadog-ci (~> 0.8.1)
debase-ruby_core_source (= 3.3.1)
libdatadog (~> 7.0.0.1.0)
libddwaf (~> 1.14.0.0.0)
ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0)
libddwaf (~> 1.8.2.0.0)
msgpack
debase-ruby_core_source (3.3.1)
debase-ruby_core_source (3.2.0)
debug (1.8.0)
irb (>= 1.5.0)
reline (>= 0.3.1)
@@ -210,16 +204,16 @@ GEM
bcrypt (~> 3.0)
devise (> 3.5.2, < 5)
rails (>= 4.2.0, < 7.2)
diff-lcs (1.5.1)
diff-lcs (1.5.0)
digest-crc (0.6.4)
rake (>= 12.0.0, < 14.0.0)
docile (1.4.0)
domain_name (0.5.20190701)
unf (>= 0.0.5, < 1.0.0)
dotenv (3.1.2)
dotenv-rails (3.1.2)
dotenv (= 3.1.2)
railties (>= 6.1)
dotenv (2.8.1)
dotenv-rails (2.8.1)
dotenv (= 2.8.1)
railties (>= 3.2)
down (5.4.0)
addressable (~> 2.8)
ecma-re-validator (0.4.0)
@@ -229,7 +223,7 @@ GEM
http (>= 3.0)
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
erubi (1.12.0)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
@@ -359,8 +353,9 @@ GEM
ruby2ruby (~> 2.4)
ruby_parser (~> 3.10)
hana (1.3.7)
hashdiff (1.1.0)
hashdiff (1.0.1)
hashie (5.0.0)
hkdf (1.0.0)
http (5.1.1)
addressable (~> 2.8)
http-cookie (~> 1.0)
@@ -422,15 +417,15 @@ GEM
addressable (~> 2.8)
letter_opener (1.8.1)
launchy (>= 2.2, < 3)
libdatadog (7.0.0.1.0)
libdatadog (7.0.0.1.0-x86_64-linux)
libddwaf (1.14.0.0.0)
libdatadog (2.0.0.1.0)
libdatadog (2.0.0.1.0-x86_64-linux)
libddwaf (1.8.2.0.0)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-arm64-darwin)
libddwaf (1.8.2.0.0-arm64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-darwin)
libddwaf (1.8.2.0.0-x86_64-darwin)
ffi (~> 1.0)
libddwaf (1.14.0.0.0-x86_64-linux)
libddwaf (1.8.2.0.0-x86_64-linux)
ffi (~> 1.0)
line-bot-api (1.28.0)
liquid (5.4.0)
@@ -440,7 +435,6 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
logger (1.6.0)
lograge (0.14.0)
actionpack (>= 4)
activesupport (>= 4)
@@ -466,8 +460,8 @@ GEM
mime-types-data (3.2023.0218.1)
mini_magick (4.12.0)
mini_mime (1.1.5)
mini_portile2 (2.8.7)
minitest (5.24.1)
mini_portile2 (2.8.6)
minitest (5.22.3)
mock_redis (0.36.0)
ruby2_keywords
msgpack (1.7.0)
@@ -480,7 +474,7 @@ GEM
uri
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.4.12)
net-imap (0.4.11)
date
net-protocol
net-pop (0.1.2)
@@ -496,14 +490,14 @@ GEM
newrelic_rpm (9.6.0)
base64
nio4r (2.7.3)
nokogiri (1.16.6)
nokogiri (1.16.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.16.6-arm64-darwin)
nokogiri (1.16.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-darwin)
nokogiri (1.16.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.16.6-x86_64-linux)
nokogiri (1.16.5-x86_64-linux)
racc (~> 1.4)
oauth (1.1.0)
oauth-tty (~> 1.0, >= 1.0.1)
@@ -533,7 +527,7 @@ GEM
omniauth-rails_csrf_protection (1.0.2)
actionpack (>= 4.2)
omniauth (~> 2.0)
openssl (3.2.0)
openssl (3.1.0)
orm_adapter (0.5.0)
os (1.1.4)
parallel (1.23.0)
@@ -551,17 +545,17 @@ GEM
method_source (~> 1.0)
pry-rails (0.3.9)
pry (>= 0.10.4)
public_suffix (6.0.0)
public_suffix (5.0.1)
puma (6.4.2)
nio4r (~> 2.0)
pundit (2.3.0)
activesupport (>= 3.0.0)
raabro (1.4.0)
racc (1.8.0)
racc (1.7.3)
rack (2.2.9)
rack-attack (6.7.0)
rack (>= 1.0, < 4)
rack-contrib (2.5.0)
rack-contrib (2.4.0)
rack (< 4)
rack-cors (2.0.0)
rack (>= 2.0.0)
@@ -575,20 +569,20 @@ GEM
rack-test (2.1.0)
rack (>= 1.3)
rack-timeout (0.6.3)
rails (7.0.8.4)
actioncable (= 7.0.8.4)
actionmailbox (= 7.0.8.4)
actionmailer (= 7.0.8.4)
actionpack (= 7.0.8.4)
actiontext (= 7.0.8.4)
actionview (= 7.0.8.4)
activejob (= 7.0.8.4)
activemodel (= 7.0.8.4)
activerecord (= 7.0.8.4)
activestorage (= 7.0.8.4)
activesupport (= 7.0.8.4)
rails (7.0.8.1)
actioncable (= 7.0.8.1)
actionmailbox (= 7.0.8.1)
actionmailer (= 7.0.8.1)
actionpack (= 7.0.8.1)
actiontext (= 7.0.8.1)
actionview (= 7.0.8.1)
activejob (= 7.0.8.1)
activemodel (= 7.0.8.1)
activerecord (= 7.0.8.1)
activestorage (= 7.0.8.1)
activesupport (= 7.0.8.1)
bundler (>= 1.15.0)
railties (= 7.0.8.4)
railties (= 7.0.8.1)
rails-dom-testing (2.2.0)
activesupport (>= 5.0.0)
minitest
@@ -596,9 +590,9 @@ GEM
rails-html-sanitizer (1.6.0)
loofah (~> 2.21)
nokogiri (~> 1.14)
railties (7.0.8.4)
actionpack (= 7.0.8.4)
activesupport (= 7.0.8.4)
railties (7.0.8.1)
actionpack (= 7.0.8.1)
activesupport (= 7.0.8.1)
method_source
rake (>= 12.2)
thor (~> 1.0)
@@ -610,7 +604,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.22.2)
redis-client (0.22.1)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -634,25 +628,24 @@ GEM
retriable (3.1.2)
reverse_markdown (2.1.1)
nokogiri
rexml (3.3.4)
strscan
rspec-core (3.13.0)
rspec-support (~> 3.13.0)
rspec-expectations (3.13.1)
rexml (3.2.5)
rspec-core (3.12.2)
rspec-support (~> 3.12.0)
rspec-expectations (3.12.3)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-mocks (3.13.1)
rspec-support (~> 3.12.0)
rspec-mocks (3.12.5)
diff-lcs (>= 1.2.0, < 2.0)
rspec-support (~> 3.13.0)
rspec-rails (6.1.3)
rspec-support (~> 3.12.0)
rspec-rails (6.0.2)
actionpack (>= 6.1)
activesupport (>= 6.1)
railties (>= 6.1)
rspec-core (~> 3.13)
rspec-expectations (~> 3.13)
rspec-mocks (~> 3.13)
rspec-support (~> 3.13)
rspec-support (3.13.1)
rspec-core (~> 3.12)
rspec-expectations (~> 3.12)
rspec-mocks (~> 3.12)
rspec-support (~> 3.12)
rspec-support (3.12.0)
rspec_junit_formatter (0.6.0)
rspec-core (>= 2, < 4, != 2.12.0)
rubocop (1.50.2)
@@ -710,24 +703,23 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.18.2)
sentry-rails (5.17.3)
railties (>= 5.0)
sentry-ruby (~> 5.18.2)
sentry-ruby (5.18.2)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.18.2)
sentry-ruby (~> 5.18.2)
sentry-sidekiq (5.17.3)
sentry-ruby (~> 5.17.3)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
activesupport (>= 5.2.0)
sidekiq (7.3.0)
sidekiq (7.2.4)
concurrent-ruby (< 2)
connection_pool (>= 2.3.0)
logger
rack (>= 2.2.4)
redis-client (>= 0.22.2)
redis-client (>= 0.19.0)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
@@ -766,7 +758,6 @@ GEM
stackprof (0.2.25)
statsd-ruby (1.5.0)
stripe (8.5.0)
strscan (3.1.0)
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.1)
@@ -807,10 +798,11 @@ GEM
activemodel (>= 6.0.0)
bindex (>= 0.4.0)
railties (>= 6.0.0)
web-push (3.0.1)
web-push (3.0.0)
hkdf (~> 1.0)
jwt (~> 2.0)
openssl (~> 3.0)
webmock (3.23.1)
webmock (3.18.1)
addressable (>= 2.8.0)
crack (>= 0.3.2)
hashdiff (>= 0.4.0, < 2.0.0)
@@ -827,7 +819,7 @@ GEM
working_hours (1.4.1)
activesupport (>= 3.2)
tzinfo
zeitwerk (2.6.16)
zeitwerk (2.6.14)
PLATFORMS
arm64-darwin-20
@@ -845,7 +837,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.20.1)
administrate-field-active_storage (>= 1.0.3)
administrate-field-active_storage (>= 1.0.2)
administrate-field-belongs_to_search (>= 0.9.0)
annotate
attr_extras
@@ -869,7 +861,7 @@ DEPENDENCIES
devise (>= 4.9.4)
devise-secure_password!
devise_token_auth (>= 1.2.3)
dotenv-rails (>= 3.0.0)
dotenv-rails
down
elastic-apm
email_reply_trimmer
@@ -924,13 +916,13 @@ DEPENDENCIES
rack-cors (= 2.0.0)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.0.8.4)
rails (~> 7.0.8.1)
redis
redis-namespace
responders (>= 3.1.1)
rest-client
reverse_markdown
rspec-rails (>= 6.1.3)
rspec-rails
rspec_junit_formatter
rubocop
rubocop-performance
@@ -939,11 +931,11 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.18.2)
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.18.2)
sentry-sidekiq (>= 5.15.0)
shoulda-matchers
sidekiq (>= 7.3.0)
sidekiq (>= 7.2.4)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
@@ -961,14 +953,14 @@ DEPENDENCIES
uglifier
valid_email2
web-console (>= 4.2.1)
web-push (>= 3.0.1)
web-push
webmock
webpacker
wisper (= 2.0.0)
working_hours
RUBY VERSION
ruby 3.3.3p89
ruby 3.2.2p185
BUNDLED WITH
2.5.16
2.4.6
+1 -1
View File
@@ -1 +1 @@
3.9.0
3.3.1
+1 -1
View File
@@ -1 +1 @@
2.8.0
2.7.0
@@ -27,7 +27,7 @@ class Messages::Messenger::MessageBuilder
file_type = attachment['type'].to_sym
params = { file_type: file_type, account_id: @message.account_id }
if [:image, :file, :audio, :video, :share, :story_mention, :ig_reel].include? file_type
if [:image, :file, :audio, :video, :share, :story_mention].include? file_type
params.merge!(file_type_params(attachment))
elsif file_type == :location
params.merge!(location_params(attachment))
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::BaseReportBuilder
pattr_initialize :account, :params
private
AVG_METRICS = %w[avg_first_response_time avg_resolution_time reply_time].freeze
COUNT_METRICS = %w[
conversations_count
incoming_messages_count
outgoing_messages_count
resolutions_count
bot_resolutions_count
bot_handoffs_count
].freeze
def builder_class(metric)
case metric
when *AVG_METRICS
V2::Reports::Timeseries::AverageReportBuilder
when *COUNT_METRICS
V2::Reports::Timeseries::CountReportBuilder
end
end
def log_invalid_metric
Rails.logger.error "ReportBuilder: Invalid metric - #{params[:metric]}"
{}
end
end
@@ -1,30 +0,0 @@
class V2::Reports::Conversations::MetricBuilder < V2::Reports::Conversations::BaseReportBuilder
def summary
{
conversations_count: count('conversations_count'),
incoming_messages_count: count('incoming_messages_count'),
outgoing_messages_count: count('outgoing_messages_count'),
avg_first_response_time: count('avg_first_response_time'),
avg_resolution_time: count('avg_resolution_time'),
resolutions_count: count('resolutions_count'),
reply_time: count('reply_time')
}
end
def bot_summary
{
bot_resolutions_count: count('bot_resolutions_count'),
bot_handoffs_count: count('bot_handoffs_count')
}
end
private
def count(metric)
builder_class(metric).new(account, builder_params(metric)).aggregate_value
end
def builder_params(metric)
params.merge({ metric: metric })
end
end
@@ -1,21 +0,0 @@
class V2::Reports::Conversations::ReportBuilder < V2::Reports::Conversations::BaseReportBuilder
def timeseries
perform_action(:timeseries)
end
def aggregate_value
perform_action(:aggregate_value)
end
private
def perform_action(method_name)
return builder.new(account, params).public_send(method_name) if builder.present?
log_invalid_metric
end
def builder
builder_class(params[:metric])
end
end
@@ -1,48 +0,0 @@
class V2::Reports::Timeseries::AverageReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_average_time = reporting_events.average(average_value_key)
grouped_event_count = reporting_events.count
grouped_average_time.each_with_object([]) do |element, arr|
event_date, average_time = element
arr << {
value: average_time,
timestamp: event_date.in_time_zone(timezone).to_i,
count: grouped_event_count[event_date]
}
end
end
def aggregate_value
object_scope.average(average_value_key)
end
private
def event_name
metric_to_event_name = {
avg_first_response_time: :first_response,
avg_resolution_time: :conversation_resolved,
reply_time: :reply_time
}
metric_to_event_name[params[:metric].to_sym]
end
def object_scope
scope.reporting_events.where(name: event_name, created_at: range)
end
def reporting_events
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
)
end
def average_value_key
@average_value_key ||= params[:business_hours].present? ? :value_in_business_hours : :value
end
end
@@ -1,46 +0,0 @@
class V2::Reports::Timeseries::BaseTimeseriesBuilder
include TimezoneHelper
include DateRangeHelper
DEFAULT_GROUP_BY = 'day'.freeze
pattr_initialize :account, :params
def scope
case params[:type].to_sym
when :account
account
when :inbox
inbox
when :agent
user
when :label
label
when :team
team
end
end
def inbox
@inbox ||= account.inboxes.find(params[:id])
end
def user
@user ||= account.users.find(params[:id])
end
def label
@label ||= account.labels.find(params[:id])
end
def team
@team ||= account.teams.find(params[:id])
end
def group_by
@group_by ||= %w[day week month year hour].include?(params[:group_by]) ? params[:group_by] : DEFAULT_GROUP_BY
end
def timezone
@timezone ||= timezone_name_from_offset(params[:timezone_offset])
end
end
@@ -1,71 +0,0 @@
class V2::Reports::Timeseries::CountReportBuilder < V2::Reports::Timeseries::BaseTimeseriesBuilder
def timeseries
grouped_count.each_with_object([]) do |element, arr|
event_date, event_count = element
# The `event_date` is in Date format (without time), such as "Wed, 15 May 2024".
# We need a timestamp for the start of the day. However, we can't use `event_date.to_time.to_i`
# because it converts the date to 12:00 AM server timezone.
# The desired output should be 12:00 AM in the specified timezone.
arr << { value: event_count, timestamp: event_date.in_time_zone(timezone).to_i }
end
end
def aggregate_value
object_scope.count
end
private
def metric
@metric ||= params[:metric]
end
def object_scope
send("scope_for_#{metric}")
end
def scope_for_conversations_count
scope.conversations.where(account_id: account.id, created_at: range)
end
def scope_for_incoming_messages_count
scope.messages.where(account_id: account.id, created_at: range).incoming.unscope(:order)
end
def scope_for_outgoing_messages_count
scope.messages.where(account_id: account.id, created_at: range).outgoing.unscope(:order)
end
def scope_for_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_resolutions_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_resolved,
conversations: { status: :resolved }, created_at: range
).distinct
end
def scope_for_bot_handoffs_count
scope.reporting_events.joins(:conversation).select(:conversation_id).where(
name: :conversation_bot_handoff,
created_at: range
).distinct
end
def grouped_count
@grouped_values = object_scope.group_by_period(
group_by,
:created_at,
default_value: 0,
range: range,
permit: %w[day week month year hour],
time_zone: timezone
).count
end
end
@@ -1,32 +0,0 @@
class Api::V1::Accounts::Google::AuthorizationsController < Api::V1::Accounts::BaseController
include GoogleConcern
before_action :check_authorization
def create
email = params[:authorization][:email]
redirect_url = google_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/google/callback",
scope: 'email profile https://mail.google.com/',
response_type: 'code',
prompt: 'consent', # the oauth flow does not return a refresh token, this is supposed to fix it
access_type: 'offline', # the default is 'online'
client_id: GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
}
)
if redirect_url
cache_key = "google::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
end
end
private
def check_authorization
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
end
end
@@ -10,7 +10,7 @@ class Api::V1::Accounts::Integrations::AppsController < Api::V1::Accounts::BaseC
private
def fetch_apps
@apps = Integrations::App.all.select { |app| app.active?(Current.account) }
@apps = Integrations::App.all.select(&:active?)
end
def fetch_app
@@ -1,22 +0,0 @@
class Api::V1::Accounts::Integrations::CaptainController < Api::V1::Accounts::BaseController
before_action :check_admin_authorization?
before_action :fetch_hook
def sso_url
params_string =
"token=#{URI.encode_www_form_component(@hook['settings']['access_token'])}" \
"&email=#{URI.encode_www_form_component(@hook['settings']['account_email'])}" \
"&account_id=#{URI.encode_www_form_component(@hook['settings']['account_id'])}"
installation_config = InstallationConfig.find_by(name: 'CAPTAIN_APP_URL')
sso_url = "#{installation_config.value}/sso?#{params_string}"
render json: { sso_url: sso_url }, status: :ok
end
private
def fetch_hook
@hook = Current.account.hooks.find_by!(app_id: 'captain')
end
end
@@ -1,93 +0,0 @@
class Api::V1::Accounts::Integrations::LinearController < Api::V1::Accounts::BaseController
before_action :fetch_conversation, only: [:link_issue, :linked_issues]
def teams
teams = linear_processor_service.teams
if teams[:error]
render json: { error: teams[:error] }, status: :unprocessable_entity
else
render json: teams[:data], status: :ok
end
end
def team_entities
team_id = permitted_params[:team_id]
team_entities = linear_processor_service.team_entities(team_id)
if team_entities[:error]
render json: { error: team_entities[:error] }, status: :unprocessable_entity
else
render json: team_entities[:data], status: :ok
end
end
def create_issue
issue = linear_processor_service.create_issue(permitted_params)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def link_issue
issue_id = permitted_params[:issue_id]
title = permitted_params[:title]
issue = linear_processor_service.link_issue(conversation_link, issue_id, title)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def unlink_issue
link_id = permitted_params[:link_id]
issue = linear_processor_service.unlink_issue(link_id)
if issue[:error]
render json: { error: issue[:error] }, status: :unprocessable_entity
else
render json: issue[:data], status: :ok
end
end
def linked_issues
issues = linear_processor_service.linked_issues(conversation_link)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
def search_issue
render json: { error: 'Specify search string with parameter q' }, status: :unprocessable_entity if params[:q].blank? && return
term = params[:q]
issues = linear_processor_service.search_issue(term)
if issues[:error]
render json: { error: issues[:error] }, status: :unprocessable_entity
else
render json: issues[:data], status: :ok
end
end
private
def conversation_link
"#{ENV.fetch('FRONTEND_URL', nil)}/app/accounts/#{Current.account.id}/conversations/#{@conversation.display_id}"
end
def fetch_conversation
@conversation = Current.account.conversations.find_by!(display_id: permitted_params[:conversation_id])
end
def linear_processor_service
Integrations::Linear::ProcessorService.new(account: Current.account)
end
def permitted_params
params.permit(:team_id, :project_id, :conversation_id, :issue_id, :link_id, :title, :description, :assignee_id, :priority, label_ids: [])
end
end
@@ -12,8 +12,8 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
}
)
if redirect_url
cache_key = "microsoft::#{email.downcase}"
::Redis::Alfred.setex(cache_key, Current.account.id, 5.minutes)
email = email.downcase
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else
render json: { success: false }, status: :unprocessable_entity
@@ -5,17 +5,19 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
before_action :check_authorization
def index
builder = V2::Reports::Conversations::ReportBuilder.new(Current.account, report_params)
data = builder.timeseries
builder = V2::ReportBuilder.new(Current.account, report_params)
data = builder.build
render json: data
end
def summary
render json: build_summary(:summary)
render json: summary_metrics
end
def bot_summary
render json: build_summary(:bot_summary)
summary = V2::ReportBuilder.new(Current.account, current_summary_params).bot_summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).bot_summary
render json: summary
end
def agents
@@ -124,11 +126,10 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
}
end
def build_summary(method)
builder = V2::Reports::Conversations::MetricBuilder
current_summary = builder.new(Current.account, current_summary_params).send(method)
previous_summary = builder.new(Current.account, previous_summary_params).send(method)
current_summary.merge(previous: previous_summary)
def summary_metrics
summary = V2::ReportBuilder.new(Current.account, current_summary_params).summary
summary[:previous] = V2::ReportBuilder.new(Current.account, previous_summary_params).summary
summary
end
def conversation_metrics
@@ -1,20 +0,0 @@
module GoogleConcern
extend ActiveSupport::Concern
def google_client
app_id = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_ID', nil)
app_secret = GlobalConfigService.load('GOOGLE_OAUTH_CLIENT_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret, {
site: 'https://oauth2.googleapis.com',
authorize_url: 'https://accounts.google.com/o/oauth2/auth',
token_url: 'https://accounts.google.com/o/oauth2/token'
})
end
private
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
end
@@ -2,10 +2,7 @@ module MicrosoftConcern
extend ActiveSupport::Concern
def microsoft_client
app_id = GlobalConfigService.load('AZURE_APP_ID', nil)
app_secret = GlobalConfigService.load('AZURE_APP_SECRET', nil)
::OAuth2::Client.new(app_id, app_secret,
::OAuth2::Client.new(ENV.fetch('AZURE_APP_ID', nil), ENV.fetch('AZURE_APP_SECRET', nil),
{
site: 'https://login.microsoftonline.com',
authorize_url: 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize',
@@ -15,6 +12,10 @@ module MicrosoftConcern
private
def parsed_body
@parsed_body ||= Rack::Utils.parse_nested_query(@response.raw_response.body)
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
+1 -16
View File
@@ -3,7 +3,6 @@ class DashboardController < ActionController::Base
before_action :set_application_pack
before_action :set_global_config
before_action :set_dashboard_scripts
around_action :switch_locale
before_action :ensure_installation_onboarding, only: [:index]
before_action :render_hc_if_custom_domain, only: [:index]
@@ -36,10 +35,6 @@ class DashboardController < ActionController::Base
).merge(app_config)
end
def set_dashboard_scripts
@dashboard_scripts = sensitive_path? ? nil : GlobalConfig.get_value('DASHBOARD_SCRIPTS')
end
def ensure_installation_onboarding
redirect_to '/installation/onboarding' if ::Redis::Alfred.get(::Redis::Alfred::CHATWOOT_INSTALLATION_ONBOARDING)
end
@@ -63,7 +58,7 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: GlobalConfigService.load('FACEBOOK_API_VERSION', 'v17.0'),
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: GlobalConfigService.load('AZURE_APP_ID', ''),
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
@@ -75,14 +70,4 @@ class DashboardController < ActionController::Base
'application'
end
end
def sensitive_path?
# dont load dashboard scripts on sensitive paths like password reset
sensitive_paths = [edit_user_password_path].freeze
# remove app prefix
current_path = request.path.gsub(%r{^/app}, '')
sensitive_paths.include?(current_path)
end
end
@@ -1,18 +0,0 @@
class Google::CallbacksController < OauthCallbackController
include GoogleConcern
private
def provider_name
'google'
end
def imap_address
'imap.gmail.com'
end
def oauth_client
# from GoogleConcern
google_client
end
end
@@ -1,17 +1,77 @@
class Microsoft::CallbacksController < OauthCallbackController
class Microsoft::CallbacksController < ApplicationController
include MicrosoftConcern
def show
@response = microsoft_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/microsoft/callback"
)
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'].downcase)
redirect_to app_microsoft_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def oauth_client
microsoft_client
def oauth_code
params[:code]
end
def provider_name
'microsoft'
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end
def imap_address
'outlook.office365.com'
def parsed_body
@parsed_body ||= @response.response.parsed
end
def account_id
::Redis::Alfred.get(users_data['email'].downcase)
end
def account
@account ||= Account.find(account_id)
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
channel_email ||= create_microsoft_channel_with_inbox
update_microsoft_channel(channel_email)
channel_email.inbox
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def create_microsoft_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def update_microsoft_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: 'outlook.office365.com',
imap_port: '993', imap_enabled: true,
provider: 'microsoft',
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end
end
+1 -1
View File
@@ -12,6 +12,6 @@ class MicrosoftController < ApplicationController
end
def microsoft_indentity
@identity_json = GlobalConfigService.load('AZURE_APP_ID', nil)
@identity_json = ENV.fetch('AZURE_APP_ID', nil)
end
end
@@ -1,108 +0,0 @@
class OauthCallbackController < ApplicationController
def show
@response = oauth_client.auth_code.get_token(
oauth_code,
redirect_uri: "#{base_url}/#{provider_name}/callback"
)
handle_response
::Redis::Alfred.delete(cache_key)
rescue StandardError => e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to '/'
end
private
def handle_response
inbox, already_exists = find_or_create_inbox
if already_exists
redirect_to app_email_inbox_settings_url(account_id: account.id, inbox_id: inbox.id)
else
redirect_to app_email_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
end
def find_or_create_inbox
channel_email = Channel::Email.find_by(email: users_data['email'], account: account)
# we need this value to know where to redirect on sucessful processing of the callback
channel_exists = channel_email.present?
channel_email ||= create_channel_with_inbox
update_channel(channel_email)
# reauthorize channel, this code path only triggers when microsoft auth is successful
# reauthorized will also update cache keys for the associated inbox
channel_email.reauthorized!
[channel_email.inbox, channel_exists]
end
def update_channel(channel_email)
channel_email.update!({
imap_login: users_data['email'], imap_address: imap_address,
imap_port: '993', imap_enabled: true,
provider: provider_name,
provider_config: {
access_token: parsed_body['access_token'],
refresh_token: parsed_body['refresh_token'],
expires_on: (Time.current.utc + 1.hour).to_s
}
})
end
def provider_name
raise NotImplementedError
end
def oauth_client
raise NotImplementedError
end
def cache_key
"#{provider_name}::#{users_data['email'].downcase}"
end
def create_channel_with_inbox
ActiveRecord::Base.transaction do
channel_email = Channel::Email.create!(email: users_data['email'], account: account)
account.inboxes.create!(
account: account,
channel: channel_email,
name: users_data['name'] || fallback_name
)
channel_email
end
end
def users_data
decoded_token = JWT.decode parsed_body[:id_token], nil, false
decoded_token[0]
end
def account_id
::Redis::Alfred.get(cache_key)
end
def account
@account ||= Account.find(account_id)
end
# Fallback name, for when name field is missing from users_data
def fallback_name
users_data['email'].split('@').first.parameterize.titleize
end
def oauth_code
params[:code]
end
def base_url
ENV.fetch('FRONTEND_URL', 'http://localhost:3000')
end
def parsed_body
@parsed_body ||= @response.response.parsed
end
end
@@ -35,12 +35,10 @@ class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
@allowed_configs = case @config
when 'facebook'
%w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET IG_VERIFY_TOKEN FACEBOOK_API_VERSION ENABLE_MESSENGER_CHANNEL_HUMAN_AGENT]
when 'microsoft'
%w[AZURE_APP_ID AZURE_APP_SECRET]
when 'email'
['MAILER_INBOUND_EMAIL_DOMAIN']
else
%w[ENABLE_ACCOUNT_SIGNUP FIREBASE_PROJECT_ID FIREBASE_CREDENTIALS]
%w[ENABLE_ACCOUNT_SIGNUP]
end
end
end
+4 -2
View File
@@ -47,8 +47,10 @@ class WidgetsController < ActionController::Base
def build_contact
return if @contact.present?
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
ActiveRecord::Base.connected_to(role: :writing) do
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
@contact = @contact_inbox.contact
end
end
def ensure_account_is_active
-19
View File
@@ -1,19 +0,0 @@
module TimezoneHelper
# ActiveSupport TimeZone is not aware of the current time, so ActiveSupport::Timezone[offset]
# would return the timezone without considering day light savings. To get the correct timezone,
# this method uses zone.now.utc_offset for comparison as referenced in the issues below
#
# https://github.com/rails/rails/pull/22243
# https://github.com/rails/rails/issues/21501
# https://github.com/rails/rails/issues/7297
def timezone_name_from_offset(offset)
return 'UTC' if offset.blank?
offset_in_seconds = offset.to_f * 3600
matching_zone = ActiveSupport::TimeZone.all.find do |zone|
zone.now.utc_offset == offset_in_seconds
end
return matching_zone.name if matching_zone
end
end
+11
View File
@@ -0,0 +1,11 @@
import App from './App';
import '../../test-matchers';
describe(`App component`, () => {
it(`should be a component`, () => {
// Arrange
// Act
expect(App).toBeVueComponent('App');
// Assert
});
});
+28 -33
View File
@@ -1,6 +1,32 @@
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>
<update-banner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<pending-email-verification-banner v-if="hideOnOnboardingView" />
<payment-pending-banner v-if="hideOnOnboardingView" />
<upgrade-banner />
</template>
<transition name="fade" mode="out-in">
<router-view />
</transition>
<add-account-modal
:show="showAddAccountModal"
:has-accounts="hasAccounts"
/>
<woot-snackbar-box />
<network-notification />
</div>
<loading-state v-else />
</template>
<script>
import { mapGetters } from 'vuex';
import router from '../dashboard/routes';
import AddAccountModal from '../dashboard/components/layout/sidebarComponents/AddAccountModal.vue';
import LoadingState from './components/widgets/LoadingState.vue';
import NetworkNotification from './components/NetworkNotification.vue';
@@ -17,7 +43,6 @@ import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default {
name: 'App',
@@ -39,7 +64,6 @@ export default {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
},
@@ -47,6 +71,7 @@ export default {
...mapGetters({
getAccount: 'accounts/getAccount',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
@@ -77,11 +102,6 @@ export default {
this.listenToThemeChanges();
this.setLocale(window.chatwootConfig.selectedLocale);
},
beforeDestroy() {
if (this.reconnectService) {
this.reconnectService.disconnect();
}
},
methods: {
initializeColorTheme() {
setColorTheme(window.matchMedia('(prefers-color-scheme: dark)').matches);
@@ -105,7 +125,6 @@ export default {
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
@@ -119,30 +138,6 @@ export default {
};
</script>
<template>
<div
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
id="app"
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
:class="{ 'app-rtl--wrapper': isRTLView }"
:dir="isRTLView ? 'rtl' : 'ltr'"
>
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
<template v-if="currentAccountId">
<PendingEmailVerificationBanner v-if="hideOnOnboardingView" />
<PaymentPendingBanner v-if="hideOnOnboardingView" />
<UpgradeBanner />
</template>
<transition name="fade" mode="out-in">
<router-view />
</transition>
<AddAccountModal :show="showAddAccountModal" :has-accounts="hasAccounts" />
<WootSnackbarBox />
<NetworkNotification />
</div>
<LoadingState v-else />
</template>
<style lang="scss">
@import './assets/scss/app';
</style>
-7
View File
@@ -9,13 +9,6 @@ class AccountAPI extends ApiClient {
createAccount(data) {
return axios.post(`${this.apiVersion}/accounts`, data);
}
async getCacheKeys() {
const response = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
return response.data.cache_keys;
}
}
export default new AccountAPI();
@@ -1,14 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class MicrosoftClient extends ApiClient {
constructor() {
super('google', { accountScoped: true });
}
generateAuthorization(payload) {
return axios.post(`${this.url}/authorization`, payload);
}
}
export default new MicrosoftClient();
@@ -15,10 +15,10 @@ describe('#enterpriseAccountAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -15,7 +15,6 @@ class ConversationApi extends ApiClient {
teamId,
conversationType,
sortBy,
updatedWithin,
}) {
return axios.get(this.url, {
params: {
@@ -27,7 +26,6 @@ class ConversationApi extends ApiClient {
labels,
conversation_type: conversationType,
sort_by: sortBy,
updated_within: updatedWithin,
},
});
}
@@ -32,10 +32,6 @@ class IntegrationsAPI extends ApiClient {
deleteHook(hookId) {
return axios.delete(`${this.baseUrl()}/integrations/hooks/${hookId}`);
}
fetchCaptainURL() {
return axios.get(`${this.baseUrl()}/integrations/captain/sso_url`);
}
}
export default new IntegrationsAPI();
@@ -1,47 +0,0 @@
/* global axios */
import ApiClient from '../ApiClient';
class LinearAPI extends ApiClient {
constructor() {
super('integrations/linear', { accountScoped: true });
}
getTeams() {
return axios.get(`${this.url}/teams`);
}
getTeamEntities(teamId) {
return axios.get(`${this.url}/team_entities?team_id=${teamId}`);
}
createIssue(data) {
return axios.post(`${this.url}/create_issue`, data);
}
link_issue(conversationId, issueId, title) {
return axios.post(`${this.url}/link_issue`, {
issue_id: issueId,
conversation_id: conversationId,
title: title,
});
}
getLinkedIssue(conversationId) {
return axios.get(
`${this.url}/linked_issues?conversation_id=${conversationId}`
);
}
unlinkIssue(linkId) {
return axios.post(`${this.url}/unlink_issue`, {
link_id: linkId,
});
}
searchIssues(query) {
return axios.get(`${this.url}/search_issue?q=${query}`);
}
}
export default new LinearAPI();
@@ -15,10 +15,10 @@ describe('#accountAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -10,10 +10,10 @@ describe('#ContactsAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -14,7 +14,7 @@ describe('#AgentAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -14,10 +14,10 @@ describe('#PortalAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -44,10 +44,10 @@ describe('#PortalAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -71,10 +71,10 @@ describe('#PortalAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -98,10 +98,10 @@ describe('#PortalAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -129,10 +129,10 @@ describe('#PortalAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -4,10 +4,10 @@ describe('#AssignableAgentsAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -13,10 +13,10 @@ describe('#FBChannel', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -17,10 +17,10 @@ describe('#ContactsAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -16,10 +16,10 @@ describe('#ConversationApi', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -11,10 +11,10 @@ describe('#Reports API', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -24,10 +24,10 @@ describe('#ConversationAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -46,7 +46,6 @@ describe('#ConversationAPI', () => {
page: 1,
labels: [],
teamId: 1,
updatedWithin: 20,
});
expect(axiosMock.get).toHaveBeenCalledWith('/api/v1/conversations', {
params: {
@@ -56,7 +55,6 @@ describe('#ConversationAPI', () => {
assignee_type: 'me',
page: 1,
labels: [],
updated_within: 20,
},
});
});
@@ -15,10 +15,10 @@ describe('#ConversationAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -17,10 +17,10 @@ describe('#InboxesAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -18,10 +18,10 @@ describe('#integrationAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -11,10 +11,10 @@ describe('#accountAPI', () => {
describe('createAMeeting', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -39,10 +39,10 @@ describe('#accountAPI', () => {
describe('addParticipantToMeeting', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -1,202 +0,0 @@
import LinearAPIClient from '../../integrations/linear';
import ApiClient from '../../ApiClient';
describe('#linearAPI', () => {
it('creates correct instance', () => {
expect(LinearAPIClient).toBeInstanceOf(ApiClient);
expect(LinearAPIClient).toHaveProperty('getTeams');
expect(LinearAPIClient).toHaveProperty('getTeamEntities');
expect(LinearAPIClient).toHaveProperty('createIssue');
expect(LinearAPIClient).toHaveProperty('link_issue');
expect(LinearAPIClient).toHaveProperty('getLinkedIssue');
expect(LinearAPIClient).toHaveProperty('unlinkIssue');
expect(LinearAPIClient).toHaveProperty('searchIssues');
});
describe('getTeams', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeams();
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/teams'
);
});
});
describe('getTeamEntities', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getTeamEntities(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/team_entities?team_id=1'
);
});
});
describe('createIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
const issueData = {
title: 'New Issue',
description: 'Issue description',
};
LinearAPIClient.createIssue(issueData);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/create_issue',
issueData
);
});
});
describe('link_issue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.link_issue(1, 2);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/link_issue',
{
issue_id: 2,
conversation_id: 1,
}
);
});
});
describe('getLinkedIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.getLinkedIssue(1);
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/linked_issues?conversation_id=1'
);
});
});
describe('unlinkIssue', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.unlinkIssue(1);
expect(axiosMock.post).toHaveBeenCalledWith(
'/api/v1/integrations/linear/unlink_issue',
{
link_id: 1,
}
);
});
});
describe('searchIssues', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
};
beforeEach(() => {
window.axios = axiosMock;
});
afterEach(() => {
window.axios = originalAxios;
});
it('creates a valid request', () => {
LinearAPIClient.searchIssues('query');
expect(axiosMock.get).toHaveBeenCalledWith(
'/api/v1/integrations/linear/search_issue?q=query'
);
});
});
});
@@ -13,10 +13,10 @@ describe('#NotificationAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -20,10 +20,10 @@ describe('#Reports API', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -12,10 +12,10 @@ describe('#SLAReports API', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -16,10 +16,10 @@ describe('#TeamsAPI', () => {
describe('API calls', () => {
const originalAxios = window.axios;
const axiosMock = {
post: vi.fn(() => Promise.resolve()),
get: vi.fn(() => Promise.resolve()),
patch: vi.fn(() => Promise.resolve()),
delete: vi.fn(() => Promise.resolve()),
post: jest.fn(() => Promise.resolve()),
get: jest.fn(() => Promise.resolve()),
patch: jest.fn(() => Promise.resolve()),
delete: jest.fn(() => Promise.resolve()),
};
beforeEach(() => {
@@ -3,9 +3,6 @@
@import 'tailwindcss/utilities';
@import 'shared/assets/fonts/plus-jakarta';
@import 'shared/assets/fonts/InterDisplay/inter-display';
@import 'shared/assets/fonts/inter';
@import 'shared/assets/stylesheets/animations';
@import 'shared/assets/stylesheets/colors';
@import 'shared/assets/stylesheets/spacing';
@@ -45,7 +42,6 @@
}
@layer base {
// scss-lint:disable PropertySortOrder
:root {
--color-amber-25: 254 253 251;
@@ -217,7 +213,6 @@
--color-orange-800: 204 78 0;
--color-orange-900: 88 45 29;
}
// scss-lint:disable QualifyingElement
body.dark {
--color-amber-25: 31 19 0;
@@ -1,10 +1,6 @@
table {
@apply border-spacing-0 text-sm w-full;
}
.woot-table {
thead {
th {
@apply font-semibold tracking-[1px] text-left px-2.5 uppercase text-slate-900 dark:text-slate-200;
@@ -20,7 +16,9 @@ table {
@apply p-2.5 text-slate-700 dark:text-slate-100;
}
}
}
.woot-table {
tr {
.show-if-hover {
transition: opacity 0.2s $swift-ease-out-function;
@@ -1,3 +1,35 @@
<template>
<div class="-mt-px text-sm">
<button
class="flex items-center select-none w-full rounded-none bg-slate-50 dark:bg-slate-800 border border-l-0 border-r-0 border-solid m-0 border-slate-100 dark:border-slate-700/50 cursor-grab justify-between py-2 px-4 drag-handle"
@click="$emit('click')"
>
<div class="flex justify-between mb-0.5">
<emoji-or-icon class="inline-block w-5" :icon="icon" :emoji="emoji" />
<h5
class="text-slate-800 text-sm dark:text-slate-100 mb-0 py-0 pr-2 pl-0"
>
{{ title }}
</h5>
</div>
<div class="flex flex-row">
<slot name="button" />
<div class="flex justify-end w-3 text-woot-500">
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
</div>
</button>
<div
v-if="isOpen"
class="bg-white dark:bg-slate-900"
:class="compact ? 'p-0' : 'p-4'"
>
<slot />
</div>
</div>
</template>
<script>
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
@@ -29,35 +61,3 @@ export default {
},
};
</script>
<template>
<div class="-mt-px text-sm">
<button
class="flex items-center select-none w-full rounded-none bg-slate-50 dark:bg-slate-800 border border-l-0 border-r-0 border-solid m-0 border-slate-100 dark:border-slate-700/50 cursor-grab justify-between py-2 px-4 drag-handle"
@click="$emit('click')"
>
<div class="flex justify-between mb-0.5">
<EmojiOrIcon class="inline-block w-5" :icon="icon" :emoji="emoji" />
<h5
class="text-slate-800 text-sm dark:text-slate-100 mb-0 py-0 pr-2 pl-0"
>
{{ title }}
</h5>
</div>
<div class="flex flex-row">
<slot name="button" />
<div class="flex justify-end w-3 text-woot-500">
<fluent-icon v-if="isOpen" size="24" icon="subtract" type="solid" />
<fluent-icon v-else size="24" icon="add" type="solid" />
</div>
</div>
</button>
<div
v-if="isOpen"
class="bg-white dark:bg-slate-900"
:class="compact ? 'p-0' : 'p-4'"
>
<slot />
</div>
</div>
</template>
@@ -1,3 +1,17 @@
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
{{ title }}
</h3>
</button>
</template>
<script>
export default {
props: {
@@ -13,20 +27,6 @@ export default {
};
</script>
<template>
<button
class="bg-white dark:bg-slate-900 cursor-pointer flex flex-col justify-end transition-all duration-200 ease-in -m-px py-4 px-0 items-center border border-solid border-slate-25 dark:border-slate-800 hover:border-woot-500 dark:hover:border-woot-500 hover:shadow-md hover:z-50 disabled:opacity-60"
@click="$emit('click')"
>
<img :src="src" :alt="title" class="w-1/2 my-4 mx-auto" />
<h3
class="text-slate-800 dark:text-slate-100 text-base text-center capitalize"
>
{{ title }}
</h3>
</button>
</template>
<style scoped lang="scss">
.inactive {
img {
+277 -221
View File
@@ -1,15 +1,191 @@
<template>
<div
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
]"
>
<slot />
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="onClickOpenAddFoldersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="resetAndFetchData"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="onToggleAdvanceFiltersModal"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="onToggleAdvanceFiltersModal"
/>
<conversation-basic-filter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
<add-custom-views
v-if="showAddFoldersModal"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
<delete-custom-views
v-if="showDeleteFoldersModal"
:show-delete-popup.sync="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
/>
<chat-type-tabs
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@chatTabChange="updateAssigneeTab"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<conversation-bulk-actions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
@select-all-conversations="selectAllConversations"
@assign-agent="onAssignAgent"
@update-conversations="onUpdateConversations"
@assign-labels="onAssignLabels"
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="conversationList"
class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<virtual-list
ref="conversationVirtualList"
:data-key="'id'"
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full h-full overflow-auto"
footer-tag="div"
>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="mt-4 mb-4 spinner" />
</div>
<p
v-if="showEndOfListMessage"
class="p-4 text-center text-slate-400 dark:text-slate-300"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfListMessage && !chatListLoading"
:options="infiniteLoaderOptions"
@observed="loadMoreConversations"
/>
</template>
</virtual-list>
</div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<conversation-advanced-filter
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
</div>
</template>
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from 'dashboard/constants/globals';
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
@@ -17,7 +193,9 @@ import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import alertMixin from 'shared/mixins/alertMixin';
import filterMixin from 'shared/mixins/filterMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
@@ -31,7 +209,6 @@ import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -39,10 +216,18 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
},
mixins: [conversationMixin, filterMixin],
mixins: [
timeMixin,
conversationMixin,
keyboardEventListenerMixins,
alertMixin,
filterMixin,
uiSettingsMixin,
],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -87,68 +272,6 @@ export default {
type: Boolean,
},
},
setup() {
const { uiSettings } = useUISettings();
const conversationListRef = ref(null);
const getKeyboardListenerParams = () => {
const allConversations = conversationListRef.value.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = conversationListRef.value.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
};
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleNextConversation(),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents, conversationListRef);
return {
uiSettings,
conversationListRef,
};
},
data() {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
@@ -169,7 +292,7 @@ export default {
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationListRef,
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
@@ -187,16 +310,19 @@ export default {
},
computed: {
...mapGetters({
currentChat: 'getSelectedChat',
currentUser: 'getCurrentUser',
chatLists: 'getAllConversations',
mineChatsList: 'getMineChats',
allChatList: 'getAllStatusChats',
unAssignedChatsList: 'getUnAssignedChats',
chatListLoading: 'getChatListLoadingStatus',
currentUserID: 'getCurrentUserID',
activeInbox: 'getSelectedInbox',
conversationStats: 'conversationStats/getStats',
appliedFilters: 'getAppliedConversationFilters',
folders: 'customViews/getCustomViews',
inboxes: 'inboxes/getInboxes',
agentList: 'agents/getAgents',
teamsList: 'teams/getTeams',
inboxesList: 'inboxes/getInboxes',
@@ -208,11 +334,18 @@ export default {
return this.appliedFilters.length !== 0;
},
hasActiveFolders() {
return Boolean(this.activeFolder && this.foldersId !== 0);
return this.activeFolder && this.foldersId !== 0;
},
hasAppliedFiltersOrActiveFolders() {
return this.hasAppliedFilters || this.hasActiveFolders;
},
savedFoldersValue() {
if (this.hasActiveFolders) {
const payload = this.activeFolder.query;
this.fetchSavedFilteredConversations(payload);
}
return {};
},
showEndOfListMessage() {
return (
this.conversationList.length &&
@@ -288,6 +421,7 @@ export default {
labels: this.label ? [this.label] : undefined,
teamId: this.teamId || undefined,
conversationType: this.conversationType || undefined,
folders: this.hasActiveFolders ? this.savedFoldersValue : undefined,
};
},
conversationListPagination() {
@@ -400,13 +534,7 @@ export default {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch(
'customViews/setActiveConversationFolder',
newVal || null
);
}
activeFolder() {
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
@@ -416,14 +544,8 @@ export default {
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
conversationFilters(newVal, oldVal) {
if (newVal !== oldVal) {
this.$store.dispatch('updateChatListFilters', newVal);
}
},
},
mounted() {
this.$store.dispatch('setChatListFilters', this.conversationFilters);
this.setFiltersFromUISettings();
this.$store.dispatch('setChatStatusFilter', this.activeStatus);
this.$store.dispatch('setChatSortFilter', this.activeSortBy);
@@ -433,7 +555,7 @@ export default {
this.$store.dispatch('campaigns/get');
}
this.$emitter.on('fetch_conversation_stats', () => {
bus.$on('fetch_conversation_stats', () => {
this.$store.dispatch('conversationStats/get', this.conversationFilters);
});
},
@@ -546,6 +668,58 @@ export default {
}))
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.conversationList.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.conversationList.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
allConversations,
activeConversation,
activeConversationIndex,
lastConversationIndex,
};
},
handlePreviousConversation() {
const { allConversations, activeConversationIndex } =
this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
},
handleNextConversation() {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = this.getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
},
getKeyboardEvents() {
return {
'Alt+KeyJ': {
action: () => this.handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => this.handleNextConversation(),
allowOnFocusedInput: true,
},
};
},
resetAndFetchData() {
this.appliedFilter = [];
this.resetBulkActions();
@@ -562,9 +736,8 @@ export default {
this.fetchConversations();
},
fetchConversations() {
this.$store.dispatch('updateChatListFilters', this.conversationFilters);
this.$store
.dispatch('fetchAllConversations')
.dispatch('fetchAllConversations', this.conversationFilters)
.then(this.emitConversationLoaded);
},
loadMoreConversations() {
@@ -604,7 +777,7 @@ export default {
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
this.resetBulkActions();
this.$emitter.emit('clearSearchInput');
bus.$emit('clearSearchInput');
this.activeAssigneeTab = selectedTab;
if (!this.currentPage) {
this.fetchConversations();
@@ -612,7 +785,7 @@ export default {
}
},
emitConversationLoaded() {
this.$emit('conversationLoad');
this.$emit('conversation-load');
this.$nextTick(() => {
// Addressing a known issue in the virtual list library where dynamically added items
// might not render correctly. This workaround involves a slight manual adjustment
@@ -695,7 +868,7 @@ export default {
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
useAlert(
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.AGENT_ASSIGNMENT.SUCCESFUL',
{
@@ -705,10 +878,10 @@ export default {
)
);
} else {
useAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
this.showAlert(this.$t('BULK_ACTION.ASSIGN_SUCCESFUL'));
}
} catch (err) {
useAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
this.showAlert(this.$t('BULK_ACTION.ASSIGN_FAILED'));
}
},
async assignPriority(priority, conversationId = null) {
@@ -723,7 +896,7 @@ export default {
newValue: priority,
from: 'Context menu',
});
useAlert(
this.showAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
@@ -766,7 +939,7 @@ export default {
conversationId,
teamId: team.id,
});
useAlert(
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL',
{
@@ -776,7 +949,7 @@ export default {
)
);
} catch (error) {
useAlert(
this.showAlert(
this.$t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED')
);
}
@@ -793,7 +966,7 @@ export default {
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
if (conversationId) {
useAlert(
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.LABEL_ASSIGNMENT.SUCCESFUL',
{
@@ -803,10 +976,10 @@ export default {
)
);
} else {
useAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_SUCCESFUL'));
}
} catch (err) {
useAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
this.showAlert(this.$t('BULK_ACTION.LABELS.ASSIGN_FAILED'));
}
},
async onAssignTeamsForBulk(team) {
@@ -819,9 +992,9 @@ export default {
},
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_SUCCESFUL'));
} catch (err) {
useAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
this.showAlert(this.$t('BULK_ACTION.TEAMS.ASSIGN_FAILED'));
}
},
async onUpdateConversations(status, snoozedUntil) {
@@ -835,9 +1008,9 @@ export default {
snoozed_until: snoozedUntil,
});
this.$store.dispatch('bulkActions/clearSelectedConversationIds');
useAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_SUCCESFUL'));
} catch (err) {
useAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
this.showAlert(this.$t('BULK_ACTION.UPDATE.UPDATE_FAILED'));
}
},
toggleConversationStatus(conversationId, status, snoozedUntil) {
@@ -848,14 +1021,14 @@ export default {
snoozedUntil,
})
.then(() => {
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
allSelectedConversationsStatus(status) {
if (!this.selectedConversations.length) return false;
return this.selectedConversations.every(item => {
return this.$store.getters.getConversationById(item)?.status === status;
return this.$store.getters.getConversationById(item).status === status;
});
},
onContextMenuToggle(state) {
@@ -864,123 +1037,6 @@ export default {
},
};
</script>
<template>
<div
class="flex flex-col flex-shrink-0 overflow-hidden border-r conversations-list-wrap rtl:border-r-0 rtl:border-l border-slate-50 dark:border-slate-800/50"
:class="[
{ hidden: !showConversationList },
isOnExpandedLayout ? 'basis-full' : 'flex-basis-clamp',
]"
>
<slot />
<ChatListHeader
:page-title="pageTitle"
:has-applied-filters="hasAppliedFilters"
:has-active-folders="hasActiveFolders"
:active-status="activeStatus"
@addFolders="onClickOpenAddFoldersModal"
@deleteFolders="onClickOpenDeleteFoldersModal"
@filtersModal="onToggleAdvanceFiltersModal"
@resetFilters="resetAndFetchData"
@basicFilterChange="onBasicFilterChange"
/>
<AddCustomViews
v-if="showAddFoldersModal"
:custom-views-query="foldersQuery"
:open-last-saved-item="openLastSavedItemInFolder"
@close="onCloseAddFoldersModal"
/>
<DeleteCustomViews
v-if="showDeleteFoldersModal"
:show-delete-popup.sync="showDeleteFoldersModal"
:active-custom-view="activeFolder"
:custom-views-id="foldersId"
:open-last-item-after-delete="openLastItemAfterDeleteInFolder"
@close="onCloseDeleteFoldersModal"
/>
<ChatTypeTabs
v-if="!hasAppliedFiltersOrActiveFolders"
:items="assigneeTabItems"
:active-tab="activeAssigneeTab"
class="tab--chat-type"
@chatTabChange="updateAssigneeTab"
/>
<p
v-if="!chatListLoading && !conversationList.length"
class="flex items-center justify-center p-4 overflow-auto"
>
{{ $t('CHAT_LIST.LIST.404') }}
</p>
<ConversationBulkActions
v-if="selectedConversations.length"
:conversations="selectedConversations"
:all-conversations-selected="allConversationsSelected"
:selected-inboxes="uniqueInboxes"
:show-open-action="allSelectedConversationsStatus('open')"
:show-resolved-action="allSelectedConversationsStatus('resolved')"
:show-snoozed-action="allSelectedConversationsStatus('snoozed')"
@selectAllConversations="selectAllConversations"
@assignAgent="onAssignAgent"
@updateConversations="onUpdateConversations"
@assignLabels="onAssignLabels"
@assignTeam="onAssignTeamsForBulk"
/>
<div
ref="conversationListRef"
class="flex-1 conversations-list"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<VirtualList
ref="conversationVirtualList"
data-key="id"
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full h-full overflow-auto"
footer-tag="div"
>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="mt-4 mb-4 spinner" />
</div>
<p
v-if="showEndOfListMessage"
class="p-4 text-center text-slate-400 dark:text-slate-300"
>
{{ $t('CHAT_LIST.EOF') }}
</p>
<IntersectionObserver
v-if="!showEndOfListMessage && !chatListLoading"
:options="infiniteLoaderOptions"
@observed="loadMoreConversations"
/>
</template>
</VirtualList>
</div>
<woot-modal
:show.sync="showAdvancedFilters"
:on-close="closeAdvanceFiltersModal"
size="medium"
>
<ConversationAdvancedFilter
v-if="showAdvancedFilters"
:initial-filter-types="advancedFilterTypes"
:initial-applied-filters="appliedFilter"
:active-folder-name="activeFolderName"
:on-close="closeAdvanceFiltersModal"
:is-folder-view="hasActiveFolders"
@applyFilter="onApplyFilter"
@updateFolder="onUpdateSavedFilter"
/>
</woot-modal>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
@@ -1,115 +0,0 @@
<script setup>
import { computed } from 'vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
const props = defineProps({
pageTitle: {
type: String,
required: true,
},
hasAppliedFilters: {
type: Boolean,
required: true,
},
hasActiveFolders: {
type: Boolean,
required: true,
},
activeStatus: {
type: String,
required: true,
},
});
const emit = defineEmits([
'addFolders',
'deleteFolders',
'resetFilters',
'basicFilterChange',
'filtersModal',
]);
const onBasicFilterChange = (value, type) => {
emit('basicFilterChange', value, type);
};
const hasAppliedFiltersOrActiveFolders = computed(() => {
return props.hasAppliedFilters || props.hasActiveFolders;
});
</script>
<template>
<div
class="flex items-center justify-between px-4 py-0"
:class="{
'pb-3 border-b border-slate-75 dark:border-slate-700':
hasAppliedFiltersOrActiveFolders,
}"
>
<div class="flex max-w-[85%] justify-center items-center">
<h1
class="text-xl font-medium break-words truncate text-black-900 dark:text-slate-100"
:title="pageTitle"
>
{{ pageTitle }}
</h1>
<span
v-if="!hasAppliedFiltersOrActiveFolders"
class="p-1 my-0.5 mx-1 rounded-md capitalize bg-slate-50 dark:bg-slate-800 text-xxs text-slate-600 dark:text-slate-300"
>
{{ $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`) }}
</span>
</div>
<div class="flex items-center gap-1">
<div v-if="hasAppliedFilters && !hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.ADD.SAVE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="save"
@click="emit('addFolders')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CLEAR_BUTTON_LABEL')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="dismiss-circle"
@click="emit('resetFilters')"
/>
</div>
<div v-if="hasActiveFolders">
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.EDIT.EDIT_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="secondary"
icon="edit"
@click="emit('filtersModal')"
/>
<woot-button
v-tooltip.top-end="$t('FILTER.CUSTOM_VIEWS.DELETE.DELETE_BUTTON')"
size="tiny"
variant="smooth"
color-scheme="alert"
icon="delete"
@click="emit('deleteFolders')"
/>
</div>
<woot-button
v-else
v-tooltip.right="$t('FILTER.TOOLTIP_LABEL')"
variant="smooth"
color-scheme="secondary"
icon="filter"
size="tiny"
@click="emit('filtersModal')"
/>
<ConversationBasicFilter
v-if="!hasAppliedFiltersOrActiveFolders"
@changeFilter="onBasicFilterChange"
/>
</div>
</div>
</template>
+25 -26
View File
@@ -1,7 +1,30 @@
<template>
<div class="code--container">
<div class="code--action-area">
<form
v-if="enableCodePen"
class="code--codeopen-form"
action="https://codepen.io/pen/define"
method="POST"
target="_blank"
>
<input type="hidden" name="data" :value="codepenScriptValue" />
<button type="submit" class="button secondary tiny">
{{ $t('COMPONENTS.CODE.CODEPEN') }}
</button>
</form>
<button class="button secondary tiny" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
</div>
<highlightjs v-if="script" :language="lang" :code="script" />
</div>
</template>
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
export default {
props: {
@@ -36,36 +59,12 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.script);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
},
};
</script>
<template>
<div class="code--container">
<div class="code--action-area">
<form
v-if="enableCodePen"
class="code--codeopen-form"
action="https://codepen.io/pen/define"
method="POST"
target="_blank"
>
<input type="hidden" name="data" :value="codepenScriptValue" />
<button type="submit" class="button secondary tiny">
{{ $t('COMPONENTS.CODE.CODEPEN') }}
</button>
</form>
<button class="button secondary tiny" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
</div>
<highlightjs v-if="script" :language="lang" :code="script" />
</div>
</template>
<style lang="scss" scoped>
.code--container {
position: relative;
@@ -1,3 +1,26 @@
<template>
<conversation-card
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="source"
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
:enable-context-menu="true"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="assignAgent"
@assign-team="assignTeam"
@assign-label="assignLabels"
@update-conversation-status="updateConversationStatus"
@context-menu-toggle="toggleContextMenu"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</template>
<script>
import ConversationCard from './widgets/conversation/ConversationCard.vue';
export default {
@@ -47,26 +70,3 @@ export default {
},
};
</script>
<template>
<ConversationCard
:key="source.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="source"
:conversation-type="conversationType"
:selected="isConversationSelected(source.id)"
:show-assignee="showAssignee"
enable-context-menu
@selectConversation="selectConversation"
@deSelectConversation="deSelectConversation"
@assignAgent="assignAgent"
@assignTeam="assignTeam"
@assignLabel="assignLabels"
@updateConversationStatus="updateConversationStatus"
@contextMenuToggle="toggleContextMenu"
@markAsUnread="markAsUnread"
@assignPriority="assignPriority"
/>
</template>
@@ -1,12 +1,147 @@
<template>
<div class="py-3 px-4">
<div class="flex items-center mb-1">
<h4 class="text-sm flex items-center m-0 w-full error">
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
<input
v-model="editedValue"
class="!my-0 mr-2 ml-0"
type="checkbox"
@change="onUpdate"
/>
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
$v.editedValue.$error
? 'text-red-400 dark:text-red-500'
: 'text-slate-800 dark:text-slate-100'
"
>
{{ label }}
<helper-text-popup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showCopyAndDeleteButton"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
color-scheme="secondary"
icon="delete"
class-names="flex justify-end w-4"
@click="onDelete"
/>
</div>
</h4>
</div>
<div v-if="notAttributeTypeCheckboxAndList">
<div v-if="isEditing" v-on-clickaway="onClickAway">
<div class="mb-2 w-full flex items-center">
<input
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: $v.editedValue.$error }"
@blur="$v.editedValue.$touch"
@keyup.enter="onUpdate"
/>
<div>
<woot-button
size="small"
icon="checkmark"
class="rounded-l-none rtl:rounded-r-none"
@click="onUpdate"
/>
</div>
</div>
<span
v-if="shouldShowErrorMessage"
class="text-red-400 dark:text-red-500 text-sm block font-normal -mt-px w-full"
>
{{ errorMessage }}
</span>
</div>
<div
v-show="!isEditing"
class="flex group"
:class="{ 'is-editable': showActions }"
>
<a
v-if="isAttributeTypeLink"
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ urlValue }}
</a>
<p
v-else
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ displayValue || '---' }}
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showCopyAndDeleteButton"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="clipboard"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onCopy"
/>
<woot-button
v-if="showEditButton"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onEdit"
/>
</div>
</div>
</div>
<div v-if="isAttributeTypeList">
<multiselect-dropdown
:options="listOptions"
:selected-item="selectedItem"
:has-thumbnail="false"
:multiselector-placeholder="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.PLACEHOLDER')
"
:no-search-result="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.NO_RESULT')
"
:input-placeholder="
$t(
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
)
"
@click="onUpdateListValue"
/>
</div>
</div>
</template>
<script>
import { format, parseISO } from 'date-fns';
import { required, url } from '@vuelidate/validators';
import { required, url } from 'vuelidate/lib/validators';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
import { isValidURL } from '../helper/URLHelper';
import { getRegexp } from 'shared/helpers/Validators';
import { useVuelidate } from '@vuelidate/core';
import customAttributeMixin from '../mixins/customAttributeMixin';
const DATE_FORMAT = 'yyyy-MM-dd';
@@ -15,6 +150,7 @@ export default {
MultiselectDropdown,
HelperTextPopup,
},
mixins: [customAttributeMixin],
props: {
label: { type: String, required: true },
description: { type: String, default: '' },
@@ -27,12 +163,10 @@ export default {
default: null,
},
regexCue: { type: String, default: null },
regexEnabled: { type: Boolean, default: false },
attributeKey: { type: String, required: true },
contactId: { type: Number, default: null },
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
isEditing: false,
@@ -40,6 +174,12 @@ export default {
};
},
computed: {
showCopyAndDeleteButton() {
return this.value && this.showActions;
},
showEditButton() {
return !this.value && this.showActions;
},
displayValue() {
if (this.isAttributeTypeDate) {
return this.value
@@ -91,13 +231,13 @@ export default {
return this.isAttributeTypeLink ? 'url' : this.attributeType;
},
shouldShowErrorMessage() {
return this.v$.editedValue.$error;
return this.$v.editedValue.$error;
},
errorMessage() {
if (this.v$.editedValue.url) {
if (this.$v.editedValue.url) {
return this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_URL');
}
if (!this.v$.editedValue.regexValidation) {
if (!this.$v.editedValue.regexValidation) {
return this.regexCue
? this.regexCue
: this.$t('CUSTOM_ATTRIBUTES.VALIDATIONS.INVALID_INPUT');
@@ -112,7 +252,7 @@ export default {
},
contactId() {
// Fix to solve validation not resetting when contactId changes in contact page
this.v$.$reset();
this.$v.$reset();
},
},
@@ -127,7 +267,8 @@ export default {
required,
regexValidation: value => {
return !(
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
this.attributeRegex &&
!this.getRegexp(this.attributeRegex).test(value)
);
},
},
@@ -135,10 +276,10 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
this.$emitter.on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
this.$emitter.off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
@@ -152,7 +293,7 @@ export default {
}
},
onClickAway() {
this.v$.$reset();
this.$v.$reset();
this.isEditing = false;
},
onEdit() {
@@ -172,8 +313,8 @@ export default {
this.attributeType === 'date'
? parseISO(this.editedValue)
: this.editedValue;
this.v$.$touch();
if (this.v$.$invalid) {
this.$v.$touch();
if (this.$v.$invalid) {
return;
}
this.isEditing = false;
@@ -181,7 +322,7 @@ export default {
},
onDelete() {
this.isEditing = false;
this.v$.$reset();
this.$v.$reset();
this.$emit('delete', this.attributeKey);
},
onCopy() {
@@ -191,142 +332,6 @@ export default {
};
</script>
<template>
<div class="px-4 py-3">
<div class="flex items-center mb-1">
<h4 class="flex items-center w-full m-0 text-sm error">
<div v-if="isAttributeTypeCheckbox" class="flex items-center">
<input
v-model="editedValue"
class="!my-0 mr-2 ml-0"
type="checkbox"
@change="onUpdate"
/>
</div>
<div class="flex items-center justify-between w-full">
<span
class="w-full inline-flex gap-1.5 items-start font-medium whitespace-nowrap text-sm mb-0"
:class="
v$.editedValue.$error
? 'text-red-400 dark:text-red-500'
: 'text-slate-800 dark:text-slate-100'
"
>
{{ label }}
<HelperTextPopup
v-if="description"
:message="description"
class="mt-0.5"
/>
</span>
<woot-button
v-if="showActions && value"
v-tooltip.left="$t('CUSTOM_ATTRIBUTES.ACTIONS.DELETE')"
variant="link"
size="medium"
color-scheme="secondary"
icon="delete"
class-names="flex justify-end w-4"
@click="onDelete"
/>
</div>
</h4>
</div>
<div v-if="notAttributeTypeCheckboxAndList">
<div v-if="isEditing" v-on-clickaway="onClickAway">
<div class="flex items-center w-full mb-2">
<input
ref="inputfield"
v-model="editedValue"
:type="inputType"
class="!h-8 ltr:!rounded-r-none rtl:!rounded-l-none !mb-0 !text-sm"
autofocus="true"
:class="{ error: v$.editedValue.$error }"
@blur="v$.editedValue.$touch"
@keyup.enter="onUpdate"
/>
<div>
<woot-button
size="small"
icon="checkmark"
class="rounded-l-none rtl:rounded-r-none"
@click="onUpdate"
/>
</div>
</div>
<span
v-if="shouldShowErrorMessage"
class="block w-full -mt-px text-sm font-normal text-red-400 dark:text-red-500"
>
{{ errorMessage }}
</span>
</div>
<div
v-show="!isEditing"
class="flex group"
:class="{ 'is-editable': showActions }"
>
<a
v-if="isAttributeTypeLink"
:href="hrefURL"
target="_blank"
rel="noopener noreferrer"
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ urlValue }}
</a>
<p
v-else
class="group-hover:bg-slate-50 group-hover:dark:bg-slate-700 inline-block rounded-sm mb-0 break-all py-0.5 px-1"
>
{{ displayValue || '---' }}
</p>
<div class="flex max-w-[2rem] gap-1 ml-1 rtl:mr-1 rtl:ml-0">
<woot-button
v-if="showActions && value"
v-tooltip="$t('CUSTOM_ATTRIBUTES.ACTIONS.COPY')"
variant="link"
size="small"
color-scheme="secondary"
icon="clipboard"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onCopy"
/>
<woot-button
v-if="showActions"
v-tooltip.right="$t('CUSTOM_ATTRIBUTES.ACTIONS.EDIT')"
variant="link"
size="small"
color-scheme="secondary"
icon="edit"
class-names="hidden group-hover:flex !w-6 flex-shrink-0"
@click="onEdit"
/>
</div>
</div>
</div>
<div v-if="isAttributeTypeList">
<MultiselectDropdown
:options="listOptions"
:selected-item="selectedItem"
:has-thumbnail="false"
:multiselector-placeholder="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.PLACEHOLDER')
"
:no-search-result="
$t('CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.NO_RESULT')
"
:input-placeholder="
$t(
'CUSTOM_ATTRIBUTES.FORM.ATTRIBUTE_TYPE.LIST.SEARCH_INPUT_PLACEHOLDER'
)
"
@click="onUpdateListValue"
/>
</div>
</div>
</template>
<style lang="scss" scoped>
::v-deep {
.selector-wrap {
@@ -1,25 +0,0 @@
<script setup>
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
const props = defineProps({
showOnCustomBrandedInstance: {
type: Boolean,
default: true,
},
});
const getters = useStoreGetters();
const isACustomBrandedInstance =
getters['globalConfig/isACustomBrandedInstance'];
const shouldShowContent = computed(
() => props.showOnCustomBrandedInstance || !isACustomBrandedInstance.value
);
</script>
<template>
<div v-if="shouldShowContent">
<slot />
</div>
</template>
@@ -1,3 +1,28 @@
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
<form class="modal-content" @submit.prevent="chooseTime">
<date-picker
v-model="snoozeTime"
type="datetime"
inline
:lang="lang"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
:popup-style="{ width: '100%' }"
/>
<div class="flex flex-row justify-end gap-2 py-2 px-0 w-full">
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
</woot-button>
<woot-button>
{{ $t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
</woot-button>
</div>
</form>
</div>
</template>
<script>
import DatePicker from 'vue2-datepicker';
@@ -22,7 +47,7 @@ export default {
this.$emit('close');
},
chooseTime() {
this.$emit('chooseTime', this.snoozeTime);
this.$emit('choose-time', this.snoozeTime);
},
disabledDate(date) {
// Disable all the previous dates
@@ -39,32 +64,6 @@ export default {
},
};
</script>
<template>
<div class="flex flex-col">
<woot-modal-header :header-title="$t('CONVERSATION.CUSTOM_SNOOZE.TITLE')" />
<form class="modal-content" @submit.prevent="chooseTime">
<DatePicker
v-model="snoozeTime"
type="datetime"
inline
:lang="lang"
:disabled-date="disabledDate"
:disabled-time="disabledTime"
:popup-style="{ width: '100%' }"
/>
<div class="flex flex-row justify-end w-full gap-2 px-0 py-2">
<woot-button variant="clear" @click.prevent="onClose">
{{ $t('CONVERSATION.CUSTOM_SNOOZE.CANCEL') }}
</woot-button>
<woot-button>
{{ $t('CONVERSATION.CUSTOM_SNOOZE.APPLY') }}
</woot-button>
</div>
</form>
</div>
</template>
<style lang="scss" scoped>
.modal-content {
@apply pt-2 px-5 pb-6;
@@ -1,16 +1,3 @@
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
<template>
<div class="flex flex-col items-start w-full gap-6">
<div class="flex flex-col w-full gap-4">
@@ -29,3 +16,16 @@ defineProps({
</div>
</div>
</template>
<script setup>
defineProps({
title: {
type: String,
default: '',
},
description: {
type: String,
default: '',
},
});
</script>
@@ -1,3 +1,7 @@
<template>
<div ref="observedElement" class="h-6 w-full" />
</template>
<script>
export default {
props: {
@@ -28,7 +32,3 @@ export default {
},
};
</script>
<template>
<div ref="observedElement" class="h-6 w-full" />
</template>
@@ -1,7 +1,23 @@
<template>
<div class="text--container">
<woot-button size="small" class="button--text" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</woot-button>
<woot-button
variant="clear"
size="small"
class="button--visibility"
color-scheme="secondary"
:icon="masked ? 'eye-show' : 'eye-hide'"
@click.prevent="toggleMasked"
/>
<highlightjs v-if="value" :code="masked ? '•'.repeat(10) : value" />
</div>
</template>
<script>
import 'highlight.js/styles/default.css';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useAlert } from 'dashboard/composables';
export default {
props: {
@@ -19,7 +35,7 @@ export default {
async onCopy(e) {
e.preventDefault();
await copyTextToClipboard(this.value);
useAlert(this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
bus.$emit('newToastMessage', this.$t('COMPONENTS.CODE.COPY_SUCCESSFUL'));
},
toggleMasked() {
this.masked = !this.masked;
@@ -28,23 +44,6 @@ export default {
};
</script>
<template>
<div class="text--container">
<woot-button size="small" class="button--text" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</woot-button>
<woot-button
variant="clear"
size="small"
class="button--visibility"
color-scheme="secondary"
:icon="masked ? 'eye-show' : 'eye-hide'"
@click.prevent="toggleMasked"
/>
<highlightjs v-if="value" :code="masked ? '•'.repeat(10) : value" />
</div>
</template>
<style lang="scss" scoped>
.text--container {
position: relative;
+34 -36
View File
@@ -1,3 +1,36 @@
<template>
<transition name="modal-fade">
<div
v-if="show"
:class="modalClassName"
transition="modal"
@mousedown="handleMouseDown"
>
<div
:class="{
'modal-container rtl:text-right shadow-md max-h-full overflow-auto relative bg-white dark:bg-slate-800 skip-context-menu': true,
'rounded-xl w-[37.5rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
}"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
@click="close"
/>
<slot />
</div>
</div>
</transition>
</template>
<script>
export default {
props: {
@@ -66,48 +99,13 @@ export default {
onMouseUp() {
if (this.mousedDownOnBackdrop) {
this.mousedDownOnBackdrop = false;
if (this.closeOnBackdropClick) {
this.onClose();
}
this.onClose();
}
},
},
};
</script>
<template>
<transition name="modal-fade">
<div
v-if="show"
:class="modalClassName"
transition="modal"
@mousedown="handleMouseDown"
>
<div
class="relative max-h-full overflow-auto bg-white shadow-md modal-container rtl:text-right dark:bg-slate-800 skip-context-menu"
:class="{
'rounded-xl w-[37.5rem]': !fullWidth,
'items-center rounded-none flex h-full justify-center w-full':
fullWidth,
[size]: true,
}"
@mouse.stop
@mousedown="event => event.stopPropagation()"
>
<woot-button
v-if="showCloseButton"
color-scheme="secondary"
icon="dismiss"
variant="clear"
class="absolute z-10 ltr:right-2 rtl:left-2 top-2"
@click="close"
/>
<slot />
</div>
</div>
</transition>
</template>
<style lang="scss">
.modal-mask {
@apply flex items-center justify-center bg-modal-backdrop-light dark:bg-modal-backdrop-dark z-[9990] h-full left-0 fixed top-0 w-full;
@@ -1,28 +1,3 @@
<script>
export default {
props: {
headerTitle: {
type: String,
default: '',
},
headerContent: {
type: String,
default: '',
},
headerContentValue: {
type: String,
default: '',
},
headerImage: {
type: String,
default: '',
},
},
};
</script>
<!-- eslint-disable vue/no-unused-refs -->
<!-- Added ref for writing specs -->
<template>
<div class="flex flex-col items-start px-8 pt-8 pb-0">
<img v-if="headerImage" :src="headerImage" alt="No image" />
@@ -48,3 +23,26 @@ export default {
<slot />
</div>
</template>
<script>
export default {
props: {
headerTitle: {
type: String,
default: '',
},
headerContent: {
type: String,
default: '',
},
headerContentValue: {
type: String,
default: '',
},
headerImage: {
type: String,
default: '',
},
},
};
</script>
@@ -1,133 +1,26 @@
<script setup>
import { ref, computed, onBeforeUnmount } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useRoute } from 'dashboard/composables/route';
import { useEmitter } from 'dashboard/composables/emitter';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
isAConversationRoute,
isAInboxViewRoute,
isNotificationRoute,
} from 'dashboard/helper/routeHelpers';
import { useEventListener } from '@vueuse/core';
const { t } = useI18n();
const route = useRoute();
const RECONNECTED_BANNER_TIMEOUT = 2000;
const showNotification = ref(!navigator.onLine);
const isDisconnected = ref(false);
const isReconnecting = ref(false);
const isReconnected = ref(false);
let reconnectTimeout = null;
const bannerText = computed(() => {
if (isReconnecting.value) return t('NETWORK.NOTIFICATION.RECONNECTING');
if (isReconnected.value) return t('NETWORK.NOTIFICATION.RECONNECT_SUCCESS');
return t('NETWORK.NOTIFICATION.OFFLINE');
});
const iconName = computed(() => (isReconnected.value ? 'wifi' : 'wifi-off'));
const canRefresh = computed(
() => !isReconnecting.value && !isReconnected.value
);
const refreshPage = () => {
window.location.reload();
};
const closeNotification = () => {
showNotification.value = false;
isReconnected.value = false;
clearTimeout(reconnectTimeout);
};
const isInAnyOfTheRoutes = routeName => {
return (
isAConversationRoute(routeName, true) ||
isAInboxViewRoute(routeName, true) ||
isNotificationRoute(routeName, true)
);
};
const updateWebsocketStatus = () => {
isDisconnected.value = true;
showNotification.value = true;
};
const handleReconnectionCompleted = () => {
isDisconnected.value = false;
isReconnecting.value = false;
isReconnected.value = true;
showNotification.value = true;
reconnectTimeout = setTimeout(closeNotification, RECONNECTED_BANNER_TIMEOUT);
};
const handleReconnecting = () => {
if (isInAnyOfTheRoutes(route.name)) {
isReconnecting.value = true;
isReconnected.value = false;
showNotification.value = true;
} else {
handleReconnectionCompleted();
}
};
const updateOnlineStatus = event => {
// Case: Websocket is not disconnected
// If the app goes offline, show the notification
// If the app goes online, close the notification
// Case: Websocket is disconnected
// If the app goes offline, show the notification
// If the app goes online but the websocket is disconnected, don't close the notification
// If the app goes online and the websocket is not disconnected, close the notification
if (event.type === 'offline') {
showNotification.value = true;
} else if (event.type === 'online' && !isDisconnected.value) {
handleReconnectionCompleted();
}
};
useEventListener('online', updateOnlineStatus);
useEventListener('offline', updateOnlineStatus);
useEmitter(BUS_EVENTS.WEBSOCKET_DISCONNECT, updateWebsocketStatus);
useEmitter(
BUS_EVENTS.WEBSOCKET_RECONNECT_COMPLETED,
handleReconnectionCompleted
);
useEmitter(BUS_EVENTS.WEBSOCKET_RECONNECT, handleReconnecting);
onBeforeUnmount(() => {
clearTimeout(reconnectTimeout);
});
</script>
<template>
<transition name="network-notification-fade" tag="div">
<div v-show="showNotification" class="fixed z-50 top-4 left-2 group">
<div v-show="showNotification" class="fixed top-4 left-2 z-50 group">
<div
class="relative flex items-center justify-between w-full px-2 py-1 bg-yellow-200 rounded-lg shadow-lg dark:bg-yellow-700"
class="flex items-center justify-between py-1 px-2 w-full rounded-lg shadow-lg bg-yellow-200 dark:bg-yellow-700 relative"
>
<fluent-icon
:icon="iconName"
icon="wifi-off"
class="text-yellow-700/50 dark:text-yellow-50"
size="18"
/>
<span
class="px-2 text-xs font-medium tracking-wide text-yellow-700/70 dark:text-yellow-50"
class="text-xs tracking-wide font-medium px-2 text-yellow-700/70 dark:text-yellow-50"
>
{{ bannerText }}
{{ $t('NETWORK.NOTIFICATION.OFFLINE') }}
</span>
<woot-button
v-if="canRefresh"
:title="$t('NETWORK.BUTTON.REFRESH')"
variant="clear"
size="small"
color-scheme="warning"
icon="arrow-clockwise"
class="visible transition-all duration-500 ease-in-out ml-1"
@click="refreshPage"
/>
<woot-button
@@ -141,3 +34,55 @@ onBeforeUnmount(() => {
</div>
</transition>
</template>
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
export default {
mixins: [globalConfigMixin],
data() {
return {
showNotification: !navigator.onLine,
};
},
computed: {
...mapGetters({ globalConfig: 'globalConfig/get' }),
},
mounted() {
window.addEventListener('offline', this.updateOnlineStatus);
window.bus.$on(BUS_EVENTS.WEBSOCKET_DISCONNECT, () => {
// TODO: Remove this after completing the conversation list refetching
// TODO: DIRTY FIX : CLEAN UP THIS WITH PROPER FIX, DELAYING THE RECONNECT FOR NOW
// THE CABLE IS FIRING IS VERY COMMON AND THUS INTERFERING USER EXPERIENCE
setTimeout(() => {
this.updateOnlineStatus({ type: 'offline' });
}, 4000);
});
},
beforeDestroy() {
window.removeEventListener('offline', this.updateOnlineStatus);
},
methods: {
refreshPage() {
window.location.reload();
},
closeNotification() {
this.showNotification = false;
},
updateOnlineStatus(event) {
if (event.type === 'offline') {
this.showNotification = true;
}
},
},
};
</script>
@@ -1,26 +1,3 @@
<script>
export default {
props: {
title: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
showBorder: {
type: Boolean,
default: true,
},
note: {
type: String,
default: '',
},
},
};
</script>
<template>
<div
class="ml-0 mr-0 flex py-8 w-full xl:w-3/4 flex-col xl:flex-row"
@@ -53,3 +30,26 @@ export default {
</div>
</div>
</template>
<script>
export default {
props: {
title: {
type: String,
default: '',
},
subTitle: {
type: String,
default: '',
},
showBorder: {
type: Boolean,
default: true,
},
note: {
type: String,
default: '',
},
},
};
</script>
@@ -1,3 +1,14 @@
<template>
<woot-button
:size="size"
variant="clear"
color-scheme="secondary"
class="-ml-3 text-black-900 dark:text-slate-300"
icon="list"
@click="onMenuItemClick"
/>
</template>
<script>
import { BUS_EVENTS } from 'shared/constants/busEvents';
@@ -10,19 +21,8 @@ export default {
},
methods: {
onMenuItemClick() {
this.$emitter.emit(BUS_EVENTS.TOGGLE_SIDEMENU);
bus.$emit(BUS_EVENTS.TOGGLE_SIDEMENU);
},
},
};
</script>
<template>
<woot-button
:size="size"
variant="clear"
color-scheme="secondary"
class="-ml-3 text-black-900 dark:text-slate-300"
icon="list"
@click="onMenuItemClick"
/>
</template>
@@ -1,3 +1,24 @@
<template>
<div>
<div
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
>
<div class="text-white dark:text-white text-sm font-medium">
{{ message }}
</div>
<div v-if="action">
<router-link
v-if="action.type == 'link'"
:to="action.to"
class="text-woot-500 dark:text-woot-500 cursor-pointer font-medium hover:text-woot-600 dark:hover:text-woot-600 select-none"
>
{{ action.message }}
</router-link>
</div>
</div>
</div>
</template>
<script>
export default {
props: {
@@ -6,6 +27,11 @@ export default {
type: Object,
default: () => {},
},
showButton: Boolean,
duration: {
type: [String, Number],
default: 3000,
},
},
data() {
return {
@@ -16,24 +42,3 @@ export default {
methods: {},
};
</script>
<template>
<div>
<div
class="shadow-sm bg-slate-800 dark:bg-slate-700 rounded-[4px] items-center gap-3 inline-flex mb-2 max-w-[25rem] min-h-[1.875rem] min-w-[15rem] px-6 py-3 text-left"
>
<div class="text-sm font-medium text-white dark:text-white">
{{ message }}
</div>
<div v-if="action">
<router-link
v-if="action.type == 'link'"
:to="action.to"
class="font-medium cursor-pointer select-none text-woot-500 dark:text-woot-500 hover:text-woot-600 dark:hover:text-woot-600"
>
{{ action.message }}
</router-link>
</div>
</div>
</div>
</template>
@@ -1,3 +1,18 @@
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
>
<woot-snackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</template>
<script>
import WootSnackbar from './Snackbar.vue';
@@ -19,13 +34,13 @@ export default {
},
mounted() {
this.$emitter.on('newToastMessage', this.onNewToastMessage);
bus.$on('newToastMessage', this.onNewToastMessage);
},
beforeDestroy() {
this.$emitter.off('newToastMessage', this.onNewToastMessage);
bus.$off('newToastMessage', this.onNewToastMessage);
},
methods: {
onNewToastMessage({ message, action }) {
onNewToastMessage(message, action) {
this.snackMessages.push({
key: new Date().getTime(),
message,
@@ -38,18 +53,3 @@ export default {
},
};
</script>
<template>
<transition-group
name="toast-fade"
tag="div"
class="left-0 my-0 mx-auto max-w-[25rem] overflow-hidden absolute right-0 text-center top-4 z-[9999]"
>
<WootSnackbar
v-for="snackMessage in snackMessages"
:key="snackMessage.key"
:message="snackMessage.message"
:action="snackMessage.action"
/>
</transition-group>
</template>
@@ -1,7 +1,18 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
<script>
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
const EMPTY_SUBSCRIPTION_INFO = {
@@ -11,13 +22,7 @@ const EMPTY_SUBSCRIPTION_INFO = {
export default {
components: { Banner },
mixins: [accountMixin],
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
mixins: [adminMixin, accountMixin],
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
@@ -75,14 +80,3 @@ export default {
},
};
</script>
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
@@ -1,12 +1,24 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="mail"
has-action-button
@click="resendVerificationEmail"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import accountMixin from 'dashboard/mixins/account';
import { useAlert } from 'dashboard/composables';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: { Banner },
mixins: [accountMixin],
mixins: [accountMixin, alertMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -24,20 +36,8 @@ export default {
methods: {
resendVerificationEmail() {
this.$store.dispatch('resendConfirmation');
useAlert(this.$t('APP_GLOBAL.EMAIL_VERIFICATION_SENT'));
this.showAlert(this.$t('APP_GLOBAL.EMAIL_VERIFICATION_SENT'));
},
},
};
</script>
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
action-button-icon="mail"
has-action-button
@click="resendVerificationEmail"
/>
</template>
@@ -1,22 +1,28 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
has-close-button
@close="dismissUpdateBanner"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { mapGetters } from 'vuex';
import { useAdmin } from 'dashboard/composables/useAdmin';
import adminMixin from 'dashboard/mixins/isAdmin';
import { hasAnUpdateAvailable } from './versionCheckHelper';
export default {
components: { Banner },
mixins: [adminMixin],
props: {
latestChatwootVersion: { type: String, default: '' },
},
setup() {
const { isAdmin } = useAdmin();
return {
isAdmin,
};
},
data() {
return { userDismissedBanner: false };
},
@@ -66,15 +72,3 @@ export default {
},
};
</script>
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="primary"
:banner-message="bannerMessage"
href-link="https://github.com/chatwoot/chatwoot/releases"
:href-link-text="$t('GENERAL_SETTINGS.LEARN_MORE')"
has-close-button
@close="dismissUpdateBanner"
/>
</template>
@@ -1,12 +1,24 @@
<template>
<banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
mixins: [accountMixin],
mixins: [adminMixin, accountMixin],
data() {
return { conversationMeta: {} };
},
@@ -75,14 +87,3 @@ export default {
},
};
</script>
<template>
<Banner
v-if="shouldShowBanner"
color-scheme="alert"
:banner-message="bannerMessage"
:action-button-label="actionButtonMessage"
has-action-button
@click="routeToBilling"
/>
</template>
@@ -1,3 +1,8 @@
<template>
<kbd class="hotkey p-0.5 min-w-[1rem] uppercase" :class="customClass">
<slot />
</kbd>
</template>
<script>
export default {
props: {
@@ -9,12 +14,6 @@ export default {
};
</script>
<template>
<kbd class="hotkey p-0.5 min-w-[1rem] uppercase" :class="customClass">
<slot />
</kbd>
</template>
<style lang="scss">
kbd.hotkey {
@apply inline-flex leading-[0.625rem] rounded tracking-wide flex-shrink-0 items-center select-none justify-center;
@@ -1,10 +1,18 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
<template>
<button :type="type" class="button nice" :class="variant" @click="onClick">
<fluent-icon
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass"
:icon="icon"
/>
<spinner v-if="isLoading" />
<slot />
</button>
</template>
<script>
export default {
components: {
Spinner,
},
props: {
isLoading: {
type: Boolean,
@@ -34,16 +42,3 @@ export default {
},
};
</script>
<template>
<button :type="type" class="button nice" :class="variant" @click="onClick">
<fluent-icon
v-if="!isLoading && icon"
class="icon"
:class="buttonIconClass"
:icon="icon"
/>
<Spinner v-if="isLoading" />
<slot />
</button>
</template>
@@ -1,3 +1,17 @@
<template>
<button
:type="type"
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
@click="onClick"
>
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span>
<spinner v-if="loading" class="ml-2" :color-scheme="spinnerClass" />
</button>
</template>
<script>
import Spinner from 'shared/components/Spinner.vue';
@@ -47,21 +61,6 @@ export default {
},
};
</script>
<template>
<button
:type="type"
data-testid="submit_button"
:disabled="disabled"
:class="computedClass"
@click="onClick"
>
<fluent-icon v-if="!!iconClass" :icon="iconClass" class="icon" />
<span>{{ buttonText }}</span>
<Spinner v-if="loading" class="ml-2" :color-scheme="spinnerClass" />
</button>
</template>
<style lang="scss" scoped>
button:disabled {
@apply bg-woot-100 dark:bg-woot-500/25 dark:text-slate-500 opacity-100;
@@ -1,147 +1,5 @@
<script setup>
import { ref, computed } from 'vue';
import { useAlert } from 'dashboard/composables';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStore, useStoreGetters } from 'dashboard/composables/store';
import { useEmitter } from 'dashboard/composables/emitter';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
const resolveActionsRef = ref(null);
const arrowDownButtonRef = ref(null);
const isLoading = ref(false);
const [showActionsDropdown, toggleDropdown] = useToggle();
const closeDropdown = () => toggleDropdown(false);
const openDropdown = () => toggleDropdown(true);
const currentChat = computed(() => getters.getSelectedChat.value);
const isOpen = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
);
const isPending = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.PENDING
);
const isResolved = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.RESOLVED
);
const isSnoozed = computed(
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
);
const buttonClass = computed(() => {
if (isPending.value) return 'primary';
if (isOpen.value) return 'success';
if (isResolved.value) return 'warning';
return '';
});
const showAdditionalActions = computed(
() => !isPending.value && !isSnoozed.value
);
const showOpenButton = computed(() => {
return isPending.value || isSnoozed.value;
});
const getConversationParams = () => {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
};
const openSnoozeModal = () => {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
};
const toggleStatus = (status, snoozedUntil) => {
closeDropdown();
isLoading.value = true;
store
.dispatch('toggleStatus', {
conversationId: currentChat.value.id,
status,
snoozedUntil,
})
.then(() => {
useAlert(t('CONVERSATION.CHANGE_STATUS'));
isLoading.value = false;
});
};
const onCmdOpenConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
};
const onCmdResolveConversation = () => {
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
};
const keyboardEvents = {
'Alt+KeyM': {
action: () => arrowDownButtonRef.value?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': {
action: async () => {
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
},
},
'$mod+Alt+KeyE': {
action: async event => {
const { all, activeIndex, lastIndex } = getConversationParams();
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
},
};
useKeyboardEvents(keyboardEvents, resolveActionsRef);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div
ref="resolveActionsRef"
class="relative flex items-center justify-end resolve-actions"
>
<div class="resolve-actions relative flex items-center justify-end">
<div class="button-group">
<woot-button
v-if="isOpen"
@@ -163,7 +21,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ t('CONVERSATION.HEADER.REOPEN_ACTION') }}
{{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }}
</woot-button>
<woot-button
v-else-if="showOpenButton"
@@ -173,11 +31,11 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
:is-loading="isLoading"
@click="onCmdOpenConversation"
>
{{ t('CONVERSATION.HEADER.OPEN_ACTION') }}
{{ $t('CONVERSATION.HEADER.OPEN_ACTION') }}
</woot-button>
<woot-button
v-if="showAdditionalActions"
ref="arrowDownButtonRef"
ref="arrowDownButton"
:color-scheme="buttonClass"
:disabled="isLoading"
icon="chevron-down"
@@ -188,10 +46,10 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
<div
v-if="showActionsDropdown"
v-on-clickaway="closeDropdown"
class="dropdown-pane dropdown-pane--open left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem]"
class="dropdown-pane dropdown-pane--open"
>
<WootDropdownMenu class="mb-0">
<WootDropdownItem v-if="!isPending">
<woot-dropdown-menu class="mb-0">
<woot-dropdown-item v-if="!isPending">
<woot-button
variant="clear"
color-scheme="secondary"
@@ -199,27 +57,212 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
icon="snooze"
@click="() => openSnoozeModal()"
>
{{ t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
</woot-button>
</WootDropdownItem>
<WootDropdownItem v-if="!isPending">
</woot-dropdown-item>
<woot-dropdown-item v-if="!isPending">
<woot-button
variant="clear"
color-scheme="secondary"
size="small"
icon="book-clock"
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
>
{{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
</woot-button>
</WootDropdownItem>
</WootDropdownMenu>
</woot-dropdown-item>
</woot-dropdown-menu>
</div>
<woot-modal
:show.sync="showCustomSnoozeModal"
:on-close="hideCustomSnoozeModal"
>
<custom-snooze-modal
@close="hideCustomSnoozeModal"
@choose-time="chooseSnoozeTime"
/>
</woot-modal>
</div>
</template>
<script>
import { getUnixTime } from 'date-fns';
import { mapGetters } from 'vuex';
import alertMixin from 'shared/mixins/alertMixin';
import CustomSnoozeModal from 'dashboard/components/CustomSnoozeModal.vue';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { findSnoozeTime } from 'dashboard/helper/snoozeHelpers';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,
CMD_SNOOZE_CONVERSATION,
} from '../../routes/dashboard/commands/commandBarBusEvents';
export default {
components: {
WootDropdownItem,
WootDropdownMenu,
CustomSnoozeModal,
},
mixins: [alertMixin, keyboardEventListenerMixins],
props: { conversationId: { type: [String, Number], required: true } },
data() {
return {
isLoading: false,
showActionsDropdown: false,
STATUS_TYPE: wootConstants.STATUS_TYPE,
showCustomSnoozeModal: false,
};
},
computed: {
...mapGetters({ currentChat: 'getSelectedChat' }),
isOpen() {
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
},
isPending() {
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
},
isResolved() {
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
},
isSnoozed() {
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
},
buttonClass() {
if (this.isPending) return 'primary';
if (this.isOpen) return 'success';
if (this.isResolved) return 'warning';
return '';
},
showAdditionalActions() {
return !this.isPending && !this.isSnoozed;
},
},
mounted() {
bus.$on(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
destroyed() {
bus.$off(CMD_SNOOZE_CONVERSATION, this.onCmdSnoozeConversation);
bus.$off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
bus.$off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
},
methods: {
getKeyboardEvents() {
return {
'Alt+KeyM': {
action: () => this.$refs.arrowDownButton?.$el.click(),
allowOnFocusedInput: true,
},
'Alt+KeyE': this.resolveOrToast,
'$mod+Alt+KeyE': async event => {
const { all, activeIndex, lastIndex } = this.getConversationParams();
await this.resolveOrToast();
if (activeIndex < lastIndex) {
all[activeIndex + 1].click();
} else if (all.length > 1) {
all[0].click();
document.querySelector('.conversations-list').scrollTop = 0;
}
event.preventDefault();
},
};
},
getConversationParams() {
const allConversations = document.querySelectorAll(
'.conversations-list .conversation'
);
const activeConversation = document.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
activeConversation
);
const lastConversationIndex = allConversations.length - 1;
return {
all: allConversations,
activeIndex: activeConversationIndex,
lastIndex: lastConversationIndex,
};
},
async resolveOrToast() {
try {
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
} catch (error) {
// error
}
},
onCmdSnoozeConversation(snoozeType) {
if (snoozeType === wootConstants.SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME) {
this.showCustomSnoozeModal = true;
} else {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
findSnoozeTime(snoozeType) || null
);
}
},
chooseSnoozeTime(customSnoozeTime) {
this.showCustomSnoozeModal = false;
if (customSnoozeTime) {
this.toggleStatus(
this.STATUS_TYPE.SNOOZED,
getUnixTime(customSnoozeTime)
);
}
},
hideCustomSnoozeModal() {
this.showCustomSnoozeModal = false;
},
onCmdOpenConversation() {
this.toggleStatus(this.STATUS_TYPE.OPEN);
},
onCmdResolveConversation() {
this.toggleStatus(this.STATUS_TYPE.RESOLVED);
},
showOpenButton() {
return this.isResolved || this.isSnoozed;
},
closeDropdown() {
this.showActionsDropdown = false;
},
openDropdown() {
this.showActionsDropdown = true;
},
toggleStatus(status, snoozedUntil) {
this.closeDropdown();
this.isLoading = true;
this.$store
.dispatch('toggleStatus', {
conversationId: this.currentChat.id,
status,
snoozedUntil,
})
.then(() => {
this.showAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
this.isLoading = false;
});
},
openSnoozeModal() {
const ninja = document.querySelector('ninja-keys');
ninja.open({ parent: 'snooze_conversation' });
},
},
};
</script>
<style lang="scss" scoped>
.dropdown-pane {
@apply left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem];
.dropdown-menu__item {
@apply mb-0;
}
@@ -1,6 +1,53 @@
<template>
<woot-dropdown-menu>
<woot-dropdown-header :title="$t('SIDEBAR.SET_AVAILABILITY_TITLE')" />
<woot-dropdown-item
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<woot-button
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class-names="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
>
<availability-status-badge :status="status.value" />
{{ status.label }}
</woot-button>
</woot-dropdown-item>
<woot-dropdown-divider />
<woot-dropdown-item class="m-0 flex items-center justify-between p-2">
<div class="flex items-center">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
icon="info"
size="14"
class="mt-px"
/>
<span
class="my-0 mx-1 text-xs font-medium text-slate-600 dark:text-slate-100"
>
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
</div>
<woot-switch
size="small"
class="mt-px mx-1 mb-0"
:value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</woot-dropdown-item>
<woot-dropdown-divider />
</woot-dropdown-menu>
</template>
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import alertMixin from 'shared/mixins/alertMixin';
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader.vue';
@@ -18,6 +65,9 @@ export default {
WootDropdownItem,
AvailabilityStatusBadge,
},
mixins: [alertMixin],
data() {
return {
isStatusMenuOpened: false,
@@ -79,7 +129,7 @@ export default {
account_id: this.currentAccountId,
});
} catch (error) {
useAlert(
this.showAlert(
this.$t('PROFILE_SETTINGS.FORM.AVAILABILITY.SET_AVAILABILITY_ERROR')
);
} finally {
@@ -89,50 +139,3 @@ export default {
},
};
</script>
<template>
<WootDropdownMenu>
<WootDropdownHeader :title="$t('SIDEBAR.SET_AVAILABILITY_TITLE')" />
<WootDropdownItem
v-for="status in availabilityStatuses"
:key="status.value"
class="flex items-baseline"
>
<woot-button
size="small"
:color-scheme="status.disabled ? '' : 'secondary'"
:variant="status.disabled ? 'smooth' : 'clear'"
class-names="status-change--dropdown-button"
@click="changeAvailabilityStatus(status.value)"
>
<AvailabilityStatusBadge :status="status.value" />
{{ status.label }}
</woot-button>
</WootDropdownItem>
<WootDropdownDivider />
<WootDropdownItem class="flex items-center justify-between p-2 m-0">
<div class="flex items-center">
<fluent-icon
v-tooltip.right-start="$t('SIDEBAR.SET_AUTO_OFFLINE.INFO_TEXT')"
icon="info"
size="14"
class="mt-px"
/>
<span
class="mx-1 my-0 text-xs font-medium text-slate-600 dark:text-slate-100"
>
{{ $t('SIDEBAR.SET_AUTO_OFFLINE.TEXT') }}
</span>
</div>
<woot-switch
size="small"
class="mx-1 mt-px mb-0"
:value="currentUserAutoOffline"
@input="updateAutoOffline"
/>
</WootDropdownItem>
<WootDropdownDivider />
</WootDropdownMenu>
</template>
@@ -1,20 +1,50 @@
<template>
<aside class="flex h-full">
<primary-sidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
:is-a-custom-branded-instance="isACustomBrandedInstance"
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@toggle-accounts="toggleAccountModal"
@key-shortcut-modal="toggleKeyShortcutModal"
@open-notification-panel="openNotificationPanel"
/>
<secondary-sidebar
v-if="showSecondarySidebar"
:class="sidebarClassName"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
:teams="teams"
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-role="currentRole"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@add-label="showAddLabelPopup"
@toggle-accounts="toggleAccountModal"
/>
</aside>
</template>
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import adminMixin from '../../mixins/isAdmin';
import { getSidebarItems } from './config/default-sidebar';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useRoute, useRouter } from 'dashboard/composables/route';
import alertMixin from 'shared/mixins/alertMixin';
import PrimarySidebar from './sidebarComponents/Primary.vue';
import SecondarySidebar from './sidebarComponents/Secondary.vue';
import { routesWithPermissions } from '../../routes';
import { hasPermissions } from '../../helper/permissionsHelper';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import router from '../../routes';
export default {
components: {
PrimarySidebar,
SecondarySidebar,
},
mixins: [adminMixin, alertMixin, keyboardEventListenerMixins],
props: {
showSecondarySidebar: {
type: Boolean,
@@ -25,52 +55,6 @@ export default {
default: '',
},
},
setup(props, { emit }) {
const sidebarRef = ref(null);
const route = useRoute();
const router = useRouter();
const toggleKeyShortcutModal = () => {
emit('openKeyShortcutModal');
};
const closeKeyShortcutModal = () => {
emit('closeKeyShortcutModal');
};
const isCurrentRouteSameAsNavigation = routeName => {
return route.name === routeName;
};
const navigateToRoute = routeName => {
if (!isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
};
const keyboardEvents = {
'$mod+Slash': {
action: toggleKeyShortcutModal,
},
'$mod+Escape': {
action: closeKeyShortcutModal,
},
'Alt+KeyC': {
action: () => navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => navigateToRoute('agent_list'),
},
};
useKeyboardEvents(keyboardEvents, sidebarRef);
return {
toggleKeyShortcutModal,
sidebarRef,
};
},
data() {
return {
showOptionsMenu: false,
@@ -80,6 +64,7 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
currentRole: 'getCurrentRole',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
inboxes: 'inboxes/getInboxes',
@@ -113,13 +98,9 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = this.currentUser.permissions;
const menuItems = this.sideMenuConfig.primaryMenu;
return menuItems.filter(menuItem => {
const isAvailableForTheUser = hasPermissions(
routesWithPermissions[menuItem.toStateName],
userPermissions
);
const isAvailableForTheUser = menuItem.roles.includes(this.currentRole);
if (!isAvailableForTheUser) {
return false;
@@ -178,48 +159,50 @@ export default {
this.$store.dispatch('customViews/get', this.activeCustomView);
}
},
toggleKeyShortcutModal() {
this.$emit('open-key-shortcut-modal');
},
closeKeyShortcutModal() {
this.$emit('close-key-shortcut-modal');
},
getKeyboardEvents() {
return {
'$mod+Slash': this.toggleKeyShortcutModal,
'$mod+Escape': this.closeKeyShortcutModal,
'Alt+KeyC': {
action: () => this.navigateToRoute('home'),
},
'Alt+KeyV': {
action: () => this.navigateToRoute('contacts_dashboard'),
},
'Alt+KeyR': {
action: () => this.navigateToRoute('account_overview_reports'),
},
'Alt+KeyS': {
action: () => this.navigateToRoute('agent_list'),
},
};
},
navigateToRoute(routeName) {
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
router.push({ name: routeName });
}
},
isCurrentRouteSameAsNavigation(routeName) {
return this.$route.name === routeName;
},
toggleSupportChatWindow() {
window.$chatwoot.toggle();
},
toggleAccountModal() {
this.$emit('toggleAccountModal');
this.$emit('toggle-account-modal');
},
showAddLabelPopup() {
this.$emit('showAddLabelPopup');
this.$emit('show-add-label-popup');
},
openNotificationPanel() {
this.$emit('openNotificationPanel');
this.$emit('open-notification-panel');
},
},
};
</script>
<template>
<aside ref="sidebarRef" class="flex h-full">
<PrimarySidebar
:logo-source="globalConfig.logoThumbnail"
:installation-name="globalConfig.installationName"
:is-a-custom-branded-instance="isACustomBrandedInstance"
:account-id="accountId"
:menu-items="primaryMenuItems"
:active-menu-item="activePrimaryMenu.key"
@toggleAccounts="toggleAccountModal"
@openKeyShortcutModal="toggleKeyShortcutModal"
@openNotificationPanel="openNotificationPanel"
/>
<SecondarySidebar
v-if="showSecondarySidebar"
:class="sidebarClassName"
:account-id="accountId"
:inboxes="inboxes"
:labels="labels"
:teams="teams"
:custom-views="customViews"
:menu-config="activeSecondaryMenu"
:current-user="currentUser"
:is-on-chatwoot-cloud="isOnChatwootCloud"
@addLabel="showAddLabelPopup"
@toggleAccounts="toggleAccountModal"
/>
</aside>
</template>
@@ -9,6 +9,7 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.INBOX_VIEW,
toState: frontendURL(`accounts/${accountId}/inbox-view`),
toStateName: 'inbox_view',
roles: ['administrator', 'agent'],
},
{
icon: 'chat',
@@ -16,14 +17,7 @@ const primaryMenuItems = accountId => [
label: 'CONVERSATIONS',
toState: frontendURL(`accounts/${accountId}/dashboard`),
toStateName: 'home',
},
{
icon: 'captain',
key: 'captain',
label: 'CAPTAIN',
featureFlag: FEATURE_FLAGS.CAPTAIN,
toState: frontendURL(`accounts/${accountId}/captain`),
toStateName: 'captain',
roles: ['administrator', 'agent'],
},
{
icon: 'book-contacts',
@@ -32,6 +26,7 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.CRM,
toState: frontendURL(`accounts/${accountId}/contacts`),
toStateName: 'contacts_dashboard',
roles: ['administrator', 'agent'],
},
{
icon: 'arrow-trending-lines',
@@ -39,7 +34,8 @@ const primaryMenuItems = accountId => [
label: 'REPORTS',
featureFlag: FEATURE_FLAGS.REPORTS,
toState: frontendURL(`accounts/${accountId}/reports`),
toStateName: 'account_overview_reports',
toStateName: 'settings_account_reports',
roles: ['administrator'],
},
{
icon: 'megaphone',
@@ -48,6 +44,7 @@ const primaryMenuItems = accountId => [
featureFlag: FEATURE_FLAGS.CAMPAIGNS,
toState: frontendURL(`accounts/${accountId}/campaigns`),
toStateName: 'ongoing_campaigns',
roles: ['administrator'],
},
{
icon: 'library',
@@ -57,6 +54,7 @@ const primaryMenuItems = accountId => [
alwaysVisibleOnChatwootInstances: true,
toState: frontendURL(`accounts/${accountId}/portals`),
toStateName: 'default_portal_articles',
roles: ['administrator'],
},
{
icon: 'settings',
@@ -64,6 +62,7 @@ const primaryMenuItems = accountId => [
label: 'SETTINGS',
toState: frontendURL(`accounts/${accountId}/settings`),
toStateName: 'settings_home',
roles: ['administrator', 'agent'],
},
];
@@ -24,6 +24,7 @@ const settings = accountId => ({
'settings_inbox_list',
'settings_inbox_new',
'settings_inbox_show',
'settings_inbox',
'settings_inboxes_add_agents',
'settings_inboxes_page_channel',
'settings_integrations_dashboard_apps',
@@ -45,9 +46,6 @@ const settings = accountId => ({
icon: 'briefcase',
label: 'ACCOUNT_SETTINGS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/general`),
toStateName: 'general_settings_index',
},
@@ -55,9 +53,6 @@ const settings = accountId => ({
icon: 'people',
label: 'AGENTS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/agents/list`),
toStateName: 'agent_list',
featureFlag: FEATURE_FLAGS.AGENT_MANAGEMENT,
@@ -66,9 +61,6 @@ const settings = accountId => ({
icon: 'people-team',
label: 'TEAMS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/teams/list`),
toStateName: 'settings_teams_list',
featureFlag: FEATURE_FLAGS.TEAM_MANAGEMENT,
@@ -77,9 +69,6 @@ const settings = accountId => ({
icon: 'mail-inbox-all',
label: 'INBOXES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/inboxes/list`),
toStateName: 'settings_inbox_list',
featureFlag: FEATURE_FLAGS.INBOX_MANAGEMENT,
@@ -88,9 +77,6 @@ const settings = accountId => ({
icon: 'tag',
label: 'LABELS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/labels/list`),
toStateName: 'labels_list',
featureFlag: FEATURE_FLAGS.LABELS,
@@ -99,9 +85,6 @@ const settings = accountId => ({
icon: 'code',
label: 'CUSTOM_ATTRIBUTES',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(
`accounts/${accountId}/settings/custom-attributes/list`
),
@@ -112,9 +95,6 @@ const settings = accountId => ({
icon: 'automation',
label: 'AUTOMATION',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/automation/list`),
toStateName: 'automation_list',
featureFlag: FEATURE_FLAGS.AUTOMATIONS,
@@ -123,9 +103,6 @@ const settings = accountId => ({
icon: 'bot',
label: 'AGENT_BOTS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
globalConfigFlag: 'csmlEditorHost',
toState: frontendURL(`accounts/${accountId}/settings/agent-bots`),
toStateName: 'agent_bots',
@@ -135,9 +112,6 @@ const settings = accountId => ({
icon: 'flash-settings',
label: 'MACROS',
hasSubMenu: false,
meta: {
permissions: ['administrator', 'agent'],
},
toState: frontendURL(`accounts/${accountId}/settings/macros`),
toStateName: 'macros_wrapper',
featureFlag: FEATURE_FLAGS.MACROS,
@@ -146,9 +120,6 @@ const settings = accountId => ({
icon: 'chat-multiple',
label: 'CANNED_RESPONSES',
hasSubMenu: false,
meta: {
permissions: ['administrator', 'agent'],
},
toState: frontendURL(
`accounts/${accountId}/settings/canned-response/list`
),
@@ -159,10 +130,15 @@ const settings = accountId => ({
icon: 'flash-on',
label: 'INTEGRATIONS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/integrations`),
toStateName: 'settings_integrations',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
{
icon: 'star-emphasis',
label: 'APPLICATIONS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/applications`),
toStateName: 'settings_applications',
featureFlag: FEATURE_FLAGS.INTEGRATIONS,
},
@@ -170,21 +146,16 @@ const settings = accountId => ({
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/audit-logs/list`),
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toStateName: 'auditlogs_list',
isEnterpriseOnly: true,
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
beta: true,
},
{
icon: 'document-list-clock',
label: 'SLA',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/sla/list`),
toStateName: 'sla_list',
isEnterpriseOnly: true,
@@ -195,9 +166,6 @@ const settings = accountId => ({
icon: 'credit-card-person',
label: 'BILLING',
hasSubMenu: false,
meta: {
permissions: ['administrator'],
},
toState: frontendURL(`accounts/${accountId}/settings/billing`),
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
@@ -1,3 +1,35 @@
<template>
<div
v-if="showShowCurrentAccountContext"
class="text-slate-700 dark:text-slate-200 rounded-md text-xs py-2 px-2 mt-2 relative border border-slate-50 dark:border-slate-800/50 hover:bg-slate-50 dark:hover:bg-slate-800 cursor-pointer"
@mouseover="setShowSwitch"
@mouseleave="resetShowSwitch"
>
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
<p
class="text-ellipsis overflow-hidden whitespace-nowrap font-medium mb-0 text-slate-800 dark:text-slate-100"
>
{{ account.name }}
</p>
<transition name="fade">
<div
v-if="showSwitchButton"
class="ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark flex items-center h-full rounded-md justify-end absolute top-0 right-0 w-full"
>
<div class="my-0 mx-2">
<woot-button
variant="clear"
size="tiny"
icon="arrow-swap"
@click="$emit('toggle-accounts')"
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div>
</div>
</transition>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
@@ -24,40 +56,6 @@ export default {
},
};
</script>
<template>
<div
v-if="showShowCurrentAccountContext"
class="relative px-2 py-2 mt-2 text-xs border rounded-md cursor-pointer text-slate-700 dark:text-slate-200 border-slate-50 dark:border-slate-800/50 hover:bg-slate-50 dark:hover:bg-slate-800"
@mouseover="setShowSwitch"
@mouseleave="resetShowSwitch"
>
{{ $t('SIDEBAR.CURRENTLY_VIEWING_ACCOUNT') }}
<p
class="mb-0 overflow-hidden font-medium text-ellipsis whitespace-nowrap text-slate-800 dark:text-slate-100"
>
{{ account.name }}
</p>
<transition name="fade">
<div
v-if="showSwitchButton"
class="absolute top-0 right-0 flex items-center justify-end w-full h-full rounded-md ltr:overlay-shadow ltr:dark:overlay-shadow-dark rtl:rtl-overlay-shadow rtl:dark:rtl-overlay-shadow-dark"
>
<div class="mx-2 my-0">
<woot-button
variant="clear"
size="tiny"
icon="arrow-swap"
@click="$emit('toggleAccounts')"
>
{{ $t('SIDEBAR.SWITCH') }}
</woot-button>
</div>
</div>
</transition>
</div>
</template>
<style scoped>
@tailwind components;
@layer components {
@@ -1,3 +1,62 @@
<template>
<woot-modal
:show="showAccountModal"
:on-close="() => $emit('close-account-modal')"
>
<woot-modal-header
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 pt-4 pb-8">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="pt-0 pb-0"
>
<button
class="flex justify-between items-center expanded clear link cursor-pointer px-4 py-3 w-full rounded-lg hover:underline hover:bg-slate-25 dark:hover:bg-slate-900"
@click="onChangeAccount(account.id)"
>
<span class="w-full">
<label :for="account.name" class="text-left rtl:text-right">
<div
class="text-slate-700 text-lg dark:text-slate-100 font-medium hover:underline-offset-4 leading-5"
>
{{ account.name }}
</div>
<div
class="text-slate-500 text-xs dark:text-slate-500 font-medium hover:underline-offset-4"
>
{{ account.role }}
</div>
</label>
</span>
<fluent-icon
v-show="account.id === accountId"
class="text-slate-800 dark:text-slate-100"
icon="checkmark-circle"
type="solid"
size="24"
/>
</button>
</div>
</div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex justify-end items-center p-8 gap-2"
>
<button
class="button success large expanded nice"
@click="$emit('show-create-account-modal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</button>
</div>
</woot-modal>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
@@ -23,62 +82,3 @@ export default {
},
};
</script>
<template>
<woot-modal
:show="showAccountModal"
:on-close="() => $emit('closeAccountModal')"
>
<woot-modal-header
:header-title="$t('SIDEBAR_ITEMS.CHANGE_ACCOUNTS')"
:header-content="$t('SIDEBAR_ITEMS.SELECTOR_SUBTITLE')"
/>
<div class="px-8 py-4">
<div
v-for="account in currentUser.accounts"
:id="`account-${account.id}`"
:key="account.id"
class="pt-0 pb-0"
>
<button
class="flex items-center justify-between w-full px-4 py-3 rounded-lg cursor-pointer expanded clear link hover:underline hover:bg-slate-25 dark:hover:bg-slate-900"
@click="onChangeAccount(account.id)"
>
<span class="w-full">
<label :for="account.name" class="text-left rtl:text-right">
<div
class="text-lg font-medium leading-5 text-slate-700 dark:text-slate-100 hover:underline-offset-4"
>
{{ account.name }}
</div>
<div
class="text-xs font-medium text-slate-500 dark:text-slate-500 hover:underline-offset-4"
>
{{ account.role }}
</div>
</label>
</span>
<fluent-icon
v-show="account.id === accountId"
class="text-slate-800 dark:text-slate-100"
icon="checkmark-circle"
type="solid"
size="24"
/>
</button>
</div>
</div>
<div
v-if="globalConfig.createNewAccountFromDashboard"
class="flex items-center justify-end gap-2 px-8 pt-4 pb-8"
>
<button
class="w-full button success large expanded nice"
@click="$emit('showCreateAccountModal')"
>
{{ $t('CREATE_ACCOUNT.NEW_ACCOUNT') }}
</button>
</div>
</woot-modal>
</template>
@@ -1,10 +1,60 @@
<template>
<woot-modal
:show="show"
:on-close="() => $emit('close-account-create-modal')"
>
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
:header-content="$t('CREATE_ACCOUNT.SELECTOR_SUBTITLE')"
/>
<div v-if="!hasAccounts" class="text-sm mt-6 mx-8 mb-0">
<div class="items-center rounded-md flex alert">
<div class="ml-1 mr-3">
<fluent-icon icon="warning" />
</div>
{{ $t('CREATE_ACCOUNT.NO_ACCOUNT_WARNING') }}
</div>
</div>
<form class="flex flex-col w-full" @submit.prevent="addAccount">
<div class="w-full">
<label :class="{ error: $v.accountName.$error }">
{{ $t('CREATE_ACCOUNT.FORM.NAME.LABEL') }}
<input
v-model.trim="accountName"
type="text"
:placeholder="$t('CREATE_ACCOUNT.FORM.NAME.PLACEHOLDER')"
@input="$v.accountName.$touch"
/>
</label>
</div>
<div class="w-full">
<div class="w-full">
<woot-submit-button
:disabled="
$v.accountName.$invalid ||
$v.accountName.$invalid ||
uiFlags.isCreating
"
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
button-class="large expanded"
/>
</div>
</div>
</form>
</div>
</woot-modal>
</template>
<script>
import { useVuelidate } from '@vuelidate/core';
import { required, minLength } from '@vuelidate/validators';
import { required, minLength } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import alertMixin from 'shared/mixins/alertMixin';
export default {
mixins: [alertMixin],
props: {
show: {
type: Boolean,
@@ -15,9 +65,6 @@ export default {
default: true,
},
},
setup() {
return { v$: useVuelidate() };
},
data() {
return {
accountName: '',
@@ -40,64 +87,17 @@ export default {
const account_id = await this.$store.dispatch('accounts/create', {
account_name: this.accountName,
});
this.$emit('closeAccountCreateModal');
useAlert(this.$t('CREATE_ACCOUNT.API.SUCCESS_MESSAGE'));
this.$emit('close-account-create-modal');
this.showAlert(this.$t('CREATE_ACCOUNT.API.SUCCESS_MESSAGE'));
window.location = `/app/accounts/${account_id}/dashboard`;
} catch (error) {
if (error.response.status === 422) {
useAlert(this.$t('CREATE_ACCOUNT.API.EXIST_MESSAGE'));
this.showAlert(this.$t('CREATE_ACCOUNT.API.EXIST_MESSAGE'));
} else {
useAlert(this.$t('CREATE_ACCOUNT.API.ERROR_MESSAGE'));
this.showAlert(this.$t('CREATE_ACCOUNT.API.ERROR_MESSAGE'));
}
}
},
},
};
</script>
<template>
<woot-modal :show="show" :on-close="() => $emit('closeAccountCreateModal')">
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="$t('CREATE_ACCOUNT.NEW_ACCOUNT')"
:header-content="$t('CREATE_ACCOUNT.SELECTOR_SUBTITLE')"
/>
<div v-if="!hasAccounts" class="mx-8 mt-6 mb-0 text-sm">
<div class="flex items-center rounded-md alert">
<div class="ml-1 mr-3">
<fluent-icon icon="warning" />
</div>
{{ $t('CREATE_ACCOUNT.NO_ACCOUNT_WARNING') }}
</div>
</div>
<form class="flex flex-col w-full" @submit.prevent="addAccount">
<div class="w-full">
<label :class="{ error: v$.accountName.$error }">
{{ $t('CREATE_ACCOUNT.FORM.NAME.LABEL') }}
<input
v-model.trim="accountName"
type="text"
:placeholder="$t('CREATE_ACCOUNT.FORM.NAME.PLACEHOLDER')"
@input="v$.accountName.$touch"
/>
</label>
</div>
<div class="w-full">
<div class="w-full">
<woot-submit-button
:disabled="
v$.accountName.$invalid ||
v$.accountName.$invalid ||
uiFlags.isCreating
"
:button-text="$t('CREATE_ACCOUNT.FORM.SUBMIT')"
:loading="uiFlags.isCreating"
button-class="large expanded"
/>
</div>
</div>
</form>
</div>
</woot-modal>
</template>
@@ -1,3 +1,19 @@
<template>
<woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
variant="link"
class="items-center flex rounded-full"
@click="handleClick"
>
<thumbnail
:src="currentUser.avatar_url"
:username="currentUser.name"
:status="statusOfAgent"
should-show-status-always
size="32px"
/>
</woot-button>
</template>
<script>
import { mapGetters } from 'vuex';
import Thumbnail from '../../widgets/Thumbnail.vue';
@@ -17,25 +33,8 @@ export default {
},
methods: {
handleClick() {
this.$emit('toggleMenu');
this.$emit('toggle-menu');
},
},
};
</script>
<template>
<woot-button
v-tooltip.right="$t(`SIDEBAR.PROFILE_SETTINGS`)"
variant="link"
class="flex items-center rounded-full"
@click="handleClick"
>
<Thumbnail
:src="currentUser.avatar_url"
:username="currentUser.name"
:status="statusOfAgent"
should-show-status-always
size="32px"
/>
</woot-button>
</template>
@@ -1,3 +1,10 @@
<template>
<div class="w-8 h-8">
<router-link :to="dashboardPath" replace>
<img :src="source" :alt="name" />
</router-link>
</div>
</template>
<script>
import { frontendURL } from 'dashboard/helper/URLHelper';
@@ -23,11 +30,3 @@ export default {
},
};
</script>
<template>
<div class="w-8 h-8">
<router-link :to="dashboardPath" replace>
<img :src="source" :alt="name" />
</router-link>
</div>
</template>
@@ -1,38 +1,7 @@
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
notificationMetadata: 'notifications/getMeta',
}),
unreadCount() {
if (!this.notificationMetadata.unreadCount) {
return '';
}
return this.notificationMetadata.unreadCount < 100
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('openNotificationPanel');
}
},
},
};
</script>
<template>
<div class="mb-4">
<button
class="relative flex items-center justify-center w-10 h-10 p-0 my-2 rounded-lg text-slate-600 dark:text-slate-100 hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600"
class="text-slate-600 dark:text-slate-100 w-10 h-10 my-2 p-0 flex items-center justify-center rounded-lg hover:bg-slate-25 dark:hover:bg-slate-700 dark:hover:text-slate-100 hover:text-slate-600 relative"
:class="{
'bg-woot-50 dark:bg-slate-800 text-woot-500 hover:bg-woot-50':
isNotificationPanelActive,
@@ -54,3 +23,34 @@ export default {
</button>
</div>
</template>
<script>
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
notificationMetadata: 'notifications/getMeta',
}),
unreadCount() {
if (!this.notificationMetadata.unreadCount) {
return '';
}
return this.notificationMetadata.unreadCount < 100
? `${this.notificationMetadata.unreadCount}`
: '99+';
},
isNotificationPanelActive() {
return this.$route.name === 'notifications_index';
},
},
methods: {
openNotificationPanel() {
if (this.$route.name !== 'notifications_index') {
this.$emit('open-notification-panel');
}
},
},
};
</script>

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