Merge branch 'release/2.16.0'

This commit is contained in:
Sojan 2023-04-18 01:04:19 +05:30
commit 7bd400772d
724 changed files with 19555 additions and 3394 deletions

View File

@ -1,8 +1,6 @@
.bundle
.env
.env.*
.git
.gitignore
docker-compose.*
docker/Dockerfile
docker/dockerfiles

View File

@ -110,6 +110,8 @@ AWS_REGION=
RAILS_LOG_TO_STDOUT=true
LOG_LEVEL=info
LOG_SIZE=500
# Configure this environment variable if you want to use lograge instead of rails logger
#LOGRAGE_ENABLED=true
### This environment variables are only required if you are setting up social media channels
@ -159,9 +161,6 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
# for mobile apps
# FCM_SERVER_KEY=
## Bot Customizations
USE_INBOX_AVATAR_FOR_BOT=true
### APM and Error Monitoring configurations
## Elastic APM
## https://www.elastic.co/guide/en/apm/agent/ruby/current/getting-started-rails.html

5
.gitignore vendored
View File

@ -61,8 +61,9 @@ test/cypress/videos/*
/config/master.key
/config/*.enc
.vscode/settings.json
#ignore files under .vscode directory
.vscode
# yalc for local testing
.yalc
yalc.lock
yalc.lock

34
Gemfile
View File

@ -87,7 +87,7 @@ gem 'line-bot-api'
gem 'twilio-ruby', '~> 5.66'
# twitty will handle subscription of twitter account events
# gem 'twitty', git: 'https://github.com/chatwoot/twitty'
gem 'twitty'
gem 'twitty', '~> 0.1.5'
# facebook client
gem 'koala'
# slack client
@ -149,7 +149,23 @@ gem 'net-imap', require: false
gem 'net-pop', require: false
gem 'net-smtp', require: false
group :production, :staging do
# Include logrange conditionally in intializer using env variable
gem 'lograge', '~> 0.12.0', require: false
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'
### Gems required only in specific deployment environments ###
##############################################################
group :production do
# we dont want request timing out in development while using byebug
gem 'rack-timeout'
end
@ -165,6 +181,10 @@ group :development do
# When we want to squash migrations
gem 'squasher'
# profiling
gem 'rack-mini-profiler', require: false
gem 'stackprof'
end
group :test do
@ -202,13 +222,3 @@ group :development, :test do
gem 'spring'
gem 'spring-watcher-listen'
end
# worked with microsoft refresh token
gem 'omniauth-oauth2'
gem 'audited', '~> 5.2'
# need for google auth
gem 'omniauth'
gem 'omniauth-google-oauth2'
gem 'omniauth-rails_csrf_protection', '~> 1.0'

View File

@ -137,7 +137,7 @@ GEM
byebug (11.1.3)
climate_control (1.1.1)
coderay (1.1.3)
commonmarker (0.23.7)
commonmarker (0.23.9)
concurrent-ruby (1.2.2)
connection_pool (2.2.5)
crack (0.4.5)
@ -416,6 +416,11 @@ GEM
llhttp-ffi (0.4.0)
ffi-compiler (~> 1.0)
rake (~> 13.0)
lograge (0.12.0)
actionpack (>= 4)
activesupport (>= 4)
railties (>= 4)
request_store (~> 1.0)
loofah (2.19.1)
crass (~> 1.0.2)
nokogiri (>= 1.5.9)
@ -459,14 +464,14 @@ GEM
sidekiq
newrelic_rpm (8.15.0)
nio4r (2.5.8)
nokogiri (1.14.2)
nokogiri (1.14.3)
mini_portile2 (~> 2.8.0)
racc (~> 1.4)
nokogiri (1.14.2-arm64-darwin)
nokogiri (1.14.3-arm64-darwin)
racc (~> 1.4)
nokogiri (1.14.2-x86_64-darwin)
nokogiri (1.14.3-x86_64-darwin)
racc (~> 1.4)
nokogiri (1.14.2-x86_64-linux)
nokogiri (1.14.3-x86_64-linux)
racc (~> 1.4)
oauth (0.5.10)
oauth2 (2.0.9)
@ -520,6 +525,8 @@ GEM
rack (>= 1.0, < 3)
rack-cors (1.1.1)
rack (>= 2.0.0)
rack-mini-profiler (3.0.0)
rack (>= 1.2.0)
rack-protection (3.0.5)
rack
rack-proxy (0.7.2)
@ -566,6 +573,8 @@ GEM
declarative (< 0.1.0)
trailblazer-option (>= 0.1.1, < 0.2.0)
uber (< 0.2.0)
request_store (1.5.1)
rack (>= 1.4)
responders (3.0.1)
actionpack (>= 5.0)
railties (>= 5.0)
@ -692,6 +701,7 @@ GEM
activesupport (>= 5.2)
sprockets (>= 3.0.0)
squasher (0.6.2)
stackprof (0.2.24)
statsd-ruby (1.5.0)
stripe (6.5.0)
telephone_number (1.4.16)
@ -707,7 +717,7 @@ GEM
faraday (>= 0.9, < 3.0)
jwt (>= 1.5, <= 2.5)
nokogiri (>= 1.6, < 2.0)
twitty (0.1.4)
twitty (0.1.5)
oauth
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
@ -821,6 +831,7 @@ DEPENDENCIES
line-bot-api
liquid
listen
lograge (~> 0.12.0)
maxminddb
mock_redis
net-imap
@ -840,6 +851,7 @@ DEPENDENCIES
pundit
rack-attack
rack-cors
rack-mini-profiler
rack-timeout
rails (~> 6.1, >= 6.1.7.3)
redis
@ -865,12 +877,13 @@ DEPENDENCIES
spring
spring-watcher-listen
squasher
stackprof
stripe
telephone_number
test-prof
time_diff
twilio-ruby (~> 5.66)
twitty
twitty (~> 0.1.5)
tzinfo-data
uglifier
valid_email2

View File

@ -1,3 +1,3 @@
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare
release: POSTGRES_STATEMENT_TIMEOUT=600s bundle exec rails db:chatwoot_prepare && echo $SOURCE_VERSION > .git_sha
web: bundle exec rails ip_lookup:setup && bin/rails server -p $PORT -e $RAILS_ENV
worker: bundle exec rails ip_lookup:setup && bundle exec sidekiq -C config/sidekiq.yml

View File

@ -19,6 +19,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def destroy
@agent.current_account_user.destroy!
delete_user_record(@agent)
head :ok
end
@ -74,4 +75,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
def validate_limit
render_payment_required('Account limit exceeded. Please purchase more licenses') if agents.count >= Current.account.usage_limits[:agents]
end
def delete_user_record(agent)
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
end
end

View File

@ -1,14 +1,19 @@
class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
before_action :portal
before_action :check_authorization
before_action :fetch_article, except: [:index, :create, :attach_file]
before_action :fetch_article, except: [:index, :create, :attach_file, :reorder]
before_action :set_current_page, only: [:index]
def index
@portal_articles = @portal.articles
@all_articles = @portal_articles.search(list_params)
@articles_count = @all_articles.count
@articles = @all_articles.order_by_updated_at.page(@current_page)
@articles = if list_params[:category_slug].present?
@all_articles.order_by_position.page(@current_page).per(50)
else
@all_articles.order_by_updated_at.page(@current_page)
end
end
def create
@ -43,6 +48,11 @@ class Api::V1::Accounts::ArticlesController < Api::V1::Accounts::BaseController
render json: { file_url: url_for(file_blob) }
end
def reorder
Article.update_positions(params[:positions_hash])
head :ok
end
private
def fetch_article

View File

@ -119,11 +119,12 @@ class Api::V1::Accounts::ContactsController < Api::V1::Accounts::BaseController
end
def fetch_contacts_with_conversation_count(contacts)
contacts_with_conversation_count = filtrate(contacts).left_outer_joins(:conversations)
.select('contacts.*, COUNT(conversations.id) as conversations_count')
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
conversation_count_sub_query = 'SELECT COUNT(*) FROM "conversations" WHERE "conversations"."contact_id" = "contacts"."id"'
contacts_with_conversation_count = filtrate(contacts)
.select("contacts.*, (#{conversation_count_sub_query}) as conversations_count")
.group('contacts.id')
.includes([{ avatar_attachment: [:blob] }])
.page(@current_page).per(RESULTS_PER_PAGE)
return contacts_with_conversation_count.includes([{ contact_inboxes: [:inbox] }]) if @include_contact_inboxes

View File

@ -82,8 +82,8 @@ class Api::V1::Accounts::InboxesController < Api::V1::Accounts::BaseController
end
def destroy
@inbox.destroy!
head :ok
::DeleteObjectJob.perform_later(@inbox) if @inbox.present?
render status: :ok, json: { message: I18n.t('messages.inbox_deletetion_response') }
end
private

View File

@ -7,11 +7,12 @@ class Api::V1::Accounts::Microsoft::AuthorizationsController < Api::V1::Accounts
redirect_url = microsoft_client.auth_code.authorize_url(
{
redirect_uri: "#{base_url}/microsoft/callback",
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid',
scope: 'offline_access https://outlook.office.com/IMAP.AccessAsUser.All https://outlook.office.com/SMTP.Send openid profile',
prompt: 'consent'
}
)
if redirect_url
email = email.downcase
::Redis::Alfred.setex(email, Current.account.id, 5.minutes)
render json: { success: true, url: redirect_url }
else

View File

@ -1,5 +1,6 @@
class Api::V1::AccountsController < Api::BaseController
include AuthHelper
include CacheKeysHelper
skip_before_action :authenticate_user!, :set_current_user, :handle_with_exception,
only: [:create], raise: false
@ -30,6 +31,10 @@ class Api::V1::AccountsController < Api::BaseController
end
end
def cache_keys
render json: { cache_keys: get_cache_keys }, status: :ok
end
def show
@latest_chatwoot_version = ::Redis::Alfred.get(::Redis::Alfred::LATEST_CHATWOOT_VERSION)
render 'api/v1/accounts/show', format: :json
@ -47,6 +52,14 @@ class Api::V1::AccountsController < Api::BaseController
private
def get_cache_keys
{
label: fetch_value_for_key(params[:id], Label.name.underscore),
inbox: fetch_value_for_key(params[:id], Inbox.name.underscore),
team: fetch_value_for_key(params[:id], Team.name.underscore)
}
end
def fetch_account
@account = current_user.accounts.find(params[:id])
@current_account_user = @account.account_users.find_by(user_id: current_user.id)

View File

@ -65,6 +65,16 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
head :ok
end
def set_custom_attributes
conversation.update!(custom_attributes: permitted_params[:custom_attributes])
end
def destroy_custom_attributes
conversation.custom_attributes = conversation.custom_attributes.excluding(params[:custom_attribute])
conversation.save!
render json: conversation
end
private
def trigger_typing_event(event)

View File

@ -1,5 +1,7 @@
class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
include Api::V2::Accounts::ReportsHelper
include Api::V2::Accounts::HeatmapHelper
before_action :check_authorization
def index
@ -32,6 +34,14 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
generate_csv('teams_report', 'api/v2/accounts/reports/teams')
end
def conversation_traffic
@report_data = generate_conversations_heatmap_report
timezone_offset = (params[:timezone_offset] || 0).to_f
@timezone = ActiveSupport::TimeZone[timezone_offset]
generate_csv('conversation_traffic_reports', 'api/v2/accounts/reports/conversation_traffic')
end
def conversations
return head :unprocessable_entity if params[:type].blank?

View File

@ -56,7 +56,8 @@ class DashboardController < ActionController::Base
FB_APP_ID: GlobalConfigService.load('FB_APP_ID', ''),
FACEBOOK_API_VERSION: 'v14.0',
IS_ENTERPRISE: ChatwootApp.enterprise?,
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', '')
AZURE_APP_ID: ENV.fetch('AZURE_APP_ID', ''),
GIT_SHA: GIT_HASH
}
end
end

View File

@ -8,7 +8,7 @@ class Microsoft::CallbacksController < ApplicationController
)
inbox = find_or_create_inbox
::Redis::Alfred.delete(users_data['email'])
::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
@ -31,7 +31,7 @@ class Microsoft::CallbacksController < ApplicationController
end
def account_id
::Redis::Alfred.get(users_data['email'])
::Redis::Alfred.get(users_data['email'].downcase)
end
def account

View File

@ -47,4 +47,11 @@ class SuperAdmin::AccountsController < SuperAdmin::ApplicationController
Internal::SeedAccountJob.perform_later(requested_resource)
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account seeding triggered')
end
def destroy
account = Account.find(params[:id])
DeleteObjectJob.perform_later(account) if account.present?
redirect_back(fallback_location: [namespace, requested_resource], notice: 'Account deletion is in progress.')
end
end

View File

@ -1,6 +1,18 @@
class SuperAdmin::UsersController < SuperAdmin::ApplicationController
# 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.
def create
resource = resource_class.new(resource_params)
authorize_resource(resource)
if resource.save
redirect_to super_admin_user_path(resource), notice: translate_with_resource('create.success')
else
notice = resource.errors.full_messages.first
redirect_to new_super_admin_user_path, notice: notice
end
end
#
# def update
# super

View File

@ -14,7 +14,7 @@ class Twitter::CallbacksController < Twitter::BaseController
redirect_to app_twitter_inbox_agents_url(account_id: account.id, inbox_id: inbox.id)
end
rescue StandardError => e
Rails.logger.error e
ChatwootExceptionTracker.new(e).capture_exception
redirect_to twitter_app_redirect_url
end
@ -49,10 +49,22 @@ class Twitter::CallbacksController < Twitter::BaseController
twitter_access_token_secret: parsed_body['oauth_token_secret'],
profile_id: parsed_body['user_id']
)
account.inboxes.create!(
inbox = account.inboxes.create!(
name: parsed_body['screen_name'],
channel: twitter_profile
)
save_profile_image(inbox)
inbox
end
def save_profile_image(inbox)
response = twitter_client.user_show(screen_name: inbox.name)
return unless response.status.to_i == 200
parsed_user_profile = response.body
::Avatar::AvatarFromUrlJob.perform_later(inbox, parsed_user_profile['profile_image_url_https'])
end
def permitted_params

View File

@ -1,4 +1,8 @@
class ContactDrop < BaseDrop
def name
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
end
def email
@obj.try(:email)
end
@ -8,10 +12,10 @@ class ContactDrop < BaseDrop
end
def first_name
@obj.try(:name).try(:split).try(:first)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
end

View File

@ -1,13 +1,17 @@
class UserDrop < BaseDrop
def name
@obj.try(:name).try(:split).try(:map, &:capitalize).try(:join, ' ')
end
def available_name
@obj.try(:available_name)
end
def first_name
@obj.try(:name).try(:split).try(:first)
@obj.try(:name).try(:split).try(:first).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
def last_name
@obj.try(:name).try(:split).try(:last) if @obj.try(:name).try(:split).try(:size) > 1
@obj.try(:name).try(:split).try(:last).try(:capitalize) if @obj.try(:name).try(:split).try(:size) > 1
end
end

View File

@ -7,12 +7,18 @@ class EmailChannelFinder
def perform
channel = nil
recipient_mails = @email_object.to.to_a + @email_object.cc.to_a
recipient_mails.each do |email|
normalized_email = normalize_email_with_plus_addressing(email)
channel = Channel::Email.find_by('lower(email) = ? OR lower(forward_to_email) = ?', normalized_email, normalized_email)
break if channel.present?
end
channel
end
def recipient_mails
recipient_addresses = @email_object.to.to_a + @email_object.cc.to_a + @email_object.bcc.to_a + [@email_object['X-Original-To'].try(:value)]
recipient_addresses.flatten.compact
end
end

View File

@ -21,12 +21,30 @@ class MessageFinder
end
def current_messages
if @params[:after].present?
messages.reorder('created_at asc').where('id >= ?', @params[:before].to_i).limit(20)
if @params[:after].present? && @params[:before].present?
messages_between(@params[:after].to_i, @params[:before].to_i)
elsif @params[:before].present?
messages.reorder('created_at desc').where('id < ?', @params[:before].to_i).limit(20).reverse
messages_before(@params[:before].to_i)
elsif @params[:after].present?
messages_after(@params[:after].to_i)
else
messages.reorder('created_at desc').limit(20).reverse
messages_latest
end
end
def messages_after(after_id)
messages.reorder('created_at asc').where('id > ?', after_id).limit(100)
end
def messages_before(before_id)
messages.reorder('created_at desc').where('id < ?', before_id).limit(20).reverse
end
def messages_between(after_id, before_id)
messages.reorder('created_at asc').where('id >= ? AND id < ?', after_id, before_id).limit(1000)
end
def messages_latest
messages.reorder('created_at desc').limit(20).reverse
end
end

View File

@ -0,0 +1,103 @@
module Api::V2::Accounts::HeatmapHelper
def generate_conversations_heatmap_report
timezone_data = generate_heatmap_data_for_timezone(params[:timezone_offset])
group_traffic_data(timezone_data)
end
private
def group_traffic_data(data)
# start with an empty array
result_arr = []
# pick all the unique dates from the data in ascending order
dates = data.pluck(:date).uniq.sort
# add the dates as the first row, leave an empty cell for the hour column
# e.g. [nil, '2023-01-01', '2023-1-02', '2023-01-03']
result_arr << ([nil] + dates)
# group the data by hour, we do not need to sort it, because the data is already sorted
# given it starts from the beginning of the day
# here each hour is a key, and the value is an array of all the items for that hour at each date
# e.g. hour = 1
# value = [{date: 2023-01-01, value: 1}, {date: 2023-01-02, value: 1}, {date: 2023-01-03, value: 1}, ...]
data.group_by { |d| d[:hour] }.each do |hour, items|
# create a new row for each hour
row = [hour]
# group the items by date, so we can easily access the value for each date
# grouped values will be a hasg with the date as the key, and the value as the value
# e.g. { '2023-01-01' => [{date: 2023-01-01, value: 1}], '2023-01-02' => [{date: 2023-01-02, value: 1}], ... }
grouped_values = items.group_by { |d| d[:date] }
# now for each unique date we have, we can access the value for that date and append it to the array
dates.each do |date|
row << (grouped_values[date][0][:value] if grouped_values[date].is_a?(Array))
end
# row will look like [22, 0, 0, 1, 4, 6, 7, 4]
# add the row to the result array
result_arr << row
end
# return the resultant array
# the result looks like this
# [
# [nil, '2023-01-01', '2023-1-02', '2023-01-03'],
# [0, 0, 0, 0],
# [1, 0, 0, 0],
# [2, 0, 0, 0],
# [3, 0, 0, 0],
# [4, 0, 0, 0],
# ]
result_arr
end
def generate_heatmap_data_for_timezone(offset)
timezone = ActiveSupport::TimeZone[offset]&.name
timezone_today = DateTime.now.in_time_zone(timezone).beginning_of_day
timezone_data_raw = generate_heatmap_data(timezone_today, offset)
transform_data(timezone_data_raw, false)
end
def generate_heatmap_data(date, offset)
report_params = {
type: :account,
group_by: 'hour',
metric: 'conversations_count',
business_hours: false
}
V2::ReportBuilder.new(Current.account, report_params.merge({
since: since_timestamp(date),
until: until_timestamp(date),
timezone_offset: offset
})).build
end
def transform_data(data, zone_transform)
# rubocop:disable Rails/TimeZone
data.map do |d|
date = zone_transform ? Time.zone.at(d[:timestamp]) : Time.at(d[:timestamp])
{
date: date.to_date.to_s,
hour: date.hour,
value: d[:value]
}
end
# rubocop:enable Rails/TimeZone
end
def since_timestamp(date)
(date - 6.days).to_i.to_s
end
def until_timestamp(date)
date.to_i.to_s
end
end

View File

@ -0,0 +1,15 @@
module CacheKeysHelper
def get_prefixed_cache_key(account_id, key)
"idb-cache-key-account-#{account_id}-#{key}"
end
def fetch_value_for_key(account_id, key)
prefixed_cache_key = get_prefixed_cache_key(account_id, key)
value_from_cache = Redis::Alfred.get(prefixed_cache_key)
return value_from_cache if value_from_cache.present?
# zero epoch time: 1970-01-01 00:00:00 UTC
'0000000000'
end
end

View File

@ -72,13 +72,6 @@ export default {
if (!this.hasAccounts) {
this.showAddAccountModal = true;
}
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
currentAccountId() {
if (this.currentAccountId) {
@ -107,6 +100,14 @@ export default {
this.updateRTLDirectionView(locale);
this.latestChatwootVersion = latestChatwootVersion;
vueActionCable.init(pubsubToken);
verifyServiceWorkerExistence(registration =>
registration.pushManager.getSubscription().then(subscription => {
if (subscription) {
registerSubscription();
}
})
);
},
},
};

View File

@ -13,6 +13,19 @@ class ApiClient {
return `${this.baseUrl()}/${this.resource}`;
}
// eslint-disable-next-line class-methods-use-this
get accountIdFromRoute() {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
return window.location.pathname.split('/')[3];
}
return '';
}
baseUrl() {
let url = this.apiVersion;
@ -20,15 +33,8 @@ class ApiClient {
url = `/enterprise${url}`;
}
if (this.options.accountScoped) {
const isInsideAccountScopedURLs = window.location.pathname.includes(
'/app/accounts'
);
if (isInsideAccountScopedURLs) {
const accountId = window.location.pathname.split('/')[3];
url = `${url}/accounts/${accountId}`;
}
if (this.options.accountScoped && this.accountIdFromRoute) {
url = `${url}/accounts/${this.accountIdFromRoute}`;
}
return url;

View File

@ -0,0 +1,82 @@
/* global axios */
import { DataManager } from '../helper/CacheHelper/DataManager';
import ApiClient from './ApiClient';
class CacheEnabledApiClient extends ApiClient {
constructor(resource, options = {}) {
super(resource, options);
this.dataManager = new DataManager(this.accountIdFromRoute);
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
throw new Error('cacheModelName is not defined');
}
get(cache = false) {
if (cache) {
return this.getFromCache();
}
return axios.get(this.url);
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data.payload;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: { payload: dataToParse } };
}
async getFromCache() {
await this.dataManager.initDb();
const { data } = await axios.get(
`/api/v1/accounts/${this.accountIdFromRoute}/cache_keys`
);
const cacheKeyFromApi = data.cache_keys[this.cacheModelName];
const isCacheValid = await this.validateCacheKey(cacheKeyFromApi);
let localData = [];
if (isCacheValid) {
localData = await this.dataManager.get({
modelName: this.cacheModelName,
});
}
if (localData.length === 0) {
return this.refetchAndCommit(cacheKeyFromApi);
}
return this.marshallData(localData);
}
async refetchAndCommit(newKey = null) {
await this.dataManager.initDb();
const response = await axios.get(this.url);
this.dataManager.replace({
modelName: this.cacheModelName,
data: this.extractDataFromResponse(response),
});
await this.dataManager.setCacheKeys({
[this.cacheModelName]: newKey,
});
return response;
}
async validateCacheKey(cacheKeyFromApi) {
if (!this.dataManager.db) {
await this.dataManager.initDb();
}
const cachekey = await this.dataManager.getCacheKey(this.cacheModelName);
return cacheKeyFromApi === cachekey;
}
}
export default CacheEnabledApiClient;

View File

@ -0,0 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
class AuditLogs extends ApiClient {
constructor() {
super('audit_logs', { accountScoped: true });
}
get({ page }) {
const url = page ? `${this.url}?page=${page}` : this.url;
return axios.get(url);
}
}
export default new AuditLogs();

View File

@ -2,7 +2,11 @@
import Cookies from 'js-cookie';
import endPoints from './endPoints';
import { setAuthCredentials, clearCookiesOnLogout } from '../store/utils/api';
import {
setAuthCredentials,
clearCookiesOnLogout,
deleteIndexedDBOnLogout,
} from '../store/utils/api';
export default {
login(creds) {
@ -50,6 +54,7 @@ export default {
axios
.delete(urlData.url)
.then(response => {
deleteIndexedDBOnLogout();
clearCookiesOnLogout();
resolve(response);
})

View File

@ -60,6 +60,13 @@ class ArticlesAPI extends PortalsAPI {
}
);
}
reorderArticles({ portalSlug, reorderedGroup, categorySlug }) {
return axios.post(`${this.url}/${portalSlug}/articles/reorder`, {
positions_hash: reorderedGroup,
category_slug: categorySlug,
});
}
}
export default new ArticlesAPI();

View File

@ -75,10 +75,12 @@ class MessageApi extends ApiClient {
return axios.delete(`${this.url}/${conversationID}/messages/${messageId}`);
}
getPreviousMessages({ conversationId, before }) {
return axios.get(`${this.url}/${conversationId}/messages`, {
params: { before },
});
getPreviousMessages({ conversationId, after, before }) {
const params = { before };
if (after && Number(after) !== Number(before)) {
params.after = after;
}
return axios.get(`${this.url}/${conversationId}/messages`, { params });
}
translateMessage(conversationId, messageId, targetLanguage) {

View File

@ -1,11 +1,16 @@
/* global axios */
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class Inboxes extends ApiClient {
class Inboxes extends CacheEnabledApiClient {
constructor() {
super('inboxes', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'inbox';
}
getCampaigns(inboxId) {
return axios.get(`${this.url}/${inboxId}/campaigns`);
}

View File

@ -1,9 +1,14 @@
import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
class LabelsAPI extends ApiClient {
class LabelsAPI extends CacheEnabledApiClient {
constructor() {
super('labels', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'label';
}
}
export default new LabelsAPI();

View File

@ -14,8 +14,8 @@ class ReportsAPI extends ApiClient {
to,
type = 'account',
id,
group_by,
business_hours,
groupBy,
businessHours,
}) {
return axios.get(`${this.url}`, {
params: {
@ -24,22 +24,22 @@ class ReportsAPI extends ApiClient {
until: to,
type,
id,
group_by,
business_hours,
group_by: groupBy,
business_hours: businessHours,
timezone_offset: getTimeOffset(),
},
});
}
getSummary(since, until, type = 'account', id, group_by, business_hours) {
getSummary(since, until, type = 'account', id, groupBy, businessHours) {
return axios.get(`${this.url}/summary`, {
params: {
since,
until,
type,
id,
group_by,
business_hours,
group_by: groupBy,
business_hours: businessHours,
},
});
}
@ -59,6 +59,12 @@ class ReportsAPI extends ApiClient {
});
}
getConversationTrafficCSV() {
return axios.get(`${this.url}/conversation_traffic`, {
params: { timezone_offset: getTimeOffset() },
});
}
getLabelReports({ from: since, to: until, businessHours }) {
return axios.get(`${this.url}/labels`, {
params: { since, until, business_hours: businessHours },

View File

@ -1,11 +1,27 @@
/* global axios */
import ApiClient from './ApiClient';
// import ApiClient from './ApiClient';
import CacheEnabledApiClient from './CacheEnabledApiClient';
export class TeamsAPI extends ApiClient {
export class TeamsAPI extends CacheEnabledApiClient {
constructor() {
super('teams', { accountScoped: true });
}
// eslint-disable-next-line class-methods-use-this
get cacheModelName() {
return 'team';
}
// eslint-disable-next-line class-methods-use-this
extractDataFromResponse(response) {
return response.data;
}
// eslint-disable-next-line class-methods-use-this
marshallData(dataToParse) {
return { data: dataToParse };
}
getAgents({ teamId }) {
return axios.get(`${this.url}/${teamId}/team_members`);
}

View File

@ -1,5 +1,5 @@
/* global axios */
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
export const getTestimonialContent = () => {
return axios.get(wootConstants.TESTIMONIAL_URL);

View File

@ -86,6 +86,12 @@
margin-left: var(--space-small);
}
&:first-child {
.button {
margin-left: 0;
}
}
&.justify-content-end {
justify-content: end;
}

View File

@ -45,7 +45,6 @@
variant="smooth"
color-scheme="alert"
icon="delete"
class="delete-custom-view__button"
@click="onClickOpenDeleteFoldersModal"
/>
</div>
@ -174,7 +173,7 @@ import ConversationCard from './widgets/conversation/ConversationCard';
import timeMixin from '../mixins/time';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import conversationMixin from '../mixins/conversations';
import wootConstants from '../constants';
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';
@ -835,17 +834,14 @@ export default {
.filter--actions {
display: flex;
align-items: center;
gap: var(--space-micro);
}
.filter__applied {
padding: 0 0 var(--space-slab) 0 !important;
padding-bottom: var(--space-slab) !important;
border-bottom: 1px solid var(--color-border);
}
.delete-custom-view__button {
margin-right: var(--space-normal);
}
.tab--chat-type {
padding: 0 var(--space-normal);

View File

@ -1,8 +1,23 @@
<template>
<div class="code--container">
<button class="button small button--copy-code" @click="onCopy">
{{ $t('COMPONENTS.CODE.BUTTON_TEXT') }}
</button>
<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>
@ -21,6 +36,24 @@ export default {
type: String,
default: 'javascript',
},
enableCodePen: {
type: Boolean,
default: false,
},
codepenTitle: {
type: String,
default: 'Chatwoot Codepen',
},
},
computed: {
codepenScriptValue() {
const lang = this.lang === 'javascript' ? 'js' : this.lang;
return JSON.stringify({
title: this.codepenTitle,
private: true,
[lang]: this.script,
});
},
},
methods: {
async onCopy(e) {
@ -37,10 +70,14 @@ export default {
position: relative;
text-align: left;
.button--copy-code {
margin-top: 0;
.code--action-area {
top: var(--space-small);
position: absolute;
right: 0;
right: var(--space-small);
}
.code--codeopen-form {
display: inline-block;
}
}
</style>

View File

@ -218,13 +218,17 @@ export default {
},
mounted() {
this.editedValue = this.formattedValue;
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, focusAttributeKey => {
bus.$on(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
destroyed() {
bus.$off(BUS_EVENTS.FOCUS_CUSTOM_ATTRIBUTE, this.onFocusAttribute);
},
methods: {
onFocusAttribute(focusAttributeKey) {
if (this.attributeKey === focusAttributeKey) {
this.onEdit();
}
});
},
methods: {
},
focusInput() {
if (this.$refs.inputfield) {
this.$refs.inputfield.focus();

View File

@ -47,7 +47,7 @@ export default {
},
computed: {
modalContainerClassName() {
let className = 'modal-container';
let className = 'modal-container skip-context-menu';
if (this.fullWidth) {
return `${className} modal-container--full-width`;
}
@ -60,7 +60,9 @@ export default {
'right-aligned': 'right-aligned',
};
return `modal-mask ${modalClassNameMap[this.modalType] || ''}`;
return `modal-mask skip-context-menu ${modalClassNameMap[
this.modalType
] || ''}`;
},
},
mounted() {

View File

@ -12,7 +12,8 @@
</template>
<script>
import Banner from 'dashboard/components/ui/Banner.vue';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../helper/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { mapGetters } from 'vuex';
import adminMixin from 'dashboard/mixins/isAdmin';
import { hasAnUpdateAvailable } from './versionCheckHelper';

View File

@ -126,7 +126,7 @@ import WootDropdownSubMenu from 'shared/components/ui/dropdown/DropdownSubMenu.v
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import wootConstants from '../../constants';
import wootConstants from 'dashboard/constants/globals';
import {
CMD_REOPEN_CONVERSATION,
CMD_RESOLVE_CONVERSATION,

View File

@ -13,6 +13,7 @@ import DropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import FeatureToggle from './widgets/FeatureToggle';
import HorizontalBar from './widgets/chart/HorizontalBarChart';
import Input from './widgets/forms/Input.vue';
import PhoneInput from './widgets/forms/PhoneInput.vue';
import Label from './ui/Label';
import LoadingState from './widgets/LoadingState';
import Modal from './Modal';
@ -40,6 +41,7 @@ const WootUIKit = {
FeatureToggle,
HorizontalBar,
Input,
PhoneInput,
Label,
LoadingState,
Modal,

View File

@ -52,8 +52,9 @@ import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu';
import WootDropdownHeader from 'shared/components/ui/dropdown/DropdownHeader';
import WootDropdownDivider from 'shared/components/ui/dropdown/DropdownDivider';
import AvailabilityStatusBadge from '../widgets/conversation/AvailabilityStatusBadge';
import wootConstants from 'dashboard/constants/globals';
const AVAILABILITY_STATUS_KEYS = ['online', 'busy', 'offline'];
const { AVAILABILITY_STATUS_KEYS } = wootConstants;
export default {
components: {

View File

@ -8,6 +8,7 @@ const settings = accountId => ({
'agent_list',
'attributes_list',
'automation_list',
'auditlogs_list',
'billing_settings_index',
'canned_list',
'general_settings_index',
@ -150,6 +151,14 @@ const settings = accountId => ({
toStateName: 'billing_settings_index',
showOnlyOnCloud: true,
},
{
icon: 'key',
label: 'AUDIT_LOGS',
hasSubMenu: false,
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
toStateName: 'auditlogs_list',
beta: true,
},
],
});

View File

@ -41,7 +41,7 @@ import PrimaryNavItem from './PrimaryNavItem';
import OptionsMenu from './OptionsMenu';
import AgentDetails from './AgentDetails';
import NotificationBell from './NotificationBell';
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
import { frontendURL } from 'dashboard/helper/URLHelper';
export default {

View File

@ -1,7 +1,5 @@
<template>
<div
v-show="show"
ref="context"
class="context-menu-container"
:style="style"
tabindex="0"
@ -39,7 +37,6 @@ export default {
},
mounted() {
this.$nextTick(() => this.$el.focus());
this.show = true;
},
};
</script>

View File

@ -9,7 +9,7 @@
</woot-tabs>
</template>
<script>
import wootConstants from '../../constants';
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndNKey } from 'shared/helpers/KeyboardHelpers';

View File

@ -11,7 +11,7 @@
</template>
<script>
import wootConstants from '../../../constants';
import wootConstants from 'dashboard/constants/globals';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import { hasPressedAltAndBKey } from 'shared/helpers/KeyboardHelpers';

View File

@ -63,7 +63,7 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import InboxName from '../InboxName';
import MoreActions from './MoreActions';
import Thumbnail from '../Thumbnail';
import wootConstants from '../../../constants';
import wootConstants from 'dashboard/constants/globals';
import { conversationListPageURL } from 'dashboard/helper/URLHelper';
export default {
components: {

View File

@ -1,7 +1,21 @@
<template>
<li v-if="shouldRenderMessage" :class="alignBubble">
<li v-if="shouldRenderMessage" :id="`message${data.id}`" :class="alignBubble">
<div :class="wrapClass">
<div v-tooltip.top-start="messageToolTip" :class="bubbleClass">
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
color-scheme="alert"
variant="clear"
icon="arrow-clockwise"
@click="retrySendMessage"
/>
</div>
<div
v-tooltip.top-start="messageToolTip"
:class="bubbleClass"
@contextmenu="openContextMenu($event)"
>
<bubble-mail-head
:email-attributes="contentAttributes.email"
:cc="emailHeadAttributes.cc"
@ -32,7 +46,11 @@
:url="attachment.data_url"
@error="onImageLoadError"
/>
<audio v-else-if="attachment.file_type === 'audio'" controls>
<audio
v-else-if="attachment.file_type === 'audio'"
controls
class="skip-context-menu"
>
<source :src="attachment.data_url" />
</audio>
<bubble-video
@ -60,6 +78,7 @@
:id="data.id"
:sender="data.sender"
:story-sender="storySender"
:external-error="externalError"
:story-id="storyId"
:is-a-tweet="isATweet"
:is-a-whatsapp-channel="isAWhatsAppChannel"
@ -73,39 +92,6 @@
:created-at="createdAt"
/>
</div>
<woot-modal
v-if="showTranslateModal"
modal-type="right-aligned"
show
:on-close="onCloseTranslateModal"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="data.content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div
v-for="(translation, language) in translations"
:key="language"
>
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
<spinner v-if="isPending" size="tiny" />
<div
v-if="showAvatar"
@ -127,29 +113,16 @@
{{ sender.name }}
</a>
</div>
<div v-if="isFailed" class="message-failed--alert">
<woot-button
v-tooltip.top-end="$t('CONVERSATION.TRY_AGAIN')"
size="tiny"
color-scheme="alert"
variant="clear"
icon="arrow-clockwise"
@click="retrySendMessage"
/>
</div>
</div>
<div v-if="shouldShowContextMenu" class="context-menu-wrap">
<context-menu
v-if="isBubble && !isMessageDeleted"
:context-menu-position="contextMenuPosition"
:is-open="showContextMenu"
:show-copy="hasText"
:show-delete="hasText || hasAttachments"
:show-canned-response-option="isOutgoing && hasText"
:menu-position="contextMenuPosition"
:message-content="data.content"
@toggle="handleContextMenuClick"
@delete="handleDelete"
@translate="handleTranslate"
:enabled-options="contextMenuEnabledOptions"
:message="data"
@open="openContextMenu"
@close="closeContextMenu"
/>
</div>
</li>
@ -172,7 +145,8 @@ import alertMixin from 'shared/mixins/alertMixin';
import contentTypeMixin from 'shared/mixins/contentTypeMixin';
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import { generateBotMessageContent } from './helpers/botMessageContentHelper';
import { mapGetters } from 'vuex';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { ACCOUNT_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
export default {
components: {
@ -216,14 +190,11 @@ export default {
return {
showContextMenu: false,
hasImageError: false,
showTranslateModal: false,
contextMenuPosition: {},
showBackgroundHighlight: false,
};
},
computed: {
...mapGetters({
getAccount: 'accounts/getAccount',
currentAccountId: 'getCurrentAccountId',
}),
shouldRenderMessage() {
return (
this.hasAttachments ||
@ -239,9 +210,6 @@ export default {
} = this.contentAttributes.email || {};
return fullHTMLContent || fullTextContent || '';
},
translations() {
return this.contentAttributes.translations || {};
},
displayQuotedButton() {
if (this.emailMessageContent.includes('<blockquote')) {
return true;
@ -253,9 +221,6 @@ export default {
return false;
},
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
message() {
// If the message is an email, emailMessageContent would be present
// In that case, we would use letter package to render the email
@ -287,9 +252,19 @@ export default {
) + botMessageContent
);
},
contextMenuEnabledOptions() {
return {
copy: this.hasText,
delete: this.hasText || this.hasAttachments,
cannedResponse: this.isOutgoing && this.hasText,
};
},
contentAttributes() {
return this.data.content_attributes || {};
},
externalError() {
return this.contentAttributes.external_error || null;
},
sender() {
return this.data.sender || {};
},
@ -320,13 +295,13 @@ export default {
const isRightAligned =
messageType === MESSAGE_TYPE.OUTGOING ||
messageType === MESSAGE_TYPE.TEMPLATE;
return {
center: isCentered,
left: isLeftAligned,
right: isRightAligned,
'has-context-menu': this.showContextMenu,
'has-tweet-menu': this.isATweet,
'has-bg': this.showBackgroundHighlight,
};
},
createdAt() {
@ -380,7 +355,7 @@ export default {
return false;
}
if (this.isFailed) {
return this.$t(`CONVERSATION.SEND_FAILED`);
return this.externalError ? '' : this.$t(`CONVERSATION.SEND_FAILED`);
}
return false;
},
@ -415,10 +390,6 @@ export default {
if (this.isPending || this.isFailed) return false;
return !this.sender.type || this.sender.type === 'agent_bot';
},
contextMenuPosition() {
const { message_type: messageType } = this.data;
return messageType ? 'right' : 'left';
},
shouldShowContextMenu() {
return !(this.isFailed || this.isPending);
},
@ -447,6 +418,12 @@ export default {
},
mounted() {
this.hasImageError = false;
bus.$on(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
this.setupHighlightTimer();
},
beforeDestroy() {
bus.$off(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL, this.closeContextMenu);
clearTimeout(this.higlightTimeout);
},
methods: {
hasMediaAttachment(type) {
@ -460,37 +437,44 @@ export default {
handleContextMenuClick() {
this.showContextMenu = !this.showContextMenu;
},
async handleDelete() {
const { conversation_id: conversationId, id: messageId } = this.data;
try {
await this.$store.dispatch('deleteMessage', {
conversationId,
messageId,
});
this.showAlert(this.$t('CONVERSATION.SUCCESS_DELETE_MESSAGE'));
this.showContextMenu = false;
} catch (error) {
this.showAlert(this.$t('CONVERSATION.FAIL_DELETE_MESSSAGE'));
}
},
async retrySendMessage() {
await this.$store.dispatch('sendMessageWithData', this.data);
},
onImageLoadError() {
this.hasImageError = true;
},
handleTranslate() {
const { locale } = this.getAccount(this.currentAccountId);
const { conversation_id: conversationId, id: messageId } = this.data;
this.$store.dispatch('translateMessage', {
conversationId,
messageId,
targetLanguage: locale || 'en',
});
this.showTranslateModal = true;
openContextMenu(e) {
const shouldSkipContextMenu =
e.target?.classList.contains('skip-context-menu') ||
e.target?.tagName.toLowerCase() === 'a';
if (shouldSkipContextMenu || getSelection().toString()) {
return;
}
e.preventDefault();
if (e.type === 'contextmenu') {
this.$track(ACCOUNT_EVENTS.OPEN_MESSAGE_CONTEXT_MENU);
}
this.contextMenuPosition = {
x: e.pageX || e.clientX,
y: e.pageY || e.clientY,
};
this.showContextMenu = true;
},
onCloseTranslateModal() {
this.showTranslateModal = false;
closeContextMenu() {
this.showContextMenu = false;
this.contextMenuPosition = { x: null, y: null };
},
setupHighlightTimer() {
if (Number(this.$route.query.messageId) !== Number(this.data.id)) {
return;
}
this.showBackgroundHighlight = true;
const HIGHLIGHT_TIMER = 1000;
this.higlightTimeout = setTimeout(() => {
this.showBackgroundHighlight = false;
}, HIGHLIGHT_TIMER);
},
},
};
@ -525,7 +509,8 @@ export default {
}
}
&.is-image.is-text > .message-text__wrap {
&.is-image.is-text > .message-text__wrap,
&.is-video.is-text > .message-text__wrap {
max-width: 32rem;
padding: var(--space-small) var(--space-normal);
}
@ -612,24 +597,20 @@ export default {
margin-top: var(--space-smaller) var(--space-smaller) 0 0;
}
.button--delete-message {
visibility: hidden;
}
li.left,
li.right {
display: flex;
align-items: flex-end;
&:hover .button--delete-message {
visibility: visible;
}
}
li.left.has-tweet-menu .context-menu {
margin-bottom: var(--space-medium);
}
li.has-bg {
background: var(--w-75);
}
li.right .context-menu-wrap {
margin-left: auto;
}
@ -644,7 +625,6 @@ li.right {
.wrap.is-failed {
display: flex;
flex-direction: row-reverse;
align-items: flex-end;
margin-left: auto;
}
@ -652,9 +632,6 @@ li.right {
.has-context-menu {
background: var(--color-background);
.button--delete-message {
visibility: visible;
}
}
.context-menu {

View File

@ -192,11 +192,6 @@ export default {
(!this.listLoadingStatus && this.isLoadingPrevious)
);
},
shouldLoadMoreChats() {
return !this.listLoadingStatus && !this.isLoadingPrevious;
},
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
@ -302,8 +297,16 @@ export default {
setSelectedTweet(tweetId) {
this.selectedTweetId = tweetId;
},
onScrollToMessage() {
this.$nextTick(() => this.scrollToBottom());
onScrollToMessage({ messageId = '' } = {}) {
this.$nextTick(() => {
const messageElement = document.getElementById('message' + messageId);
if (messageElement) {
messageElement.scrollIntoView({ behavior: 'smooth' });
this.fetchPreviousMessages();
} else {
this.scrollToBottom();
}
});
this.makeMessagesRead();
},
showPopoutReplyBox() {
@ -354,33 +357,42 @@ export default {
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
},
handleScroll(e) {
async fetchPreviousMessages(scrollTop = 0) {
this.setScrollParams();
const shouldLoadMoreMessages =
this.getMessages.dataFetched === true &&
!this.listLoadingStatus &&
!this.isLoadingPrevious;
const dataFetchCheck =
this.getMessages.dataFetched === true && this.shouldLoadMoreChats;
if (
e.target.scrollTop < 100 &&
scrollTop < 100 &&
!this.isLoadingPrevious &&
dataFetchCheck
shouldLoadMoreMessages
) {
this.isLoadingPrevious = true;
this.$store
.dispatch('fetchPreviousMessages', {
try {
await this.$store.dispatch('fetchPreviousMessages', {
conversationId: this.currentChat.id,
before: this.getMessages.messages[0].id,
})
.then(() => {
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.isLoadingPrevious = false;
this.setScrollParams();
});
const heightDifference =
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
this.conversationPanel.scrollTop =
this.scrollTopBeforeLoad + heightDifference;
this.setScrollParams();
} catch (error) {
// Ignore Error
} finally {
this.isLoadingPrevious = false;
}
}
},
handleScroll(e) {
bus.$emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
this.fetchPreviousMessages(e.target.scrollTop);
},
makeMessagesRead() {
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
},

View File

@ -174,9 +174,10 @@ import inboxMixin from 'shared/mixins/inboxMixin';
import uiSettingsMixin from 'dashboard/mixins/uiSettings';
import { DirectUpload } from 'activestorage';
import { frontendURL } from '../../../helper/URLHelper';
import { LocalStorage, LOCAL_STORAGE_KEYS } from '../../../helper/localStorage';
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
import { LocalStorage } from 'shared/helpers/localStorage';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants';
import wootConstants from 'dashboard/constants/globals';
import { isEditorHotKeyEnabled } from 'dashboard/mixins/uiSettings';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import rtlMixin from 'shared/mixins/rtlMixin';
@ -336,6 +337,12 @@ export default {
this.message.length > this.maxLength
);
},
sender() {
return {
name: this.currentUser.name,
thumbnail: this.currentUser.avatar_url,
};
},
conversationType() {
const { additional_attributes: additionalAttributes } = this.currentChat;
const type = additionalAttributes ? additionalAttributes.type : '';
@ -452,7 +459,12 @@ export default {
return !this.isOnPrivateNote && this.isAnEmailChannel;
},
enableMultipleFileUpload() {
return this.isAnEmailChannel || this.isAWebWidgetInbox || this.isAPIInbox;
return (
this.isAnEmailChannel ||
this.isAWebWidgetInbox ||
this.isAPIInbox ||
this.isAWhatsAppChannel
);
},
isSignatureEnabledForInbox() {
return !this.isPrivate && this.isAnEmailChannel && this.sendWithSignature;
@ -991,6 +1003,7 @@ export default {
files: [attachedFile],
private: false,
message: caption,
sender: this.sender,
};
multipleMessagePayload.push(attachmentPayload);
caption = '';
@ -1000,6 +1013,7 @@ export default {
conversationId: this.currentChat.id,
message,
private: false,
sender: this.sender,
};
multipleMessagePayload.push(messagePayload);
}
@ -1011,6 +1025,7 @@ export default {
conversationId: this.currentChat.id,
message,
private: this.isPrivate,
sender: this.sender,
};
if (this.inReplyTo) {

View File

@ -9,6 +9,14 @@
>
{{ readableTime }}
</span>
<span v-if="externalError" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="externalError"
icon="error-circle"
class="action--icon"
size="14"
/>
</span>
<span v-if="showReadIndicator" class="read-indicator-wrap">
<fluent-icon
v-tooltip.top-start="$t('CHAT_LIST.MESSAGE_READ')"
@ -98,6 +106,10 @@ export default {
type: String,
default: '',
},
externalError: {
type: String,
default: '',
},
storyId: {
type: String,
default: '',
@ -163,7 +175,7 @@ export default {
return MESSAGE_STATUS.SENT === this.messageStatus;
},
readableTime() {
return this.messageStamp(this.createdAt, 'LLL d, h:mm a');
return this.messageTimestamp(this.createdAt, 'LLL d, h:mm a');
},
screenName() {
const { additional_attributes: additionalAttributes = {} } =

View File

@ -2,7 +2,7 @@
<div class="image message-text__wrap">
<img :src="url" @click="onClick" @error="onImgError()" />
<woot-modal :full-width="true" :show.sync="show" :on-close="onClose">
<img :src="url" class="modal-image" />
<img :src="url" class="modal-image skip-context-menu" />
</woot-modal>
</div>
</template>

View File

@ -0,0 +1,59 @@
<template>
<woot-modal
modal-type="right-aligned"
class="text-left"
show
:on-close="onClose"
>
<div class="column content">
<p>
<b>{{ $t('TRANSLATE_MODAL.ORIGINAL_CONTENT') }}</b>
</p>
<p v-dompurify-html="content" />
<br />
<hr />
<div v-if="translationsAvailable">
<p>
<b>{{ $t('TRANSLATE_MODAL.TRANSLATED_CONTENT') }}</b>
</p>
<div v-for="(translation, language) in translations" :key="language">
<p>
<strong>{{ language }}:</strong>
</p>
<p v-dompurify-html="translation" />
<br />
</div>
</div>
<p v-else>
{{ $t('TRANSLATE_MODAL.NO_TRANSLATIONS_AVAILABLE') }}
</p>
</div>
</woot-modal>
</template>
<script>
export default {
props: {
contentAttributes: {
type: Object,
default: () => ({}),
},
content: {
type: String,
default: '',
},
},
computed: {
translationsAvailable() {
return !!Object.keys(this.translations).length;
},
translations() {
return this.contentAttributes.translations || {};
},
},
methods: {
onClose() {
this.$emit('close');
},
},
};
</script>

View File

@ -2,7 +2,12 @@
<div class="video message-text__wrap">
<video :src="url" muted playsInline @click="onClick" />
<woot-modal :show.sync="show" :on-close="onClose">
<video :src="url" controls playsInline class="modal-video" />
<video
:src="url"
controls
playsInline
class="modal-video skip-context-menu"
/>
</woot-modal>
</div>
</template>

View File

@ -69,7 +69,7 @@
<script>
import MenuItem from './menuItem.vue';
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
import wootConstants from 'dashboard/constants.js';
import wootConstants from 'dashboard/constants/globals';
import snoozeTimesMixin from 'dashboard/mixins/conversation/snoozeTimesMixin';
import { mapGetters } from 'vuex';
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';

View File

@ -1,5 +1,5 @@
<template>
<div class="menu" @click.stop="$emit('click')">
<div class="menu" role="button" @click.stop="$emit('click')">
<fluent-icon
v-if="variant === 'icon' && option.icon"
:icon="option.icon"

View File

@ -67,6 +67,7 @@ export default {
&:hover {
background-color: var(--w-75);
.submenu {
display: block;
}
@ -83,7 +84,7 @@ export default {
}
&.disabled {
opacity: 50%;
opacity: 0.5;
cursor: not-allowed;
}
}

View File

@ -0,0 +1,361 @@
<template>
<div class="phone-input--wrap">
<div class="phone-input" :class="{ 'has-error': error }">
<div class="country-emoji--wrap" @click="toggleCountryDropdown">
<h5 v-if="activeCountry.emoji">{{ activeCountry.emoji }}</h5>
<fluent-icon v-else icon="globe" class="fluent-icon" size="16" />
<fluent-icon icon="chevron-down" class="fluent-icon" size="12" />
</div>
<span v-if="activeDialCode" class="country-dial--code">
{{ activeDialCode }}
</span>
<input
:value="phoneNumber"
type="tel"
class="phone-input--field"
:placeholder="placeholder"
:readonly="readonly"
:style="styles"
@input="onChange"
@blur="onBlur"
/>
</div>
<div v-if="showDropdown" ref="dropdown" class="country-dropdown">
<div class="dropdown-search--wrap">
<input
ref="searchbar"
v-model="searchCountry"
type="text"
placeholder="Search"
class="dropdown-search"
/>
</div>
<div
v-for="(country, index) in filteredCountriesBySearch"
ref="dropdownItem"
:key="index"
class="country-dropdown--item"
:class="{
active: country.id === activeCountryCode,
focus: index === selectedIndex,
}"
@click="onSelectCountry(country)"
>
<span class="country-emoji">{{ country.emoji }}</span>
<span class="country-name">
{{ country.name }}
</span>
<span class="country-dial-code">{{ country.dial_code }}</span>
</div>
<div v-if="filteredCountriesBySearch.length === 0">
<span class="no-results">No results found</span>
</div>
</div>
</div>
</template>
<script>
import countries from 'shared/constants/countries.js';
import parsePhoneNumber from 'libphonenumber-js';
import eventListenerMixins from 'shared/mixins/eventListenerMixins';
import {
hasPressedArrowUpKey,
hasPressedArrowDownKey,
isEnter,
} from 'shared/helpers/KeyboardHelpers';
export default {
mixins: [eventListenerMixins],
props: {
value: {
type: [String, Number],
default: '',
},
placeholder: {
type: String,
default: '',
},
readonly: {
type: Boolean,
default: false,
},
styles: {
type: Object,
default: () => {},
},
error: {
type: Boolean,
default: false,
},
},
data() {
return {
countries: [
{
name: 'Select Country',
dial_code: '',
emoji: '',
id: '',
},
...countries,
],
selectedIndex: -1,
showDropdown: false,
searchCountry: '',
activeCountryCode: '',
activeDialCode: '',
phoneNumber: this.value,
};
},
computed: {
filteredCountriesBySearch() {
return this.countries.filter(country => {
const { name, dial_code, id } = country;
const search = this.searchCountry.toLowerCase();
return (
name.toLowerCase().includes(search) ||
dial_code.toLowerCase().includes(search) ||
id.toLowerCase().includes(search)
);
});
},
activeCountry() {
if (this.activeCountryCode) {
return this.countries.find(
country => country.id === this.activeCountryCode
);
}
return '';
},
},
watch: {
value() {
const number = parsePhoneNumber(this.value);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = `+${number.countryCallingCode}`;
this.phoneNumber = this.value.replace(
`+${number.countryCallingCode}`,
''
);
}
},
},
mounted() {
window.addEventListener('mouseup', this.onOutsideClick);
this.setActiveCountry();
},
beforeDestroy() {
window.removeEventListener('mouseup', this.onOutsideClick);
},
methods: {
onOutsideClick(e) {
if (
this.showDropdown &&
e.target !== this.$refs.dropdown &&
!this.$refs.dropdown.contains(e.target)
) {
this.closeDropdown();
}
},
onChange(e) {
this.phoneNumber = e.target.value;
this.$emit('input', e.target.value, this.activeDialCode);
},
onBlur(e) {
this.$emit('blur', e.target.value);
},
dropdownItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll(
'div.country-dropdown div.country-dropdown--item'
)
);
},
focusedItem() {
return Array.from(
this.$refs.dropdown.querySelectorAll('div.country-dropdown div.focus')
);
},
focusedItemIndex() {
return Array.from(this.dropdownItem()).indexOf(this.focusedItem()[0]);
},
onKeyDownHandler(e) {
const { showDropdown, filteredCountriesBySearch, onSelectCountry } = this;
const { selectedIndex } = this;
if (showDropdown) {
if (hasPressedArrowDownKey(e)) {
e.preventDefault();
this.selectedIndex = Math.min(
selectedIndex + 1,
filteredCountriesBySearch.length - 1
);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28;
} else if (hasPressedArrowUpKey(e)) {
e.preventDefault();
this.selectedIndex = Math.max(selectedIndex - 1, 0);
this.$refs.dropdown.scrollTop = this.focusedItemIndex() * 28 - 56;
} else if (isEnter(e)) {
e.preventDefault();
onSelectCountry(filteredCountriesBySearch[selectedIndex]);
}
}
},
onSelectCountry(country) {
this.activeCountryCode = country.id;
this.searchCountry = '';
this.activeDialCode = country.dial_code;
this.$emit('setCode', country.dial_code);
this.closeDropdown();
},
setActiveCountry() {
const { phoneNumber } = this;
if (!phoneNumber) return;
const number = parsePhoneNumber(phoneNumber);
if (number) {
this.activeCountryCode = number.country;
this.activeDialCode = number.countryCallingCode;
}
},
toggleCountryDropdown() {
this.showDropdown = !this.showDropdown;
this.selectedIndex = -1;
if (this.showDropdown) {
this.$nextTick(() => {
this.$refs.searchbar.focus();
});
}
},
closeDropdown() {
this.selectedIndex = -1;
this.showDropdown = false;
},
},
};
</script>
<style scoped lang="scss">
.phone-input--wrap {
position: relative;
.phone-input {
display: flex;
align-items: center;
justify-content: flex-start;
margin-bottom: var(--space-normal);
border: 1px solid var(--s-200);
border-radius: var(--border-radius-normal);
&.has-error {
border: 1px solid var(--r-400);
}
}
.country-emoji--wrap {
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-small);
background: var(--s-25);
height: 4rem;
width: 5.2rem;
border-radius: var(--border-radius-normal) 0 0 var(--border-radius-normal);
padding: var(--space-small) var(--space-smaller) var(--space-small)
var(--space-small);
h5 {
margin-bottom: 0;
}
}
.country-dial--code {
display: flex;
color: var(--s-300);
font-size: var(--space-normal);
font-weight: normal;
line-height: 1.5;
padding: var(--space-small) 0 var(--space-small) var(--space-small);
}
.phone-input--field {
margin-bottom: 0;
border: 0;
}
.country-dropdown {
z-index: var(--z-index-low);
position: absolute;
height: var(--space-giga);
width: 20rem;
overflow-y: auto;
top: 4rem;
border-radius: var(--border-radius-default);
padding: 0 0 var(--space-smaller) 0;
background-color: var(--white);
box-shadow: var(--shadow-context-menu);
border-radius: var(--border-radius-normal);
.dropdown-search--wrap {
top: 0;
position: sticky;
background-color: var(--white);
padding: var(--space-smaller);
.dropdown-search {
height: var(--space-large);
margin-bottom: 0;
font-size: var(--font-size-small);
border: 1px solid var(--s-200) !important;
}
}
.country-dropdown--item {
display: flex;
align-items: center;
height: 2.8rem;
padding: 0 var(--space-smaller);
cursor: pointer;
&.active {
background-color: var(--s-50);
}
&.focus {
background-color: var(--s-25);
}
&:hover {
background-color: var(--s-50);
}
.country-emoji {
font-size: var(--font-size-default);
margin-right: var(--space-smaller);
}
.country-name {
max-width: 12rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.country-dial-code {
margin-left: var(--space-smaller);
color: var(--s-300);
font-size: var(--font-size-mini);
}
}
.no-results {
display: flex;
align-items: center;
justify-content: center;
color: var(--s-500);
margin-top: var(--space-normal);
}
}
}
</style>

View File

@ -24,5 +24,6 @@ export default {
DOCS_URL: '//www.chatwoot.com/docs/product/',
TESTIMONIAL_URL: 'https://testimonials.cdn.chatwoot.com/content.json',
SMALL_SCREEN_BREAKPOINT: 1024,
AVAILABILITY_STATUS_KEYS: ['online', 'busy', 'offline'],
};
export const DEFAULT_REDIRECT_URL = '/app/';

View File

@ -0,0 +1,5 @@
export const LOCAL_STORAGE_KEYS = {
DISMISSED_UPDATES: 'dismissedUpdates',
WIDGET_BUILDER: 'widgetBubble_',
DRAFT_MESSAGES: 'draftMessages',
};

View File

@ -13,6 +13,7 @@ export const ACCOUNT_EVENTS = Object.freeze({
ADDED_TO_CANNED_RESPONSE: 'Used added to canned response option',
ADDED_A_CUSTOM_ATTRIBUTE: 'Added a custom attribute',
ADDED_AN_INBOX: 'Added an inbox',
OPEN_MESSAGE_CONTEXT_MENU: 'Opened message context menu',
});
export const LABEL_EVENTS = Object.freeze({

View File

@ -0,0 +1,70 @@
import { openDB } from 'idb';
import { DATA_VERSION } from './version';
export class DataManager {
constructor(accountId) {
this.modelsToSync = ['inbox', 'label', 'team'];
this.accountId = accountId;
this.db = null;
}
async initDb() {
if (this.db) return this.db;
this.db = await openDB(`cw-store-${this.accountId}`, DATA_VERSION, {
upgrade(db) {
db.createObjectStore('cache-keys');
db.createObjectStore('inbox', { keyPath: 'id' });
db.createObjectStore('label', { keyPath: 'id' });
db.createObjectStore('team', { keyPath: 'id' });
},
});
return this.db;
}
validateModel(name) {
if (!name) throw new Error('Model name is not defined');
if (!this.modelsToSync.includes(name)) {
throw new Error(`Model ${name} is not defined`);
}
return true;
}
async replace({ modelName, data }) {
this.validateModel(modelName);
this.db.clear(modelName);
return this.push({ modelName, data });
}
async push({ modelName, data }) {
this.validateModel(modelName);
if (Array.isArray(data)) {
const tx = this.db.transaction(modelName, 'readwrite');
data.forEach(item => {
tx.store.add(item);
});
await tx.done;
} else {
await this.db.add(modelName, data);
}
}
async get({ modelName }) {
this.validateModel(modelName);
return this.db.getAll(modelName);
}
async setCacheKeys(cacheKeys) {
Object.keys(cacheKeys).forEach(async modelName => {
this.db.put('cache-keys', cacheKeys[modelName], modelName);
});
}
async getCacheKey(modelName) {
this.validateModel(modelName);
return this.db.get('cache-keys', modelName);
}
}

View File

@ -0,0 +1,3 @@
// Monday, 13 March 2023
// Change this version if you want to invalidate old data
export const DATA_VERSION = '1678706392';

View File

@ -1,4 +1,4 @@
import { DEFAULT_REDIRECT_URL } from '../constants';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
export const frontendURL = (path, params) => {
const stringifiedParams = params ? `?${new URLSearchParams(params)}` : '';

View File

@ -26,6 +26,7 @@ class ActionCableConnector extends BaseActionCableConnector {
'first.reply.created': this.onFirstReplyCreated,
'conversation.read': this.onConversationRead,
'conversation.updated': this.onConversationUpdated,
'account.cache_invalidated': this.onCacheInvalidate,
};
}
@ -156,6 +157,13 @@ class ActionCableConnector extends BaseActionCableConnector {
onFirstReplyCreated = () => {
bus.$emit('fetch_overview_reports');
};
onCacheInvalidate = data => {
const keys = data.cache_keys;
this.app.$store.dispatch('labels/revalidate', { newKey: keys.label });
this.app.$store.dispatch('inboxes/revalidate', { newKey: keys.inbox });
this.app.$store.dispatch('teams/revalidate', { newKey: keys.team });
};
}
export default {

View File

@ -136,6 +136,7 @@ export const getConditionOptions = ({
team_id: teams,
campaigns: generateConditionOptions(campaigns),
browser_language: languages,
conversation_language: languages,
country_code: countries,
message_type: MESSAGE_CONDITION_VALUES,
};

View File

@ -7,15 +7,22 @@ export const replaceVariablesInMessage = ({ message, variables }) => {
});
};
export const capitalizeName = name => {
return name.replace(/\b(\w)/g, s => s.toUpperCase());
};
const skipCodeBlocks = str => str.replace(/```(?:.|\n)+?```/g, '');
export const getFirstName = ({ user }) => {
return user?.name ? user.name.split(' ').shift() : '';
const firstName = user?.name ? user.name.split(' ').shift() : '';
return capitalizeName(firstName);
};
export const getLastName = ({ user }) => {
if (user && user.name) {
return user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
const lastName =
user.name.split(' ').length > 1 ? user.name.split(' ').pop() : '';
return capitalizeName(lastName);
}
return '';
};
@ -27,14 +34,14 @@ export const getMessageVariables = ({ conversation }) => {
} = conversation;
return {
'contact.name': sender?.name,
'contact.name': capitalizeName(sender?.name),
'contact.first_name': getFirstName({ user: sender }),
'contact.last_name': getLastName({ user: sender }),
'contact.email': sender?.email,
'contact.phone': sender?.phone_number,
'contact.id': sender?.id,
'conversation.id': id,
'agent.name': assignee?.name ? assignee?.name : '',
'agent.name': capitalizeName(assignee?.name || ''),
'agent.first_name': getFirstName({ user: assignee }),
'agent.last_name': getLastName({ user: assignee }),
'agent.email': assignee?.email ?? '',

View File

@ -0,0 +1,114 @@
import { DataManager } from '../../CacheHelper/DataManager';
describe('DataManager', () => {
const accountId = 'test-account';
let dataManager;
beforeAll(async () => {
dataManager = new DataManager(accountId);
await dataManager.initDb();
});
afterEach(async () => {
const tx = dataManager.db.transaction(
dataManager.modelsToSync,
'readwrite'
);
dataManager.modelsToSync.forEach(modelName => {
tx.objectStore(modelName).clear();
});
await tx.done;
});
describe('initDb', () => {
it('should initialize the database', async () => {
expect(dataManager.db).not.toBeNull();
});
it('should return the same instance of the database', async () => {
const db1 = await dataManager.initDb();
const db2 = await dataManager.initDb();
expect(db1).toBe(db2);
});
});
describe('validateModel', () => {
it('should throw an error for empty input', async () => {
expect(() => {
dataManager.validateModel();
}).toThrow();
});
it('should throw an error for invalid model', async () => {
expect(() => {
dataManager.validateModel('invalid-model');
}).toThrow();
});
it('should not throw an error for valid model', async () => {
expect(dataManager.validateModel('label')).toBeTruthy();
});
});
describe('replace', () => {
it('should replace existing data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
const newData = [
{ id: 3, name: 'inbox-3' },
{ id: 4, name: 'inbox-4' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
await dataManager.replace({ modelName: 'inbox', data: newData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(newData);
});
});
describe('push', () => {
it('should add data to the specified model', async () => {
const inboxData = { id: 1, name: 'inbox-1' };
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual([inboxData]);
});
it('should add multiple items to the specified model if an array of data is provided', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('get', () => {
it('should return all data in the specified model', async () => {
const inboxData = [
{ id: 1, name: 'inbox-1' },
{ id: 2, name: 'inbox-2' },
];
await dataManager.push({ modelName: 'inbox', data: inboxData });
const result = await dataManager.get({ modelName: 'inbox' });
expect(result).toEqual(inboxData);
});
});
describe('setCacheKeys', () => {
it('should add cache keys for each model', async () => {
const cacheKeys = { inbox: 'cache-key-1', label: 'cache-key-2' };
await dataManager.setCacheKeys(cacheKeys);
const result = await dataManager.getCacheKey('inbox');
expect(result).toEqual(cacheKeys.inbox);
});
});
});

View File

@ -4,6 +4,7 @@ import {
getLastName,
getMessageVariables,
getUndefinedVariablesInMessage,
capitalizeName,
} from '../messageHelper';
const variables = {
@ -87,11 +88,11 @@ describe('#getMessageVariables', () => {
const conversation = {
meta: {
assignee: {
name: 'Samuel Smith',
name: 'samuel Smith',
email: 'samuel@example.com',
},
sender: {
name: 'John Doe',
name: 'john Doe',
email: 'john.doe@gmail.com',
phone_number: '1234567890',
},
@ -136,3 +137,30 @@ describe('#getUndefinedVariablesInMessage', () => {
);
});
});
describe('#capitalizeName', () => {
it('capitalize name if name is passed', () => {
const string = 'john peter';
expect(capitalizeName(string)).toBe('John Peter');
});
it('capitalize first name if full name is passed', () => {
const string = 'john Doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('returns empty string if the string is empty', () => {
const string = '';
expect(capitalizeName(string)).toBe('');
});
it('capitalize last name if last name is passed', () => {
const string = 'john doe';
expect(capitalizeName(string)).toBe('John Doe');
});
it('capitalize first name if first name is passed', () => {
const string = 'john';
expect(capitalizeName(string)).toBe('John');
});
it('capitalize last name if last name is passed', () => {
const string = 'doe';
expect(capitalizeName(string)).toBe('Doe');
});
});

View File

@ -22,7 +22,8 @@
"is_not_present": "غير موجود",
"is_greater_than": "هو أكبر من",
"is_less_than": "هو أقل من",
"days_before": "قبل x أيام"
"days_before": "قبل x أيام",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "صحيح",

View File

@ -74,6 +74,11 @@
"LABEL": "عنوان البريد الإلكتروني",
"PLACEHOLDER": "الرجاء إدخال عنوان البريد الإلكتروني للموظف"
},
"AGENT_AVAILABILITY": {
"LABEL": "التوفر",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "تعديل حساب الموظف"
},
"BUTTON_TEXT": "تعديل",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "أدخل اسم الشركة",
"LABEL": "اسم الشركة"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "اسم الدولة",
"SELECT_PLACEHOLDER": "اختر",
"REMOVE": "حذف",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
"PLACEHOLDER": "أدخل اسم مستخدم فيسبوك",

View File

@ -166,7 +166,15 @@
"COPY": "نسخ",
"DELETE": "حذف",
"CREATE_A_CANNED_RESPONSE": "إضافة إلى الردود السريعة",
"TRANSLATE": "ترجم"
"TRANSLATE": "ترجم",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "حذف",
"CANCEL": "إلغاء"
}
}
},
"EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "الخصائص",
"DISPLAY_FILE_PICKER": "عرض أداة انتقاء الملفات في الـ widget",
"DISPLAY_EMOJI_PICKER": "عرض منتقي الرموز التعبيرية على الـ widget",
"ALLOW_END_CONVERSATION": "السماح للمستخدمين بإنهاء المحادثة من عنصر واجهة المستخدم"
"ALLOW_END_CONVERSATION": "السماح للمستخدمين بإنهاء المحادثة من عنصر واجهة المستخدم",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
},
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "كود \"الماسنجر\"",

View File

@ -164,6 +164,7 @@
"COMPONENTS": {
"CODE": {
"BUTTON_TEXT": "نسخ",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "تم نسخ الكود إلى الحافظة بنجاح"
},
"SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Не присъства",
"is_greater_than": "Is greater than",
"is_less_than": "Is lesser than",
"days_before": "Is x days before"
"days_before": "Is x days before",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "True",

View File

@ -74,6 +74,11 @@
"LABEL": "Имейл адрес",
"PLACEHOLDER": "Моля, въведете имейл адрес на агента"
},
"AGENT_AVAILABILITY": {
"LABEL": "Availability",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Редактирай агента"
},
"BUTTON_TEXT": "Редактирай",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Въведете име на фирма",
"LABEL": "Име на фирма"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Име на държавата",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Remove",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
"PLACEHOLDER": "Въведете Facebook потребителско име",

View File

@ -166,7 +166,15 @@
"COPY": "Copy",
"DELETE": "Изтрий",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate"
"TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Изтрий",
"CANCEL": "Отмени"
}
}
},
"EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Features",
"DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget"
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
},
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger Script",

View File

@ -164,6 +164,7 @@
"COMPONENTS": {
"CODE": {
"BUTTON_TEXT": "Copy",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "Code copied to clipboard successfully"
},
"SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "No és present",
"is_greater_than": "És més gran que",
"is_less_than": "És més petit que",
"days_before": "Is x days before"
"days_before": "Is x days before",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "Cert",

View File

@ -74,6 +74,11 @@
"LABEL": "Adreça de correu electrònic",
"PLACEHOLDER": "Introduïu l'adreça de correu electrònic de l'agent"
},
"AGENT_AVAILABILITY": {
"LABEL": "Disponibilitat",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Editar l'agent"
},
"BUTTON_TEXT": "Edita",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Introdueix el nom de la companyia",
"LABEL": "Nom de la companyia"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Nom del país",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Suprimeix",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
"PLACEHOLDER": "Introduïu el nom d'usuari de Facebook",

View File

@ -166,7 +166,15 @@
"COPY": "Copia",
"DELETE": "Esborrar",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate"
"TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Esborrar",
"CANCEL": "Cancel·la"
}
}
},
"EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Característiques",
"DISPLAY_FILE_PICKER": "Mostra el selector de fitxers al widget",
"DISPLAY_EMOJI_PICKER": "Mostra el selector d'emoji al widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget"
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
},
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Script del missatger",

View File

@ -164,6 +164,7 @@
"COMPONENTS": {
"CODE": {
"BUTTON_TEXT": "Copia",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "El codi s'ha copiat al porta-retalls amb èxit"
},
"SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Is not present",
"is_greater_than": "Is greater than",
"is_less_than": "Je menší než",
"days_before": "Is x days before"
"days_before": "Is x days before",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "True",

View File

@ -74,6 +74,11 @@
"LABEL": "E-mailová adresa",
"PLACEHOLDER": "Zadejte prosím e-mailovou adresu agenta"
},
"AGENT_AVAILABILITY": {
"LABEL": "Dostupnost",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Upravit agenta"
},
"BUTTON_TEXT": "Upravit",

View File

@ -132,6 +132,17 @@
"PLACEHOLDER": "Zadejte název společnosti",
"LABEL": "Název společnosti"
},
"COUNTRY": {
"PLACEHOLDER": "Enter the country name",
"LABEL": "Country Name",
"SELECT_PLACEHOLDER": "Select",
"REMOVE": "Odebrat",
"SELECT_COUNTRY": "Select Country"
},
"CITY": {
"PLACEHOLDER": "Enter the city name",
"LABEL": "City Name"
},
"SOCIAL_PROFILES": {
"FACEBOOK": {
"PLACEHOLDER": "Zadejte uživatelské jméno na Facebooku",

View File

@ -166,7 +166,15 @@
"COPY": "Kopírovat",
"DELETE": "Vymazat",
"CREATE_A_CANNED_RESPONSE": "Add to canned responses",
"TRANSLATE": "Translate"
"TRANSLATE": "Translate",
"COPY_PERMALINK": "Copy link to the message",
"LINK_COPIED": "Message URL copied to the clipboard",
"DELETE_CONFIRMATION": {
"TITLE": "Are you sure you want to delete this message?",
"MESSAGE": "You cannot undo this action",
"DELETE": "Vymazat",
"CANCEL": "Zrušit"
}
}
},
"EMAIL_TRANSCRIPT": {

View File

@ -439,7 +439,8 @@
"LABEL": "Funkce",
"DISPLAY_FILE_PICKER": "Display file picker on the widget",
"DISPLAY_EMOJI_PICKER": "Display emoji picker on the widget",
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget"
"ALLOW_END_CONVERSATION": "Allow users to end conversation from the widget",
"USE_INBOX_AVATAR_FOR_BOT": "Use inbox name and avatar for the bot"
},
"SETTINGS_POPUP": {
"MESSENGER_HEADING": "Messenger skript",

View File

@ -164,6 +164,7 @@
"COMPONENTS": {
"CODE": {
"BUTTON_TEXT": "Kopírovat",
"CODEPEN": "Open in CodePen",
"COPY_SUCCESSFUL": "Kód byl úspěšně zkopírován do schránky"
},
"SHOW_MORE_BLOCK": {

View File

@ -22,7 +22,8 @@
"is_not_present": "Er ikke til stede",
"is_greater_than": "Er større end",
"is_less_than": "Er mindre end",
"days_before": "Er x dage før"
"days_before": "Er x dage før",
"starts_with": "Starts with"
},
"ATTRIBUTE_LABELS": {
"TRUE": "Sandt",

View File

@ -74,6 +74,11 @@
"LABEL": "E-Mail Adresse",
"PLACEHOLDER": "Indtast venligst en e-mail adresse på agenten"
},
"AGENT_AVAILABILITY": {
"LABEL": "Tilgængelighed",
"PLACEHOLDER": "Please select an availability status",
"ERROR": "Availability is required"
},
"SUBMIT": "Rediger Agent"
},
"BUTTON_TEXT": "Rediger",

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