Compare commits

...
Author SHA1 Message Date
Sojan b40ad399f6 Merge branch 'release/3.4.0'
Publish Chatwoot CE docker images / build (push) Waiting to run
2023-12-18 21:22:55 -08:00
Sojan 29478880ff Bump version to 3.4.0 2023-12-18 21:22:06 -08:00
Vishnu NarayananandGitHub c9f8cdb51b fix: switch to nodejs ppa for linux installations (#8337)
Nodesource script-based installation is deprecated. Switch to a different ppa for nodejs.

Fixes: https://linear.app/chatwoot/issue/CW-2686/upgrade-warning
Fixes: #8151
2023-12-18 21:18:42 -08:00
Pranav Raj SandGitHub a2ebf92932 fix: Fix the issue "Undefined method `inbox' for Instagram::MessageText" (#8582) 2023-12-19 08:43:43 +05:30
Pranav Raj SandGitHub 00eb5b152a chore(migration): Re-run database migration job for caching. (#8581)
A support request that came to Chatwoot Cloud revealed that the job was timed prematurely. The default timeout for Sidekiq queues was set as 25 minutes in sidekiq.yml file. The cache was not created properly for the accounts with more than 100k conversations.

This change removes the cache logic in the previous migration and creates a new migration with a new job which processes conversations in batch.
2023-12-18 17:29:02 -08:00
Sojan JoseandGitHub 0b2f539ad0 chore: Update self-hosted billing redirect (#8580) 2023-12-18 16:34:47 -08:00
Shivam MishraandGitHub 1ab3966b75 fix: @inbox usage in logs (#8578) 2023-12-18 19:30:41 +05:30
552d967c81 fix: Use ogg format for Telegram audio messages (#8505)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-16 11:55:47 -08:00
Sivin VargheseandGitHub e5c8a68a76 fix: Typing indicator to support dark mode (#8564) 2023-12-15 09:57:13 +05:30
Sivin VargheseandGitHub 15f50635e7 fix: undefined method `available_name' for nil:NilClass (#8563) 2023-12-15 09:41:35 +05:30
LiamandGitHub cf0d6dd7c6 feat: Implement the ability to be able to rotate an image on agent conversation. (#8559) 2023-12-15 09:20:24 +05:30
Sivin VargheseandGitHub fd1813949a fix: Undefined method `name' for nil:NilClass (#8562) 2023-12-15 09:03:16 +05:30
Shivam MishraandGitHub 6c480098f7 chore: log authentication error for FB and Instagram (#8551) 2023-12-14 16:18:23 +05:30
Shivam MishraandGitHub ae649a5b3f refactor: add better logs to Instagram::MessageText (#8538) 2023-12-14 14:03:40 +05:30
34675da6fa feat: Settings screen for custom branding (#8543)
- Ability to configure Chatwoot custom branding from UI when using paid plans

Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
2023-12-13 13:16:24 -08:00
075b0292b5 feat: Conversation list virtualization (#8540)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-13 17:28:30 +05:30
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>Sivin VargheseShivam Mishra
0e9825f298 chore(deps): bump axios from 0.21.2 to 1.6.0 (#8339)
Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
2023-12-13 16:24:10 +05:30
bf883794a4 fix: accepting external source_id (#8359)
Co-authored-by: Sojan <sojan@pepalo.com>
2023-12-13 16:21:55 +05:30
Sivin VargheseandGitHub 3adaa2d602 fix: Retry message not working if the conversation has an external issue (#8529) 2023-12-13 15:46:10 +05:30
60a312ace5 feat: Advanced conversation sort options (#8532)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-12 19:59:31 -08:00
Pranav Raj SandGitHub 8c9a351c84 feat: Add support for ascending and descending options in sort (#8542) 2023-12-12 17:15:48 -08:00
Pranav Raj SandGitHub d28f512de4 fix: Update the label name to use .strip to avoid unnecessary whitespace characters (#8541)
- The cache stores the labels as label1, label2. Without removing the whitespace, the output of cached_label_list_array would be [label1, [whitespace]label2], which doesn't match with the existing labels. This list is returned with proper label names.
2023-12-12 15:04:29 -08:00
Sojan JoseandGitHub 376de685fb chore: Adds API for agent bot avatar upload (#8533)
Adds API for agent bot avatar upload
- accounts/agent_bot
- platform/agent_bot
2023-12-11 19:02:11 -08:00
Pranav Raj SandGitHub 890515edfd feat(perf): Cache labels on the conversation model (#8527) 2023-12-11 18:27:55 -08:00
Shivam MishraandGitHub 79412ba2c6 feat: disable reply to message in WhatsApp via 360 dialog (#8500) 2023-12-11 16:54:47 +05:30
03ebb6947b chore(synk): Upgrade sentry-rails from 5.13.0 to 5.14.0 (#8420)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 22:00:17 -08:00
c53591f049 chore(snyk): Security upgrade sidekiq-cron from 1.11.0 to 1.12.0 (#8523)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 21:59:12 -08:00
Muhsin KelothandGitHub db9a32a4c0 feat: Add sticker support in Line channel (#8488) 2023-12-10 20:45:44 -08:00
2ae9bbb24e chore(snyk): Security upgrade factory_bot_rails from 6.2.0 to 6.4.2 (#8371)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-10 20:43:58 -08:00
8dfe193461 feat: Add infinite loader, option for increasing page size (#8525)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-12-10 20:09:17 -08:00
Sivin VargheseandGitHub 27239ae14a feat: Adds API for retry messages in conversation (#8518) 2023-12-11 09:33:39 +05:30
Muhsin KelothandGitHub 80ff5e2d0a chore: Change the RemoveMessageNotifications job implementation. (#8521) 2023-12-11 08:27:30 +05:30
38240a68aa [Snyk] Security upgrade rack-mini-profiler from 3.1.1 to 3.2.0 (#8508)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-12-10 15:02:59 -08:00
Sojan JoseandGitHub 89710b406e chore: Remove missing styles for super admin (#8519)
- Remove missing stylesheet references for super admin
2023-12-08 20:47:57 -08:00
f002870c6a feat: Super admin design improvements (#8517)
- Revamp the super admin design
- Introduce a new settings page for support and billing settings
- Move the access tokens into users, agent bots and platform app show pages


Co-authored-by: Sojan <sojan@pepalo.com>
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-12-08 19:40:35 -08:00
Muhsin KelothandGitHub b1a68759cf feat: Add the support for custom attributes in message variables (#8511) 2023-12-08 14:13:35 -08:00
Muhsin KelothandGitHub bf49a17efa feat: Handle notification.deleted action cable event (#8501) 2023-12-06 16:49:00 +05:30
Muhsin KelothandGitHub 6b0d1d77d3 fix: Email notifications (#8502) 2023-12-06 15:50:23 +05:30
Muhsin KelothandGitHub db33b7d1dc feat: Clear all previous notifications if a new notification is added to a conversation (#8490) 2023-12-06 14:03:43 +05:30
Muhsin KelothandGitHub 76711d95ff chore: Change primary actor to Conversation for all the notification types. (#8435) 2023-12-06 10:43:09 +05:30
Sivin VargheseandGitHub 17faa6c3b2 fix: Undefined method `length' for nil:NilClass (#8491) 2023-12-05 16:34:29 +05:30
Sivin VargheseandGitHub c1ac354c9b fix: Remove article meta tag is not working (#8489) 2023-12-05 16:17:41 +05:30
Muhsin KelothandGitHub 449503bb94 feat: Snooze notification API (#8439) 2023-12-04 12:32:35 +05:30
Muhsin KelothandGitHub aad18e1ca4 feat: Add notification_deleted action cable event (#8431) 2023-12-01 23:06:22 +05:30
Muhsin KelothandGitHub fdc1123b18 feat: Add support for attachments(image and video) in LINE channel (#8425) 2023-12-01 07:23:35 +05:30
Anthony MeirlaenandGitHub 0974b1e705 fix: Install node 20.x in non-production docker (#8441) (#8442) 2023-11-30 16:22:34 -08:00
Muhsin KelothandGitHub bc6e3e598f feat: Delete notification by id API (#8430) 2023-11-29 22:45:11 +05:30
683f9d9a28 fix: article meta does not show up on reload (#8415)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-29 16:15:31 +05:30
Martín GómezandGitHub f9bf0883c6 feat: Allow more image formats in avatar uploads form (#8419) 2023-11-28 11:18:16 +05:30
ad8ba299c9 feat: Author sections in category block and category list (#8414)
Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
2023-11-27 15:13:38 +05:30
Muhsin KelothandGitHub 3ae64822e1 chore: Enforce HTTP Basic Authentication for Twilio medias. (#8396) 2023-11-27 13:24:53 +05:30
Sivin VargheseandGitHub a49aaee9cf fix: Email messages in dark mode (#8300) 2023-11-24 12:14:02 +05:30
Sivin VargheseandGitHub 2d1f70eb79 feat: Update public portal colors with new design (#8230) 2023-11-23 08:16:52 +05:30
Sojan JoseandGitHub 4fc5f765de chore: Make super admin auth error readable (#8406)
- Remove the cryptic error for super admin auth and make it more readable
2023-11-22 17:26:08 -08:00
Nithin David ThomasandGitHub 7efc2726d9 fix: Updates the text alignment on articles table (#8405) 2023-11-23 02:58:00 +05:30
Pranav Raj SandGitHub 29a778697c fix: Remove validation for message signature (#8404) 2023-11-22 13:09:17 -08:00
Pranav Raj SandGitHub 90649e72bb fix: Add a .present? check for sentiment file path to avoid false positives (#8401) 2023-11-22 10:35:51 -08:00
Pranav Raj SandGitHub 1904ec7df4 fix: Update campaign routes to fix the rendering issue (#8400) 2023-11-22 09:39:41 -08:00
Nithin David ThomasandGitHub 96add30331 chore: Add analytics event for insert article feature (#8393) 2023-11-22 08:18:58 -08:00
9c7148e2ad feat: Split dashboard to chunks for build performance (#8394)
Co-authored-by: Pranav Raj S <pranav@chatwoot.com>
2023-11-21 14:51:05 -08:00
31c709be5c fix: Opens foreign links from article page in new tab [cw-2725] (#8304)
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
2023-11-22 01:57:58 +05:30
e750ee6d28 chore: [Snyk] Security upgrade administrate-field-active_storage from 0.4.2 to 1.0.0 (#8382)
Co-authored-by: snyk-bot <snyk-bot@snyk.io>
2023-11-20 19:42:54 -08:00
Vishnu NarayananandGitHub 5b3f9ac1cd chore: skip node upgrade if latest on cwctl upgrade (#8336) 2023-11-20 12:48:39 +05:30
Muhsin KelothandGitHub 6c8dacfa0d feat: Facebook delivery reports (#8136) 2023-11-20 12:22:45 +05:30
Sojan feead30b0b Merge branch 'release/3.3.1' into develop 2023-11-18 10:24:35 -08:00
213 changed files with 3672 additions and 1524 deletions
+6 -6
View File
@@ -75,7 +75,7 @@ gem 'jwt'
gem 'pundit'
# super admin
gem 'administrate', '>= 0.19.0'
gem 'administrate-field-active_storage'
gem 'administrate-field-active_storage', '>= 1.0.0'
gem 'administrate-field-belongs_to_search'
##--- gems for pubsub service ---##
@@ -109,14 +109,14 @@ 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.13.0', require: false
gem 'sentry-rails', '>= 5.14.0', require: false
gem 'sentry-ruby', require: false
gem 'sentry-sidekiq', '>= 5.13.0', require: false
gem 'sentry-sidekiq', '>= 5.14.0', require: false
##-- background job processing --##
gem 'sidekiq', '>= 7.1.3'
# We want cron jobs
gem 'sidekiq-cron', '>= 1.11.0'
gem 'sidekiq-cron', '>= 1.12.0'
##-- Push notification service --##
gem 'fcm'
@@ -198,7 +198,7 @@ group :development do
gem 'squasher'
# profiling
gem 'rack-mini-profiler', '>= 3.1.1', require: false
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request'
@@ -224,7 +224,7 @@ group :development, :test do
gem 'byebug', platform: :mri
gem 'climate_control'
gem 'debug', '~> 1.8'
gem 'factory_bot_rails'
gem 'factory_bot_rails', '>= 6.4.2'
gem 'listen'
gem 'mock_redis'
gem 'pry-rails'
+32 -32
View File
@@ -113,7 +113,7 @@ GEM
kaminari (>= 1.0)
sassc-rails (~> 2.1)
selectize-rails (~> 0.6)
administrate-field-active_storage (0.4.2)
administrate-field-active_storage (1.0.0)
administrate (>= 0.2.2)
rails (>= 7.0)
administrate-field-belongs_to_search (0.8.0)
@@ -183,7 +183,7 @@ GEM
activerecord (>= 5.a)
database_cleaner-core (~> 2.0.0)
database_cleaner-core (2.0.1)
date (3.3.3)
date (3.3.4)
ddtrace (1.11.1)
debase-ruby_core_source (>= 0.10.16, <= 3.2.0)
libdatadog (~> 2.0.0.1.0)
@@ -230,10 +230,10 @@ GEM
facebook-messenger (2.0.1)
httparty (~> 0.13, >= 0.13.7)
rack (>= 1.4.5)
factory_bot (6.2.1)
factory_bot (6.4.2)
activesupport (>= 5.0.0)
factory_bot_rails (6.2.0)
factory_bot (~> 6.2.0)
factory_bot_rails (6.4.2)
factory_bot (~> 6.4)
railties (>= 5.0.0)
faker (3.2.0)
i18n (>= 1.8.11, < 2)
@@ -256,7 +256,7 @@ GEM
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
googleauth (~> 1)
ffi (1.15.5)
ffi (1.16.3)
ffi-compiler (1.0.1)
ffi (>= 1.0.0)
rake
@@ -440,7 +440,7 @@ GEM
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.21.4)
loofah (2.22.0)
crass (~> 1.0.2)
nokogiri (>= 1.12.0)
mail (2.8.1)
@@ -472,14 +472,14 @@ GEM
activerecord (>= 5.2)
net-http-persistent (4.0.2)
connection_pool (~> 2.2)
net-imap (0.3.7)
net-imap (0.4.5)
date
net-protocol
net-pop (0.1.2)
net-protocol
net-protocol (0.2.1)
net-protocol (0.2.2)
timeout
net-smtp (0.3.3)
net-smtp (0.4.0)
net-protocol
netrc (0.11.0)
newrelic-sidekiq-metrics (1.6.2)
@@ -487,15 +487,15 @@ GEM
sidekiq
newrelic_rpm (9.6.0)
base64
nio4r (2.5.9)
nokogiri (1.15.4)
nio4r (2.6.0)
nokogiri (1.15.5)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
nokogiri (1.15.4-arm64-darwin)
nokogiri (1.15.5-arm64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-darwin)
nokogiri (1.15.5-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.15.4-x86_64-linux)
nokogiri (1.15.5-x86_64-linux)
racc (~> 1.4)
numo-narray (0.9.2.1)
oauth (1.1.0)
@@ -566,7 +566,7 @@ GEM
rack (< 4)
rack-cors (2.0.1)
rack (>= 2.0.0)
rack-mini-profiler (3.1.1)
rack-mini-profiler (3.2.0)
rack (>= 1.2.0)
rack-protection (3.0.6)
rack
@@ -610,7 +610,7 @@ GEM
ffi (~> 1.0)
redis (5.0.6)
redis-client (>= 0.9.0)
redis-client (0.18.0)
redis-client (0.19.0)
connection_pool
redis-namespace (1.10.0)
redis (>= 4)
@@ -709,13 +709,13 @@ GEM
activesupport (>= 4)
selectize-rails (0.12.6)
semantic_range (3.0.0)
sentry-rails (5.13.0)
sentry-rails (5.14.0)
railties (>= 5.0)
sentry-ruby (~> 5.13.0)
sentry-ruby (5.13.0)
sentry-ruby (~> 5.14.0)
sentry-ruby (5.14.0)
concurrent-ruby (~> 1.0, >= 1.0.2)
sentry-sidekiq (5.13.0)
sentry-ruby (~> 5.13.0)
sentry-sidekiq (5.14.0)
sentry-ruby (~> 5.14.0)
sidekiq (>= 3.0)
sexp_processor (4.17.0)
shoulda-matchers (5.3.0)
@@ -725,7 +725,7 @@ GEM
connection_pool (>= 2.3.0)
rack (>= 2.2.4)
redis-client (>= 0.14.0)
sidekiq-cron (1.11.0)
sidekiq-cron (1.12.0)
fugit (~> 1.8)
globalid (>= 1.0.1)
sidekiq (>= 6)
@@ -752,7 +752,7 @@ GEM
spring-watcher-listen (2.1.0)
listen (>= 2.7, < 4.0)
spring (>= 4)
sprockets (4.2.0)
sprockets (4.2.1)
concurrent-ruby (~> 1.0)
rack (>= 2.2.4, < 4)
sprockets-rails (3.4.2)
@@ -766,11 +766,11 @@ GEM
telephone_number (1.4.20)
test-prof (1.2.1)
thor (1.3.0)
tilt (2.2.0)
tilt (2.3.0)
time_diff (0.3.0)
activesupport
i18n
timeout (0.4.0)
timeout (0.4.1)
trailblazer-option (0.1.2)
twilio-ruby (5.77.0)
faraday (>= 0.9, < 3.0)
@@ -841,7 +841,7 @@ DEPENDENCIES
activerecord-import
acts-as-taggable-on
administrate (>= 0.19.0)
administrate-field-active_storage
administrate-field-active_storage (>= 1.0.0)
administrate-field-belongs_to_search
annotate
attr_extras
@@ -870,7 +870,7 @@ DEPENDENCIES
elastic-apm
email_reply_trimmer
facebook-messenger
factory_bot_rails
factory_bot_rails (>= 6.4.2)
faker
fcm
flag_shih_tzu
@@ -918,7 +918,7 @@ DEPENDENCIES
pundit
rack-attack (>= 6.7.0)
rack-cors
rack-mini-profiler (>= 3.1.1)
rack-mini-profiler (>= 3.2.0)
rack-timeout
rails (~> 7.0.8.0)
redis
@@ -935,12 +935,12 @@ DEPENDENCIES
scout_apm
scss_lint
seed_dump
sentry-rails (>= 5.13.0)
sentry-rails (>= 5.14.0)
sentry-ruby
sentry-sidekiq (>= 5.13.0)
sentry-sidekiq (>= 5.14.0)
shoulda-matchers
sidekiq (>= 7.1.3)
sidekiq-cron (>= 1.11.0)
sidekiq-cron (>= 1.12.0)
simplecov (= 0.17.1)
slack-ruby-client (~> 2.2.0)
spring
+1 -1
View File
@@ -1 +1 @@
3.1.0
3.3.1
+1 -1
View File
@@ -1 +1 @@
2.6.0
2.7.0
@@ -25,7 +25,6 @@
@import 'components/flashes';
@import 'components/form-actions';
@import 'components/main-content';
@import 'components/navigation';
@import 'components/pagination';
@import 'components/search';
@import 'components/reports';
@@ -1,7 +1,7 @@
html {
background-color: $color-white;
box-sizing: border-box;
font-size: 10px;
font-size: 16px;
-webkit-font-smoothing: antialiased;
}
@@ -16,8 +16,8 @@
.attribute-data {
float: left;
margin-bottom: $base-spacing;
margin-left: 2rem;
width: calc(84% - 1rem);
margin-left: 1.25rem;
width: calc(84% - 0.625rem);
}
.attribute--nested {
@@ -9,22 +9,22 @@
.field-unit__label {
float: left;
margin-left: 1rem;
margin-left: 0.625rem;
text-align: right;
width: calc(15% - 1rem);
width: calc(15% - 0.625rem);
}
.field-unit__field {
float: left;
margin-left: 2rem;
max-width: 50rem;
margin-left: 1.25rem;
max-width: 31.15rem;
width: 100%;
}
.field-unit--nested {
border: $base-border;
margin-left: 7.5%;
max-width: 60rem;
max-width: 37.5rem;
padding: $small-spacing;
width: 100%;
@@ -1,3 +1,3 @@
.form-actions {
margin-left: calc(15% + 2rem);
margin-left: calc(15% + 1.25rem);
}
@@ -13,6 +13,10 @@
table {
font-size: $font-size-small;
}
form {
margin-top: $space-two;
}
}
.main-content__header {
@@ -20,7 +24,7 @@
background-color: $color-white;
border-bottom: 1px solid $color-border;
display: flex;
min-height: 5.6rem;
min-height: 3.5rem;
padding: $space-small $space-normal;
}
@@ -1,88 +0,0 @@
.logo-brand {
margin-bottom: $space-normal;
padding: $space-normal $space-smaller $space-small;
text-align: left;
img {
margin-bottom: $space-smaller;
max-height: 3rem;
}
}
.navigation {
background: $white;
border-right: 1px solid $color-border;
display: flex;
flex-direction: column;
font-size: $font-size-default;
font-weight: $font-weight-medium;
height: 100%;
justify-content: flex-start;
left: 0;
margin: 0;
overflow: auto;
padding: $space-normal;
position: fixed;
top: 0;
width: 21rem;
z-index: 1023;
li {
align-items: center;
display: flex;
font-size: $font-size-small;
a {
color: $color-gray;
text-decoration: none;
}
i {
min-width: $space-medium;
}
}
hr {
margin: $space-slab;
}
}
.navigation__link {
background-color: transparent;
color: $color-gray;
display: block;
line-height: 1;
margin-bottom: $space-smaller;
padding: $space-small;
&:hover {
color: $blue;
a {
color: $blue;
}
}
&.navigation__link--active {
background-color: $color-background;
border-radius: $base-border-radius;
color: $blue;
a {
color: $blue;
}
}
}
.logout {
bottom: $space-normal;
left: $space-normal;
position: fixed;
}
.app-version {
color: $color-gray;
font-size: $font-size-small;
padding-top: $space-smaller;
}
@@ -1,7 +1,7 @@
.search {
margin-left: auto;
margin-right: 2rem;
max-width: 44rem;
margin-right: 1.25rem;
max-width: 27.5rem;
position: relative;
width: 100%;
}
@@ -1,10 +1,10 @@
// Typography
$base-font-family: PlusJakarta, Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
$base-font-family: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto",
"Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
sans-serif !default;
$heading-font-family: $base-font-family !default;
$base-font-size: 14px !default;
$base-font-size: 16px !default;
$base-line-height: 1.5 !default;
$heading-line-height: 1.2 !default;
@@ -1,30 +1,30 @@
// Font sizes
$font-size-nano: 0.8rem;
$font-size-micro: 1.0rem;
$font-size-mini: 1.2rem;
$font-size-small: 1.4rem;
$font-size-default: 1.6rem;
$font-size-medium: 1.8rem;
$font-size-large: 2.2rem;
$font-size-big: 2.4rem;
$font-size-bigger: 3.0rem;
$font-size-mega: 3.4rem;
$font-size-giga: 4.0rem;
$font-size-nano: 0.5rem;
$font-size-micro: 0.675rem;
$font-size-mini: 0.75rem;
$font-size-small: 0.875rem;
$font-size-default: 1rem;
$font-size-medium: 1.125rem;
$font-size-large: 1.375rem;
$font-size-big: 1.5rem;
$font-size-bigger: 1.75rem;
$font-size-mega: 2.125rem;
$font-size-giga: 2.5rem;
// spaces
$zero: 0;
$space-micro: 0.2rem;
$space-smaller: 0.4rem;
$space-small: 0.8rem;
$space-one: 1rem;
$space-slab: 1.2rem;
$space-normal: 1.6rem;
$space-two: 2.0rem;
$space-medium: 2.4rem;
$space-large: 3.2rem;
$space-larger: 4.8rem;
$space-jumbo: 6.4rem;
$space-mega: 10.0rem;
$space-micro: 0.125rem;
$space-smaller: 0.25rem;
$space-small: 0.5rem;
$space-one: 0.675rem;
$space-slab: 0.75rem;
$space-normal: 1rem;
$space-two: 1.25rem;
$space-medium: 1.5rem;
$space-large: 2rem;
$space-larger: 3rem;
$space-jumbo: 4rem;
$space-mega: 6.25rem;
// font-weight
$font-weight-feather: 100;
@@ -25,7 +25,9 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
build_contact_inbox
build_message
end
rescue Koala::Facebook::AuthenticationError
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
rescue StandardError => e
ChatwootExceptionTracker.new(e, account: @inbox.account).capture_exception
@@ -108,11 +110,15 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
}
end
# rubocop:disable Metrics/AbcSize
# rubocop:disable Metrics/MethodLength
def contact_params
begin
k = Koala::Facebook::API.new(@inbox.channel.page_access_token) if @inbox.facebook?
result = k.get_object(@sender_id) || {}
rescue Koala::Facebook::AuthenticationError
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Facebook authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue Koala::Facebook::ClientError => e
@@ -130,4 +136,6 @@ class Messages::Facebook::MessageBuilder < Messages::Messenger::MessageBuilder
end
process_contact_params_result(result)
end
# rubocop:enable Metrics/AbcSize
# rubocop:enable Metrics/MethodLength
end
@@ -20,7 +20,9 @@ class Messages::Instagram::MessageBuilder < Messages::Messenger::MessageBuilder
ActiveRecord::Base.transaction do
build_message
end
rescue Koala::Facebook::AuthenticationError
rescue Koala::Facebook::AuthenticationError => e
Rails.logger.warn("Instagram authentication error for inbox: #{@inbox.id} with error: #{e.message}")
Rails.logger.error e
@inbox.channel.authorization_error!
raise
rescue StandardError => e
+2 -1
View File
@@ -149,7 +149,8 @@ class Messages::MessageBuilder
content_type: @params[:content_type],
items: @items,
in_reply_to: @in_reply_to,
echo_id: @params[:echo_id]
echo_id: @params[:echo_id],
source_id: @params[:source_id]
}.merge(external_created_at).merge(automation_rule_id).merge(campaign_id).merge(template_params)
end
end
+4 -3
View File
@@ -1,5 +1,5 @@
class NotificationBuilder
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!]
pattr_initialize [:notification_type!, :user!, :account!, :primary_actor!, :secondary_actor]
def perform
return unless user_subscribed_to_notification?
@@ -9,7 +9,7 @@ class NotificationBuilder
private
def secondary_actor
def current_user
Current.user
end
@@ -29,7 +29,8 @@ class NotificationBuilder
notification_type: notification_type,
account: account,
primary_actor: primary_actor,
secondary_actor: secondary_actor
# secondary_actor is secondary_actor if present, else current_user
secondary_actor: secondary_actor || current_user
)
end
end
@@ -10,11 +10,18 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
def show; end
def create
@agent_bot = Current.account.agent_bots.create!(permitted_params)
@agent_bot = Current.account.agent_bots.create!(permitted_params.except(:avatar_url))
process_avatar_from_url
end
def update
@agent_bot.update!(permitted_params)
@agent_bot.update!(permitted_params.except(:avatar_url))
process_avatar_from_url
end
def avatar
@agent_bot.avatar.purge if @agent_bot.avatar.attached?
@agent_bot
end
def destroy
@@ -30,6 +37,10 @@ class Api::V1::Accounts::AgentBotsController < Api::V1::Accounts::BaseController
end
def permitted_params
params.permit(:name, :description, :outgoing_url, :bot_type, bot_config: [:csml_content])
params.permit(:name, :description, :outgoing_url, :avatar, :avatar_url, :bot_type, bot_config: [:csml_content])
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@agent_bot, params[:avatar_url]) if params[:avatar_url].present?
end
end
@@ -83,14 +83,14 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.new(permitted_params.except(:avatar_url))
@contact.save!
@contact_inbox = build_contact_inbox
process_avatar
process_avatar_from_url
end
end
def update
@contact.assign_attributes(contact_update_params)
@contact.save!
process_avatar if permitted_params[:avatar].present? || permitted_params[:avatar_url].present?
process_avatar_from_url
end
def destroy
@@ -174,7 +174,7 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
@contact = Current.account.contacts.includes(contact_inboxes: [:inbox]).find(params[:id])
end
def process_avatar
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@contact, params[:avatar_url]) if params[:avatar_url].present?
end
@@ -14,7 +14,8 @@ class Api::V1::Accounts::Conversations::AssignmentsController < Api::V1::Account
def set_agent
@agent = Current.account.users.find_by(id: params[:assignee_id])
@conversation.update_assignee(@agent)
@conversation.assignee = @agent
@conversation.save!
render_agent
end
@@ -18,6 +18,15 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
end
end
def retry
return if message.blank?
message.update!(status: :sent, content_attributes: {})
::SendReplyJob.perform_later(message.id)
rescue StandardError => e
render_could_not_create_error(e.message)
end
def translate
return head :ok if already_translated_content_available?
@@ -110,8 +110,8 @@ class Api::V1::Accounts::ConversationsController < Api::V1::Accounts::BaseContro
end
def assign_conversation
@agent = Current.account.users.find(current_user.id)
@conversation.update_assignee(@agent)
@conversation.assignee = current_user
@conversation.save!
end
def conversation
@@ -1,7 +1,8 @@
class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseController
RESULTS_PER_PAGE = 15
include DateRangeHelper
before_action :fetch_notification, only: [:update]
before_action :fetch_notification, only: [:update, :destroy, :snooze]
before_action :set_primary_actor, only: [:read_all]
before_action :set_current_page, only: [:index]
@@ -28,11 +29,21 @@ class Api::V1::Accounts::NotificationsController < Api::V1::Accounts::BaseContro
render json: @notification
end
def destroy
@notification.destroy
head :ok
end
def unread_count
@unread_count = current_user.notifications.where(account_id: current_account.id, read_at: nil).count
render json: @unread_count
end
def snooze
@notification.update(snoozed_until: parse_date_time(params[:snoozed_until].to_s)) if params[:snoozed_until]
render json: @notification
end
private
def set_primary_actor
@@ -9,13 +9,15 @@ class Platform::Api::V1::AgentBotsController < PlatformController
def show; end
def create
@resource = AgentBot.new(agent_bot_params)
@resource = AgentBot.new(agent_bot_params.except(:avatar_url))
@resource.save!
process_avatar_from_url
@platform_app.platform_app_permissibles.find_or_create_by(permissible: @resource)
end
def update
@resource.update!(agent_bot_params)
@resource.update!(agent_bot_params.except(:avatar_url))
process_avatar_from_url
end
def destroy
@@ -23,6 +25,11 @@ class Platform::Api::V1::AgentBotsController < PlatformController
head :ok
end
def avatar
@resource.avatar.purge if @resource.avatar.attached?
@resource
end
private
def set_resource
@@ -30,6 +37,10 @@ class Platform::Api::V1::AgentBotsController < PlatformController
end
def agent_bot_params
params.permit(:name, :description, :account_id, :outgoing_url)
params.permit(:name, :description, :account_id, :outgoing_url, :avatar, :avatar_url)
end
def process_avatar_from_url
::Avatar::AvatarFromUrlJob.perform_later(@resource, params[:avatar_url]) if params[:avatar_url].present?
end
end
@@ -11,11 +11,7 @@ class Public::Api::V1::Portals::BaseController < PublicController
end
def set_color_scheme
@theme = if %w[dark light].include?(params[:theme])
params[:theme]
else
'system'
end
@theme_from_params = params[:theme] if %w[dark light].include?(params[:theme])
end
def portal
@@ -1,21 +1,38 @@
class SuperAdmin::AppConfigsController < SuperAdmin::ApplicationController
before_action :set_config
before_action :allowed_configs
def show
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
# ref: https://github.com/rubocop/rubocop/issues/7767
# rubocop:disable Style/HashTransformValues
@fb_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
@app_config = InstallationConfig.where(name: @allowed_configs)
.pluck(:name, :serialized_value)
.map { |name, serialized_value| [name, serialized_value['value']] }
.to_h
# rubocop:enable Style/HashTransformValues
end
def create
params['app_config'].each do |key, value|
next unless @allowed_configs.include?(key)
i = InstallationConfig.where(name: key).first_or_create(value: value, locked: false)
i.value = value
i.save!
end
redirect_to super_admin_app_config_url
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'App Configs updated successfully'
# rubocop:enable Rails/I18nLocaleTexts
end
private
def set_config
@config = params[:config]
end
def allowed_configs
@allowed_configs = %w[FB_APP_ID FB_VERIFY_TOKEN FB_APP_SECRET]
end
end
SuperAdmin::AppConfigsController.prepend_mod_with('SuperAdmin::AppConfigsController')
@@ -20,4 +20,13 @@ class SuperAdmin::ApplicationController < Administrate::ApplicationController
params.fetch(resource_name, {}).fetch(:direction, 'desc')
)
end
private
def invalid_action_perfomed
# rubocop:disable Rails/I18nLocaleTexts
flash[:error] = 'Invalid action performed'
# rubocop:enable Rails/I18nLocaleTexts
redirect_back(fallback_location: root_path)
end
end
@@ -27,7 +27,8 @@ class SuperAdmin::Devise::SessionsController < Devise::SessionsController
true
rescue StandardError => e
@error_message = e.message
Rails.logger.error e.message
@error_message = 'Invalid credentials. Please try again.'
false
end
end
@@ -1,4 +1,5 @@
class SuperAdmin::InstallationConfigsController < SuperAdmin::ApplicationController
rescue_from ActiveRecord::RecordNotUnique, :with => :invalid_action_perfomed
# Overwrite any of the RESTful controller actions to implement custom behavior
# For example, you may want to send an email after a foo is updated.
#
@@ -0,0 +1,10 @@
class SuperAdmin::SettingsController < SuperAdmin::ApplicationController
def show; end
def refresh
Internal::CheckNewVersionsJob.perform_now
# rubocop:disable Rails/I18nLocaleTexts
redirect_to super_admin_settings_path, notice: 'Instance status refreshed'
# rubocop:enable Rails/I18nLocaleTexts
end
end
-4
View File
@@ -30,11 +30,7 @@ class AccessTokenDashboard < Administrate::BaseDashboard
# SHOW_PAGE_ATTRIBUTES
# an array of attributes that will be displayed on the model's show page.
SHOW_PAGE_ATTRIBUTES = %i[
owner
id
token
created_at
updated_at
].freeze
# FORM_ATTRIBUTES
+1
View File
@@ -46,6 +46,7 @@ class AgentBotDashboard < Administrate::BaseDashboard
name
description
outgoing_url
access_token
].freeze
# FORM_ATTRIBUTES
+1
View File
@@ -32,6 +32,7 @@ class PlatformAppDashboard < Administrate::BaseDashboard
name
created_at
updated_at
access_token
].freeze
# FORM_ATTRIBUTES
+3 -1
View File
@@ -36,7 +36,8 @@ class UserDashboard < Administrate::BaseDashboard
updated_at: Field::DateTime,
pubsub_token: Field::String,
type: Field::Select.with_options(collection: [nil, 'SuperAdmin']),
accounts: CountField
accounts: CountField,
access_token: Field::HasOne
}.freeze
# COLLECTION_ATTRIBUTES
@@ -67,6 +68,7 @@ class UserDashboard < Administrate::BaseDashboard
updated_at
confirmed_at
account_users
access_token
].freeze
# FORM_ATTRIBUTES
+5
View File
@@ -18,4 +18,9 @@ class ContactDrop < BaseDrop
def last_name
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
end
+5
View File
@@ -19,6 +19,11 @@ class ConversationDrop < BaseDrop
end
end
def custom_attribute
custom_attributes = @obj.try(:custom_attributes) || {}
custom_attributes.transform_keys(&:to_s)
end
private
def message_sender_name(sender)
+17 -8
View File
@@ -3,13 +3,21 @@ class ConversationFinder
DEFAULT_STATUS = 'open'.freeze
SORT_OPTIONS = {
latest: 'latest',
sort_on_created_at: 'sort_on_created_at',
last_user_message_at: 'last_user_message_at',
sort_on_priority: 'sort_on_priority',
sort_on_waiting_since: 'sort_on_waiting_since'
}.with_indifferent_access
'last_activity_at_asc' => %w[sort_on_last_activity_at asc],
'last_activity_at_desc' => %w[sort_on_last_activity_at desc],
'created_at_asc' => %w[sort_on_created_at asc],
'created_at_desc' => %w[sort_on_created_at desc],
'priority_asc' => %w[sort_on_priority asc],
'priority_desc' => %w[sort_on_priority desc],
'waiting_since_asc' => %w[sort_on_waiting_since asc],
'waiting_since_desc' => %w[sort_on_waiting_since desc],
# To be removed in v3.5.0
'latest' => %w[sort_on_last_activity_at desc],
'sort_on_created_at' => %w[sort_on_created_at asc],
'sort_on_priority' => %w[sort_on_priority desc],
'sort_on_waiting_since' => %w[sort_on_waiting_since asc]
}.with_indifferent_access
# assumptions
# inbox_id if not given, take from all conversations, else specific to inbox
# assignee_type if not given, take 'all'
@@ -159,7 +167,8 @@ class ConversationFinder
@conversations = @conversations.includes(
:taggings, :inbox, { assignee: { avatar_attachment: [:blob] } }, { contact: { avatar_attachment: [:blob] } }, :team, :contact_inbox
)
sort_by = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['latest']
@conversations.send(sort_by).page(current_page)
sort_by, sort_order = SORT_OPTIONS[params[:sort_by]] || SORT_OPTIONS['last_activity_at_desc']
@conversations.send(sort_by, sort_order).page(current_page).per(ENV.fetch('CONVERSATION_RESULTS_PER_PAGE', '25').to_i)
end
end
+42 -19
View File
@@ -6,7 +6,17 @@ module PortalHelper
def generate_portal_bg(portal_color, theme)
bg_image = theme == 'dark' ? 'hexagon-dark.svg' : 'hexagon-light.svg'
"background: url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
"url(/assets/images/hc/#{bg_image}) #{generate_portal_bg_color(portal_color, theme)}"
end
def generate_gradient_to_bottom(theme)
base_color = theme == 'dark' ? '#151718' : 'white'
"linear-gradient(to bottom, transparent, #{base_color})"
end
def generate_portal_hover_color(portal_color, theme)
base_color = theme == 'dark' ? '#1B1B1B' : '#F9F9F9'
"color-mix(in srgb, #{portal_color} 5%, #{base_color})"
end
def language_name(locale)
@@ -14,35 +24,48 @@ module PortalHelper
language_map[locale] || locale
end
def get_theme_names(theme)
if theme == 'light'
I18n.t('public_portal.header.appearance.light')
elsif theme == 'dark'
I18n.t('public_portal.header.appearance.dark')
def theme_query_string(theme)
theme.present? && theme != 'system' ? "?theme=#{theme}" : ''
end
def generate_home_link(portal_slug, portal_locale, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{portal_locale}#{theme_query_string(theme)}"
else
I18n.t('public_portal.header.appearance.system')
"/hc/#{portal_slug}/#{portal_locale}"
end
end
def get_theme_icon(theme)
if theme == 'light'
'icons/sun'
elsif theme == 'dark'
'icons/moon'
def generate_category_link(params)
portal_slug = params[:portal_slug]
category_locale = params[:category_locale]
category_slug = params[:category_slug]
theme = params[:theme]
is_plain_layout_enabled = params[:is_plain_layout_enabled]
if is_plain_layout_enabled
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}#{theme_query_string(theme)}"
else
'icons/monitor'
"/hc/#{portal_slug}/#{category_locale}/categories/#{category_slug}"
end
end
def generate_gradient_to_bottom(theme)
"background-image: linear-gradient(to bottom, transparent, #{theme == 'dark' ? '#151718' : 'white'})"
end
def generate_article_link(portal_slug, article_slug, theme)
"/hc/#{portal_slug}/articles/#{article_slug}#{theme.present? && theme != 'system' ? "?theme=#{theme}" : ''}"
def generate_article_link(portal_slug, article_slug, theme, is_plain_layout_enabled)
if is_plain_layout_enabled
"/hc/#{portal_slug}/articles/#{article_slug}#{theme_query_string(theme)}"
else
"/hc/#{portal_slug}/articles/#{article_slug}"
end
end
def render_category_content(content)
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
end
def thumbnail_bg_color(username)
colors = ['#6D95BA', '#A4C3C3', '#E19191']
return colors.sample if username.blank?
colors[username.length % colors.size]
end
end
@@ -86,6 +86,12 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
retry(conversationID, messageId) {
return axios.post(
`${this.url}/${conversationID}/messages/${messageId}/retry`
);
}
getPreviousMessages({ conversationId, after, before }) {
const params = { before };
if (after && Number(after) !== Number(before)) {
Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 19 KiB

@@ -1,39 +1,8 @@
@import '../variables';
@import 'shared/assets/fonts/inter';
@import 'tailwindcss/base';
@import 'tailwindcss/components';
@import 'tailwindcss/utilities';
.superadmin-body {
background: var(--color-background);
.hero--title {
font-size: var(--font-size-mega);
font-weight: var(--font-weight-light);
margin-top: var(--space-large);
}
.update-subscription--checkbox {
display: flex;
input {
line-height: 1.5;
margin-right: var(--space-one);
margin-top: var(--space-smaller);
}
label {
font-size: var(--font-size-small);
line-height: 1.5;
margin-bottom: var(--space-normal);
}
}
body {
font-family: Inter, -apple-system, system-ui, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif !important;
}
.alert-box {
background-color: var(--r-500);
border-radius: 5px;
color: var(--color-white);
font-size: 14px;
margin-bottom: 14px;
padding: 10px;
text-align: center;
}
@@ -1,3 +0,0 @@
@import 'shared/assets/fonts/plus-jakarta';
@import '../variables';
@import '~shared/assets/stylesheets/ionicons';
@@ -126,49 +126,33 @@
@assign-team="onAssignTeamsForBulk"
/>
<div
ref="activeConversation"
ref="conversationList"
class="conversations-list flex-1"
:class="{ 'overflow-hidden': isContextMenuOpen }"
>
<div>
<conversation-card
v-for="chat in conversationList"
:key="chat.id"
:active-label="label"
:team-id="teamId"
:folders-id="foldersId"
:chat="chat"
:conversation-type="conversationType"
:show-assignee="showAssigneeInConversationCard"
:selected="isConversationSelected(chat.id)"
@select-conversation="selectConversation"
@de-select-conversation="deSelectConversation"
@assign-agent="onAssignAgent"
@assign-team="onAssignTeam"
@assign-label="onAssignLabels"
@update-conversation-status="toggleConversationStatus"
@context-menu-toggle="onContextMenuToggle"
@mark-as-unread="markAsUnread"
@assign-priority="assignPriority"
/>
</div>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<woot-button
v-if="!hasCurrentPageEndReached && !chatListLoading"
variant="clear"
size="expanded"
class="load-more--button"
@click="loadMoreConversations"
<virtual-list
ref="conversationVirtualList"
:data-key="'id'"
:data-sources="conversationList"
:data-component="itemComponent"
:extra-props="virtualListExtraProps"
class="w-full overflow-auto h-full"
footer-tag="div"
>
{{ $t('CHAT_LIST.LOAD_MORE_CONVERSATIONS') }}
</woot-button>
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
{{ $t('CHAT_LIST.EOF') }}
</p>
<template #footer>
<div v-if="chatListLoading" class="text-center">
<span class="spinner mt-4 mb-4" />
</div>
<p v-if="showEndOfListMessage" class="text-center text-muted p-4">
{{ $t('CHAT_LIST.EOF') }}
</p>
<intersection-observer
v-if="!showEndOfListMessage && !chatListLoading"
:options="infiniteLoaderOptions"
@observed="loadMoreConversations"
/>
</template>
</virtual-list>
</div>
<woot-modal
:show.sync="showAdvancedFilters"
@@ -191,11 +175,12 @@
<script>
import { mapGetters } from 'vuex';
import VirtualList from 'vue-virtual-scroll-list';
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
import ConversationBasicFilter from './widgets/conversation/ConversationBasicFilter.vue';
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
import ConversationCard from './widgets/conversation/ConversationCard.vue';
import ConversationItem from './ConversationItem.vue';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
@@ -222,16 +207,20 @@ import {
isOnUnattendedView,
} from '../store/modules/conversations/helpers/actionHelpers';
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
import IntersectionObserver from './IntersectionObserver.vue';
export default {
components: {
AddCustomViews,
ChatTypeTabs,
ConversationCard,
// eslint-disable-next-line vue/no-unused-components
ConversationItem,
ConversationAdvancedFilter,
DeleteCustomViews,
ConversationBulkActions,
ConversationBasicFilter,
IntersectionObserver,
VirtualList,
},
mixins: [
timeMixin,
@@ -241,6 +230,20 @@ export default {
filterMixin,
uiSettingsMixin,
],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
selectConversation: this.selectConversation,
deSelectConversation: this.deSelectConversation,
assignAgent: this.onAssignAgent,
assignTeam: this.onAssignTeam,
assignLabels: this.onAssignLabels,
updateConversationStatus: this.toggleConversationStatus,
toggleContextMenu: this.onContextMenuToggle,
markAsUnread: this.markAsUnread,
assignPriority: this.assignPriority,
};
},
props: {
conversationInbox: {
type: [String, Number],
@@ -275,7 +278,7 @@ export default {
return {
activeAssigneeTab: wootConstants.ASSIGNEE_TYPE.ME,
activeStatus: wootConstants.STATUS_TYPE.OPEN,
activeSortBy: wootConstants.SORT_BY_TYPE.LATEST,
activeSortBy: wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC,
showAdvancedFilters: false,
advancedFilterTypes: advancedFilterTypes.map(filter => ({
...filter,
@@ -291,6 +294,21 @@ export default {
selectedInboxes: [],
isContextMenuOpen: false,
appliedFilter: [],
infiniteLoaderOptions: {
root: this.$refs.conversationList,
rootMargin: '100px 0px 100px 0px',
},
itemComponent: ConversationItem,
// virtualListExtraProps is to pass the props to the conversationItem component.
virtualListExtraProps: {
label: this.label,
teamId: this.teamId,
foldersId: this.foldersId,
conversationType: this.conversationType,
showAssignee: false,
isConversationSelected: this.isConversationSelected,
},
};
},
computed: {
@@ -509,16 +527,22 @@ export default {
},
label() {
this.resetAndFetchData();
this.updateVirtualListProps('label', this.label);
},
conversationType() {
this.resetAndFetchData();
this.updateVirtualListProps('conversationType', this.conversationType);
},
activeFolder() {
this.resetAndFetchData();
this.updateVirtualListProps('foldersId', this.foldersId);
},
chatLists() {
this.chatsOnView = this.conversationList;
},
showAssigneeInConversationCard(newVal) {
this.updateVirtualListProps('showAssignee', newVal);
},
},
mounted() {
this.setFiltersFromUISettings();
@@ -535,6 +559,12 @@ export default {
});
},
methods: {
updateVirtualListProps(key, value) {
this.virtualListExtraProps = {
...this.virtualListExtraProps,
[key]: value,
};
},
onApplyFilter(payload) {
this.resetBulkActions();
this.foldersQuery = filterQueryGenerator(payload);
@@ -555,7 +585,10 @@ export default {
const { conversations_filter_by: filterBy = {} } = this.uiSettings;
const { status, order_by: orderBy } = filterBy;
this.activeStatus = status || wootConstants.STATUS_TYPE.OPEN;
this.activeSortBy = orderBy || wootConstants.SORT_BY_TYPE.LATEST;
this.activeSortBy =
Object.keys(wootConstants.SORT_BY_TYPE).find(
sortField => sortField === orderBy
) || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC;
},
onClickOpenAddFoldersModal() {
this.showAddFoldersModal = true;
@@ -635,10 +668,10 @@ export default {
);
},
getKeyboardListenerParams() {
const allConversations = this.$refs.activeConversation.querySelectorAll(
const allConversations = this.$refs.conversationList.querySelectorAll(
'div.conversations-list div.conversation'
);
const activeConversation = this.$refs.activeConversation.querySelector(
const activeConversation = this.$refs.conversationList.querySelector(
'div.conversations-list div.conversation.active'
);
const activeConversationIndex = [...allConversations].indexOf(
@@ -694,9 +727,12 @@ export default {
fetchConversations() {
this.$store
.dispatch('fetchAllConversations', this.conversationFilters)
.then(() => this.$emit('conversation-load'));
.then(this.emitConversationLoaded);
},
loadMoreConversations() {
if (this.hasCurrentPageEndReached || this.chatListLoading) {
return;
}
if (!this.hasAppliedFiltersOrActiveFolders) {
this.fetchConversations();
}
@@ -715,7 +751,7 @@ export default {
queryData: filterQueryGenerator(payload),
page,
})
.then(() => this.$emit('conversation-load'));
.then(this.emitConversationLoaded);
this.showAdvancedFilters = false;
},
fetchSavedFilteredConversations(payload) {
@@ -725,7 +761,7 @@ export default {
queryData: payload,
page,
})
.then(() => this.$emit('conversation-load'));
.then(this.emitConversationLoaded);
},
updateAssigneeTab(selectedTab) {
if (this.activeAssigneeTab !== selectedTab) {
@@ -737,6 +773,20 @@ export default {
}
}
},
emitConversationLoaded() {
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
// to the scroll position, triggering the list to refresh its rendering.
const virtualList = this.$refs.conversationVirtualList;
const scrollToOffset = virtualList?.scrollToOffset;
const currentOffset = virtualList?.getOffset() || 0;
if (scrollToOffset) {
scrollToOffset(currentOffset + 1);
}
});
},
resetBulkActions() {
this.selectedConversations = [];
this.selectedInboxes = [];
@@ -0,0 +1,72 @@
<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 {
components: {
ConversationCard,
},
inject: [
'selectConversation',
'deSelectConversation',
'assignAgent',
'assignTeam',
'assignLabels',
'updateConversationStatus',
'toggleContextMenu',
'markAsUnread',
'assignPriority',
],
props: {
source: {
type: Object,
required: true,
},
teamId: {
type: [String, Number],
default: 0,
},
label: {
type: String,
default: '',
},
conversationType: {
type: String,
default: '',
},
foldersId: {
type: [String, Number],
default: 0,
},
isConversationSelected: {
type: Function,
default: () => {},
},
showAssignee: {
type: Boolean,
default: false,
},
},
};
</script>
@@ -0,0 +1,34 @@
<template>
<div ref="observedElement" class="h-6 w-full" />
</template>
<script>
export default {
props: {
options: {
type: Object,
default: () => ({ root: document, rootMargin: '100px 0 100px 0)' }),
},
},
mounted() {
this.intersectionObserver = null;
this.registerInfiniteLoader();
},
beforeDestroy() {
this.unobserveInfiniteLoadObserver();
},
methods: {
registerInfiniteLoader() {
this.intersectionObserver = new IntersectionObserver(entries => {
if (entries && entries[0].isIntersecting) {
this.$emit('observed');
}
}, this.options);
this.intersectionObserver.observe(this.$refs.observedElement);
},
unobserveInfiniteLoadObserver() {
this.intersectionObserver.unobserve(this.$refs.observedElement);
},
},
};
</script>
@@ -144,6 +144,7 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import {
ALLOWED_FILE_TYPES,
ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP,
ALLOWED_FILE_TYPES_FOR_LINE,
} from 'shared/constants/messages';
import VideoCallButton from '../VideoCallButton.vue';
import AIAssistanceButton from '../AIAssistanceButton.vue';
@@ -270,6 +271,9 @@ export default {
return this.showFileUpload || this.isNote;
},
showAudioRecorderButton() {
if (this.isALineChannel) {
return false;
}
// Disable audio recorder for safari browser as recording is not supported
const isSafari = /^((?!chrome|android|crios|fxios).)*safari/i.test(
navigator.userAgent
@@ -291,6 +295,9 @@ export default {
if (this.isATwilioWhatsAppChannel) {
return ALLOWED_FILE_TYPES_FOR_TWILIO_WHATSAPP;
}
if (this.isALineChannel) {
return ALLOWED_FILE_TYPES_FOR_LINE;
}
return ALLOWED_FILE_TYPES;
},
enableDragAndDrop() {
@@ -34,7 +34,7 @@
type="sort"
:selected-value="sortFilter"
:items="chatSortItems"
path-prefix="CHAT_LIST.CHAT_SORT_FILTER_ITEMS"
path-prefix="CHAT_LIST.SORT_ORDER_ITEMS"
@onChangeFilter="onChangeFilter"
/>
</div>
@@ -58,7 +58,7 @@ export default {
return {
showActionsDropdown: false,
chatStatusItems: this.$t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.CHAT_SORT_FILTER_ITEMS'),
chatSortItems: this.$t('CHAT_LIST.SORT_ORDER_ITEMS'),
};
},
computed: {
@@ -70,7 +70,9 @@ export default {
return this.chatStatusFilter || wootConstants.STATUS_TYPE.OPEN;
},
sortFilter() {
return this.chatSortFilter || wootConstants.SORT_BY_TYPE.LATEST;
return (
this.chatSortFilter || wootConstants.SORT_BY_TYPE.LAST_ACTIVITY_AT_DESC
);
},
},
methods: {
@@ -31,7 +31,7 @@
size="40px"
/>
<div
class="px-0 py-3 border-b group-last:border-transparent group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
class="px-0 py-3 border-b group-hover:border-transparent border-slate-50 dark:border-slate-800/75 columns"
>
<div class="flex justify-between">
<inbox-name v-if="showInboxName" :inbox="inbox" />
@@ -175,6 +175,10 @@ export default {
type: Boolean,
default: false,
},
enableContextMenu: {
type: Boolean,
default: false,
},
},
data() {
return {
@@ -289,6 +293,7 @@ export default {
this.$emit(action, this.chat.id, this.inbox.id);
},
openContextMenu(e) {
if (!this.enableContextMenu) return;
e.preventDefault();
this.$emit('context-menu-toggle', true);
this.contextMenu.x = e.pageX || e.clientX;
@@ -1,7 +1,7 @@
<template>
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass">
<div v-if="isFailed" class="message-failed--alert">
<div v-if="isFailed && !hasOneDayPassed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
@@ -148,6 +148,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { getDayDifferenceFromNow } from 'shared/helpers/DateHelper';
export default {
components: {
@@ -209,6 +210,10 @@ export default {
created_at: this.data.created_at || '',
}));
},
hasOneDayPassed() {
// Disable retry button if the message is failed and the message is older than 24 hours
return getDayDifferenceFromNow(new Date(), this.data?.created_at) >= 1;
},
shouldRenderMessage() {
return (
this.hasAttachments ||
@@ -20,7 +20,15 @@
icon="info"
/>
</template>
<span v-if="message.content">
<span v-if="message.content && isMessageSticker">
<fluent-icon
size="16"
class="-mt-0.5 align-middle inline-block text-slate-600 dark:text-slate-300"
icon="image"
/>
{{ $t('CHAT_LIST.ATTACHMENTS.image.CONTENT') }}
</span>
<span v-else-if="message.content">
{{ parsedLastMessage }}
</span>
<span v-else-if="message.attachments">
@@ -88,6 +96,9 @@ export default {
attachmentMessageContent() {
return `CHAT_LIST.ATTACHMENTS.${this.lastMessageFileType}.CONTENT`;
},
isMessageSticker() {
return this.message && this.message.content_type === 'sticker';
},
},
};
</script>
@@ -284,10 +284,12 @@ export default {
return this.currentChat.unread_count || 0;
},
inboxSupportsReplyTo() {
return {
incoming: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO),
outgoing: this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING),
};
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
const outgoing =
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
!this.is360DialogWhatsAppChannel;
return { incoming, outgoing };
},
},
@@ -275,7 +275,8 @@ export default {
return (
this.inReplyTo?.id &&
!this.isPrivate &&
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO)
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO) &&
!this.is360DialogWhatsAppChannel
);
},
showRichContentEditor() {
@@ -392,7 +393,8 @@ export default {
this.isAPIInbox ||
this.isAnEmailChannel ||
this.isASmsInbox ||
this.isATelegramChannel
this.isATelegramChannel ||
this.isALineChannel
);
},
replyButtonLabel() {
@@ -495,7 +497,11 @@ export default {
return `draft-${this.conversationIdByRoute}-${this.replyType}`;
},
audioRecordFormat() {
if (this.isAWhatsAppChannel || this.isAPIInbox) {
if (
this.isAWhatsAppChannel ||
this.isAPIInbox ||
this.isATelegramChannel
) {
return AUDIO_FORMATS.OGG;
}
return AUDIO_FORMATS.WAV;
@@ -624,6 +630,8 @@ export default {
`${this.$t('CONVERSATION.REPLYBOX.INSERT_READ_MORE')} ${url}`
);
}
this.$track(CONVERSATION_EVENTS.INSERT_ARTICLE_LINK);
},
toggleRichContentEditor() {
this.updateUISettings({
@@ -7,7 +7,11 @@
}"
>
<div v-if="!isEmail" v-dompurify-html="message" class="text-content" />
<letter v-else class="text-content" :html="message" />
<letter
v-else
class="text-content bg-white dark:bg-white text-slate-900 dark:text-slate-900 p-2 rounded-[4px]"
:html="message"
/>
<button
v-if="showQuoteToggle"
class="text-slate-300 dark:text-slate-300 cursor-pointer text-xs py-1"
@@ -46,9 +46,25 @@
</span>
</div>
<div
class="items-center flex gap-2 justify-end min-w-[15rem]"
class="items-center flex gap-2 justify-end min-w-[8rem] sm:min-w-[15rem]"
@click.stop
>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-counter-clockwise"
@click="onRotate('counter-clockwise')"
/>
<woot-button
v-if="isImage"
size="large"
color-scheme="secondary"
variant="clear"
icon="arrow-rotate-clockwise"
@click="onRotate('clockwise')"
/>
<woot-button
size="large"
color-scheme="secondary"
@@ -89,6 +105,7 @@
:key="activeAttachment.message_id"
:src="activeAttachment.data_url"
class="modal-image skip-context-menu my-0 mx-auto"
:style="imageRotationStyle"
@click.stop
/>
<video
@@ -186,6 +203,7 @@ export default {
this.allAttachments.findIndex(
attachment => attachment.message_id === this.attachment.message_id
) || 0,
activeImageRotation: 0,
};
},
computed: {
@@ -232,6 +250,11 @@ export default {
const fileName = dataUrl?.split('/').pop();
return fileName || '';
},
imageRotationStyle() {
return {
transform: `rotate(${this.activeImageRotation}deg)`,
};
},
},
mounted() {
this.setImageAndVideoSrc(this.attachment);
@@ -246,6 +269,7 @@ export default {
}
this.activeImageIndex = index;
this.setImageAndVideoSrc(attachment);
this.activeImageRotation = 0;
},
setImageAndVideoSrc(attachment) {
const { file_type: type } = attachment;
@@ -280,6 +304,20 @@ export default {
link.download = `attachment.${type}`;
link.click();
},
onRotate(type) {
if (!this.isImage) {
return;
}
const rotation = type === 'clockwise' ? 90 : -90;
// Reset rotation if it is 360
if (Math.abs(this.activeImageRotation) === 360) {
this.activeImageRotation = rotation;
} else {
this.activeImageRotation += rotation;
}
},
},
};
</script>
@@ -25,7 +25,7 @@
id="file"
ref="file"
type="file"
accept="image/png, image/jpeg, image/gif"
accept="image/png, image/jpeg, image/jpg, image/gif, image/webp"
@change="handleImageUpload"
/>
<slot />
@@ -13,10 +13,14 @@ export default {
ALL: 'all',
},
SORT_BY_TYPE: {
LATEST: 'latest',
CREATED_AT: 'sort_on_created_at',
PRIORITY: 'sort_on_priority',
WATIING_SINCE: 'waiting_since',
LAST_ACTIVITY_AT_ASC: 'last_activity_at_asc',
LAST_ACTIVITY_AT_DESC: 'last_activity_at_desc',
CREATED_AT_ASC: 'created_at_asc',
CREATED_AT_DESC: 'created_at_desc',
PRIORITY_ASC: 'priority_asc',
PRIORITY_DESC: 'priority_desc',
WAITING_SINCE_ASC: 'waiting_since_asc',
WAITING_SINCE_DESC: 'waiting_since_desc',
},
ARTICLE_STATUS_TYPES: {
DRAFT: 0,
@@ -9,6 +9,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
CHANGE_PRIORITY: 'Assigned priority to a conversation',
INSERT_ARTICLE_LINK: 'Inserted article into reply via article search',
});
export const ACCOUNT_EVENTS = Object.freeze({
@@ -23,6 +23,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'contact.updated': this.onContactUpdate,
'conversation.mentioned': this.onConversationMentioned,
'notification.created': this.onNotificationCreated,
'notification.deleted': this.onNotificationDeleted,
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
@@ -195,6 +196,10 @@ class ActionCableConnector extends BaseActionCableConnector {
this.app.$store.dispatch('notifications/addNotification', data);
};
onNotificationDeleted = data => {
this.app.$store.dispatch('notifications/deleteNotification', data);
};
// eslint-disable-next-line class-methods-use-this
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
@@ -51,18 +51,30 @@
"ACTIVE": "Last activity"
}
},
"CHAT_SORT_FILTER_ITEMS": {
"latest": {
"TEXT": "Last activity"
"SORT_ORDER_ITEMS": {
"last_activity_at_asc": {
"TEXT": "Last activity: Oldest first"
},
"sort_on_created_at": {
"TEXT": "Created at"
"last_activity_at_desc": {
"TEXT": "Last activity: Newest first"
},
"sort_on_priority": {
"TEXT": "Priority"
"created_at_desc": {
"TEXT": "Created at: Newest first"
},
"sort_on_waiting_since": {
"TEXT": "Pending Response"
"created_at_asc": {
"TEXT": "Created at: Oldest first"
},
"priority_desc": {
"TEXT": "Priority: Highest first"
},
"priority_asc": {
"TEXT": "Priority: Lowest first"
},
"waiting_since_asc": {
"TEXT": "Pending Response: Longest first"
},
"waiting_since_desc": {
"TEXT": "Pending Response: Shortest first"
}
},
"ATTACHMENTS": {
@@ -1,7 +1,8 @@
/* eslint-disable storybook/default-exports */
import SearchView from './components/SearchView.vue';
import { frontendURL } from '../../helper/URLHelper';
const SearchView = () => import('./components/SearchView.vue');
export const routes = [
{
path: frontendURL('accounts/:accountId/search'),
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import ContactsView from './components/ContactsView';
import ContactManageView from './pages/ContactManageView';
import { frontendURL } from '../../../helper/URLHelper';
const ContactsView = () => import('./components/ContactsView.vue');
const ContactManageView = () => import('./pages/ContactManageView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
/* eslint arrow-body-style: 0 */
import ConversationView from './ConversationView';
import { frontendURL } from '../../../helper/URLHelper';
const ConversationView = () => import('./ConversationView');
export default {
routes: [
@@ -1,4 +1,3 @@
import AppContainer from './Dashboard';
import settings from './settings/settings.routes';
import conversation from './conversation/conversation.routes';
import { routes as searchRoutes } from '../../modules/search/search.routes';
@@ -7,7 +6,8 @@ import { routes as notificationRoutes } from './notifications/routes';
import { frontendURL } from '../../helper/URLHelper';
import helpcenterRoutes from './helpcenter/helpcenter.routes';
const Suspended = () => import('./suspended/Index');
const AppContainer = () => import('./Dashboard.vue');
const Suspended = () => import('./suspended/Index.vue');
export default {
routes: [
@@ -161,7 +161,7 @@ export default {
}
span.article-column {
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-right capitalize;
@apply text-slate-700 dark:text-slate-100 text-sm font-semibold py-2 px-0 text-left capitalize last:text-right;
&.article-title {
@apply items-start flex gap-2 col-span-4 text-left;
@@ -10,17 +10,17 @@
{{ $t('HELP_CENTER.TABLE.HEADERS.TITLE') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.CATEGORY') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right hidden lg:block"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left hidden lg:block"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.READ_COUNT') }}
</div>
<div
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-right"
class="font-semibold capitalize text-sm py-2 px-0 text-slate-700 dark:text-slate-100 text-left"
>
{{ $t('HELP_CENTER.TABLE.HEADERS.STATUS') }}
</div>
@@ -91,6 +91,7 @@
@search-change="handleSearchChange"
@close="onBlur"
@tag="addTagValue"
@remove="removeTag"
/>
</label>
</div>
@@ -157,16 +158,23 @@ export default {
return this.metaTags.map(item => item.name);
},
},
watch: {
article: {
handler() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
},
deep: true,
immediate: true,
},
},
mounted() {
if (!isEmptyObject(this.article.meta || {})) {
const {
meta: { title = '', description = '', tags = [] },
} = this.article;
this.metaTitle = title;
this.metaDescription = description;
this.metaTags = this.formattedTags({ tags });
}
this.saveArticle = debounce(
() => {
this.$emit('save-article', {
@@ -196,6 +204,9 @@ export default {
this.metaTags.push(...this.formattedTags({ tags: [...new Set(tags)] }));
this.saveArticle();
},
removeTag() {
this.saveArticle();
},
handleSearchChange(value) {
this.tagInputValue = value;
},
@@ -1,7 +1,7 @@
/* eslint arrow-body-style: 0 */
import NotificationsView from './components/NotificationsView.vue';
import { frontendURL } from '../../../helper/URLHelper';
import SettingsWrapper from '../settings/Wrapper';
const SettingsWrapper = () => import('../settings/Wrapper.vue');
const NotificationsView = () => import('./components/NotificationsView.vue');
export const routes = [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,8 +1,8 @@
import SettingsContent from '../Wrapper';
const Bot = () => import('./Index.vue');
const CsmlEditBot = () => import('./csml/Edit.vue');
const CsmlNewBot = () => import('./csml/New.vue');
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AgentHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AgentHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import AttributesHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import AuditLogsHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const AuditLogsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Automation from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Automation = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,6 +1,6 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -20,7 +20,7 @@ export default {
path: 'ongoing',
name: 'settings_account_campaigns',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -36,7 +36,7 @@ export default {
path: 'one_off',
name: 'one_off',
roles: ['administrator'],
component: { ...Index },
component: Index,
},
],
},
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import CannedHome from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const CannedHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import Settings from './Settings';
import InboxHome from './Index';
import InboxChannel from './InboxChannels';
import ChannelList from './ChannelList';
import channelFactory from './channel-factory';
import AddAgents from './AddAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
const ChannelList = () => import('./ChannelList.vue');
const AddAgents = () => import('./AddAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
export default {
routes: [
@@ -1,7 +1,7 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import IntegrationHooks from './IntegrationHooks';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const IntegrationHooks = () => import('./IntegrationHooks.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
@@ -1,11 +1,12 @@
import Index from './Index';
import SettingsContent from '../Wrapper';
import Webhook from './Webhooks/Index';
import DashboardApps from './DashboardApps/Index';
import ShowIntegration from './ShowIntegration';
import Slack from './Slack';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Webhook = () => import('./Webhooks/Index.vue');
const DashboardApps = () => import('./DashboardApps/Index.vue');
const ShowIntegration = () => import('./ShowIntegration.vue');
const Slack = () => import('./Slack.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,6 +1,8 @@
<template>
<div class="flex flex-row h-full">
<div class="w-[60%] macros-canvas">
<div class="flex flex-col md:flex-row h-auto md:h-full w-full">
<div
class="flex-1 w-full md:w-auto macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto"
>
<macro-nodes
v-model="macro.actions"
:files="files"
@@ -9,7 +11,7 @@
@resetAction="resetNode"
/>
</div>
<div class="w-[34%]">
<div class="w-full md:w-1/3">
<macro-properties
:macro-name="macro.name"
:macro-visibility="macro.visibility"
@@ -138,7 +140,4 @@ export default {
background-size: 1rem 1rem;
}
}
.macros-canvas {
@apply macro-gradient-radial dark:macro-dark-gradient-radial macro-gradient-radial-size h-full max-h-full py-4 px-12 overflow-y-auto;
}
</style>
@@ -18,7 +18,7 @@
>
{{ $t('MACROS.EDITOR.VISIBILITY.LABEL') }}
</p>
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-1 lg:grid-cols-2 gap-3">
<button
class="p-2 relative rounded-md border border-solid text-left cursor-default"
:class="isActive('global')"
@@ -1,8 +1,9 @@
import SettingsContent from '../Wrapper';
import Macros from './Index';
const MacroEditor = () => import('./MacroEditor');
import { frontendURL } from 'dashboard/helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Macros = () => import('./Index.vue');
const MacroEditor = () => import('./MacroEditor.vue');
export default {
routes: [
{
@@ -22,14 +22,12 @@
:enabled-menu-options="customEditorMenuList"
:enable-suggestions="false"
:show-image-resize-toolbar="true"
@blur="$v.messageSignature.$touch"
/>
</div>
<woot-button
:is-loading="isUpdating"
type="button"
:is-disabled="$v.messageSignature.$invalid"
@click.prevent="updateSignature()"
@click.prevent="updateSignature"
>
{{ $t('PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.BTN_TEXT') }}
</woot-button>
@@ -38,7 +36,6 @@
</template>
<script>
import { required } from 'vuelidate/lib/validators';
import { mapGetters } from 'vuex';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
@@ -59,11 +56,6 @@ export default {
customEditorMenuList: MESSAGE_SIGNATURE_EDITOR_MENU_OPTIONS,
};
},
validations: {
messageSignature: {
required,
},
},
computed: {
...mapGetters({
currentUser: 'getCurrentUser',
@@ -79,15 +71,9 @@ export default {
this.messageSignature = messageSignature || '';
},
async updateSignature() {
this.$v.$touch();
if (this.$v.$invalid) {
this.showAlert(this.$t('PROFILE_SETTINGS.FORM.ERROR'));
return;
}
try {
await this.$store.dispatch('updateProfile', {
message_signature: this.messageSignature,
message_signature: this.messageSignature || '',
});
this.errorMessage = this.$t(
'PROFILE_SETTINGS.FORM.MESSAGE_SIGNATURE_SECTION.API_SUCCESS'
@@ -1,7 +1,8 @@
import SettingsContent from '../Wrapper';
import Index from './Index.vue';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
export default {
routes: [
{
@@ -1,13 +1,14 @@
import Index from './Index';
import AgentReports from './AgentReports';
import LabelReports from './LabelReports';
import InboxReports from './InboxReports';
import TeamReports from './TeamReports';
import CsatResponses from './CsatResponses';
import LiveReports from './LiveReports';
import SettingsContent from '../Wrapper';
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsContent = () => import('../Wrapper.vue');
const Index = () => import('./Index.vue');
const AgentReports = () => import('./AgentReports.vue');
const LabelReports = () => import('./LabelReports.vue');
const InboxReports = () => import('./InboxReports.vue');
const TeamReports = () => import('./TeamReports.vue');
const CsatResponses = () => import('./CsatResponses.vue');
const LiveReports = () => import('./LiveReports.vue');
export default {
routes: [
{
@@ -1,15 +1,16 @@
/* eslint arrow-body-style: 0 */
import SettingsContent from '../Wrapper';
import TeamsHome from './Index';
import CreateStepWrap from './Create/Index';
import EditStepWrap from './Edit/Index';
import CreateTeam from './Create/CreateTeam';
import EditTeam from './Edit/EditTeam';
import AddAgents from './Create/AddAgents';
import EditAgents from './Edit/EditAgents';
import FinishSetup from './FinishSetup';
import { frontendURL } from '../../../../helper/URLHelper';
const CreateStepWrap = () => import('./Create/Index.vue');
const EditStepWrap = () => import('./Edit/Index.vue');
const CreateTeam = () => import('./Create/CreateTeam.vue');
const EditTeam = () => import('./Edit/EditTeam.vue');
const AddAgents = () => import('./Create/AddAgents.vue');
const EditAgents = () => import('./Edit/EditAgents.vue');
const FinishSetup = () => import('./FinishSetup.vue');
const SettingsContent = () => import('../Wrapper.vue');
const TeamsHome = () => import('./Index.vue');
export default {
routes: [
{
@@ -11,6 +11,19 @@ import {
} from './helpers/actionHelpers';
import messageReadActions from './actions/messageReadActions';
import messageTranslateActions from './actions/messageTranslateActions';
export const hasMessageFailedWithExternalError = pendingMessage => {
// This helper is used to check if the message has failed with an external error.
// We have two cases
// 1. Messages that fail from the UI itself (due to large attachments or a failed network):
// In this case, the message will have a status of failed but no external error. So we need to create that message again
// 2. Messages sent from Chatwoot but failed to deliver to the customer for some reason (user blocking or client system down):
// In this case, the message will have a status of failed and an external error. So we need to retry that message
const { content_attributes: contentAttributes, status } = pendingMessage;
const externalError = contentAttributes?.external_error ?? '';
return status === MESSAGE_STATUS.FAILED && externalError !== '';
};
// actions
const actions = {
getConversation: async ({ commit }, conversationId) => {
@@ -242,12 +255,15 @@ const actions = {
},
sendMessageWithData: async ({ commit }, pendingMessage) => {
const { conversation_id: conversationId, id } = pendingMessage;
try {
commit(types.ADD_MESSAGE, {
...pendingMessage,
status: MESSAGE_STATUS.PROGRESS,
});
const response = await MessageApi.create(pendingMessage);
const response = hasMessageFailedWithExternalError(pendingMessage)
? await MessageApi.retry(conversationId, id)
: await MessageApi.create(pendingMessage);
commit(types.ADD_MESSAGE, {
...response.data,
status: MESSAGE_STATUS.SENT,
@@ -1,8 +1,5 @@
import {
MESSAGE_TYPE,
CONVERSATION_PRIORITY_ORDER,
} from 'shared/constants/messages';
import { applyPageFilters } from './helpers';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { applyPageFilters, sortComparator } from './helpers';
export const getSelectedChatConversation = ({
allConversations,
@@ -10,36 +7,9 @@ export const getSelectedChatConversation = ({
}) =>
allConversations.filter(conversation => conversation.id === selectedChatId);
const sortComparator = {
latest: (a, b) => b.last_activity_at - a.last_activity_at,
sort_on_created_at: (a, b) => a.created_at - b.created_at,
sort_on_priority: (a, b) => {
return (
CONVERSATION_PRIORITY_ORDER[a.priority] -
CONVERSATION_PRIORITY_ORDER[b.priority]
);
},
sort_on_waiting_since: (a, b) => {
if (!a.waiting_since && !b.waiting_since) {
return a.created_at - b.created_at;
}
if (!a.waiting_since) {
return 1;
}
if (!b.waiting_since) {
return -1;
}
return a.waiting_since - b.waiting_since;
},
};
// getters
const getters = {
getAllConversations: ({ allConversations, chatSortFilter }) => {
return allConversations.sort(sortComparator[chatSortFilter]);
getAllConversations: ({ allConversations, chatSortFilter: sortKey }) => {
return allConversations.sort((a, b) => sortComparator(a, b, sortKey));
},
getSelectedChat: ({ selectedChatId, allConversations }) => {
const selectedChat = allConversations.find(
@@ -1,3 +1,5 @@
import { CONVERSATION_PRIORITY_ORDER } from 'shared/constants/messages';
export const findPendingMessageIndex = (chat, message) => {
const { echo_id: tempMessageId } = message;
return chat.messages.findIndex(
@@ -59,3 +61,53 @@ export const applyPageFilters = (conversation, filters) => {
return shouldFilter;
};
const SORT_OPTIONS = {
last_activity_at_asc: ['sortOnLastActivityAt', 'asc'],
last_activity_at_desc: ['sortOnLastActivityAt', 'desc'],
created_at_asc: ['sortOnCreatedAt', 'asc'],
created_at_desc: ['sortOnCreatedAt', 'desc'],
priority_asc: ['sortOnPriority', 'asc'],
priority_desc: ['sortOnPriority', 'desc'],
waiting_since_asc: ['sortOnWaitingSince', 'asc'],
waiting_since_desc: ['sortOnWaitingSince', 'desc'],
};
const sortAscending = (valueA, valueB) => valueA - valueB;
const sortDescending = (valueA, valueB) => valueB - valueA;
const getSortOrderFunction = sortOrder =>
sortOrder === 'asc' ? sortAscending : sortDescending;
const sortConfig = {
sortOnLastActivityAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.last_activity_at, b.last_activity_at),
sortOnCreatedAt: (a, b, sortDirection) =>
getSortOrderFunction(sortDirection)(a.created_at, b.created_at),
sortOnPriority: (a, b, sortDirection) => {
const DEFAULT_FOR_NULL = sortDirection === 'asc' ? 5 : 0;
const p1 = CONVERSATION_PRIORITY_ORDER[a.priority] || DEFAULT_FOR_NULL;
const p2 = CONVERSATION_PRIORITY_ORDER[b.priority] || DEFAULT_FOR_NULL;
return getSortOrderFunction(sortDirection)(p1, p2);
},
sortOnWaitingSince: (a, b, sortDirection) => {
const sortFunc = getSortOrderFunction(sortDirection);
if (!a.waiting_since || !b.waiting_since) {
if (!a.waiting_since && !b.waiting_since) {
return sortFunc(a.created_at, b.created_at);
}
return sortFunc(a.waiting_since ? 0 : 1, b.waiting_since ? 0 : 1);
}
return sortFunc(a.waiting_since, b.waiting_since);
},
};
export const sortComparator = (a, b, sortKey) => {
const [sortMethod, sortDirection] = SORT_OPTIONS[sortKey] || [];
return sortConfig[sortMethod](a, b, sortDirection);
};
@@ -56,4 +56,7 @@ export const actions = {
addNotification({ commit }, data) {
commit(types.ADD_NOTIFICATION, data);
},
deleteNotification({ commit }, data) {
commit(types.DELETE_NOTIFICATION, data);
},
};
@@ -55,4 +55,10 @@ export const mutations = {
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
[types.DELETE_NOTIFICATION]($state, data) {
const { notification, unread_count: unreadCount, count } = data;
Vue.delete($state.records, notification.id);
Vue.set($state.meta, 'unreadCount', unreadCount);
Vue.set($state.meta, 'count', count);
},
};
@@ -1,5 +1,7 @@
import axios from 'axios';
import actions from '../../conversations/actions';
import actions, {
hasMessageFailedWithExternalError,
} from '../../conversations/actions';
import types from '../../../mutation-types';
const dataToSend = {
payload: [
@@ -18,6 +20,41 @@ const dispatch = jest.fn();
global.axios = axios;
jest.mock('axios');
describe('#hasMessageFailedWithExternalError', () => {
it('returns false if message is sent', () => {
const pendingMessage = {
status: 'sent',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns false if status is not failed', () => {
const pendingMessage = {
status: 'progress',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns false if status is failed but no external error', () => {
const pendingMessage = {
status: 'failed',
content_attributes: {},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(false);
});
it('returns true if status is failed and has external error', () => {
const pendingMessage = {
status: 'failed',
content_attributes: {
external_error: 'error',
},
};
expect(hasMessageFailedWithExternalError(pendingMessage)).toBe(true);
});
});
describe('#actions', () => {
describe('#getConversation', () => {
it('sends correct actions if API is success', async () => {
@@ -47,6 +47,49 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on last activity with ascending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
],
chatSortFilter: 'latest_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1466424480,
last_activity_at: 1466424480,
},
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 2466424490,
last_activity_at: 2466424490,
},
]);
});
it('order conversations based on created at', () => {
const state = {
allConversations: [
@@ -67,7 +110,7 @@ describe('#getters', () => {
last_activity_at: 1466424480,
},
],
chatSortFilter: 'sort_on_created_at',
chatSortFilter: 'created_at_last',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -89,6 +132,50 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on created at with descending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801, // Tuesday, 9 May 2023
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801, // Monday, 9 May 2022
last_activity_at: 1466424480,
},
],
chatSortFilter: 'created_at_first',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
created_at: 1652109801,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on default order', () => {
const state = {
allConversations: [
@@ -159,7 +246,7 @@ describe('#getters', () => {
last_activity_at: 1466421280,
},
],
chatSortFilter: 'sort_on_priority',
chatSortFilter: 'priority_first',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -190,6 +277,68 @@ describe('#getters', () => {
},
]);
});
it('order conversations based on with descending order', () => {
const state = {
allConversations: [
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
],
chatSortFilter: 'priority_last',
};
expect(getters.getAllConversations(state)).toEqual([
{
id: 1,
messages: [
{
content: 'test1',
},
],
priority: 'low',
created_at: 1683645801,
last_activity_at: 2466424490,
},
{
id: 3,
messages: [{ content: 'test3' }],
priority: 'medium',
created_at: 1652109801,
last_activity_at: 1466421280,
},
{
id: 2,
messages: [{ content: 'test2' }],
priority: 'urgent',
created_at: 1652109801,
last_activity_at: 1466424480,
},
]);
});
it('order conversations based on waiting_since', () => {
const state = {
allConversations: [
@@ -214,7 +363,7 @@ describe('#getters', () => {
waiting_since: 1683645800,
},
],
chatSortFilter: 'sort_on_waiting_since',
chatSortFilter: 'waiting_since_last',
};
expect(getters.getAllConversations(state)).toEqual([
@@ -98,4 +98,13 @@ describe('#actions', () => {
]);
});
});
describe('#deleteNotification', () => {
it('sends correct actions', async () => {
await actions.deleteNotification({ commit }, { data: 1 });
expect(commit.mock.calls).toEqual([
[types.DELETE_NOTIFICATION, { data: 1 }],
]);
});
});
});
@@ -118,4 +118,27 @@ describe('#mutations', () => {
expect(state.meta.count).toEqual(232);
});
});
describe('#DELETE_NOTIFICATION', () => {
it('delete notification', () => {
const state = {
meta: { unreadCount: 4, count: 231 },
records: {
1: { id: 1, primary_actor_id: 1 },
2: { id: 2, primary_actor_id: 2 },
},
};
const data = {
notification: { id: 1, primary_actor_id: 1 },
unread_count: 5,
count: 232,
};
mutations[types.DELETE_NOTIFICATION](state, data);
expect(state.records).toEqual({
2: { id: 2, primary_actor_id: 2 },
});
expect(state.meta.unreadCount).toEqual(5);
expect(state.meta.count).toEqual(232);
});
});
});
@@ -133,6 +133,7 @@ export default {
SET_NOTIFICATIONS_UI_FLAG: 'SET_NOTIFICATIONS_UI_FLAG',
UPDATE_NOTIFICATION: 'UPDATE_NOTIFICATION',
ADD_NOTIFICATION: 'ADD_NOTIFICATION',
DELETE_NOTIFICATION: 'DELETE_NOTIFICATION',
UPDATE_ALL_NOTIFICATIONS: 'UPDATE_ALL_NOTIFICATIONS',
SET_NOTIFICATIONS_ITEM: 'SET_NOTIFICATIONS_ITEM',
SET_NOTIFICATIONS: 'SET_NOTIFICATIONS',
-1
View File
@@ -1,2 +1 @@
import '../dashboard/assets/scss/app.scss';
import '../dashboard/assets/scss/super_admin/index.scss';
-1
View File
@@ -1,2 +1 @@
import '../dashboard/assets/scss/super_admin/pages.scss';
import 'chart.js';

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