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
2334 changed files with 42106 additions and 67545 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,
},
};
+8
View File
@@ -25,5 +25,13 @@ jobs:
with:
issue-inactive-days: '30'
issue-lock-reason: 'resolved'
issue-comment: >
This issue has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
pr-inactive-days: '30'
pr-lock-reason: 'resolved'
pr-comment: >
This pull request has been automatically locked since there
has not been any recent activity after it was closed.
Please open a new issue for related bugs.
-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;
},
}));
+13 -13
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 ---##
@@ -96,12 +96,12 @@ gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3', '>= 0.7.0'
gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -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.19.0', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.19.0', require: false
gem 'sentry-sidekiq', '>= 5.15.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.3.1'
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/
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
@@ -200,7 +200,7 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
gem 'meta_request', '>= 0.8.0'
end
group :test do
@@ -228,7 +228,7 @@ group :development, :test do
gem 'mock_redis'
gem 'pry-rails'
gem 'rspec_junit_formatter'
gem 'rspec-rails', '>= 6.1.4'
gem 'rspec-rails'
gem 'rubocop', require: false
gem 'rubocop-performance', require: false
gem 'rubocop-rails', require: false
+148 -156
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.4)
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,8 +223,8 @@ GEM
http (>= 3.0)
ruby2_keywords
email_reply_trimmer (0.1.13)
erubi (1.13.0)
et-orbi (1.2.11)
erubi (1.12.0)
et-orbi (1.2.7)
tzinfo
execjs (2.8.1)
facebook-messenger (2.0.1)
@@ -257,7 +251,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.1.0)
faraday (~> 2.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
@@ -268,10 +262,10 @@ GEM
rake
flag_shih_tzu (0.3.23)
foreman (0.87.2)
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
fugit (1.9.0)
et-orbi (~> 1, >= 1.2.7)
raabro (~> 1.4)
gapic-common (0.20.0)
gapic-common (0.18.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
@@ -301,15 +295,15 @@ GEM
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.18.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-location (0.4.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
@@ -319,17 +313,17 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.3)
google-protobuf (3.25.3-arm64-darwin)
google-protobuf (3.25.3-x86_64-darwin)
google-protobuf (3.25.3-x86_64-linux)
googleapis-common-protos (1.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
@@ -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)
@@ -457,7 +451,7 @@ GEM
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
@@ -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.25.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.14)
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)
@@ -522,7 +516,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.3)
omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -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.6)
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.4)
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.19.0)
sentry-rails (5.17.3)
railties (>= 5.0)
sentry-ruby (~> 5.19.0)
sentry-ruby (5.19.0)
sentry-ruby (~> 5.17.3)
sentry-ruby (5.17.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.19.0)
sentry-ruby (~> 5.19.0)
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.1)
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)
@@ -796,7 +787,7 @@ GEM
uniform_notifier (1.16.0)
uri (0.13.0)
uri_template (0.7.0)
valid_email2 (5.2.6)
valid_email2 (4.0.6)
activemodel (>= 3.2)
mail (~> 2.5)
version_gem (1.1.4)
@@ -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
@@ -881,9 +873,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-dialogflow-v2
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
google-cloud-translate-v3
groupdate
grpc
haikunator
@@ -903,14 +895,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.3)
meta_request (>= 0.8.0)
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
@@ -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.4)
rspec-rails
rspec_junit_formatter
rubocop
rubocop-performance
@@ -939,11 +931,11 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.19.0)
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.19.0)
sentry-sidekiq (>= 5.15.0)
shoulda-matchers
sidekiq (>= 7.3.1)
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
+2 -21
View File
@@ -32,13 +32,11 @@ class AccountBuilder
end
def validate_email
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
address = ValidEmail2::Address.new(@email)
if address.valid? && !address.disposable?
if address.valid? # && !address.disposable?
true
else
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
raise InvalidEmail.new(valid: address.valid?)
end
end
@@ -81,21 +79,4 @@ class AccountBuilder
@user.confirm if @confirmed
@user.save!
end
def domain_blocked?
domain = @email.split('@').last
blocked_domains.each do |blocked_domain|
return true if domain.match?(blocked_domain)
end
false
end
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip)
end
end
@@ -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
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def update
@agent.update!(agent_params.slice(:name).compact)
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
end
def destroy
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
@agent = agents.find(params[:id])
end
def account_user_attributes
[:role, :availability, :auto_offline]
end
def allowed_agent_params
[:name, :email, :name, :role, :availability, :auto_offline]
end
def agent_params
params.require(:agent).permit(allowed_agent_params)
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
end
def new_agent_params
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
@@ -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
@@ -11,17 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
end
def process_event
response = @hook.process_event(params[:event])
# for cases like an invalid event, or when conversation does not have enough messages
# for a label suggestion, the response is nil
if response.nil?
render json: { message: nil }
elsif response[:error]
render json: { error: response[:error] }, status: :unprocessable_entity
else
render json: { message: response[:message] }
end
render json: { message: @hook.process_event(params[:event]) }
end
def destroy
@@ -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
@@ -1,68 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
result = if params[:attachment].present?
create_from_file
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
end
private
def create_from_file
attachment = params[:attachment]
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
ActiveStorage::Blob.create_and_upload!(
io: io,
filename: filename,
content_type: content_type
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
end
file_blob.save!
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
render json: { error: message }, status: status
end
end
@@ -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
});
});
+33 -34
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';
@@ -10,13 +36,13 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
import vueActionCable from './helper/actionCable';
import WootSnackbarBox from './components/SnackbarContainer.vue';
import rtlMixin from 'shared/mixins/rtlMixin';
import { setColorTheme } from './helper/themeHelper';
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
import {
registerSubscription,
verifyServiceWorkerExistence,
} from './helper/pushHelper';
import ReconnectService from 'dashboard/helper/ReconnectService';
export default {
name: 'App',
@@ -31,19 +57,21 @@ export default {
UpgradeBanner,
PendingEmailVerificationBanner,
},
mixins: [rtlMixin],
data() {
return {
showAddAccountModal: false,
latestChatwootVersion: null,
reconnectService: null,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
isRTL: 'accounts/isRTL',
currentUser: 'getCurrentUser',
globalConfig: 'globalConfig/get',
authUIFlags: 'getAuthUIFlags',
accountUIFlags: 'accounts/getUIFlags',
currentAccountId: 'getCurrentAccountId',
@@ -74,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);
@@ -99,9 +122,9 @@ export default {
this.getAccount(this.currentAccountId);
const { pubsub_token: pubsubToken } = this.currentUser || {};
this.setLocale(locale);
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
this.reconnectService = new ReconnectService(this.$store, router);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
@@ -115,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': isRTL }"
:dir="isRTL ? '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 {
+289 -257
View File
@@ -1,22 +1,201 @@
<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 { useFilter } from 'shared/composables/useFilter';
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';
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';
@@ -30,7 +209,6 @@ import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
ChatListHeader,
AddCustomViews,
ChatTypeTabs,
// eslint-disable-next-line vue/no-unused-components
@@ -38,9 +216,18 @@ export default {
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
},
mixins: [
timeMixin,
conversationMixin,
keyboardEventListenerMixins,
alertMixin,
filterMixin,
uiSettingsMixin,
],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -85,86 +272,6 @@ export default {
type: Boolean,
},
},
setup() {
const { uiSettings } = useUISettings();
const conversationListRef = ref(null);
const {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
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 handleConversationNavigation = direction => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
// Determine the new index based on the direction
const newIndex =
direction === 'previous'
? activeConversationIndex - 1
: activeConversationIndex + 1;
// Check if the new index is within the valid range
if (
allConversations.length > 0 &&
newIndex >= 0 &&
newIndex <= lastConversationIndex
) {
// Click the conversation at the new index
allConversations[newIndex].click();
} else if (allConversations.length > 0) {
// If the new index is out of range, click the first or last conversation based on the direction
const fallbackIndex =
direction === 'previous' ? 0 : lastConversationIndex;
allConversations[fallbackIndex].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
allowOnFocusedInput: true,
},
};
useKeyboardEvents(keyboardEvents);
return {
uiSettings,
conversationListRef,
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
};
},
data() {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
@@ -185,7 +292,7 @@ export default {
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationListRef,
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
@@ -203,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',
@@ -224,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 &&
@@ -304,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() {
@@ -416,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);
},
@@ -432,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);
@@ -449,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);
});
},
@@ -562,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();
@@ -578,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() {
@@ -620,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();
@@ -628,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
@@ -711,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',
{
@@ -721,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) {
@@ -739,7 +896,7 @@ export default {
newValue: priority,
from: 'Context menu',
});
useAlert(
this.showAlert(
this.$t('CONVERSATION.PRIORITY.CHANGE_PRIORITY.SUCCESSFUL', {
priority,
conversationId,
@@ -782,7 +939,7 @@ export default {
conversationId,
teamId: team.id,
});
useAlert(
this.showAlert(
this.$t(
'CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.SUCCESFUL',
{
@@ -792,7 +949,7 @@ export default {
)
);
} catch (error) {
useAlert(
this.showAlert(
this.$t('CONVERSATION.CARD_CONTEXT_MENU.API.TEAM_ASSIGNMENT.FAILED')
);
}
@@ -809,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',
{
@@ -819,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) {
@@ -835,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) {
@@ -851,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) {
@@ -864,157 +1021,22 @@ 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) {
this.isContextMenuOpen = state;
},
initializeExistingFilterToModal() {
const statusFilter = this.initializeStatusAndAssigneeFilterToModal(
this.activeStatus,
this.currentUserDetails,
this.activeAssigneeTab
);
if (statusFilter) {
this.appliedFilter.push(statusFilter);
}
const otherFilters = this.initializeInboxTeamAndLabelFilterToModal(
this.conversationInbox,
this.inbox,
this.teamId,
this.activeTeam,
this.label
);
this.appliedFilter.push(...otherFilters);
},
},
};
</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"
@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 {
@@ -1028,4 +1050,14 @@ export default {
.conversations-list {
@apply overflow-hidden hover:overflow-y-auto;
}
.tab--chat-type {
@apply py-0 px-4;
::v-deep {
.tabs {
@apply p-0;
}
}
}
</style>
@@ -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,152 +332,14 @@ 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 {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
@@ -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,8 +1,19 @@
<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 { useAccount } from 'dashboard/composables/useAccount';
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 = {
status: null,
@@ -11,16 +22,7 @@ const EMPTY_SUBSCRIPTION_INFO = {
export default {
components: { Banner },
setup() {
const { isAdmin } = useAdmin();
const { accountId } = useAccount();
return {
accountId,
isAdmin,
};
},
mixins: [adminMixin, accountMixin],
computed: {
...mapGetters({
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
@@ -78,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,10 +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 { useAlert } from 'dashboard/composables';
import accountMixin from 'dashboard/mixins/account';
import alertMixin from 'shared/mixins/alertMixin';
export default {
components: { Banner },
mixins: [accountMixin, alertMixin],
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -22,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,17 +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 { useAccount } from 'dashboard/composables/useAccount';
import adminMixin from 'dashboard/mixins/isAdmin';
import accountMixin from 'dashboard/mixins/account';
import { differenceInDays } from 'date-fns';
export default {
components: { Banner },
setup() {
const { accountId } = useAccount();
return {
accountId,
};
},
mixins: [adminMixin, accountMixin],
data() {
return { conversationMeta: {} };
},
@@ -80,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,143 +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/helper/commandbar/events';
const store = useStore();
const getters = useStoreGetters();
const { t } = useI18n();
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);
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
</script>
<template>
<div 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"
@@ -159,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"
@@ -169,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"
@@ -184,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"
@@ -195,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,22 +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 { 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 {
getUserPermissions,
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,
@@ -27,50 +55,6 @@ export default {
default: '',
},
},
setup(props, { emit }) {
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);
return {
toggleKeyShortcutModal,
};
},
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,16 +98,9 @@ export default {
return getSidebarItems(this.accountId);
},
primaryMenuItems() {
const userPermissions = getUserPermissions(
this.currentUser,
this.accountId
);
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;
@@ -181,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 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 {

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