Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b605362f09 | ||
|
|
c697964110 | ||
|
|
53216766b8 | ||
|
|
a0153daca4 | ||
|
|
eb9adb4810 |
@@ -96,12 +96,12 @@ gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.2.0'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'google-cloud-dialogflow-v2'
|
||||
gem 'grpc'
|
||||
# Translate integrations
|
||||
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||
# this dependency breaks the slack-ruby-client gem
|
||||
gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
gem 'google-cloud-translate-v3'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
## Gems for reponse bot
|
||||
@@ -200,7 +200,7 @@ group :development do
|
||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
gem 'meta_request', '>= 0.8.0'
|
||||
end
|
||||
|
||||
group :test do
|
||||
@@ -228,7 +228,7 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '>= 6.1.5'
|
||||
gem 'rspec-rails', '>= 6.1.4'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
|
||||
+38
-38
@@ -169,7 +169,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.4)
|
||||
concurrent-ruby (1.3.3)
|
||||
connection_pool (2.4.1)
|
||||
crack (1.0.0)
|
||||
bigdecimal
|
||||
@@ -230,7 +230,7 @@ GEM
|
||||
ruby2_keywords
|
||||
email_reply_trimmer (0.1.13)
|
||||
erubi (1.13.0)
|
||||
et-orbi (1.2.11)
|
||||
et-orbi (1.2.7)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
@@ -257,7 +257,7 @@ GEM
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday-retry (2.1.0)
|
||||
faraday (~> 2.0)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
@@ -268,10 +268,10 @@ GEM
|
||||
rake
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
fugit (1.9.0)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.20.0)
|
||||
gapic-common (0.18.0)
|
||||
faraday (>= 1.9, < 3.a)
|
||||
faraday-retry (>= 1.0, < 3.a)
|
||||
google-protobuf (~> 3.14)
|
||||
@@ -301,15 +301,15 @@ GEM
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-dialogflow-v2 (0.31.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-dialogflow-v2 (0.23.0)
|
||||
gapic-common (>= 0.18.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-location (>= 0.4, < 2.a)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-location (0.6.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-location (0.4.0)
|
||||
gapic-common (>= 0.17.1, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
@@ -319,17 +319,17 @@ GEM
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-translate-v3 (0.6.0)
|
||||
gapic-common (>= 0.17.1, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.25.5)
|
||||
google-protobuf (3.25.5-arm64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-darwin)
|
||||
google-protobuf (3.25.5-x86_64-linux)
|
||||
googleapis-common-protos (1.6.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (~> 1.7)
|
||||
grpc (~> 1.41)
|
||||
google-protobuf (3.25.3)
|
||||
google-protobuf (3.25.3-arm64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-linux)
|
||||
googleapis-common-protos (1.4.0)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (~> 1.2)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.5.2)
|
||||
@@ -457,7 +457,7 @@ GEM
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
meta_request (0.8.3)
|
||||
meta_request (0.8.2)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
method_source (1.1.0)
|
||||
@@ -467,7 +467,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.25.1)
|
||||
minitest (5.24.1)
|
||||
mock_redis (0.36.0)
|
||||
ruby2_keywords
|
||||
msgpack (1.7.0)
|
||||
@@ -480,7 +480,7 @@ GEM
|
||||
uri
|
||||
net-http-persistent (4.0.2)
|
||||
connection_pool (~> 2.2)
|
||||
net-imap (0.4.14)
|
||||
net-imap (0.4.12)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -522,7 +522,7 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
omniauth-google-oauth2 (1.1.2)
|
||||
jwt (>= 2.0)
|
||||
oauth2 (~> 2.0)
|
||||
omniauth (~> 2.0)
|
||||
@@ -552,7 +552,7 @@ GEM
|
||||
pry-rails (0.3.9)
|
||||
pry (>= 0.10.4)
|
||||
public_suffix (6.0.0)
|
||||
puma (6.4.3)
|
||||
puma (6.4.2)
|
||||
nio4r (~> 2.0)
|
||||
pundit (2.3.0)
|
||||
activesupport (>= 3.0.0)
|
||||
@@ -634,20 +634,20 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.6)
|
||||
rexml (3.3.4)
|
||||
strscan
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-expectations (3.13.2)
|
||||
rspec-expectations (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (7.0.1)
|
||||
actionpack (>= 7.0)
|
||||
activesupport (>= 7.0)
|
||||
railties (>= 7.0)
|
||||
rspec-rails (6.1.4)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
rspec-core (~> 3.13)
|
||||
rspec-expectations (~> 3.13)
|
||||
rspec-mocks (~> 3.13)
|
||||
@@ -796,7 +796,7 @@ GEM
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (5.2.6)
|
||||
valid_email2 (4.0.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
@@ -819,7 +819,7 @@ GEM
|
||||
rack-proxy (>= 0.6.1)
|
||||
railties (>= 5.2)
|
||||
semantic_range (>= 2.3.0)
|
||||
webrick (1.8.2)
|
||||
webrick (1.8.1)
|
||||
websocket-driver (0.7.6)
|
||||
websocket-extensions (>= 0.1.0)
|
||||
websocket-extensions (0.1.5)
|
||||
@@ -881,9 +881,9 @@ DEPENDENCIES
|
||||
foreman
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
google-cloud-dialogflow-v2
|
||||
google-cloud-storage
|
||||
google-cloud-translate-v3 (>= 0.7.0)
|
||||
google-cloud-translate-v3
|
||||
groupdate
|
||||
grpc
|
||||
haikunator
|
||||
@@ -903,14 +903,14 @@ DEPENDENCIES
|
||||
listen
|
||||
lograge (~> 0.14.0)
|
||||
maxminddb
|
||||
meta_request (>= 0.8.3)
|
||||
meta_request (>= 0.8.0)
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-google-oauth2 (>= 1.1.2)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
pg
|
||||
@@ -930,7 +930,7 @@ DEPENDENCIES
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
rspec-rails (>= 6.1.5)
|
||||
rspec-rails (>= 6.1.4)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-performance
|
||||
|
||||
@@ -32,13 +32,11 @@ class AccountBuilder
|
||||
end
|
||||
|
||||
def validate_email
|
||||
raise InvalidEmail.new({ domain_blocked: domain_blocked }) if domain_blocked?
|
||||
|
||||
address = ValidEmail2::Address.new(@email)
|
||||
if address.valid? && !address.disposable?
|
||||
if address.valid? # && !address.disposable?
|
||||
true
|
||||
else
|
||||
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
||||
raise InvalidEmail.new(valid: address.valid?)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -81,21 +79,4 @@ class AccountBuilder
|
||||
@user.confirm if @confirmed
|
||||
@user.save!
|
||||
end
|
||||
|
||||
def domain_blocked?
|
||||
domain = @email.split('@').last
|
||||
|
||||
blocked_domains.each do |blocked_domain|
|
||||
return true if domain.match?(blocked_domain)
|
||||
end
|
||||
|
||||
false
|
||||
end
|
||||
|
||||
def blocked_domains
|
||||
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
||||
return [] if domains.blank?
|
||||
|
||||
domains.split("\n").map(&:strip)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -24,7 +24,7 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
|
||||
def update
|
||||
@agent.update!(agent_params.slice(:name).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(:role, :availability, :auto_offline).compact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -67,16 +67,8 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
@agent = agents.find(params[:id])
|
||||
end
|
||||
|
||||
def account_user_attributes
|
||||
[:role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def allowed_agent_params
|
||||
[:name, :email, :name, :role, :availability, :auto_offline]
|
||||
end
|
||||
|
||||
def agent_params
|
||||
params.require(:agent).permit(allowed_agent_params)
|
||||
params.require(:agent).permit(:name, :email, :name, :role, :availability, :auto_offline)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
@@ -109,5 +101,3 @@ class Api::V1::Accounts::AgentsController < Api::V1::Accounts::BaseController
|
||||
DeleteObjectJob.perform_later(agent) if agent.reload.account_users.blank?
|
||||
end
|
||||
end
|
||||
|
||||
Api::V1::Accounts::AgentsController.prepend_mod_with('Api::V1::Accounts::AgentsController')
|
||||
|
||||
@@ -13,7 +13,7 @@ class Api::V1::Accounts::Conversations::MessagesController < Api::V1::Accounts::
|
||||
|
||||
def destroy
|
||||
ActiveRecord::Base.transaction do
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_type: :text, content_attributes: { deleted: true })
|
||||
message.update!(content: I18n.t('conversations.messages.deleted'), content_attributes: { deleted: true })
|
||||
message.attachments.destroy_all
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,17 +11,7 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def process_event
|
||||
response = @hook.process_event(params[:event])
|
||||
|
||||
# for cases like an invalid event, or when conversation does not have enough messages
|
||||
# for a label suggestion, the response is nil
|
||||
if response.nil?
|
||||
render json: { message: nil }
|
||||
elsif response[:error]
|
||||
render json: { error: response[:error] }, status: :unprocessable_entity
|
||||
else
|
||||
render json: { message: response[:message] }
|
||||
end
|
||||
render json: { message: @hook.process_event(params[:event]) }
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -1,68 +1,13 @@
|
||||
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
result = if params[:attachment].present?
|
||||
create_from_file
|
||||
elsif params[:external_url].present?
|
||||
create_from_url
|
||||
else
|
||||
render_error('No file or URL provided', :unprocessable_entity)
|
||||
end
|
||||
|
||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_from_file
|
||||
attachment = params[:attachment]
|
||||
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
|
||||
end
|
||||
|
||||
def create_from_url
|
||||
uri = parse_uri(params[:external_url])
|
||||
return if performed?
|
||||
|
||||
fetch_and_process_file_from_uri(uri)
|
||||
end
|
||||
|
||||
def parse_uri(url)
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
uri.open do |file|
|
||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||
end
|
||||
rescue OpenURI::HTTPError => e
|
||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||
rescue SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
rescue StandardError
|
||||
render_error('An unexpected error occurred', :internal_server_error)
|
||||
end
|
||||
|
||||
def create_and_save_blob(io, filename, content_type)
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: io,
|
||||
filename: filename,
|
||||
content_type: content_type
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
end
|
||||
file_blob.save!
|
||||
|
||||
def render_success(file_blob)
|
||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def render_error(message, status)
|
||||
render json: { error: message }, status: status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -11,8 +11,6 @@ class Api::V1::Widget::ConversationsController < Api::V1::Widget::BaseController
|
||||
process_update_contact
|
||||
@conversation = create_conversation
|
||||
conversation.messages.create!(message_params)
|
||||
# TODO: Temporary fix for message type cast issue, since message_type is returning as string instead of integer
|
||||
conversation.reload
|
||||
end
|
||||
end
|
||||
|
||||
|
||||
@@ -66,9 +66,7 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
end
|
||||
|
||||
def check_authorization
|
||||
return if Current.account_user.administrator?
|
||||
|
||||
raise Pundit::NotAuthorizedError
|
||||
raise Pundit::NotAuthorizedError unless Current.account_user.administrator?
|
||||
end
|
||||
|
||||
def common_params
|
||||
@@ -137,5 +135,3 @@ class Api::V2::Accounts::ReportsController < Api::V1::Accounts::BaseController
|
||||
V2::ReportBuilder.new(Current.account, conversation_params).conversation_metrics
|
||||
end
|
||||
end
|
||||
|
||||
Api::V2::Accounts::ReportsController.prepend_mod_with('Api::V2::Accounts::ReportsController')
|
||||
|
||||
@@ -6,7 +6,7 @@ class Public::Api::V1::Portals::ArticlesController < Public::Api::V1::Portals::B
|
||||
layout 'portal'
|
||||
|
||||
def index
|
||||
@articles = @portal.articles.published
|
||||
@articles = @portal.articles
|
||||
search_articles
|
||||
order_by_sort_param
|
||||
@articles.page(list_params[:page]) if list_params[:page].present?
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
import ApiClient from './ApiClient';
|
||||
|
||||
class CustomRole extends ApiClient {
|
||||
constructor() {
|
||||
super('custom_roles', { accountScoped: true });
|
||||
}
|
||||
}
|
||||
|
||||
export default new CustomRole();
|
||||
@@ -4,7 +4,6 @@ import { mapGetters } from 'vuex';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import VirtualList from 'vue-virtual-scroll-list';
|
||||
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
@@ -17,6 +16,7 @@ import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
|
||||
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
|
||||
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
|
||||
@@ -27,11 +27,6 @@ import {
|
||||
} from '../store/modules/conversations/helpers/actionHelpers';
|
||||
import { CONVERSATION_EVENTS } from '../helper/AnalyticsHelper/events';
|
||||
import IntersectionObserver from './IntersectionObserver.vue';
|
||||
import {
|
||||
getUserPermissions,
|
||||
filterItemsByPermission,
|
||||
} from 'dashboard/helper/permissionsHelper.js';
|
||||
import { ASSIGNEE_TYPE_TAB_PERMISSIONS } from 'dashboard/constants/permissions.js';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -46,6 +41,7 @@ export default {
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
},
|
||||
mixins: [filterMixin],
|
||||
provide() {
|
||||
return {
|
||||
// Actions to be performed on virtual list item and context menu.
|
||||
@@ -95,15 +91,6 @@ export default {
|
||||
|
||||
const conversationListRef = ref(null);
|
||||
|
||||
const {
|
||||
setFilterAttributes,
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
} = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
|
||||
const getKeyboardListenerParams = () => {
|
||||
const allConversations = conversationListRef.value.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
@@ -122,52 +109,43 @@ export default {
|
||||
lastConversationIndex,
|
||||
};
|
||||
};
|
||||
const handleConversationNavigation = direction => {
|
||||
const handlePreviousConversation = () => {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
};
|
||||
const handleNextConversation = () => {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = getKeyboardListenerParams();
|
||||
|
||||
// Determine the new index based on the direction
|
||||
const newIndex =
|
||||
direction === 'previous'
|
||||
? activeConversationIndex - 1
|
||||
: activeConversationIndex + 1;
|
||||
|
||||
// Check if the new index is within the valid range
|
||||
if (
|
||||
allConversations.length > 0 &&
|
||||
newIndex >= 0 &&
|
||||
newIndex <= lastConversationIndex
|
||||
) {
|
||||
// Click the conversation at the new index
|
||||
allConversations[newIndex].click();
|
||||
} else if (allConversations.length > 0) {
|
||||
// If the new index is out of range, click the first or last conversation based on the direction
|
||||
const fallbackIndex =
|
||||
direction === 'previous' ? 0 : lastConversationIndex;
|
||||
allConversations[fallbackIndex].click();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
}
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyJ': {
|
||||
action: () => handleConversationNavigation('previous'),
|
||||
action: () => handlePreviousConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => handleConversationNavigation('next'),
|
||||
action: () => handleNextConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, conversationListRef);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
conversationListRef,
|
||||
setFilterAttributes,
|
||||
initializeStatusAndAssigneeFilterToModal,
|
||||
initializeInboxTeamAndLabelFilterToModal,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -209,7 +187,6 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
chatLists: 'getAllConversations',
|
||||
mineChatsList: 'getMineChats',
|
||||
allChatList: 'getAllStatusChats',
|
||||
@@ -249,19 +226,20 @@ export default {
|
||||
name,
|
||||
};
|
||||
},
|
||||
userPermissions() {
|
||||
return getUserPermissions(this.currentUser, this.currentAccountId);
|
||||
},
|
||||
assigneeTabItems() {
|
||||
return filterItemsByPermission(
|
||||
ASSIGNEE_TYPE_TAB_PERMISSIONS,
|
||||
this.userPermissions,
|
||||
item => item.permissions
|
||||
).map(({ key, count: countKey }) => ({
|
||||
key,
|
||||
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
||||
count: this.conversationStats[countKey] || 0,
|
||||
}));
|
||||
const ASSIGNEE_TYPE_TAB_KEYS = {
|
||||
me: 'mineCount',
|
||||
unassigned: 'unAssignedCount',
|
||||
all: 'allCount',
|
||||
};
|
||||
return Object.keys(ASSIGNEE_TYPE_TAB_KEYS).map(key => {
|
||||
const count = this.conversationStats[ASSIGNEE_TYPE_TAB_KEYS[key]] || 0;
|
||||
return {
|
||||
key,
|
||||
name: this.$t(`CHAT_LIST.ASSIGNEE_TYPE_TABS.${key}`),
|
||||
count,
|
||||
};
|
||||
});
|
||||
},
|
||||
showAssigneeInConversationCard() {
|
||||
return (
|
||||
@@ -882,25 +860,6 @@ export default {
|
||||
onContextMenuToggle(state) {
|
||||
this.isContextMenuOpen = state;
|
||||
},
|
||||
initializeExistingFilterToModal() {
|
||||
const statusFilter = this.initializeStatusAndAssigneeFilterToModal(
|
||||
this.activeStatus,
|
||||
this.currentUserDetails,
|
||||
this.activeAssigneeTab
|
||||
);
|
||||
if (statusFilter) {
|
||||
this.appliedFilter.push(statusFilter);
|
||||
}
|
||||
|
||||
const otherFilters = this.initializeInboxTeamAndLabelFilterToModal(
|
||||
this.conversationInbox,
|
||||
this.inbox,
|
||||
this.teamId,
|
||||
this.activeTeam,
|
||||
this.label
|
||||
);
|
||||
this.appliedFilter.push(...otherFilters);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -331,12 +331,10 @@ export default {
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
|
||||
.selector-name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
@apply ml-0;
|
||||
}
|
||||
|
||||
@@ -13,12 +13,13 @@ import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const resolveActionsRef = ref(null);
|
||||
const arrowDownButtonRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
@@ -130,14 +131,17 @@ const keyboardEvents = {
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, resolveActionsRef);
|
||||
|
||||
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
|
||||
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative flex items-center justify-end resolve-actions">
|
||||
<div
|
||||
ref="resolveActionsRef"
|
||||
class="relative flex items-center justify-end resolve-actions"
|
||||
>
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
v-if="isOpen"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getSidebarItems } from './config/default-sidebar';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
@@ -7,10 +8,7 @@ import { useRoute, useRouter } from 'dashboard/composables/route';
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import { routesWithPermissions } from '../../routes';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../../helper/permissionsHelper';
|
||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
@@ -28,6 +26,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const sidebarRef = ref(null);
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -65,10 +64,11 @@ export default {
|
||||
action: () => navigateToRoute('agent_list'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, sidebarRef);
|
||||
|
||||
return {
|
||||
toggleKeyShortcutModal,
|
||||
sidebarRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -113,10 +113,7 @@ export default {
|
||||
return getSidebarItems(this.accountId);
|
||||
},
|
||||
primaryMenuItems() {
|
||||
const userPermissions = getUserPermissions(
|
||||
this.currentUser,
|
||||
this.accountId
|
||||
);
|
||||
const userPermissions = this.currentUser.permissions;
|
||||
const menuItems = this.sideMenuConfig.primaryMenu;
|
||||
return menuItems.filter(menuItem => {
|
||||
const isAvailableForTheUser = hasPermissions(
|
||||
@@ -198,7 +195,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<aside class="flex h-full">
|
||||
<aside ref="sidebarRef" class="flex h-full">
|
||||
<PrimarySidebar
|
||||
:logo-source="globalConfig.logoThumbnail"
|
||||
:installation-name="globalConfig.installationName"
|
||||
|
||||
@@ -39,7 +39,6 @@ const settings = accountId => ({
|
||||
'settings_teams_list',
|
||||
'settings_teams_new',
|
||||
'sla_list',
|
||||
'custom_roles_list',
|
||||
],
|
||||
menuItems: [
|
||||
{
|
||||
@@ -179,18 +178,6 @@ const settings = accountId => ({
|
||||
isEnterpriseOnly: true,
|
||||
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||
},
|
||||
{
|
||||
icon: 'scan-person',
|
||||
label: 'CUSTOM_ROLES',
|
||||
hasSubMenu: false,
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/custom-roles/list`),
|
||||
toStateName: 'custom_roles_list',
|
||||
isEnterpriseOnly: true,
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
icon: 'document-list-clock',
|
||||
label: 'SLA',
|
||||
|
||||
@@ -52,13 +52,9 @@ export default {
|
||||
{{ account.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-xs font-medium lowercase text-slate-500 dark:text-slate-500 hover:underline-offset-4"
|
||||
class="text-xs font-medium text-slate-500 dark:text-slate-500 hover:underline-offset-4"
|
||||
>
|
||||
{{
|
||||
account.custom_role_id
|
||||
? account.custom_role.name
|
||||
: account.role
|
||||
}}
|
||||
{{ account.role }}
|
||||
</div>
|
||||
</label>
|
||||
</span>
|
||||
|
||||
@@ -4,10 +4,7 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
|
||||
import AccountContext from './AccountContext.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../../../helper/permissionsHelper';
|
||||
import { hasPermissions } from '../../../helper/permissionsHelper';
|
||||
import { routesWithPermissions } from '../../../routes';
|
||||
|
||||
export default {
|
||||
@@ -62,10 +59,7 @@ export default {
|
||||
accessibleMenuItems() {
|
||||
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
||||
menuItem => {
|
||||
const userPermissions = getUserPermissions(
|
||||
this.currentUser,
|
||||
this.accountId
|
||||
);
|
||||
const { permissions: userPermissions = [] } = this.currentUser;
|
||||
return hasPermissions(
|
||||
routesWithPermissions[menuItem.toStateName],
|
||||
userPermissions
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
<script setup>
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../helper/permissionsHelper';
|
||||
|
||||
import { hasPermissions } from '../helper/permissionsHelper';
|
||||
const props = defineProps({
|
||||
permissions: {
|
||||
type: Array,
|
||||
@@ -14,17 +10,12 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const user = computed(() => getters.getCurrentUser.value);
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
const userPermissions = computed(() => {
|
||||
return getUserPermissions(user.value, accountId.value);
|
||||
});
|
||||
const hasPermission = computed(() => {
|
||||
return hasPermissions(props.permissions, userPermissions.value);
|
||||
});
|
||||
const user = getters.getCurrentUser.value;
|
||||
const hasPermission = computed(() =>
|
||||
hasPermissions(props.permissions, user.permissions)
|
||||
);
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="hasPermission">
|
||||
<slot />
|
||||
|
||||
@@ -4,10 +4,10 @@ import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import AICTAModal from './AICTAModal.vue';
|
||||
import AIAssistanceModal from './AIAssistanceModal.vue';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||
|
||||
export default {
|
||||
@@ -16,17 +16,17 @@ export default {
|
||||
AICTAModal,
|
||||
AIAssistanceCTAButton,
|
||||
},
|
||||
mixins: [aiMixin],
|
||||
setup(props, { emit }) {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const aiAssistanceButtonRef = ref(null);
|
||||
const initialMessage = ref('');
|
||||
|
||||
const initializeMessage = draftMsg => {
|
||||
initialMessage.value = draftMsg;
|
||||
const initializeMessage = draftMessage => {
|
||||
initialMessage.value = draftMessage;
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'$mod+KeyZ': {
|
||||
@@ -39,17 +39,15 @@ export default {
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, aiAssistanceButtonRef);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isAdmin,
|
||||
aiAssistanceButtonRef,
|
||||
initialMessage,
|
||||
initializeMessage,
|
||||
recordAnalytics,
|
||||
isAIIntegrationEnabled,
|
||||
draftMessage,
|
||||
};
|
||||
},
|
||||
data: () => ({
|
||||
@@ -120,7 +118,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div ref="aiAssistanceButtonRef">
|
||||
<div v-if="isAIIntegrationEnabled" class="relative">
|
||||
<AIAssistanceCTAButton
|
||||
v-if="shouldShowAIAssistCTAButton"
|
||||
|
||||
@@ -1,23 +1,20 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import AILoader from './AILoader.vue';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AILoader,
|
||||
},
|
||||
mixins: [aiMixin, messageFormatterMixin],
|
||||
props: {
|
||||
aiOption: {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
const { draftMessage, processEvent, recordAnalytics } = useAI();
|
||||
return { draftMessage, processEvent, recordAnalytics, formatMessage };
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
generatedContent: '',
|
||||
|
||||
@@ -3,16 +3,16 @@ import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
|
||||
export default {
|
||||
mixins: [aiMixin],
|
||||
setup() {
|
||||
const { updateUISettings } = useUISettings();
|
||||
const { recordAnalytics } = useAI();
|
||||
const v$ = useVuelidate();
|
||||
|
||||
return { updateUISettings, v$, recordAnalytics };
|
||||
return { updateUISettings, v$ };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { ref, computed } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
@@ -16,16 +16,15 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['chatTabChange']);
|
||||
|
||||
const chatTypeTabsRef = ref(null);
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
return props.items.findIndex(item => item.key === props.activeTab);
|
||||
});
|
||||
|
||||
const onTabChange = selectedTabIndex => {
|
||||
if (selectedTabIndex >= 0 && selectedTabIndex < props.items.length) {
|
||||
const selectedItem = props.items[selectedTabIndex];
|
||||
if (selectedItem.key !== props.activeTab) {
|
||||
emit('chatTabChange', selectedItem.key);
|
||||
}
|
||||
if (props.items[selectedTabIndex].key !== props.activeTab) {
|
||||
emit('chatTabChange', props.items[selectedTabIndex].key);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -35,29 +34,29 @@ const keyboardEvents = {
|
||||
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||
onTabChange(0);
|
||||
} else {
|
||||
const nextIndex = (activeTabIndex.value + 1) % props.items.length;
|
||||
onTabChange(nextIndex);
|
||||
onTabChange(activeTabIndex.value + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, chatTypeTabsRef);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="w-full px-4 py-0 tab--chat-type"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:name="item.name"
|
||||
:count="item.count"
|
||||
/>
|
||||
</woot-tabs>
|
||||
<div ref="chatTypeTabsRef">
|
||||
<woot-tabs
|
||||
:index="activeTabIndex"
|
||||
class="tab--chat-type py-0 px-4 w-full"
|
||||
@change="onTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="item in items"
|
||||
:key="item.key"
|
||||
:name="item.name"
|
||||
:count="item.count"
|
||||
/>
|
||||
</woot-tabs>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
|
||||
@@ -18,6 +18,8 @@ const props = defineProps({
|
||||
|
||||
const emit = defineEmits(['add', 'remove']);
|
||||
|
||||
const labelSelectorWrapRef = ref(null);
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showSearchDropdownLabel = ref(false);
|
||||
@@ -55,11 +57,15 @@ const keyboardEvents = {
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, labelSelectorWrapRef);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
||||
<div
|
||||
ref="labelSelectorWrapRef"
|
||||
v-on-clickaway="closeDropdownLabel"
|
||||
class="relative leading-6"
|
||||
>
|
||||
<AddLabel @add="toggleLabels" />
|
||||
<woot-label
|
||||
v-for="label in savedLabels"
|
||||
|
||||
@@ -1,34 +1,38 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 120,
|
||||
data() {
|
||||
return {
|
||||
showMore: false,
|
||||
};
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
const showMore = ref(false);
|
||||
computed: {
|
||||
textToBeDisplayed() {
|
||||
if (this.showMore || this.text.length <= this.limit) {
|
||||
return this.text;
|
||||
}
|
||||
|
||||
const textToBeDisplayed = computed(() => {
|
||||
if (showMore.value || props.text.length <= props.limit) {
|
||||
return props.text;
|
||||
}
|
||||
|
||||
return props.text.slice(0, props.limit) + '...';
|
||||
});
|
||||
const buttonLabel = computed(() => {
|
||||
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||
});
|
||||
|
||||
const toggleShowMore = () => {
|
||||
showMore.value = !showMore.value;
|
||||
return this.text.slice(0, this.limit) + '...';
|
||||
},
|
||||
buttonLabel() {
|
||||
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleShowMore() {
|
||||
this.showMore = !this.showMore;
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -37,10 +41,16 @@ const toggleShowMore = () => {
|
||||
{{ textToBeDisplayed }}
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="text-woot-500 !p-0 !border-0 align-top"
|
||||
class="show-more--button"
|
||||
@click="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.show-more--button {
|
||||
color: var(--w-500);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,8 +17,6 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -26,7 +24,6 @@ import {
|
||||
scrollCursorIntoView,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
@@ -38,8 +35,10 @@ import {
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import {
|
||||
replaceVariablesInMessage,
|
||||
createTypingIndicator,
|
||||
} from '@chatwoot/utils';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
@@ -72,12 +71,7 @@ const createState = (
|
||||
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: {
|
||||
TagAgents,
|
||||
CannedResponse,
|
||||
VariableList,
|
||||
KeyboardEmojiSelector,
|
||||
},
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
@@ -97,7 +91,6 @@ export default {
|
||||
allowSignature: { type: Boolean, default: false },
|
||||
channelType: { type: String, default: '' },
|
||||
showImageResizeToolbar: { type: Boolean, default: false }, // A kill switch to show or hide the image toolbar
|
||||
focusOnMount: { type: Boolean, default: true },
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
@@ -126,11 +119,9 @@ export default {
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariables: false,
|
||||
showEmojiMenu: false,
|
||||
mentionSearchKey: '',
|
||||
cannedSearchTerm: '',
|
||||
variableSearchTerm: '',
|
||||
emojiSearchTerm: '',
|
||||
editorView: null,
|
||||
range: null,
|
||||
state: undefined,
|
||||
@@ -178,7 +169,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.mentionSearchKey = args.text;
|
||||
this.mentionSearchKey = args.text.replace('@', '');
|
||||
|
||||
return false;
|
||||
},
|
||||
@@ -207,7 +198,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.cannedSearchTerm = args.text;
|
||||
this.cannedSearchTerm = args.text.replace('/', '');
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
@@ -235,7 +226,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.variableSearchTerm = args.text;
|
||||
this.variableSearchTerm = args.text.replace('{{', '');
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
@@ -247,31 +238,6 @@ export default {
|
||||
return event.keyCode === 13 && this.showVariables;
|
||||
},
|
||||
}),
|
||||
suggestionsPlugin({
|
||||
matcher: triggerCharacters(':', 2), // Trigger after ':' and at least 2 characters
|
||||
suggestionClass: '',
|
||||
onEnter: args => {
|
||||
this.showEmojiMenu = true;
|
||||
this.emojiSearchTerm = args.text || '';
|
||||
this.range = args.range;
|
||||
this.editorView = args.view;
|
||||
return false;
|
||||
},
|
||||
onChange: args => {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
this.emojiSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
this.emojiSearchTerm = '';
|
||||
this.showEmojiMenu = false;
|
||||
return false;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
return event.keyCode === 13 && this.showEmojiMenu;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
sendWithSignature() {
|
||||
@@ -301,8 +267,6 @@ export default {
|
||||
},
|
||||
editorId() {
|
||||
this.showCannedMenu = false;
|
||||
this.showEmojiMenu = false;
|
||||
this.showVariables = false;
|
||||
this.cannedSearchTerm = '';
|
||||
this.reloadState(this.value);
|
||||
},
|
||||
@@ -347,9 +311,7 @@ export default {
|
||||
mounted() {
|
||||
this.createEditorView();
|
||||
this.editorView.updateState(this.state);
|
||||
if (this.focusOnMount) {
|
||||
this.focusEditorInputField();
|
||||
}
|
||||
this.focusEditorInputField();
|
||||
|
||||
// BUS Event to insert text or markdown into the editor at the
|
||||
// current cursor position.
|
||||
@@ -386,7 +348,7 @@ export default {
|
||||
// these drafts can also have a signature, so we need to check if the body is empty
|
||||
// and handle things accordingly
|
||||
this.handleEmptyBodyWithSignature();
|
||||
} else if (this.focusOnMount) {
|
||||
} else {
|
||||
// this is in the else block, handleEmptyBodyWithSignature also has a call to the focus method
|
||||
// the position is set to start, because the signature is added at the end of the body
|
||||
this.focusEditorInputField('end');
|
||||
@@ -555,36 +517,57 @@ export default {
|
||||
this.editorView.dispatch(tr.setSelection(selection));
|
||||
this.editorView.focus();
|
||||
},
|
||||
/**
|
||||
* Inserts special content (mention, canned response, variable, emoji) into the editor.
|
||||
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
|
||||
* @param {Object|string} content - The content to insert, depending on the type.
|
||||
*/
|
||||
insertSpecialContent(type, content) {
|
||||
insertMentionNode(mentionItem) {
|
||||
if (!this.editorView) {
|
||||
return;
|
||||
return null;
|
||||
}
|
||||
const node = this.editorView.state.schema.nodes.mention.create({
|
||||
userId: mentionItem.id,
|
||||
userFullName: mentionItem.name,
|
||||
});
|
||||
|
||||
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
|
||||
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
|
||||
|
||||
return false;
|
||||
},
|
||||
insertCannedResponse(cannedItem) {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message: cannedItem,
|
||||
variables: this.variables,
|
||||
});
|
||||
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let { node, from, to } = getContentNode(
|
||||
this.editorView,
|
||||
type,
|
||||
content,
|
||||
this.range,
|
||||
this.variables
|
||||
let node = new MessageMarkdownTransformer(messageSchema).parse(
|
||||
updatedMessage
|
||||
);
|
||||
|
||||
if (!node) return;
|
||||
const from =
|
||||
node.textContent === updatedMessage
|
||||
? this.range.from
|
||||
: this.range.from - 1;
|
||||
|
||||
this.insertNodeIntoEditor(node, from, this.range.to);
|
||||
|
||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
return false;
|
||||
},
|
||||
insertVariable(variable) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `{{${variable}}}`;
|
||||
let node = this.editorView.state.schema.text(content);
|
||||
const { from, to } = this.range;
|
||||
|
||||
this.insertNodeIntoEditor(node, from, to);
|
||||
|
||||
const event_map = {
|
||||
mention: CONVERSATION_EVENTS.USED_MENTIONS,
|
||||
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
||||
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
||||
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
||||
};
|
||||
|
||||
this.$track(event_map[type]);
|
||||
this.showVariables = false;
|
||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
|
||||
return false;
|
||||
},
|
||||
openFileBrowser() {
|
||||
this.$refs.imageUpload.click();
|
||||
@@ -704,22 +687,17 @@ export default {
|
||||
<TagAgents
|
||||
v-if="showUserMentions && isPrivate"
|
||||
:search-key="mentionSearchKey"
|
||||
@click="content => insertSpecialContent('mention', content)"
|
||||
@click="insertMentionNode"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="shouldShowCannedResponses"
|
||||
:search-key="cannedSearchTerm"
|
||||
@click="content => insertSpecialContent('cannedResponse', content)"
|
||||
@click="insertCannedResponse"
|
||||
/>
|
||||
<VariableList
|
||||
v-if="shouldShowVariables"
|
||||
:search-key="variableSearchTerm"
|
||||
@click="content => insertSpecialContent('variable', content)"
|
||||
/>
|
||||
<KeyboardEmojiSelector
|
||||
v-if="showEmojiMenu"
|
||||
:search-key="emojiSearchTerm"
|
||||
@click="emoji => insertSpecialContent('emoji', emoji)"
|
||||
@click="insertVariable"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
EditorState,
|
||||
Selection,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
@@ -56,7 +55,7 @@ export default {
|
||||
return {
|
||||
editorView: null,
|
||||
state: undefined,
|
||||
plugins: [imagePastePlugin(this.handleImageUpload)],
|
||||
plugins: [],
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -77,7 +76,6 @@ export default {
|
||||
this.reloadState();
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
this.state = createState(
|
||||
this.value,
|
||||
@@ -97,24 +95,6 @@ export default {
|
||||
openFileBrowser() {
|
||||
this.$refs.imageUploadInput.click();
|
||||
},
|
||||
async handleImageUpload(url) {
|
||||
try {
|
||||
const fileUrl = await this.$store.dispatch(
|
||||
'articles/uploadExternalImage',
|
||||
{
|
||||
portalSlug: this.$route.params.portalSlug,
|
||||
url,
|
||||
}
|
||||
);
|
||||
|
||||
return fileUrl;
|
||||
} catch (error) {
|
||||
useAlert(
|
||||
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
|
||||
);
|
||||
return '';
|
||||
}
|
||||
},
|
||||
onFileChange() {
|
||||
const file = this.$refs.imageUploadInput.files[0];
|
||||
|
||||
@@ -140,6 +120,7 @@ export default {
|
||||
if (fileUrl) {
|
||||
this.onImageUploadStart(fileUrl);
|
||||
}
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
||||
}
|
||||
@@ -192,18 +173,6 @@ export default {
|
||||
blur: () => {
|
||||
this.onBlur();
|
||||
},
|
||||
paste: (view, event) => {
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
data.forEach(file => {
|
||||
// Check if the file is an image
|
||||
if (file.type.includes('image')) {
|
||||
this.uploadImageToStorage(file);
|
||||
}
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { ref, watchEffect, computed } from 'vue';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
@@ -116,13 +117,15 @@ export default {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
const uploadRef = ref(null);
|
||||
// TODO: This is really hacky, we need to replace the file picker component with
|
||||
// a custom one, where the logic and the component markup is isolated.
|
||||
// Once we have the custom component, we can remove the hacky logic below.
|
||||
const uploadRefElem = computed(() => uploadRef.value?.$el);
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyA': {
|
||||
action: () => {
|
||||
// TODO: This is really hacky, we need to replace the file picker component with
|
||||
// a custom one, where the logic and the component markup is isolated.
|
||||
// Once we have the custom component, we can remove the hacky logic below.
|
||||
|
||||
const uploadTriggerButton = document.querySelector(
|
||||
'#conversationAttachment'
|
||||
);
|
||||
@@ -132,11 +135,14 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
watchEffect(() => {
|
||||
useKeyboardEvents(keyboardEvents, uploadRefElem);
|
||||
});
|
||||
|
||||
return {
|
||||
setSignatureFlagForInbox,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
uploadRef,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -220,7 +226,11 @@ export default {
|
||||
: this.$t('CONVERSATION.FOOTER.ENABLE_SIGN_TOOLTIP');
|
||||
},
|
||||
enableInsertArticleInReply() {
|
||||
return this.portalSlug;
|
||||
const isFeatEnabled = this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
FEATURE_FLAGS.INSERT_ARTICLE_IN_REPLY
|
||||
);
|
||||
return isFeatEnabled && this.portalSlug;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
@@ -257,6 +267,7 @@ export default {
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
ref="uploadRef"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
input-id="conversationAttachment"
|
||||
:size="4096 * 4096"
|
||||
@@ -399,7 +410,6 @@ export default {
|
||||
label {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
&:hover button {
|
||||
@apply dark:bg-slate-800 bg-slate-100;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
export default {
|
||||
@@ -22,6 +23,8 @@ export default {
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const replyTopRef = ref(null);
|
||||
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
};
|
||||
@@ -41,11 +44,12 @@ export default {
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, replyTopRef);
|
||||
|
||||
return {
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
replyTopRef,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -72,7 +76,10 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex justify-between bg-black-50 dark:bg-slate-800">
|
||||
<div
|
||||
ref="replyTopRef"
|
||||
class="flex justify-between bg-black-50 dark:bg-slate-800"
|
||||
>
|
||||
<div class="button-group">
|
||||
<woot-button
|
||||
variant="clear"
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<script setup>
|
||||
import { shallowRef, computed, onMounted } from 'vue';
|
||||
import emojiGroups from 'shared/components/emoji/emojisGroup.json';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const allEmojis = shallowRef([]);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) return [];
|
||||
const searchTerm = props.searchKey.toLowerCase();
|
||||
return allEmojis.value.filter(emoji =>
|
||||
emoji.searchString.includes(searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
function loadEmojis() {
|
||||
allEmojis.value = emojiGroups.flatMap(({ emojis }) =>
|
||||
emojis.map(({ name, slug, ...rest }) => ({
|
||||
...rest,
|
||||
name,
|
||||
slug,
|
||||
searchString: `${name.replace(/\s+/g, '')} ${slug}`.toLowerCase(), // Remove all whitespace and convert to lowercase
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function handleMentionClick(item = {}) {
|
||||
emit('click', item.emoji);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEmojis();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
type="emoji"
|
||||
:items="items"
|
||||
@mentionSelect="handleMentionClick"
|
||||
>
|
||||
<template #default="{ item, selected }">
|
||||
<span
|
||||
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
|
||||
>
|
||||
{{ item.emoji }}
|
||||
<p
|
||||
class="relative mb-0 truncate bottom-px"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': selected,
|
||||
'font-normal': !selected,
|
||||
}"
|
||||
>
|
||||
:{{ item.name }}
|
||||
</p>
|
||||
</span>
|
||||
</template>
|
||||
</MentionBox>
|
||||
</template>
|
||||
@@ -41,11 +41,14 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
:items="items"
|
||||
@mentionSelect="handleMentionClick"
|
||||
/>
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</template>
|
||||
</MentionBox>
|
||||
</template>
|
||||
|
||||
+4
-20
@@ -5,7 +5,7 @@ import languages from './advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { filterAttributeGroups } from './advancedFilterItems';
|
||||
import { useFilter } from 'shared/composables/useFilter';
|
||||
import filterMixin from 'shared/mixins/filterMixin';
|
||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
|
||||
@@ -14,6 +14,7 @@ export default {
|
||||
components: {
|
||||
FilterInputBox,
|
||||
},
|
||||
mixins: [filterMixin],
|
||||
props: {
|
||||
onClose: {
|
||||
type: Function,
|
||||
@@ -36,15 +37,6 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { setFilterAttributes } = useFilter({
|
||||
filteri18nKey: 'FILTER',
|
||||
attributeModel: 'conversation_attribute',
|
||||
});
|
||||
return {
|
||||
setFilterAttributes,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
@@ -75,11 +67,7 @@ export default {
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
const { filterGroups, filterTypes } = this.setFilterAttributes();
|
||||
|
||||
this.filterTypes = [...this.filterTypes, ...filterTypes];
|
||||
this.filterGroups = filterGroups;
|
||||
|
||||
this.setFilterAttributes();
|
||||
this.$store.dispatch('campaigns/get');
|
||||
if (this.getAppliedConversationFilters.length) {
|
||||
this.appliedFilters = [];
|
||||
@@ -338,11 +326,7 @@ export default {
|
||||
:show-query-operator="i !== appliedFilters.length - 1"
|
||||
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
|
||||
grouped-filters
|
||||
:error-message="
|
||||
validationErrors[`filter_${i}`]
|
||||
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
|
||||
: ''
|
||||
"
|
||||
:error-message="validationErrors[`filter_${i}`]"
|
||||
@resetFilter="resetFilter(i, appliedFilters[i])"
|
||||
@removeFilter="removeFilter(i)"
|
||||
/>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
@@ -22,7 +24,7 @@ export default {
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
mixins: [inboxMixin, agentMixin],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
@@ -42,12 +44,18 @@ export default {
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const conversationHeaderActionsRef = ref(null);
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: () => emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
|
||||
|
||||
return {
|
||||
conversationHeaderActionsRef,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -175,6 +183,7 @@ export default {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="conversationHeaderActionsRef"
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import BubbleActions from './bubble/Actions.vue';
|
||||
import BubbleContact from './bubble/Contact.vue';
|
||||
import BubbleFile from './bubble/File.vue';
|
||||
@@ -39,6 +39,7 @@ export default {
|
||||
InstagramStoryReply,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -73,12 +74,6 @@ export default {
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showContextMenu: false,
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
<script>
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'MessagePreview',
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
@@ -19,12 +20,6 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
return {
|
||||
getPlainText,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
messageByAgent() {
|
||||
const { message_type: messageType } = this.message;
|
||||
|
||||
@@ -3,7 +3,6 @@ import { ref } from 'vue';
|
||||
// composable
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
@@ -16,6 +15,7 @@ import { mapGetters } from 'vuex';
|
||||
|
||||
// mixins
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
// utils
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
@@ -40,7 +40,7 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
mixins: [inboxMixin, aiMixin],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
@@ -52,6 +52,7 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const conversationFooterRef = ref(null);
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -69,24 +70,14 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
conversationFooterRef,
|
||||
isPopOutReplyBox,
|
||||
closePopOutReplyBox,
|
||||
showPopOutReplyBox,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -527,6 +518,7 @@ export default {
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
ref="conversationFooterRef"
|
||||
class="conversation-footer"
|
||||
:class="{ 'modal-mask': isPopOutReplyBox }"
|
||||
>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CMD_MUTE_CONVERSATION,
|
||||
CMD_SEND_TRANSCRIPT,
|
||||
CMD_UNMUTE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
} from '../../../routes/dashboard/commands/commandBarBusEvents';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -17,6 +17,7 @@ import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { AUDIO_FORMATS } from 'shared/constants/messages';
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import {
|
||||
@@ -60,7 +61,12 @@ export default {
|
||||
MessageSignatureMissingAlert,
|
||||
ArticleSearchPopover,
|
||||
},
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
mixins: [
|
||||
inboxMixin,
|
||||
messageFormatterMixin,
|
||||
fileUploadMixin,
|
||||
keyboardEventListenerMixins,
|
||||
],
|
||||
props: {
|
||||
popoutReplyBox: {
|
||||
type: Boolean,
|
||||
@@ -237,9 +243,6 @@ export default {
|
||||
if (this.isASmsInbox) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAnEmailChannel) {
|
||||
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||
}
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
|
||||
@@ -40,6 +40,7 @@ const onSelect = () => {
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
elementRef: tagAgentsRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
|
||||
@@ -56,14 +56,20 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
type="variable"
|
||||
:items="items"
|
||||
@mentionSelect="handleVariableClick"
|
||||
/>
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<span class="text-capitalize variable--list-label">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
({{ item.label }})
|
||||
</template>
|
||||
</MentionBox>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -35,6 +35,7 @@ const ALLOWED_FILE_TYPES = {
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
const galleryViewRef = ref(null);
|
||||
const zoomScale = ref(1);
|
||||
const activeAttachment = ref({});
|
||||
const activeFileType = ref('');
|
||||
@@ -201,7 +202,7 @@ const keyboardEvents = {
|
||||
},
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, galleryViewRef);
|
||||
|
||||
onMounted(() => {
|
||||
setImageAndVideoSrc(props.attachment);
|
||||
@@ -217,6 +218,7 @@ onMounted(() => {
|
||||
:on-close="onClose"
|
||||
>
|
||||
<div
|
||||
ref="galleryViewRef"
|
||||
v-on-clickaway="onClose"
|
||||
class="bg-white dark:bg-slate-900 flex flex-col h-[inherit] w-[inherit] overflow-hidden"
|
||||
@click="onClose"
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import {
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
} from 'dashboard/helper/agentHelper.js';
|
||||
import MenuItem from './menuItem.vue';
|
||||
import MenuItemWithSubmenu from './menuItemWithSubmenu.vue';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import agentMixin from 'dashboard/mixins/agentMixin';
|
||||
import { mapGetters } from 'vuex';
|
||||
import AgentLoadingPlaceholder from './agentLoadingPlaceholder.vue';
|
||||
export default {
|
||||
components: {
|
||||
@@ -14,6 +11,7 @@ export default {
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [agentMixin],
|
||||
props: {
|
||||
chatId: {
|
||||
type: Number,
|
||||
@@ -114,19 +112,13 @@ export default {
|
||||
labels: 'labels/getLabels',
|
||||
teams: 'teams/getTeams',
|
||||
assignableAgentsUiFlags: 'inboxAssignableAgents/getUIFlags',
|
||||
currentUser: 'getCurrentUser',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
filteredAgentOnAvailability() {
|
||||
const agents = this.$store.getters[
|
||||
'inboxAssignableAgents/getAssignableAgents'
|
||||
](this.inboxId);
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
this.currentUser,
|
||||
this.currentAccountId
|
||||
);
|
||||
const filteredAgents = getSortedAgentsByAvailability(
|
||||
const agentsByUpdatedPresence = this.getAgentsByUpdatedPresence(agents);
|
||||
const filteredAgents = this.sortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
return filteredAgents;
|
||||
|
||||
+3
-13
@@ -2,9 +2,7 @@
|
||||
// components
|
||||
import WootButton from '../../../ui/WootButton.vue';
|
||||
import Avatar from '../../Avatar.vue';
|
||||
|
||||
// composables
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
// store & api
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -20,6 +18,7 @@ export default {
|
||||
Avatar,
|
||||
WootButton,
|
||||
},
|
||||
mixins: [aiMixin],
|
||||
props: {
|
||||
suggestedLabels: {
|
||||
type: Array,
|
||||
@@ -31,11 +30,6 @@ export default {
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isAIIntegrationEnabled } = useAI();
|
||||
|
||||
return { isAIIntegrationEnabled };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDismissed: false,
|
||||
@@ -47,11 +41,7 @@ export default {
|
||||
...mapGetters({
|
||||
allLabels: 'labels/getLabels',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
currentChat: 'getSelectedChat',
|
||||
}),
|
||||
conversationId() {
|
||||
return this.currentChat?.id;
|
||||
},
|
||||
labelTooltip() {
|
||||
if (this.preparedLabels.length > 1) {
|
||||
return this.$t('LABEL_MGMT.SUGGESTIONS.TOOLTIP.MULTIPLE_SUGGESTION');
|
||||
@@ -172,7 +162,7 @@ export default {
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="label-suggestion--option !px-0"
|
||||
class="label-suggestion--option"
|
||||
@click="pushOrAddLabel(label.title)"
|
||||
>
|
||||
<woot-label
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
} from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
|
||||
@@ -48,7 +48,11 @@ const loadLinkedIssue = async () => {
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
// We don't want to show an error message here, as it's not critical. When someone clicks on the Linear icon, we can inform them that the integration is disabled.
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
<script>
|
||||
import countries from 'shared/constants/countries.js';
|
||||
import parsePhoneNumber from 'libphonenumber-js';
|
||||
import {
|
||||
getActiveCountryCode,
|
||||
getActiveDialCode,
|
||||
} from 'shared/components/PhoneInput/helper';
|
||||
|
||||
export default {
|
||||
props: {
|
||||
@@ -34,8 +30,8 @@ export default {
|
||||
selectedIndex: -1,
|
||||
showDropdown: false,
|
||||
searchCountry: '',
|
||||
activeCountryCode: getActiveCountryCode(),
|
||||
activeDialCode: getActiveDialCode(),
|
||||
activeCountryCode: '',
|
||||
activeDialCode: '',
|
||||
phoneNumber: this.value,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ const onSelect = () => {
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
elementRef: mentionsListContainerRef,
|
||||
items: computed(() => props.items),
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -90,24 +91,22 @@ const variableKey = (item = {}) => {
|
||||
}"
|
||||
@click="onListItemSelection(index)"
|
||||
>
|
||||
<slot :item="item" :index="index" :selected="index === selectedIndex">
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ variableKey(item) }}
|
||||
</p>
|
||||
</slot>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ variableKey(item) }}
|
||||
</p>
|
||||
</button>
|
||||
</woot-dropdown-item>
|
||||
</ul>
|
||||
|
||||
@@ -1,197 +0,0 @@
|
||||
export const mockAssignableAgents = [
|
||||
{
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'john@doe.com',
|
||||
available_name: 'John Doe',
|
||||
name: 'John Doe',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const mockCurrentChat = {
|
||||
meta: {
|
||||
sender: {
|
||||
additional_attributes: {},
|
||||
availability_status: 'offline',
|
||||
email: null,
|
||||
id: 212,
|
||||
name: 'Chatwoot',
|
||||
phone_number: null,
|
||||
identifier: null,
|
||||
thumbnail: '',
|
||||
custom_attributes: {},
|
||||
last_activity_at: 1723553344,
|
||||
created_at: 1722588710,
|
||||
},
|
||||
channel: 'Channel::WebWidget',
|
||||
assignee: {
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'john@doe.com',
|
||||
available_name: 'John Doe',
|
||||
name: 'John Doe',
|
||||
role: 'administrator',
|
||||
thumbnail: '',
|
||||
},
|
||||
hmac_verified: false,
|
||||
},
|
||||
id: 138,
|
||||
messages: [
|
||||
{
|
||||
id: 3348,
|
||||
content: 'Hello, how can I assist you today?',
|
||||
account_id: 1,
|
||||
inbox_id: 1,
|
||||
conversation_id: 138,
|
||||
message_type: 1,
|
||||
created_at: 1724398739,
|
||||
updated_at: '2024-08-23T07:38:59.763Z',
|
||||
private: false,
|
||||
status: 'sent',
|
||||
source_id: null,
|
||||
content_type: 'text',
|
||||
content_attributes: {},
|
||||
sender_type: 'User',
|
||||
sender_id: 1,
|
||||
external_source_ids: {},
|
||||
additional_attributes: {},
|
||||
processed_message_content: 'Hello, how can I assist you today?',
|
||||
sentiment: {},
|
||||
conversation: {
|
||||
assignee_id: 1,
|
||||
unread_count: 0,
|
||||
last_activity_at: 1724398739,
|
||||
contact_inbox: {
|
||||
source_id: '5e57317d-053b-4a72-8292-a25b9f29c401',
|
||||
},
|
||||
},
|
||||
sender: {
|
||||
id: 1,
|
||||
name: 'John Doe',
|
||||
available_name: 'John Doe',
|
||||
avatar_url: '',
|
||||
type: 'user',
|
||||
availability_status: 'online',
|
||||
thumbnail: '',
|
||||
},
|
||||
},
|
||||
],
|
||||
account_id: 1,
|
||||
uuid: '69dd6922-2f0c-4317-8796-bbeb3679cead',
|
||||
additional_attributes: {
|
||||
browser: {
|
||||
device_name: 'Unknown',
|
||||
browser_name: 'Chrome',
|
||||
platform_name: 'macOS',
|
||||
browser_version: '127.0.0.0',
|
||||
platform_version: '10.15.7',
|
||||
},
|
||||
referer: 'http://chatwoot.com/widget_tests?dark_mode=auto',
|
||||
initiated_at: {
|
||||
timestamp: 'Fri Aug 02 2024 15:21:18 GMT+0530 (India Standard Time)',
|
||||
},
|
||||
browser_language: 'en',
|
||||
},
|
||||
agent_last_seen_at: 1724400730,
|
||||
assignee_last_seen_at: 1724400686,
|
||||
can_reply: true,
|
||||
contact_last_seen_at: 1723553351,
|
||||
custom_attributes: {},
|
||||
inbox_id: 1,
|
||||
labels: ['billing'],
|
||||
muted: false,
|
||||
snoozed_until: null,
|
||||
status: 'open',
|
||||
created_at: 1722592278,
|
||||
timestamp: 1724398739,
|
||||
first_reply_created_at: 1722592316,
|
||||
unread_count: 0,
|
||||
last_non_activity_message: {},
|
||||
last_activity_at: 1724398739,
|
||||
priority: null,
|
||||
waiting_since: 0,
|
||||
sla_policy_id: 10,
|
||||
applied_sla: {
|
||||
id: 143,
|
||||
sla_id: 10,
|
||||
sla_status: 'missed',
|
||||
created_at: 1722592279,
|
||||
updated_at: 1722874214,
|
||||
sla_description: '',
|
||||
sla_name: 'Hacker SLA',
|
||||
sla_first_response_time_threshold: 600,
|
||||
sla_next_response_time_threshold: 240,
|
||||
sla_only_during_business_hours: false,
|
||||
sla_resolution_time_threshold: 259200,
|
||||
},
|
||||
sla_events: [
|
||||
{
|
||||
id: 270,
|
||||
event_type: 'nrt',
|
||||
meta: {
|
||||
message_id: 2743,
|
||||
},
|
||||
updated_at: 1722592819,
|
||||
created_at: 1722592819,
|
||||
},
|
||||
{
|
||||
id: 275,
|
||||
event_type: 'rt',
|
||||
meta: {},
|
||||
updated_at: 1722852322,
|
||||
created_at: 1722852322,
|
||||
},
|
||||
],
|
||||
allMessagesLoaded: false,
|
||||
dataFetched: true,
|
||||
};
|
||||
|
||||
export const mockTeamsList = [
|
||||
{
|
||||
id: 5,
|
||||
name: 'design',
|
||||
description: 'design team',
|
||||
allow_auto_assign: true,
|
||||
account_id: 1,
|
||||
is_member: false,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockActiveLabels = [
|
||||
{
|
||||
id: 16,
|
||||
title: 'billing',
|
||||
description: '',
|
||||
color: '#D8EA19',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const mockInactiveLabels = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'Feature Request',
|
||||
description: '',
|
||||
color: '#D8EA19',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
];
|
||||
|
||||
export const MOCK_FEATURE_FLAGS = {
|
||||
CRM: 'crm',
|
||||
AGENT_MANAGEMENT: 'agent_management',
|
||||
TEAM_MANAGEMENT: 'team_management',
|
||||
INBOX_MANAGEMENT: 'inbox_management',
|
||||
REPORTS: 'reports',
|
||||
LABELS: 'labels',
|
||||
CANNED_RESPONSES: 'canned_responses',
|
||||
INTEGRATIONS: 'integrations',
|
||||
};
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useAppearanceHotKeys } from '../useAppearanceHotKeys';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
|
||||
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('shared/helpers/localStorage');
|
||||
vi.mock('dashboard/helper/themeHelper.js');
|
||||
|
||||
describe('useAppearanceHotKeys', () => {
|
||||
beforeEach(() => {
|
||||
useI18n.mockReturnValue({
|
||||
t: vi.fn(key => key),
|
||||
});
|
||||
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: false });
|
||||
});
|
||||
|
||||
it('should return goToAppearanceHotKeys computed property', () => {
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
expect(goToAppearanceHotKeys.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should have the correct number of appearance options', () => {
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
expect(goToAppearanceHotKeys.value.length).toBe(4); // 1 parent + 3 theme options
|
||||
});
|
||||
|
||||
it('should have the correct parent option', () => {
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
const parentOption = goToAppearanceHotKeys.value.find(
|
||||
option => option.id === 'appearance_settings'
|
||||
);
|
||||
expect(parentOption).toBeDefined();
|
||||
expect(parentOption.children.length).toBe(3);
|
||||
});
|
||||
|
||||
it('should have the correct theme options', () => {
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
const themeOptions = goToAppearanceHotKeys.value.filter(
|
||||
option => option.parent === 'appearance_settings'
|
||||
);
|
||||
expect(themeOptions.length).toBe(3);
|
||||
expect(themeOptions.map(option => option.id)).toEqual([
|
||||
'light',
|
||||
'dark',
|
||||
'auto',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should call setAppearance when a theme option is selected', () => {
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
const lightThemeOption = goToAppearanceHotKeys.value.find(
|
||||
option => option.id === 'light'
|
||||
);
|
||||
|
||||
lightThemeOption.handler();
|
||||
|
||||
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
|
||||
'light'
|
||||
);
|
||||
expect(setColorTheme).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should handle system dark mode preference', () => {
|
||||
window.matchMedia = vi.fn().mockReturnValue({ matches: true });
|
||||
|
||||
const { goToAppearanceHotKeys } = useAppearanceHotKeys();
|
||||
const autoThemeOption = goToAppearanceHotKeys.value.find(
|
||||
option => option.id === 'auto'
|
||||
);
|
||||
|
||||
autoThemeOption.handler();
|
||||
|
||||
expect(LocalStorage.set).toHaveBeenCalledWith(
|
||||
LOCAL_STORAGE_KEYS.COLOR_SCHEME,
|
||||
'auto'
|
||||
);
|
||||
expect(setColorTheme).toHaveBeenCalledWith(true);
|
||||
});
|
||||
});
|
||||
@@ -1,102 +0,0 @@
|
||||
import { useBulkActionsHotKeys } from '../useBulkActionsHotKeys';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('shared/helpers/mitt');
|
||||
|
||||
describe('useBulkActionsHotKeys', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
getters: {
|
||||
'bulkActions/getSelectedConversationIds': [],
|
||||
},
|
||||
};
|
||||
|
||||
useStore.mockReturnValue(store);
|
||||
useMapGetter.mockImplementation(key => ({
|
||||
value: store.getters[key],
|
||||
}));
|
||||
|
||||
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||
emitter.emit = vi.fn();
|
||||
});
|
||||
|
||||
it('should return bulk actions when conversations are selected', () => {
|
||||
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
|
||||
expect(bulkActionsHotKeys.value.length).toBeGreaterThan(0);
|
||||
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'bulk_action_snooze_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
})
|
||||
);
|
||||
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'bulk_action_reopen_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
})
|
||||
);
|
||||
expect(bulkActionsHotKeys.value).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: 'bulk_action_resolve_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('should include snooze options in bulk actions', () => {
|
||||
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
|
||||
const snoozeAction = bulkActionsHotKeys.value.find(
|
||||
action => action.id === 'bulk_action_snooze_conversation'
|
||||
);
|
||||
expect(snoozeAction).toBeDefined();
|
||||
expect(snoozeAction.children).toEqual(
|
||||
Object.values(wootConstants.SNOOZE_OPTIONS)
|
||||
);
|
||||
});
|
||||
|
||||
it('should create handlers for reopen and resolve actions', () => {
|
||||
store.getters['bulkActions/getSelectedConversationIds'] = [1, 2, 3];
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
|
||||
const reopenAction = bulkActionsHotKeys.value.find(
|
||||
action => action.id === 'bulk_action_reopen_conversation'
|
||||
);
|
||||
const resolveAction = bulkActionsHotKeys.value.find(
|
||||
action => action.id === 'bulk_action_resolve_conversation'
|
||||
);
|
||||
|
||||
expect(reopenAction.handler).toBeDefined();
|
||||
expect(resolveAction.handler).toBeDefined();
|
||||
|
||||
reopenAction.handler();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
'CMD_BULK_ACTION_REOPEN_CONVERSATION'
|
||||
);
|
||||
|
||||
resolveAction.handler();
|
||||
expect(emitter.emit).toHaveBeenCalledWith(
|
||||
'CMD_BULK_ACTION_RESOLVE_CONVERSATION'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return an empty array when no conversations are selected', () => {
|
||||
store.getters['bulkActions/getSelectedConversationIds'] = [];
|
||||
const { bulkActionsHotKeys } = useBulkActionsHotKeys();
|
||||
|
||||
expect(bulkActionsHotKeys.value).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { useConversationHotKeys } from '../useConversationHotKeys';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useRoute } from 'dashboard/composables/route';
|
||||
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import {
|
||||
mockAssignableAgents,
|
||||
mockCurrentChat,
|
||||
mockTeamsList,
|
||||
mockActiveLabels,
|
||||
mockInactiveLabels,
|
||||
} from './fixtures';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('dashboard/composables/route');
|
||||
vi.mock('dashboard/composables/useConversationLabels');
|
||||
vi.mock('dashboard/composables/useAI');
|
||||
vi.mock('dashboard/composables/useAgentsList');
|
||||
|
||||
describe('useConversationHotKeys', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
dispatch: vi.fn(),
|
||||
getters: {
|
||||
getSelectedChat: mockCurrentChat,
|
||||
'draftMessages/getReplyEditorMode': REPLY_EDITOR_MODES.REPLY,
|
||||
getContextMenuChatId: null,
|
||||
'teams/getTeams': mockTeamsList,
|
||||
'draftMessages/get': vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
useStore.mockReturnValue(store);
|
||||
useMapGetter.mockImplementation(key => ({
|
||||
value: store.getters[key],
|
||||
}));
|
||||
|
||||
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||
useRoute.mockReturnValue({ name: 'inbox_conversation' });
|
||||
useConversationLabels.mockReturnValue({
|
||||
activeLabels: { value: mockActiveLabels },
|
||||
inactiveLabels: { value: mockInactiveLabels },
|
||||
addLabelToConversation: vi.fn(),
|
||||
removeLabelFromConversation: vi.fn(),
|
||||
});
|
||||
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: true } });
|
||||
useAgentsList.mockReturnValue({
|
||||
agentsList: { value: [] },
|
||||
assignableAgents: { value: mockAssignableAgents },
|
||||
});
|
||||
});
|
||||
|
||||
it('should return the correct computed properties', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
expect(conversationHotKeys.value).toBeDefined();
|
||||
});
|
||||
|
||||
it('should generate conversation hot keys', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
expect(conversationHotKeys.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should include AI assist actions when AI integration is enabled', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const aiAssistAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'ai_assist'
|
||||
);
|
||||
expect(aiAssistAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not include AI assist actions when AI integration is disabled', () => {
|
||||
useAI.mockReturnValue({ isAIIntegrationEnabled: { value: false } });
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const aiAssistAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'ai_assist'
|
||||
);
|
||||
expect(aiAssistAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should dispatch actions when handlers are called', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const assignAgentAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'assign_an_agent'
|
||||
);
|
||||
expect(assignAgentAction).toBeDefined();
|
||||
|
||||
if (assignAgentAction && assignAgentAction.children) {
|
||||
const childAction = conversationHotKeys.value.find(
|
||||
action => action.id === assignAgentAction.children[0]
|
||||
);
|
||||
if (childAction && childAction.handler) {
|
||||
childAction.handler({ agentInfo: { id: 2 } });
|
||||
expect(store.dispatch).toHaveBeenCalledWith('assignAgent', {
|
||||
conversationId: 1,
|
||||
agentId: 2,
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should return snooze actions when in snooze context', () => {
|
||||
store.getters.getContextMenuChatId = 1;
|
||||
useMapGetter.mockImplementation(key => ({
|
||||
value: store.getters[key],
|
||||
}));
|
||||
useRoute.mockReturnValue({ name: 'inbox_conversation' });
|
||||
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const snoozeAction = conversationHotKeys.value.find(action =>
|
||||
action.id.includes('snooze_conversation')
|
||||
);
|
||||
expect(snoozeAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return the correct label actions when there are active labels', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const addLabelAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'add_a_label_to_the_conversation'
|
||||
);
|
||||
const removeLabelAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'remove_a_label_to_the_conversation'
|
||||
);
|
||||
|
||||
expect(addLabelAction).toBeDefined();
|
||||
expect(removeLabelAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return only add label actions when there are no active labels', () => {
|
||||
useConversationLabels.mockReturnValue({
|
||||
activeLabels: { value: [] },
|
||||
inactiveLabels: { value: [{ title: 'inactive_label' }] },
|
||||
addLabelToConversation: vi.fn(),
|
||||
removeLabelFromConversation: vi.fn(),
|
||||
});
|
||||
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const addLabelAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'add_a_label_to_the_conversation'
|
||||
);
|
||||
const removeLabelAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'remove_a_label_to_the_conversation'
|
||||
);
|
||||
|
||||
expect(addLabelAction).toBeDefined();
|
||||
expect(removeLabelAction).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should return the correct team assignment actions', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const assignTeamAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'assign_a_team'
|
||||
);
|
||||
|
||||
expect(assignTeamAction).toBeDefined();
|
||||
expect(assignTeamAction.children.length).toBe(mockTeamsList.length);
|
||||
});
|
||||
|
||||
it('should return the correct priority assignment actions', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const assignPriorityAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'assign_priority'
|
||||
);
|
||||
|
||||
expect(assignPriorityAction).toBeDefined();
|
||||
expect(assignPriorityAction.children.length).toBe(4);
|
||||
});
|
||||
|
||||
it('should return the correct conversation additional actions', () => {
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const muteAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'mute_conversation'
|
||||
);
|
||||
const sendTranscriptAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'send_transcript'
|
||||
);
|
||||
|
||||
expect(muteAction).toBeDefined();
|
||||
expect(sendTranscriptAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return unmute action when conversation is muted', () => {
|
||||
store.getters.getSelectedChat = { ...mockCurrentChat, muted: true };
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
const unmuteAction = conversationHotKeys.value.find(
|
||||
action => action.id === 'unmute_conversation'
|
||||
);
|
||||
|
||||
expect(unmuteAction).toBeDefined();
|
||||
});
|
||||
|
||||
it('should not return conversation hot keys when not in conversation or inbox route', () => {
|
||||
useRoute.mockReturnValue({ name: 'some_other_route' });
|
||||
const { conversationHotKeys } = useConversationHotKeys();
|
||||
|
||||
expect(conversationHotKeys.value.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,188 +0,0 @@
|
||||
import { useGoToCommandHotKeys } from '../useGoToCommandHotKeys';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useRouter } from 'dashboard/composables/route';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { MOCK_FEATURE_FLAGS } from './fixtures';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('dashboard/composables/route');
|
||||
vi.mock('dashboard/composables/useAdmin');
|
||||
vi.mock('dashboard/helper/URLHelper');
|
||||
|
||||
const mockRoutes = [
|
||||
{ path: 'accounts/:accountId/dashboard', name: 'dashboard' },
|
||||
{
|
||||
path: 'accounts/:accountId/contacts',
|
||||
name: 'contacts',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.CRM,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/agents/list',
|
||||
name: 'agent_settings',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.AGENT_MANAGEMENT,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/teams/list',
|
||||
name: 'team_settings',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.TEAM_MANAGEMENT,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/inboxes/list',
|
||||
name: 'inbox_settings',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.INBOX_MANAGEMENT,
|
||||
},
|
||||
{ path: 'accounts/:accountId/profile/settings', name: 'profile_settings' },
|
||||
{ path: 'accounts/:accountId/notifications', name: 'notifications' },
|
||||
{
|
||||
path: 'accounts/:accountId/reports/overview',
|
||||
name: 'reports_overview',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.REPORTS,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/labels/list',
|
||||
name: 'label_settings',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.LABELS,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/canned-response/list',
|
||||
name: 'canned_responses',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.CANNED_RESPONSES,
|
||||
},
|
||||
{
|
||||
path: 'accounts/:accountId/settings/applications',
|
||||
name: 'applications',
|
||||
featureFlag: MOCK_FEATURE_FLAGS.INTEGRATIONS,
|
||||
},
|
||||
];
|
||||
|
||||
describe('useGoToCommandHotKeys', () => {
|
||||
let store;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
getters: {
|
||||
getCurrentAccountId: 1,
|
||||
'accounts/isFeatureEnabledonAccount': vi.fn().mockReturnValue(true),
|
||||
},
|
||||
};
|
||||
|
||||
useStore.mockReturnValue(store);
|
||||
useMapGetter.mockImplementation(key => ({
|
||||
value: store.getters[key],
|
||||
}));
|
||||
|
||||
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||
useRouter.mockReturnValue({ push: vi.fn() });
|
||||
useAdmin.mockReturnValue({ isAdmin: { value: true } });
|
||||
frontendURL.mockImplementation(url => url);
|
||||
});
|
||||
|
||||
it('should return goToCommandHotKeys computed property', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
expect(goToCommandHotKeys.value).toBeDefined();
|
||||
expect(goToCommandHotKeys.value.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should filter commands based on feature flags', () => {
|
||||
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(
|
||||
(accountId, flag) => flag !== MOCK_FEATURE_FLAGS.CRM
|
||||
);
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
|
||||
mockRoutes.forEach(route => {
|
||||
const command = goToCommandHotKeys.value.find(cmd =>
|
||||
cmd.id.includes(route.name)
|
||||
);
|
||||
if (route.featureFlag === MOCK_FEATURE_FLAGS.CRM) {
|
||||
expect(command).toBeUndefined();
|
||||
} else if (!route.featureFlag) {
|
||||
expect(command).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('should filter commands for non-admin users', () => {
|
||||
useAdmin.mockReturnValue({ isAdmin: { value: false } });
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
|
||||
const adminOnlyCommands = goToCommandHotKeys.value.filter(
|
||||
cmd =>
|
||||
cmd.id.includes('agent_settings') ||
|
||||
cmd.id.includes('team_settings') ||
|
||||
cmd.id.includes('inbox_settings')
|
||||
);
|
||||
expect(adminOnlyCommands.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should include commands for both admin and agent roles when user is admin', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const adminCommand = goToCommandHotKeys.value.find(cmd =>
|
||||
cmd.id.includes('agent_settings')
|
||||
);
|
||||
const agentCommand = goToCommandHotKeys.value.find(cmd =>
|
||||
cmd.id.includes('profile_settings')
|
||||
);
|
||||
expect(adminCommand).toBeDefined();
|
||||
expect(agentCommand).toBeDefined();
|
||||
});
|
||||
|
||||
it('should translate section and title for each command', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
goToCommandHotKeys.value.forEach(command => {
|
||||
expect(useI18n().t).toHaveBeenCalledWith(
|
||||
expect.stringContaining('COMMAND_BAR.SECTIONS.')
|
||||
);
|
||||
expect(useI18n().t).toHaveBeenCalledWith(
|
||||
expect.stringContaining('COMMAND_BAR.COMMANDS.')
|
||||
);
|
||||
expect(command.section).toBeDefined();
|
||||
expect(command.title).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should call router.push with correct URL when handler is called', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
goToCommandHotKeys.value.forEach(command => {
|
||||
command.handler();
|
||||
expect(useRouter().push).toHaveBeenCalledWith(expect.any(String));
|
||||
});
|
||||
});
|
||||
|
||||
it('should use current account ID in the path', () => {
|
||||
store.getters.getCurrentAccountId = 42;
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
goToCommandHotKeys.value.forEach(command => {
|
||||
command.handler();
|
||||
expect(useRouter().push).toHaveBeenCalledWith(
|
||||
expect.stringContaining('42')
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it('should include icon for each command', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
goToCommandHotKeys.value.forEach(command => {
|
||||
expect(command.icon).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('should return commands for all enabled features', () => {
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const enabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
|
||||
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
|
||||
);
|
||||
expect(enabledFeatureCommands.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('should not return commands for disabled features', () => {
|
||||
store.getters['accounts/isFeatureEnabledonAccount'] = vi.fn(() => false);
|
||||
const { goToCommandHotKeys } = useGoToCommandHotKeys();
|
||||
const disabledFeatureCommands = goToCommandHotKeys.value.filter(cmd =>
|
||||
mockRoutes.some(route => route.featureFlag && cmd.id.includes(route.name))
|
||||
);
|
||||
expect(disabledFeatureCommands.length).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -1,37 +0,0 @@
|
||||
import { useInboxHotKeys } from '../useInboxHotKeys';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useRoute } from 'dashboard/composables/route';
|
||||
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('dashboard/composables/route');
|
||||
vi.mock('dashboard/helper/routeHelpers');
|
||||
vi.mock('shared/helpers/mitt');
|
||||
|
||||
describe('useInboxHotKeys', () => {
|
||||
beforeEach(() => {
|
||||
useI18n.mockReturnValue({ t: vi.fn(key => key) });
|
||||
useRoute.mockReturnValue({ name: 'inbox_dashboard' });
|
||||
isAInboxViewRoute.mockReturnValue(true);
|
||||
});
|
||||
|
||||
it('should return inbox hot keys when on an inbox view route', () => {
|
||||
const { inboxHotKeys } = useInboxHotKeys();
|
||||
expect(inboxHotKeys.value.length).toBeGreaterThan(0);
|
||||
expect(inboxHotKeys.value[0].id).toBe('snooze_notification');
|
||||
});
|
||||
|
||||
it('should return an empty array when not on an inbox view route', () => {
|
||||
isAInboxViewRoute.mockReturnValue(false);
|
||||
const { inboxHotKeys } = useInboxHotKeys();
|
||||
expect(inboxHotKeys.value).toEqual([]);
|
||||
});
|
||||
|
||||
it('should have the correct structure for snooze actions', () => {
|
||||
const { inboxHotKeys } = useInboxHotKeys();
|
||||
const snoozeNotificationAction = inboxHotKeys.value.find(
|
||||
action => action.id === 'snooze_notification'
|
||||
);
|
||||
expect(snoozeNotificationAction).toBeDefined();
|
||||
});
|
||||
});
|
||||
@@ -1,70 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import {
|
||||
ICON_APPEARANCE,
|
||||
ICON_LIGHT_MODE,
|
||||
ICON_DARK_MODE,
|
||||
ICON_SYSTEM_MODE,
|
||||
} from 'dashboard/helper/commandbar/icons';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { setColorTheme } from 'dashboard/helper/themeHelper.js';
|
||||
|
||||
const getThemeOptions = t => [
|
||||
{
|
||||
key: 'light',
|
||||
label: t('COMMAND_BAR.COMMANDS.LIGHT_MODE'),
|
||||
icon: ICON_LIGHT_MODE,
|
||||
},
|
||||
{
|
||||
key: 'dark',
|
||||
label: t('COMMAND_BAR.COMMANDS.DARK_MODE'),
|
||||
icon: ICON_DARK_MODE,
|
||||
},
|
||||
{
|
||||
key: 'auto',
|
||||
label: t('COMMAND_BAR.COMMANDS.SYSTEM_MODE'),
|
||||
icon: ICON_SYSTEM_MODE,
|
||||
},
|
||||
];
|
||||
|
||||
const setAppearance = theme => {
|
||||
LocalStorage.set(LOCAL_STORAGE_KEYS.COLOR_SCHEME, theme);
|
||||
const isOSOnDarkMode = window.matchMedia(
|
||||
'(prefers-color-scheme: dark)'
|
||||
).matches;
|
||||
setColorTheme(isOSOnDarkMode);
|
||||
};
|
||||
|
||||
export function useAppearanceHotKeys() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const themeOptions = computed(() => getThemeOptions(t));
|
||||
|
||||
const goToAppearanceHotKeys = computed(() => {
|
||||
const options = themeOptions.value.map(theme => ({
|
||||
id: theme.key,
|
||||
title: theme.label,
|
||||
parent: 'appearance_settings',
|
||||
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
|
||||
icon: theme.icon,
|
||||
handler: () => {
|
||||
setAppearance(theme.key);
|
||||
},
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: 'appearance_settings',
|
||||
title: t('COMMAND_BAR.COMMANDS.CHANGE_APPEARANCE'),
|
||||
section: t('COMMAND_BAR.SECTIONS.APPEARANCE'),
|
||||
icon: ICON_APPEARANCE,
|
||||
children: options.map(option => option.id),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
goToAppearanceHotKeys,
|
||||
};
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import {
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
CMD_BULK_ACTION_REOPEN_CONVERSATION,
|
||||
CMD_BULK_ACTION_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
import {
|
||||
ICON_SNOOZE_CONVERSATION,
|
||||
ICON_REOPEN_CONVERSATION,
|
||||
ICON_RESOLVE_CONVERSATION,
|
||||
} from 'dashboard/helper/commandbar/icons';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import { createSnoozeHandlers } from 'dashboard/helper/commandbar/actions';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
const createEmitHandler = event => () => emitter.emit(event);
|
||||
|
||||
const SNOOZE_CONVERSATION_BULK_ACTIONS = [
|
||||
{
|
||||
id: 'bulk_action_snooze_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
children: Object.values(SNOOZE_OPTIONS),
|
||||
},
|
||||
...createSnoozeHandlers(
|
||||
CMD_BULK_ACTION_SNOOZE_CONVERSATION,
|
||||
'bulk_action_snooze_conversation',
|
||||
'COMMAND_BAR.SECTIONS.BULK_ACTIONS'
|
||||
),
|
||||
];
|
||||
|
||||
const RESOLVED_CONVERSATION_BULK_ACTIONS = [
|
||||
{
|
||||
id: 'bulk_action_reopen_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.REOPEN_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
icon: ICON_REOPEN_CONVERSATION,
|
||||
handler: createEmitHandler(CMD_BULK_ACTION_REOPEN_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
const OPEN_CONVERSATION_BULK_ACTIONS = [
|
||||
{
|
||||
id: 'bulk_action_resolve_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.RESOLVE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.BULK_ACTIONS',
|
||||
icon: ICON_RESOLVE_CONVERSATION,
|
||||
handler: createEmitHandler(CMD_BULK_ACTION_RESOLVE_CONVERSATION),
|
||||
},
|
||||
];
|
||||
|
||||
export function useBulkActionsHotKeys() {
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedConversations = useMapGetter(
|
||||
'bulkActions/getSelectedConversationIds'
|
||||
);
|
||||
|
||||
const prepareActions = actions => {
|
||||
return actions.map(action => ({
|
||||
...action,
|
||||
title: t(action.title),
|
||||
section: t(action.section),
|
||||
}));
|
||||
};
|
||||
|
||||
const bulkActionsHotKeys = computed(() => {
|
||||
let actions = [];
|
||||
if (selectedConversations.value.length > 0) {
|
||||
actions = [
|
||||
...SNOOZE_CONVERSATION_BULK_ACTIONS,
|
||||
...RESOLVED_CONVERSATION_BULK_ACTIONS,
|
||||
...OPEN_CONVERSATION_BULK_ACTIONS,
|
||||
];
|
||||
}
|
||||
return prepareActions(actions);
|
||||
});
|
||||
|
||||
return {
|
||||
bulkActionsHotKeys,
|
||||
};
|
||||
}
|
||||
@@ -1,408 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute } from 'dashboard/composables/route';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { useConversationLabels } from 'dashboard/composables/useConversationLabels';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useAgentsList } from 'dashboard/composables/useAgentsList';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import {
|
||||
ICON_ADD_LABEL,
|
||||
ICON_ASSIGN_AGENT,
|
||||
ICON_ASSIGN_PRIORITY,
|
||||
ICON_ASSIGN_TEAM,
|
||||
ICON_REMOVE_LABEL,
|
||||
ICON_PRIORITY_URGENT,
|
||||
ICON_PRIORITY_HIGH,
|
||||
ICON_PRIORITY_LOW,
|
||||
ICON_PRIORITY_MEDIUM,
|
||||
ICON_PRIORITY_NONE,
|
||||
ICON_AI_ASSIST,
|
||||
ICON_AI_SUMMARY,
|
||||
ICON_AI_SHORTEN,
|
||||
ICON_AI_EXPAND,
|
||||
ICON_AI_GRAMMAR,
|
||||
} from 'dashboard/helper/commandbar/icons';
|
||||
|
||||
import {
|
||||
OPEN_CONVERSATION_ACTIONS,
|
||||
SNOOZE_CONVERSATION_ACTIONS,
|
||||
RESOLVED_CONVERSATION_ACTIONS,
|
||||
SEND_TRANSCRIPT_ACTION,
|
||||
UNMUTE_ACTION,
|
||||
MUTE_ACTION,
|
||||
} from 'dashboard/helper/commandbar/actions';
|
||||
import {
|
||||
isAConversationRoute,
|
||||
isAInboxViewRoute,
|
||||
} from 'dashboard/helper/routeHelpers';
|
||||
|
||||
const prepareActions = (actions, t) => {
|
||||
return actions.map(action => ({
|
||||
...action,
|
||||
title: t(action.title),
|
||||
section: t(action.section),
|
||||
}));
|
||||
};
|
||||
|
||||
const createPriorityOptions = (t, currentPriority) => {
|
||||
return [
|
||||
{
|
||||
label: t('CONVERSATION.PRIORITY.OPTIONS.NONE'),
|
||||
key: null,
|
||||
icon: ICON_PRIORITY_NONE,
|
||||
},
|
||||
{
|
||||
label: t('CONVERSATION.PRIORITY.OPTIONS.URGENT'),
|
||||
key: 'urgent',
|
||||
icon: ICON_PRIORITY_URGENT,
|
||||
},
|
||||
{
|
||||
label: t('CONVERSATION.PRIORITY.OPTIONS.HIGH'),
|
||||
key: 'high',
|
||||
icon: ICON_PRIORITY_HIGH,
|
||||
},
|
||||
{
|
||||
label: t('CONVERSATION.PRIORITY.OPTIONS.MEDIUM'),
|
||||
key: 'medium',
|
||||
icon: ICON_PRIORITY_MEDIUM,
|
||||
},
|
||||
{
|
||||
label: t('CONVERSATION.PRIORITY.OPTIONS.LOW'),
|
||||
key: 'low',
|
||||
icon: ICON_PRIORITY_LOW,
|
||||
},
|
||||
].filter(item => item.key !== currentPriority);
|
||||
};
|
||||
|
||||
const createNonDraftMessageAIAssistActions = (t, replyMode) => {
|
||||
if (replyMode === REPLY_EDITOR_MODES.REPLY) {
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPLY_SUGGESTION'),
|
||||
key: 'reply_suggestion',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
];
|
||||
}
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SUMMARIZE'),
|
||||
key: 'summarize',
|
||||
icon: ICON_AI_SUMMARY,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
const createDraftMessageAIAssistActions = t => {
|
||||
return [
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.REPHRASE'),
|
||||
key: 'rephrase',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.FIX_SPELLING_GRAMMAR'),
|
||||
key: 'fix_spelling_grammar',
|
||||
icon: ICON_AI_GRAMMAR,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.EXPAND'),
|
||||
key: 'expand',
|
||||
icon: ICON_AI_EXPAND,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SHORTEN'),
|
||||
key: 'shorten',
|
||||
icon: ICON_AI_SHORTEN,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FRIENDLY'),
|
||||
key: 'make_friendly',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.MAKE_FORMAL'),
|
||||
key: 'make_formal',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
{
|
||||
label: t('INTEGRATION_SETTINGS.OPEN_AI.OPTIONS.SIMPLIFY'),
|
||||
key: 'simplify',
|
||||
icon: ICON_AI_ASSIST,
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export function useConversationHotKeys() {
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
|
||||
const {
|
||||
activeLabels,
|
||||
inactiveLabels,
|
||||
addLabelToConversation,
|
||||
removeLabelFromConversation,
|
||||
} = useConversationLabels();
|
||||
|
||||
const { isAIIntegrationEnabled } = useAI();
|
||||
const { agentsList } = useAgentsList();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
const contextMenuChatId = useMapGetter('getContextMenuChatId');
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
const getDraftMessage = useMapGetter('draftMessages/get');
|
||||
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
const draftKey = computed(
|
||||
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||
);
|
||||
|
||||
const draftMessage = computed(() => getDraftMessage.value(draftKey.value));
|
||||
|
||||
const hasAnAssignedTeam = computed(() => !!currentChat.value?.meta?.team);
|
||||
|
||||
const teamsList = computed(() => {
|
||||
if (hasAnAssignedTeam.value) {
|
||||
return [{ id: 0, name: t('TEAMS_SETTINGS.LIST.NONE') }, ...teams.value];
|
||||
}
|
||||
return teams.value;
|
||||
});
|
||||
|
||||
const onChangeAssignee = action => {
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value.id,
|
||||
agentId: action.agentInfo.id,
|
||||
});
|
||||
};
|
||||
|
||||
const onChangePriority = action => {
|
||||
store.dispatch('assignPriority', {
|
||||
conversationId: currentChat.value.id,
|
||||
priority: action.priority.key,
|
||||
});
|
||||
};
|
||||
|
||||
const onChangeTeam = action => {
|
||||
store.dispatch('assignTeam', {
|
||||
conversationId: currentChat.value.id,
|
||||
teamId: action.teamInfo.id,
|
||||
});
|
||||
};
|
||||
|
||||
const statusActions = computed(() => {
|
||||
const isOpen = currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
const isSnoozed =
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.SNOOZED;
|
||||
const isResolved =
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.RESOLVED;
|
||||
|
||||
let actions = [];
|
||||
if (isOpen) {
|
||||
actions = [...OPEN_CONVERSATION_ACTIONS, ...SNOOZE_CONVERSATION_ACTIONS];
|
||||
} else if (isResolved || isSnoozed) {
|
||||
actions = RESOLVED_CONVERSATION_ACTIONS;
|
||||
}
|
||||
return prepareActions(actions, t);
|
||||
});
|
||||
|
||||
const priorityOptions = computed(() =>
|
||||
createPriorityOptions(t, currentChat.value?.priority)
|
||||
);
|
||||
|
||||
const assignAgentActions = computed(() => {
|
||||
const agentOptions = agentsList.value.map(agent => ({
|
||||
id: `agent-${agent.id}`,
|
||||
title: agent.name,
|
||||
parent: 'assign_an_agent',
|
||||
section: t('COMMAND_BAR.SECTIONS.CHANGE_ASSIGNEE'),
|
||||
agentInfo: agent,
|
||||
icon: ICON_ASSIGN_AGENT,
|
||||
handler: onChangeAssignee,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: 'assign_an_agent',
|
||||
title: t('COMMAND_BAR.COMMANDS.ASSIGN_AN_AGENT'),
|
||||
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||
icon: ICON_ASSIGN_AGENT,
|
||||
children: agentOptions.map(option => option.id),
|
||||
},
|
||||
...agentOptions,
|
||||
];
|
||||
});
|
||||
|
||||
const assignPriorityActions = computed(() => {
|
||||
const options = priorityOptions.value.map(priority => ({
|
||||
id: `priority-${priority.key}`,
|
||||
title: priority.label,
|
||||
parent: 'assign_priority',
|
||||
section: t('COMMAND_BAR.SECTIONS.CHANGE_PRIORITY'),
|
||||
priority: priority,
|
||||
icon: priority.icon,
|
||||
handler: onChangePriority,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: 'assign_priority',
|
||||
title: t('COMMAND_BAR.COMMANDS.ASSIGN_PRIORITY'),
|
||||
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||
icon: ICON_ASSIGN_PRIORITY,
|
||||
children: options.map(option => option.id),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
});
|
||||
|
||||
const assignTeamActions = computed(() => {
|
||||
const teamOptions = teamsList.value.map(team => ({
|
||||
id: `team-${team.id}`,
|
||||
title: team.name,
|
||||
parent: 'assign_a_team',
|
||||
section: t('COMMAND_BAR.SECTIONS.CHANGE_TEAM'),
|
||||
teamInfo: team,
|
||||
icon: ICON_ASSIGN_TEAM,
|
||||
handler: onChangeTeam,
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: 'assign_a_team',
|
||||
title: t('COMMAND_BAR.COMMANDS.ASSIGN_A_TEAM'),
|
||||
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||
icon: ICON_ASSIGN_TEAM,
|
||||
children: teamOptions.map(option => option.id),
|
||||
},
|
||||
...teamOptions,
|
||||
];
|
||||
});
|
||||
|
||||
const addLabelActions = computed(() => {
|
||||
const availableLabels = inactiveLabels.value.map(label => ({
|
||||
id: label.title,
|
||||
title: `#${label.title}`,
|
||||
parent: 'add_a_label_to_the_conversation',
|
||||
section: t('COMMAND_BAR.SECTIONS.ADD_LABEL'),
|
||||
icon: ICON_ADD_LABEL,
|
||||
handler: action => addLabelToConversation({ title: action.id }),
|
||||
}));
|
||||
return [
|
||||
...availableLabels,
|
||||
{
|
||||
id: 'add_a_label_to_the_conversation',
|
||||
title: t('COMMAND_BAR.COMMANDS.ADD_LABELS_TO_CONVERSATION'),
|
||||
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||
icon: ICON_ADD_LABEL,
|
||||
children: inactiveLabels.value.map(label => label.title),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const removeLabelActions = computed(() => {
|
||||
const activeLabelsComputed = activeLabels.value.map(label => ({
|
||||
id: label.title,
|
||||
title: `#${label.title}`,
|
||||
parent: 'remove_a_label_to_the_conversation',
|
||||
section: t('COMMAND_BAR.SECTIONS.REMOVE_LABEL'),
|
||||
icon: ICON_REMOVE_LABEL,
|
||||
handler: action => removeLabelFromConversation(action.id),
|
||||
}));
|
||||
return [
|
||||
...activeLabelsComputed,
|
||||
{
|
||||
id: 'remove_a_label_to_the_conversation',
|
||||
title: t('COMMAND_BAR.COMMANDS.REMOVE_LABEL_FROM_CONVERSATION'),
|
||||
section: t('COMMAND_BAR.SECTIONS.CONVERSATION'),
|
||||
icon: ICON_REMOVE_LABEL,
|
||||
children: activeLabels.value.map(label => label.title),
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
const labelActions = computed(() => {
|
||||
if (activeLabels.value.length) {
|
||||
return [...addLabelActions.value, ...removeLabelActions.value];
|
||||
}
|
||||
return addLabelActions.value;
|
||||
});
|
||||
|
||||
const conversationAdditionalActions = computed(() => {
|
||||
return prepareActions(
|
||||
[
|
||||
currentChat.value.muted ? UNMUTE_ACTION : MUTE_ACTION,
|
||||
SEND_TRANSCRIPT_ACTION,
|
||||
],
|
||||
t
|
||||
);
|
||||
});
|
||||
|
||||
const AIAssistActions = computed(() => {
|
||||
const aiOptions = draftMessage.value
|
||||
? createDraftMessageAIAssistActions(t)
|
||||
: createNonDraftMessageAIAssistActions(t, replyMode.value);
|
||||
const options = aiOptions.map(item => ({
|
||||
id: `ai-assist-${item.key}`,
|
||||
title: item.label,
|
||||
parent: 'ai_assist',
|
||||
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
|
||||
priority: item,
|
||||
icon: item.icon,
|
||||
handler: () => emitter.emit(CMD_AI_ASSIST, item.key),
|
||||
}));
|
||||
return [
|
||||
{
|
||||
id: 'ai_assist',
|
||||
title: t('COMMAND_BAR.COMMANDS.AI_ASSIST'),
|
||||
section: t('COMMAND_BAR.SECTIONS.AI_ASSIST'),
|
||||
icon: ICON_AI_ASSIST,
|
||||
children: options.map(option => option.id),
|
||||
},
|
||||
...options,
|
||||
];
|
||||
});
|
||||
|
||||
const isConversationOrInboxRoute = computed(() => {
|
||||
return isAConversationRoute(route.name) || isAInboxViewRoute(route.name);
|
||||
});
|
||||
|
||||
const shouldShowSnoozeOption = computed(() => {
|
||||
return (
|
||||
isAConversationRoute(route.name, true, false) && contextMenuChatId.value
|
||||
);
|
||||
});
|
||||
|
||||
const getDefaultConversationHotKeys = computed(() => {
|
||||
const defaultConversationHotKeys = [
|
||||
...statusActions.value,
|
||||
...conversationAdditionalActions.value,
|
||||
...assignAgentActions.value,
|
||||
...assignTeamActions.value,
|
||||
...labelActions.value,
|
||||
...assignPriorityActions.value,
|
||||
];
|
||||
if (isAIIntegrationEnabled.value) {
|
||||
return [...defaultConversationHotKeys, ...AIAssistActions.value];
|
||||
}
|
||||
return defaultConversationHotKeys;
|
||||
});
|
||||
|
||||
const conversationHotKeys = computed(() => {
|
||||
if (shouldShowSnoozeOption.value) {
|
||||
return prepareActions(SNOOZE_CONVERSATION_ACTIONS, t);
|
||||
}
|
||||
if (isConversationOrInboxRoute.value) {
|
||||
return getDefaultConversationHotKeys.value;
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return {
|
||||
conversationHotKeys,
|
||||
};
|
||||
}
|
||||
@@ -1,63 +0,0 @@
|
||||
import { allAgentsData } from 'dashboard/helper/specs/fixtures/agentFixtures';
|
||||
|
||||
export { allAgentsData };
|
||||
export const formattedAgentsData = [
|
||||
{
|
||||
account_id: 0,
|
||||
confirmed: true,
|
||||
email: 'None',
|
||||
id: 0,
|
||||
name: 'None',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
@@ -1,37 +0,0 @@
|
||||
export const summary = {
|
||||
avg_first_response_time: '198.6666666666667',
|
||||
avg_resolution_time: '208.3333333333333',
|
||||
conversations_count: 5000,
|
||||
incoming_messages_count: 5,
|
||||
outgoing_messages_count: 3,
|
||||
previous: {
|
||||
avg_first_response_time: '89.0',
|
||||
avg_resolution_time: '145.0',
|
||||
conversations_count: 4,
|
||||
incoming_messages_count: 5,
|
||||
outgoing_messages_count: 4,
|
||||
resolutions_count: 0,
|
||||
},
|
||||
resolutions_count: 3,
|
||||
};
|
||||
|
||||
export const botSummary = {
|
||||
bot_resolutions_count: 10,
|
||||
bot_handoffs_count: 20,
|
||||
previous: {
|
||||
bot_resolutions_count: 8,
|
||||
bot_handoffs_count: 5,
|
||||
},
|
||||
};
|
||||
|
||||
export const report = {
|
||||
data: [
|
||||
{ value: '0.00', timestamp: 1647541800, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647628200, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647714600, count: 0 },
|
||||
{ value: '0.00', timestamp: 1647801000, count: 0 },
|
||||
{ value: '0.01', timestamp: 1647887400, count: 4 },
|
||||
{ value: '0.00', timestamp: 1647973800, count: 0 },
|
||||
{ value: '0.00', timestamp: 1648060200, count: 0 },
|
||||
],
|
||||
};
|
||||
@@ -1,119 +0,0 @@
|
||||
import { useAI } from '../useAI';
|
||||
import {
|
||||
useStore,
|
||||
useStoreGetters,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from '../useI18n';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
vi.mock('../useI18n');
|
||||
vi.mock('dashboard/api/integrations/openapi');
|
||||
vi.mock('dashboard/helper/AnalyticsHelper/events', () => ({
|
||||
OPEN_AI_EVENTS: {
|
||||
TEST_EVENT: 'open_ai_test_event',
|
||||
},
|
||||
}));
|
||||
|
||||
describe('useAI', () => {
|
||||
const mockStore = {
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
|
||||
const mockGetters = {
|
||||
'integrations/getUIFlags': { value: { isFetching: false } },
|
||||
'draftMessages/get': { value: () => 'Draft message' },
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useStore.mockReturnValue(mockStore);
|
||||
useStoreGetters.mockReturnValue(mockGetters);
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const mockValues = {
|
||||
'integrations/getAppIntegrations': [],
|
||||
getSelectedChat: { id: '123' },
|
||||
'draftMessages/getReplyEditorMode': 'reply',
|
||||
};
|
||||
return { value: mockValues[getter] };
|
||||
});
|
||||
useTrack.mockReturnValue(vi.fn());
|
||||
useI18n.mockReturnValue({ t: vi.fn() });
|
||||
useAlert.mockReturnValue(vi.fn());
|
||||
});
|
||||
|
||||
it('initializes computed properties correctly', async () => {
|
||||
const { uiFlags, appIntegrations, currentChat, replyMode, draftMessage } =
|
||||
useAI();
|
||||
|
||||
expect(uiFlags.value).toEqual({ isFetching: false });
|
||||
expect(appIntegrations.value).toEqual([]);
|
||||
expect(currentChat.value).toEqual({ id: '123' });
|
||||
expect(replyMode.value).toBe('reply');
|
||||
expect(draftMessage.value).toBe('Draft message');
|
||||
});
|
||||
|
||||
it('fetches integrations if required', async () => {
|
||||
const { fetchIntegrationsIfRequired } = useAI();
|
||||
await fetchIntegrationsIfRequired();
|
||||
expect(mockStore.dispatch).toHaveBeenCalledWith('integrations/get');
|
||||
});
|
||||
|
||||
it('does not fetch integrations if already loaded', async () => {
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const mockValues = {
|
||||
'integrations/getAppIntegrations': [{ id: 'openai' }],
|
||||
getSelectedChat: { id: '123' },
|
||||
'draftMessages/getReplyEditorMode': 'reply',
|
||||
};
|
||||
return { value: mockValues[getter] };
|
||||
});
|
||||
|
||||
const { fetchIntegrationsIfRequired } = useAI();
|
||||
await fetchIntegrationsIfRequired();
|
||||
expect(mockStore.dispatch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('records analytics correctly', async () => {
|
||||
const mockTrack = vi.fn();
|
||||
useTrack.mockReturnValue(mockTrack);
|
||||
const { recordAnalytics } = useAI();
|
||||
|
||||
await recordAnalytics('TEST_EVENT', { data: 'test' });
|
||||
|
||||
expect(mockTrack).toHaveBeenCalledWith('open_ai_test_event', {
|
||||
type: 'TEST_EVENT',
|
||||
data: 'test',
|
||||
});
|
||||
});
|
||||
|
||||
it('fetches label suggestions', async () => {
|
||||
OpenAPI.processEvent.mockResolvedValue({
|
||||
data: { message: 'label1, label2' },
|
||||
});
|
||||
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const mockValues = {
|
||||
'integrations/getAppIntegrations': [
|
||||
{ id: 'openai', hooks: [{ id: 'hook1' }] },
|
||||
],
|
||||
getSelectedChat: { id: '123' },
|
||||
};
|
||||
return { value: mockValues[getter] };
|
||||
});
|
||||
|
||||
const { fetchLabelSuggestions } = useAI();
|
||||
const result = await fetchLabelSuggestions();
|
||||
|
||||
expect(OpenAPI.processEvent).toHaveBeenCalledWith({
|
||||
type: 'label_suggestion',
|
||||
hookId: 'hook1',
|
||||
conversationId: '123',
|
||||
});
|
||||
|
||||
expect(result).toEqual(['label1', 'label2']);
|
||||
});
|
||||
});
|
||||
@@ -1,91 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { useAgentsList } from '../useAgentsList';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { allAgentsData, formattedAgentsData } from './fixtures/agentFixtures';
|
||||
import * as agentHelper from 'dashboard/helper/agentHelper';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/agentHelper');
|
||||
|
||||
const mockUseMapGetter = (overrides = {}) => {
|
||||
const defaultGetters = {
|
||||
getCurrentUser: ref(allAgentsData[0]),
|
||||
getSelectedChat: ref({ inbox_id: 1, meta: { assignee: true } }),
|
||||
getCurrentAccountId: ref(1),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => allAgentsData),
|
||||
};
|
||||
|
||||
const mergedGetters = { ...defaultGetters, ...overrides };
|
||||
|
||||
useMapGetter.mockImplementation(getter => mergedGetters[getter]);
|
||||
};
|
||||
|
||||
describe('useAgentsList', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
agentHelper.getAgentsByUpdatedPresence.mockImplementation(agents => agents);
|
||||
agentHelper.getSortedAgentsByAvailability.mockReturnValue(
|
||||
formattedAgentsData.slice(1)
|
||||
);
|
||||
agentHelper.getCombinedAgents.mockImplementation(
|
||||
(agents, includeNone, isAgentSelected) => {
|
||||
if (includeNone && isAgentSelected) {
|
||||
return [agentHelper.createNoneAgent, ...agents];
|
||||
}
|
||||
return agents;
|
||||
}
|
||||
);
|
||||
|
||||
mockUseMapGetter();
|
||||
});
|
||||
|
||||
it('returns agentsList and assignableAgents', () => {
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual(allAgentsData);
|
||||
expect(agentsList.value).toEqual([
|
||||
agentHelper.createNoneAgent,
|
||||
...formattedAgentsData.slice(1),
|
||||
]);
|
||||
});
|
||||
|
||||
it('includes None agent when includeNoneAgent is true', () => {
|
||||
const { agentsList } = useAgentsList(true);
|
||||
|
||||
expect(agentsList.value[0]).toEqual(agentHelper.createNoneAgent);
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.length);
|
||||
});
|
||||
|
||||
it('excludes None agent when includeNoneAgent is false', () => {
|
||||
const { agentsList } = useAgentsList(false);
|
||||
|
||||
expect(agentsList.value[0]).not.toEqual(agentHelper.createNoneAgent);
|
||||
expect(agentsList.value.length).toBe(formattedAgentsData.length - 1);
|
||||
});
|
||||
|
||||
it('handles empty assignable agents', () => {
|
||||
mockUseMapGetter({
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
});
|
||||
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
|
||||
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual([]);
|
||||
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||
});
|
||||
|
||||
it('handles missing inbox_id', () => {
|
||||
mockUseMapGetter({
|
||||
getSelectedChat: ref({ meta: { assignee: true } }),
|
||||
'inboxAssignableAgents/getAssignableAgents': ref(() => []),
|
||||
});
|
||||
agentHelper.getSortedAgentsByAvailability.mockReturnValue([]);
|
||||
|
||||
const { agentsList, assignableAgents } = useAgentsList();
|
||||
|
||||
expect(assignableAgents.value).toEqual([]);
|
||||
expect(agentsList.value).toEqual([agentHelper.createNoneAgent]);
|
||||
});
|
||||
});
|
||||
@@ -1,295 +0,0 @@
|
||||
import { useAutomation } from '../useAutomation';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from '../useI18n';
|
||||
import * as automationHelper from 'dashboard/helper/automationHelper';
|
||||
import {
|
||||
customAttributes,
|
||||
agents,
|
||||
teams,
|
||||
labels,
|
||||
statusFilterOptions,
|
||||
campaigns,
|
||||
contacts,
|
||||
inboxes,
|
||||
languages,
|
||||
countries,
|
||||
slaPolicies,
|
||||
} from 'dashboard/helper/specs/fixtures/automationFixtures.js';
|
||||
import { MESSAGE_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/composables');
|
||||
vi.mock('../useI18n');
|
||||
vi.mock('dashboard/helper/automationHelper');
|
||||
|
||||
describe('useAutomation', () => {
|
||||
beforeEach(() => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'attributes/getAttributes': { value: customAttributes },
|
||||
'attributes/getAttributesByModel': {
|
||||
value: model => {
|
||||
return model === 'conversation_attribute'
|
||||
? [{ id: 1, name: 'Conversation Attribute' }]
|
||||
: [{ id: 2, name: 'Contact Attribute' }];
|
||||
},
|
||||
},
|
||||
});
|
||||
useMapGetter.mockImplementation(getter => {
|
||||
const getterMap = {
|
||||
'agents/getAgents': agents,
|
||||
'campaigns/getAllCampaigns': campaigns,
|
||||
'contacts/getContacts': contacts,
|
||||
'inboxes/getInboxes': inboxes,
|
||||
'labels/getLabels': labels,
|
||||
'teams/getTeams': teams,
|
||||
'sla/getSLA': slaPolicies,
|
||||
};
|
||||
return { value: getterMap[getter] };
|
||||
});
|
||||
useI18n.mockReturnValue({ t: key => key });
|
||||
useAlert.mockReturnValue(vi.fn());
|
||||
|
||||
// Mock getConditionOptions for different types
|
||||
automationHelper.getConditionOptions.mockImplementation(options => {
|
||||
const { type } = options;
|
||||
switch (type) {
|
||||
case 'status':
|
||||
return statusFilterOptions;
|
||||
case 'team_id':
|
||||
return teams;
|
||||
case 'assignee_id':
|
||||
return agents;
|
||||
case 'contact':
|
||||
return contacts;
|
||||
case 'inbox_id':
|
||||
return inboxes;
|
||||
case 'campaigns':
|
||||
return campaigns;
|
||||
case 'browser_language':
|
||||
return languages;
|
||||
case 'country_code':
|
||||
return countries;
|
||||
case 'message_type':
|
||||
return MESSAGE_CONDITION_VALUES;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
// Mock getActionOptions for different types
|
||||
automationHelper.getActionOptions.mockImplementation(options => {
|
||||
const { type } = options;
|
||||
switch (type) {
|
||||
case 'add_label':
|
||||
return labels;
|
||||
case 'assign_team':
|
||||
return teams;
|
||||
case 'assign_agent':
|
||||
return agents;
|
||||
case 'send_email_to_team':
|
||||
return teams;
|
||||
case 'send_message':
|
||||
return [];
|
||||
case 'add_sla':
|
||||
return slaPolicies;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes computed properties correctly', () => {
|
||||
const {
|
||||
agents: computedAgents,
|
||||
campaigns: computedCampaigns,
|
||||
contacts: computedContacts,
|
||||
inboxes: computedInboxes,
|
||||
labels: computedLabels,
|
||||
teams: computedTeams,
|
||||
slaPolicies: computedSlaPolicies,
|
||||
} = useAutomation();
|
||||
|
||||
expect(computedAgents.value).toEqual(agents);
|
||||
expect(computedCampaigns.value).toEqual(campaigns);
|
||||
expect(computedContacts.value).toEqual(contacts);
|
||||
expect(computedInboxes.value).toEqual(inboxes);
|
||||
expect(computedLabels.value).toEqual(labels);
|
||||
expect(computedTeams.value).toEqual(teams);
|
||||
expect(computedSlaPolicies.value).toEqual(slaPolicies);
|
||||
});
|
||||
|
||||
it('appends new condition and action correctly', () => {
|
||||
const { appendNewCondition, appendNewAction } = useAutomation();
|
||||
const mockAutomation = {
|
||||
event_name: 'message_created',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
automationHelper.getDefaultConditions.mockReturnValue([{}]);
|
||||
automationHelper.getDefaultActions.mockReturnValue([{}]);
|
||||
|
||||
appendNewCondition(mockAutomation);
|
||||
appendNewAction(mockAutomation);
|
||||
|
||||
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
|
||||
'message_created'
|
||||
);
|
||||
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
|
||||
expect(mockAutomation.conditions).toHaveLength(1);
|
||||
expect(mockAutomation.actions).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes filter and action correctly', () => {
|
||||
const { removeFilter, removeAction } = useAutomation();
|
||||
const mockAutomation = {
|
||||
conditions: [{ id: 1 }, { id: 2 }],
|
||||
actions: [{ id: 1 }, { id: 2 }],
|
||||
};
|
||||
|
||||
removeFilter(mockAutomation, 0);
|
||||
removeAction(mockAutomation, 0);
|
||||
|
||||
expect(mockAutomation.conditions).toHaveLength(1);
|
||||
expect(mockAutomation.actions).toHaveLength(1);
|
||||
expect(mockAutomation.conditions[0].id).toBe(2);
|
||||
expect(mockAutomation.actions[0].id).toBe(2);
|
||||
});
|
||||
|
||||
it('resets filter and action correctly', () => {
|
||||
const { resetFilter, resetAction } = useAutomation();
|
||||
const mockAutomation = {
|
||||
event_name: 'message_created',
|
||||
conditions: [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: 'open',
|
||||
},
|
||||
],
|
||||
actions: [{ action_name: 'assign_agent', action_params: [1] }],
|
||||
};
|
||||
const mockAutomationTypes = {
|
||||
message_created: {
|
||||
conditions: [
|
||||
{ key: 'status', filterOperators: [{ value: 'not_equal_to' }] },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
resetFilter(
|
||||
mockAutomation,
|
||||
mockAutomationTypes,
|
||||
0,
|
||||
mockAutomation.conditions[0]
|
||||
);
|
||||
resetAction(mockAutomation, 0);
|
||||
|
||||
expect(mockAutomation.conditions[0].filter_operator).toBe('not_equal_to');
|
||||
expect(mockAutomation.conditions[0].values).toBe('');
|
||||
expect(mockAutomation.actions[0].action_params).toEqual([]);
|
||||
});
|
||||
|
||||
it('formats automation correctly', () => {
|
||||
const { formatAutomation } = useAutomation();
|
||||
const mockAutomation = {
|
||||
conditions: [{ attribute_key: 'status', values: ['open'] }],
|
||||
actions: [{ action_name: 'assign_agent', action_params: [1] }],
|
||||
};
|
||||
const mockAutomationTypes = {};
|
||||
const mockAutomationActionTypes = [
|
||||
{ key: 'assign_agent', inputType: 'search_select' },
|
||||
];
|
||||
|
||||
automationHelper.getConditionOptions.mockReturnValue([
|
||||
{ id: 'open', name: 'open' },
|
||||
]);
|
||||
automationHelper.getActionOptions.mockReturnValue([
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
]);
|
||||
|
||||
const result = formatAutomation(
|
||||
mockAutomation,
|
||||
customAttributes,
|
||||
mockAutomationTypes,
|
||||
mockAutomationActionTypes
|
||||
);
|
||||
|
||||
expect(result.conditions[0].values).toEqual([{ id: 'open', name: 'open' }]);
|
||||
expect(result.actions[0].action_params).toEqual([
|
||||
{ id: 1, name: 'Agent 1' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('manifests custom attributes correctly', () => {
|
||||
const { manifestCustomAttributes } = useAutomation();
|
||||
const mockAutomationTypes = {
|
||||
message_created: { conditions: [] },
|
||||
conversation_created: { conditions: [] },
|
||||
conversation_updated: { conditions: [] },
|
||||
conversation_opened: { conditions: [] },
|
||||
};
|
||||
|
||||
automationHelper.generateCustomAttributeTypes.mockReturnValue([]);
|
||||
automationHelper.generateCustomAttributes.mockReturnValue([]);
|
||||
|
||||
manifestCustomAttributes(mockAutomationTypes);
|
||||
|
||||
expect(automationHelper.generateCustomAttributeTypes).toHaveBeenCalledTimes(
|
||||
2
|
||||
);
|
||||
expect(automationHelper.generateCustomAttributes).toHaveBeenCalledTimes(1);
|
||||
Object.values(mockAutomationTypes).forEach(type => {
|
||||
expect(type.conditions).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
it('gets condition dropdown values correctly', () => {
|
||||
const { getConditionDropdownValues } = useAutomation();
|
||||
|
||||
expect(getConditionDropdownValues('status')).toEqual(statusFilterOptions);
|
||||
expect(getConditionDropdownValues('team_id')).toEqual(teams);
|
||||
expect(getConditionDropdownValues('assignee_id')).toEqual(agents);
|
||||
expect(getConditionDropdownValues('contact')).toEqual(contacts);
|
||||
expect(getConditionDropdownValues('inbox_id')).toEqual(inboxes);
|
||||
expect(getConditionDropdownValues('campaigns')).toEqual(campaigns);
|
||||
expect(getConditionDropdownValues('browser_language')).toEqual(languages);
|
||||
expect(getConditionDropdownValues('country_code')).toEqual(countries);
|
||||
expect(getConditionDropdownValues('message_type')).toEqual(
|
||||
MESSAGE_CONDITION_VALUES
|
||||
);
|
||||
});
|
||||
|
||||
it('gets action dropdown values correctly', () => {
|
||||
const { getActionDropdownValues } = useAutomation();
|
||||
|
||||
expect(getActionDropdownValues('add_label')).toEqual(labels);
|
||||
expect(getActionDropdownValues('assign_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('assign_agent')).toEqual(agents);
|
||||
expect(getActionDropdownValues('send_email_to_team')).toEqual(teams);
|
||||
expect(getActionDropdownValues('send_message')).toEqual([]);
|
||||
expect(getActionDropdownValues('add_sla')).toEqual(slaPolicies);
|
||||
});
|
||||
|
||||
it('handles event change correctly', () => {
|
||||
const { onEventChange } = useAutomation();
|
||||
const mockAutomation = {
|
||||
event_name: 'message_created',
|
||||
conditions: [],
|
||||
actions: [],
|
||||
};
|
||||
|
||||
automationHelper.getDefaultConditions.mockReturnValue([{}]);
|
||||
automationHelper.getDefaultActions.mockReturnValue([{}]);
|
||||
|
||||
onEventChange(mockAutomation);
|
||||
|
||||
expect(automationHelper.getDefaultConditions).toHaveBeenCalledWith(
|
||||
'message_created'
|
||||
);
|
||||
expect(automationHelper.getDefaultActions).toHaveBeenCalled();
|
||||
expect(mockAutomation.conditions).toHaveLength(1);
|
||||
expect(mockAutomation.actions).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -1,56 +0,0 @@
|
||||
import { useIntegrationHook } from '../useIntegrationHook';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { nextTick } from 'vue';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
|
||||
describe('useIntegrationHook', () => {
|
||||
let integrationGetter;
|
||||
|
||||
beforeEach(() => {
|
||||
integrationGetter = vi.fn();
|
||||
useMapGetter.mockReturnValue({ value: integrationGetter });
|
||||
});
|
||||
|
||||
it('should return the correct computed properties', async () => {
|
||||
const mockIntegration = {
|
||||
id: 1,
|
||||
hook_type: 'inbox',
|
||||
hooks: ['hook1', 'hook2'],
|
||||
allow_multiple_hooks: true,
|
||||
};
|
||||
integrationGetter.mockReturnValue(mockIntegration);
|
||||
|
||||
const hook = useIntegrationHook(1);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(hook.integration.value).toEqual(mockIntegration);
|
||||
expect(hook.integrationType.value).toBe('multiple');
|
||||
expect(hook.isIntegrationMultiple.value).toBe(true);
|
||||
expect(hook.isIntegrationSingle.value).toBe(false);
|
||||
expect(hook.isHookTypeInbox.value).toBe(true);
|
||||
expect(hook.hasConnectedHooks.value).toBe(true);
|
||||
});
|
||||
|
||||
it('should handle single integration type correctly', async () => {
|
||||
const mockIntegration = {
|
||||
id: 2,
|
||||
hook_type: 'channel',
|
||||
hooks: [],
|
||||
allow_multiple_hooks: false,
|
||||
};
|
||||
integrationGetter.mockReturnValue(mockIntegration);
|
||||
|
||||
const hook = useIntegrationHook(2);
|
||||
|
||||
await nextTick();
|
||||
|
||||
expect(hook.integration.value).toEqual(mockIntegration);
|
||||
expect(hook.integrationType.value).toBe('single');
|
||||
expect(hook.isIntegrationMultiple.value).toBe(false);
|
||||
expect(hook.isIntegrationSingle.value).toBe(true);
|
||||
expect(hook.isHookTypeInbox.value).toBe(false);
|
||||
expect(hook.hasConnectedHooks.value).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -1,3 +1,4 @@
|
||||
import { unref } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
@@ -10,13 +11,15 @@ describe('useKeyboardEvents', () => {
|
||||
});
|
||||
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const el = document.createElement('div');
|
||||
const elRef = unref({ value: el });
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events);
|
||||
useKeyboardEvents(events, elRef);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ vi.mock('../useKeyboardEvents', () => ({
|
||||
}));
|
||||
|
||||
describe('useKeyboardNavigableList', () => {
|
||||
let elementRef;
|
||||
let items;
|
||||
let onSelect;
|
||||
let adjustScroll;
|
||||
@@ -17,6 +18,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
const createMockEvent = () => ({ preventDefault: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
elementRef = ref(null);
|
||||
items = ref(['item1', 'item2', 'item3']);
|
||||
onSelect = vi.fn();
|
||||
adjustScroll = vi.fn();
|
||||
@@ -26,6 +28,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should return moveSelectionUp and moveSelectionDown functions', () => {
|
||||
const result = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -40,6 +43,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should move selection up correctly', () => {
|
||||
const { moveSelectionUp } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -61,6 +65,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should move selection down correctly', () => {
|
||||
const { moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -82,6 +87,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should call adjustScroll after moving selection', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -97,6 +103,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should include Enter key handler when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -111,6 +118,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should not include Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
@@ -123,6 +131,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should not trigger onSelect when items are empty', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items: ref([]),
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -136,18 +145,23 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should call useKeyboardEvents with correct parameters', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
|
||||
expect(useKeyboardEvents).toHaveBeenCalledWith(
|
||||
expect.any(Object),
|
||||
elementRef
|
||||
);
|
||||
});
|
||||
|
||||
// Keyboard event handlers
|
||||
it('should handle ArrowUp key', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -164,6 +178,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should handle Control+KeyP', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -180,6 +195,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should handle ArrowDown key', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -196,6 +212,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should handle Control+KeyN', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -212,6 +229,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should handle Enter key when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -227,6 +245,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should not have Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
@@ -238,6 +257,7 @@ describe('useKeyboardNavigableList', () => {
|
||||
|
||||
it('should set allowOnFocusedInput to true for all key handlers', () => {
|
||||
useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useMacros } from '../useMacros';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
import { ref } from 'vue';
|
||||
import { useReportMetrics } from '../useReportMetrics';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { summary, botSummary } from './fixtures/reportFixtures';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
formatTime: vi.fn(time => `formatted_${time}`),
|
||||
}));
|
||||
|
||||
describe('useReportMetrics', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
useMapGetter.mockReturnValue(ref(summary));
|
||||
});
|
||||
|
||||
it('calculates trend correctly', () => {
|
||||
const { calculateTrend } = useReportMetrics();
|
||||
|
||||
expect(calculateTrend('conversations_count')).toBe(124900);
|
||||
expect(calculateTrend('incoming_messages_count')).toBe(0);
|
||||
expect(calculateTrend('avg_first_response_time')).toBe(123);
|
||||
});
|
||||
|
||||
it('returns 0 for trend when previous value is not available', () => {
|
||||
const { calculateTrend } = useReportMetrics();
|
||||
|
||||
expect(calculateTrend('non_existent_key')).toBe(0);
|
||||
});
|
||||
|
||||
it('identifies average metric types correctly', () => {
|
||||
const { isAverageMetricType } = useReportMetrics();
|
||||
|
||||
expect(isAverageMetricType('avg_first_response_time')).toBe(true);
|
||||
expect(isAverageMetricType('avg_resolution_time')).toBe(true);
|
||||
expect(isAverageMetricType('reply_time')).toBe(true);
|
||||
expect(isAverageMetricType('conversations_count')).toBe(false);
|
||||
});
|
||||
|
||||
it('displays metrics correctly for account', () => {
|
||||
const { displayMetric } = useReportMetrics();
|
||||
|
||||
expect(displayMetric('conversations_count')).toBe('5,000');
|
||||
expect(displayMetric('incoming_messages_count')).toBe('5');
|
||||
});
|
||||
|
||||
it('displays the metric for bot', () => {
|
||||
const customKey = 'getBotSummary';
|
||||
useMapGetter.mockReturnValue(ref(botSummary));
|
||||
const { displayMetric } = useReportMetrics(customKey);
|
||||
|
||||
expect(displayMetric('bot_resolutions_count')).toBe('10');
|
||||
expect(displayMetric('bot_handoffs_count')).toBe('20');
|
||||
});
|
||||
|
||||
it('handles non-existent metrics', () => {
|
||||
const { displayMetric } = useReportMetrics();
|
||||
|
||||
expect(displayMetric('non_existent_key')).toBe('0');
|
||||
});
|
||||
});
|
||||
@@ -1,204 +0,0 @@
|
||||
import { computed, onMounted } from 'vue';
|
||||
import {
|
||||
useStore,
|
||||
useStoreGetters,
|
||||
useMapGetter,
|
||||
} from 'dashboard/composables/store';
|
||||
import { useAlert, useTrack } from 'dashboard/composables';
|
||||
import { useI18n } from './useI18n';
|
||||
import { OPEN_AI_EVENTS } from 'dashboard/helper/AnalyticsHelper/events';
|
||||
import OpenAPI from 'dashboard/api/integrations/openapi';
|
||||
|
||||
/**
|
||||
* Cleans and normalizes a list of labels.
|
||||
* @param {string} labels - A comma-separated string of labels.
|
||||
* @returns {string[]} An array of cleaned and unique labels.
|
||||
*/
|
||||
const cleanLabels = labels => {
|
||||
return labels
|
||||
.toLowerCase() // Set it to lowercase
|
||||
.split(',') // split the string into an array
|
||||
.filter(label => label.trim()) // remove any empty strings
|
||||
.map(label => label.trim()) // trim the words
|
||||
.filter((label, index, self) => self.indexOf(label) === index);
|
||||
};
|
||||
|
||||
/**
|
||||
* A composable function for AI-related operations in the dashboard.
|
||||
* @returns {Object} An object containing AI-related methods and computed properties.
|
||||
*/
|
||||
export function useAI() {
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const track = useTrack();
|
||||
const { t } = useI18n();
|
||||
|
||||
/**
|
||||
* Computed property for UI flags.
|
||||
* @type {import('vue').ComputedRef<Object>}
|
||||
*/
|
||||
const uiFlags = computed(() => getters['integrations/getUIFlags'].value);
|
||||
|
||||
const appIntegrations = useMapGetter('integrations/getAppIntegrations');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const replyMode = useMapGetter('draftMessages/getReplyEditorMode');
|
||||
|
||||
/**
|
||||
* Computed property for the AI integration.
|
||||
* @type {import('vue').ComputedRef<Object|undefined>}
|
||||
*/
|
||||
const aiIntegration = computed(
|
||||
() =>
|
||||
appIntegrations.value.find(
|
||||
integration => integration.id === 'openai' && !!integration.hooks.length
|
||||
)?.hooks[0]
|
||||
);
|
||||
|
||||
/**
|
||||
* Computed property to check if AI integration is enabled.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isAIIntegrationEnabled = computed(() => !!aiIntegration.value);
|
||||
|
||||
/**
|
||||
* Computed property to check if label suggestion feature is enabled.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isLabelSuggestionFeatureEnabled = computed(() => {
|
||||
if (aiIntegration.value) {
|
||||
const { settings = {} } = aiIntegration.value || {};
|
||||
return settings.label_suggestion;
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
/**
|
||||
* Computed property to check if app integrations are being fetched.
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isFetchingAppIntegrations = computed(() => uiFlags.value.isFetching);
|
||||
|
||||
/**
|
||||
* Computed property for the hook ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
*/
|
||||
const hookId = computed(() => aiIntegration.value?.id);
|
||||
|
||||
/**
|
||||
* Computed property for the conversation ID.
|
||||
* @type {import('vue').ComputedRef<string|undefined>}
|
||||
*/
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
/**
|
||||
* Computed property for the draft key.
|
||||
* @type {import('vue').ComputedRef<string>}
|
||||
*/
|
||||
const draftKey = computed(
|
||||
() => `draft-${conversationId.value}-${replyMode.value}`
|
||||
);
|
||||
|
||||
/**
|
||||
* Computed property for the draft message.
|
||||
* @type {import('vue').ComputedRef<string>}
|
||||
*/
|
||||
const draftMessage = computed(() =>
|
||||
getters['draftMessages/get'].value(draftKey.value)
|
||||
);
|
||||
|
||||
/**
|
||||
* Fetches integrations if they haven't been loaded yet.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const fetchIntegrationsIfRequired = async () => {
|
||||
if (!appIntegrations.value.length) {
|
||||
await store.dispatch('integrations/get');
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Records analytics for AI-related events.
|
||||
* @param {string} type - The type of event.
|
||||
* @param {Object} payload - Additional data for the event.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const recordAnalytics = async (type, payload) => {
|
||||
const event = OPEN_AI_EVENTS[type.toUpperCase()];
|
||||
if (event) {
|
||||
track(event, {
|
||||
type,
|
||||
...payload,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetches label suggestions for the current conversation.
|
||||
* @returns {Promise<string[]>} An array of suggested labels.
|
||||
*/
|
||||
const fetchLabelSuggestions = async () => {
|
||||
if (!conversationId.value) return [];
|
||||
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
type: 'label_suggestion',
|
||||
hookId: hookId.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
|
||||
const {
|
||||
data: { message: labels },
|
||||
} = result;
|
||||
|
||||
return cleanLabels(labels);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Processes an AI event, such as rephrasing content.
|
||||
* @param {string} [type='rephrase'] - The type of AI event to process.
|
||||
* @returns {Promise<string>} The generated message or an empty string if an error occurs.
|
||||
*/
|
||||
const processEvent = async (type = 'rephrase') => {
|
||||
try {
|
||||
const result = await OpenAPI.processEvent({
|
||||
hookId: hookId.value,
|
||||
type,
|
||||
content: draftMessage.value,
|
||||
conversationId: conversationId.value,
|
||||
});
|
||||
const {
|
||||
data: { message: generatedMessage },
|
||||
} = result;
|
||||
return generatedMessage;
|
||||
} catch (error) {
|
||||
const errorData = error.response.data.error;
|
||||
const errorMessage =
|
||||
errorData?.error?.message ||
|
||||
t('INTEGRATION_SETTINGS.OPEN_AI.GENERATE_ERROR');
|
||||
useAlert(errorMessage);
|
||||
return '';
|
||||
}
|
||||
};
|
||||
|
||||
onMounted(() => {
|
||||
fetchIntegrationsIfRequired();
|
||||
});
|
||||
|
||||
return {
|
||||
draftMessage,
|
||||
uiFlags,
|
||||
appIntegrations,
|
||||
currentChat,
|
||||
replyMode,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
isFetchingAppIntegrations,
|
||||
fetchIntegrationsIfRequired,
|
||||
recordAnalytics,
|
||||
fetchLabelSuggestions,
|
||||
processEvent,
|
||||
};
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import {
|
||||
getAgentsByUpdatedPresence,
|
||||
getSortedAgentsByAvailability,
|
||||
getCombinedAgents,
|
||||
} from 'dashboard/helper/agentHelper';
|
||||
|
||||
/**
|
||||
* A composable function that provides a list of agents for assignment.
|
||||
*
|
||||
* @param {boolean} [includeNoneAgent=true] - Whether to include a 'None' agent option.
|
||||
* @returns {Object} An object containing the agents list and assignable agents.
|
||||
*/
|
||||
export function useAgentsList(includeNoneAgent = true) {
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const assignable = useMapGetter('inboxAssignableAgents/getAssignableAgents');
|
||||
|
||||
const inboxId = computed(() => currentChat.value?.inbox_id);
|
||||
const isAgentSelected = computed(() => currentChat.value?.meta?.assignee);
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const assignableAgents = computed(() => {
|
||||
return inboxId.value ? assignable.value(inboxId.value) : [];
|
||||
});
|
||||
|
||||
/**
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const agentsList = computed(() => {
|
||||
const agents = assignableAgents.value || [];
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
currentUser.value,
|
||||
currentAccountId.value
|
||||
);
|
||||
|
||||
const filteredAgentsByAvailability = getSortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
|
||||
return getCombinedAgents(
|
||||
filteredAgentsByAvailability,
|
||||
includeNoneAgent,
|
||||
isAgentSelected.value
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
agentsList,
|
||||
assignableAgents,
|
||||
};
|
||||
}
|
||||
@@ -1,349 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters, useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from './useI18n';
|
||||
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
|
||||
import countries from 'shared/constants/countries';
|
||||
import {
|
||||
generateCustomAttributeTypes,
|
||||
getActionOptions,
|
||||
getConditionOptions,
|
||||
getCustomAttributeInputType,
|
||||
getDefaultConditions,
|
||||
getDefaultActions,
|
||||
filterCustomAttributes,
|
||||
getStandardAttributeInputType,
|
||||
isCustomAttribute,
|
||||
generateCustomAttributes,
|
||||
} from 'dashboard/helper/automationHelper';
|
||||
|
||||
/**
|
||||
* Composable for handling automation-related functionality.
|
||||
* @returns {Object} An object containing various automation-related functions and computed properties.
|
||||
*/
|
||||
export function useAutomation() {
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const agents = useMapGetter('agents/getAgents');
|
||||
const campaigns = useMapGetter('campaigns/getAllCampaigns');
|
||||
const contacts = useMapGetter('contacts/getContacts');
|
||||
const inboxes = useMapGetter('inboxes/getInboxes');
|
||||
const labels = useMapGetter('labels/getLabels');
|
||||
const teams = useMapGetter('teams/getTeams');
|
||||
const slaPolicies = useMapGetter('sla/getSLA');
|
||||
|
||||
const booleanFilterOptions = computed(() => [
|
||||
{ id: true, name: t('FILTER.ATTRIBUTE_LABELS.TRUE') },
|
||||
{ id: false, name: t('FILTER.ATTRIBUTE_LABELS.FALSE') },
|
||||
]);
|
||||
|
||||
const statusFilterOptions = computed(() => {
|
||||
const statusFilters = t('CHAT_LIST.CHAT_STATUS_FILTER_ITEMS');
|
||||
return [
|
||||
...Object.keys(statusFilters).map(status => ({
|
||||
id: status,
|
||||
name: statusFilters[status].TEXT,
|
||||
})),
|
||||
{ id: 'all', name: t('CHAT_LIST.FILTER_ALL') },
|
||||
];
|
||||
});
|
||||
|
||||
/**
|
||||
* Handles the event change for an automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
*/
|
||||
const onEventChange = automation => {
|
||||
automation.conditions = getDefaultConditions(automation.event_name);
|
||||
automation.actions = getDefaultActions();
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the condition dropdown values for a given type.
|
||||
* @param {string} type - The type of condition.
|
||||
* @returns {Array} An array of condition dropdown values.
|
||||
*/
|
||||
const getConditionDropdownValues = type => {
|
||||
return getConditionOptions({
|
||||
agents: agents.value,
|
||||
booleanFilterOptions: booleanFilterOptions.value,
|
||||
campaigns: campaigns.value,
|
||||
contacts: contacts.value,
|
||||
customAttributes: getters['attributes/getAttributes'].value,
|
||||
inboxes: inboxes.value,
|
||||
statusFilterOptions: statusFilterOptions.value,
|
||||
teams: teams.value,
|
||||
languages,
|
||||
countries,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a new condition to the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
*/
|
||||
const appendNewCondition = automation => {
|
||||
automation.conditions.push(...getDefaultConditions(automation.event_name));
|
||||
};
|
||||
|
||||
/**
|
||||
* Appends a new action to the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
*/
|
||||
const appendNewAction = automation => {
|
||||
automation.actions.push(...getDefaultActions());
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a filter from the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
* @param {number} index - The index of the filter to remove.
|
||||
*/
|
||||
const removeFilter = (automation, index) => {
|
||||
if (automation.conditions.length <= 1) {
|
||||
useAlert(t('AUTOMATION.CONDITION.DELETE_MESSAGE'));
|
||||
} else {
|
||||
automation.conditions.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes an action from the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
* @param {number} index - The index of the action to remove.
|
||||
*/
|
||||
const removeAction = (automation, index) => {
|
||||
if (automation.actions.length <= 1) {
|
||||
useAlert(t('AUTOMATION.ACTION.DELETE_MESSAGE'));
|
||||
} else {
|
||||
automation.actions.splice(index, 1);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets a filter in the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
* @param {Object} automationTypes - The automation types object.
|
||||
* @param {number} index - The index of the filter to reset.
|
||||
* @param {Object} currentCondition - The current condition object.
|
||||
*/
|
||||
const resetFilter = (
|
||||
automation,
|
||||
automationTypes,
|
||||
index,
|
||||
currentCondition
|
||||
) => {
|
||||
automation.conditions[index].filter_operator = automationTypes[
|
||||
automation.event_name
|
||||
].conditions.find(
|
||||
condition => condition.key === currentCondition.attribute_key
|
||||
).filterOperators[0].value;
|
||||
automation.conditions[index].values = '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Resets an action in the automation.
|
||||
* @param {Object} automation - The automation object to update.
|
||||
* @param {number} index - The index of the action to reset.
|
||||
*/
|
||||
const resetAction = (automation, index) => {
|
||||
automation.actions[index].action_params = [];
|
||||
};
|
||||
|
||||
/**
|
||||
* This function sets the conditions for automation.
|
||||
* It help to format the conditions for the automation when we open the edit automation modal.
|
||||
* @param {Object} automation - The automation object containing conditions to manifest.
|
||||
* @param {Array} allCustomAttributes - List of all custom attributes.
|
||||
* @param {Object} automationTypes - Object containing automation type definitions.
|
||||
* @returns {Array} An array of manifested conditions.
|
||||
*/
|
||||
const manifestConditions = (
|
||||
automation,
|
||||
allCustomAttributes,
|
||||
automationTypes
|
||||
) => {
|
||||
const customAttributes = filterCustomAttributes(allCustomAttributes);
|
||||
return automation.conditions.map(condition => {
|
||||
const customAttr = isCustomAttribute(
|
||||
customAttributes,
|
||||
condition.attribute_key
|
||||
);
|
||||
let inputType = 'plain_text';
|
||||
if (customAttr) {
|
||||
inputType = getCustomAttributeInputType(customAttr.type);
|
||||
} else {
|
||||
inputType = getStandardAttributeInputType(
|
||||
automationTypes,
|
||||
automation.event_name,
|
||||
condition.attribute_key
|
||||
);
|
||||
}
|
||||
if (inputType === 'plain_text' || inputType === 'date') {
|
||||
return { ...condition, values: condition.values[0] };
|
||||
}
|
||||
if (inputType === 'comma_separated_plain_text') {
|
||||
return { ...condition, values: condition.values.join(',') };
|
||||
}
|
||||
return {
|
||||
...condition,
|
||||
query_operator: condition.query_operator || 'and',
|
||||
values: [...getConditionDropdownValues(condition.attribute_key)].filter(
|
||||
item => [...condition.values].includes(item.id)
|
||||
),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Gets the action dropdown values for a given type.
|
||||
* @param {string} type - The type of action.
|
||||
* @returns {Array} An array of action dropdown values.
|
||||
*/
|
||||
const getActionDropdownValues = type => {
|
||||
return getActionOptions({
|
||||
agents: agents.value,
|
||||
labels: labels.value,
|
||||
teams: teams.value,
|
||||
slaPolicies: slaPolicies.value,
|
||||
languages,
|
||||
type,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Generates an array of actions for the automation.
|
||||
* @param {Object} action - The action object.
|
||||
* @param {Array} automationActionTypes - List of available automation action types.
|
||||
* @returns {Array|Object} Generated actions array or object based on input type.
|
||||
*/
|
||||
const generateActionsArray = (action, automationActionTypes) => {
|
||||
const params = action.action_params;
|
||||
const inputType = automationActionTypes.find(
|
||||
item => item.key === action.action_name
|
||||
).inputType;
|
||||
if (inputType === 'multi_select' || inputType === 'search_select') {
|
||||
return [...getActionDropdownValues(action.action_name)].filter(item =>
|
||||
[...params].includes(item.id)
|
||||
);
|
||||
}
|
||||
if (inputType === 'team_message') {
|
||||
return {
|
||||
team_ids: [...getActionDropdownValues(action.action_name)].filter(
|
||||
item => [...params[0].team_ids].includes(item.id)
|
||||
),
|
||||
message: params[0].message,
|
||||
};
|
||||
}
|
||||
return [...params];
|
||||
};
|
||||
|
||||
/**
|
||||
* This function sets the actions for automation.
|
||||
* It help to format the actions for the automation when we open the edit automation modal.
|
||||
* @param {Object} automation - The automation object containing actions.
|
||||
* @param {Array} automationActionTypes - List of available automation action types.
|
||||
* @returns {Array} An array of manifested actions.
|
||||
*/
|
||||
const manifestActions = (automation, automationActionTypes) => {
|
||||
return automation.actions.map(action => ({
|
||||
...action,
|
||||
action_params: action.action_params.length
|
||||
? generateActionsArray(action, automationActionTypes)
|
||||
: [],
|
||||
}));
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats the automation object for use when we edit the automation.
|
||||
* It help to format the conditions and actions for the automation when we open the edit automation modal.
|
||||
* @param {Object} automation - The automation object to format.
|
||||
* @param {Array} allCustomAttributes - List of all custom attributes.
|
||||
* @param {Object} automationTypes - Object containing automation type definitions.
|
||||
* @param {Array} automationActionTypes - List of available automation action types.
|
||||
* @returns {Object} A new object with formatted automation data, including automation conditions and actions.
|
||||
*/
|
||||
const formatAutomation = (
|
||||
automation,
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automationActionTypes
|
||||
) => {
|
||||
return {
|
||||
...automation,
|
||||
conditions: manifestConditions(
|
||||
automation,
|
||||
allCustomAttributes,
|
||||
automationTypes
|
||||
),
|
||||
actions: manifestActions(automation, automationActionTypes),
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* This function formats the custom attributes for automation types.
|
||||
* It retrieves custom attributes for conversations and contacts,
|
||||
* generates custom attribute types, and adds them to the relevant automation types.
|
||||
* @param {Object} automationTypes - The automation types object to update with custom attributes.
|
||||
*/
|
||||
const manifestCustomAttributes = automationTypes => {
|
||||
const conversationCustomAttributesRaw = getters[
|
||||
'attributes/getAttributesByModel'
|
||||
].value('conversation_attribute');
|
||||
const contactCustomAttributesRaw =
|
||||
getters['attributes/getAttributesByModel'].value('contact_attribute');
|
||||
|
||||
const conversationCustomAttributeTypes = generateCustomAttributeTypes(
|
||||
conversationCustomAttributesRaw,
|
||||
'conversation_attribute'
|
||||
);
|
||||
const contactCustomAttributeTypes = generateCustomAttributeTypes(
|
||||
contactCustomAttributesRaw,
|
||||
'contact_attribute'
|
||||
);
|
||||
|
||||
const manifestedCustomAttributes = generateCustomAttributes(
|
||||
conversationCustomAttributeTypes,
|
||||
contactCustomAttributeTypes,
|
||||
t('AUTOMATION.CONDITION.CONVERSATION_CUSTOM_ATTR_LABEL'),
|
||||
t('AUTOMATION.CONDITION.CONTACT_CUSTOM_ATTR_LABEL')
|
||||
);
|
||||
|
||||
automationTypes.message_created.conditions.push(
|
||||
...manifestedCustomAttributes
|
||||
);
|
||||
automationTypes.conversation_created.conditions.push(
|
||||
...manifestedCustomAttributes
|
||||
);
|
||||
automationTypes.conversation_updated.conditions.push(
|
||||
...manifestedCustomAttributes
|
||||
);
|
||||
automationTypes.conversation_opened.conditions.push(
|
||||
...manifestedCustomAttributes
|
||||
);
|
||||
};
|
||||
|
||||
return {
|
||||
agents,
|
||||
campaigns,
|
||||
contacts,
|
||||
inboxes,
|
||||
labels,
|
||||
teams,
|
||||
slaPolicies,
|
||||
booleanFilterOptions,
|
||||
statusFilterOptions,
|
||||
onEventChange,
|
||||
getConditionDropdownValues,
|
||||
appendNewCondition,
|
||||
appendNewAction,
|
||||
removeFilter,
|
||||
removeAction,
|
||||
resetFilter,
|
||||
resetAction,
|
||||
formatAutomation,
|
||||
getActionDropdownValues,
|
||||
manifestCustomAttributes,
|
||||
};
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
/**
|
||||
* Composable for managing integration hooks
|
||||
* @param {string|number} integrationId - The ID of the integration
|
||||
* @returns {Object} An object containing computed properties for the integration
|
||||
*/
|
||||
export const useIntegrationHook = integrationId => {
|
||||
const integrationGetter = useMapGetter('integrations/getIntegration');
|
||||
|
||||
/**
|
||||
* The integration object
|
||||
* @type {import('vue').ComputedRef<Object>}
|
||||
*/
|
||||
const integration = computed(() => {
|
||||
return integrationGetter.value(integrationId);
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration hook type is 'inbox'
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isHookTypeInbox = computed(() => {
|
||||
return integration.value.hook_type === 'inbox';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration has any connected hooks
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const hasConnectedHooks = computed(() => {
|
||||
return !!integration.value.hooks.length;
|
||||
});
|
||||
|
||||
/**
|
||||
* The type of integration: 'multiple' or 'single'
|
||||
* @type {import('vue').ComputedRef<string>}
|
||||
*/
|
||||
const integrationType = computed(() => {
|
||||
return integration.value.allow_multiple_hooks ? 'multiple' : 'single';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration allows multiple hooks
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isIntegrationMultiple = computed(() => {
|
||||
return integrationType.value === 'multiple';
|
||||
});
|
||||
|
||||
/**
|
||||
* Whether the integration allows only a single hook
|
||||
* @type {import('vue').ComputedRef<boolean>}
|
||||
*/
|
||||
const isIntegrationSingle = computed(() => {
|
||||
return integrationType.value === 'single';
|
||||
});
|
||||
|
||||
return {
|
||||
integration,
|
||||
integrationType,
|
||||
isIntegrationMultiple,
|
||||
isIntegrationSingle,
|
||||
isHookTypeInbox,
|
||||
hasConnectedHooks,
|
||||
};
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
import { onMounted, onBeforeUnmount, unref } from 'vue';
|
||||
import {
|
||||
isActiveElementTypeable,
|
||||
isEscape,
|
||||
@@ -6,7 +7,8 @@ import {
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { createKeybindingsHandler } from 'tinykeys';
|
||||
import { onUnmounted, onMounted } from 'vue';
|
||||
|
||||
const keyboardListenerMap = new WeakMap();
|
||||
|
||||
/**
|
||||
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
||||
@@ -67,24 +69,49 @@ async function wrapEventsInKeybindingsHandler(events) {
|
||||
return wrappedEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up keyboard event listeners on the specified element.
|
||||
* @param {Element} root - The DOM element to attach listeners to.
|
||||
* @param {Object} events - The events to listen for.
|
||||
*/
|
||||
const setupListeners = (root, events) => {
|
||||
if (root instanceof Element && events) {
|
||||
const keydownHandler = createKeybindingsHandler(events);
|
||||
document.addEventListener('keydown', keydownHandler);
|
||||
keyboardListenerMap.set(root, keydownHandler);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes keyboard event listeners from the specified element.
|
||||
* @param {Element} root - The DOM element to remove listeners from.
|
||||
*/
|
||||
const removeListeners = root => {
|
||||
// In the future, let's use the abort controller to remove the listeners
|
||||
// https://caniuse.com/abortcontroller
|
||||
if (root instanceof Element) {
|
||||
const handlerToRemove = keyboardListenerMap.get(root);
|
||||
document.removeEventListener('keydown', handlerToRemove);
|
||||
keyboardListenerMap.delete(root);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
||||
* @param {Object} keyboardEvents - The keyboard events to handle.
|
||||
* @param {ref} elRef - A Vue ref to the element to attach the keyboard events to.
|
||||
*/
|
||||
export async function useKeyboardEvents(keyboardEvents) {
|
||||
let abortController = new AbortController();
|
||||
|
||||
export function useKeyboardEvents(keyboardEvents, elRef = null) {
|
||||
onMounted(async () => {
|
||||
if (!keyboardEvents) return;
|
||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
|
||||
const keydownHandler = createKeybindingsHandler(wrappedEvents);
|
||||
|
||||
document.addEventListener('keydown', keydownHandler, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
const el = unref(elRef);
|
||||
const getKeyboardEvents = () => keyboardEvents || null;
|
||||
const events = getKeyboardEvents();
|
||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(events);
|
||||
setupListeners(el, wrappedEvents);
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
abortController.abort();
|
||||
onBeforeUnmount(() => {
|
||||
const el = unref(elRef);
|
||||
removeListeners(el);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
|
||||
* }} An object containing functions to move the selection up and down.
|
||||
*/
|
||||
export function useKeyboardNavigableList({
|
||||
elementRef,
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
@@ -108,7 +109,7 @@ export function useKeyboardNavigableList({
|
||||
items
|
||||
);
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
useKeyboardEvents(keyboardEvents, elementRef);
|
||||
|
||||
return {
|
||||
moveSelectionUp,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/helper/automationHelper.js';
|
||||
|
||||
/**
|
||||
* Composable for handling macro-related functionality
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { formatTime } from '@chatwoot/utils';
|
||||
|
||||
/**
|
||||
* A composable function for report metrics calculations and display.
|
||||
*
|
||||
* @param {string} [accountSummaryKey='getAccountSummary'] - The key for accessing account summary data.
|
||||
* @returns {Object} An object containing utility functions for report metrics.
|
||||
*/
|
||||
export function useReportMetrics(accountSummaryKey = 'getAccountSummary') {
|
||||
const accountSummary = useMapGetter(accountSummaryKey);
|
||||
|
||||
/**
|
||||
* Calculates the trend percentage for a given metric.
|
||||
*
|
||||
* @param {string} key - The key of the metric to calculate trend for.
|
||||
* @returns {number} The calculated trend percentage, rounded to the nearest integer.
|
||||
*/
|
||||
const calculateTrend = key => {
|
||||
if (!accountSummary.value.previous[key]) return 0;
|
||||
const diff = accountSummary.value[key] - accountSummary.value.previous[key];
|
||||
return Math.round((diff / accountSummary.value.previous[key]) * 100);
|
||||
};
|
||||
|
||||
/**
|
||||
* Checks if a given metric key represents an average metric type.
|
||||
*
|
||||
* @param {string} key - The key of the metric to check.
|
||||
* @returns {boolean} True if the metric is an average type, false otherwise.
|
||||
*/
|
||||
const isAverageMetricType = key => {
|
||||
return [
|
||||
'avg_first_response_time',
|
||||
'avg_resolution_time',
|
||||
'reply_time',
|
||||
].includes(key);
|
||||
};
|
||||
|
||||
/**
|
||||
* Formats and displays a metric value based on its type.
|
||||
*
|
||||
* @param {string} key - The key of the metric to display.
|
||||
* @returns {string} The formatted metric value as a string.
|
||||
*/
|
||||
const displayMetric = key => {
|
||||
if (isAverageMetricType(key)) {
|
||||
return formatTime(accountSummary.value[key]);
|
||||
}
|
||||
return Number(accountSummary.value[key] || '').toLocaleString();
|
||||
};
|
||||
|
||||
return {
|
||||
calculateTrend,
|
||||
isAverageMetricType,
|
||||
displayMetric,
|
||||
};
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
export const DEFAULT_MESSAGE_CREATED_CONDITION = [
|
||||
{
|
||||
attribute_key: 'message_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_CONVERSATION_OPENED_CONDITION = [
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_OTHER_CONDITION = [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
|
||||
export const DEFAULT_ACTIONS = [
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: [],
|
||||
},
|
||||
];
|
||||
|
||||
export const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
export const PRIORITY_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'Low',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
name: 'High',
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
name: 'Urgent',
|
||||
},
|
||||
];
|
||||
@@ -1,53 +0,0 @@
|
||||
export const AVAILABLE_CUSTOM_ROLE_PERMISSIONS = [
|
||||
'conversation_manage',
|
||||
'conversation_unassigned_manage',
|
||||
'conversation_participating_manage',
|
||||
'contact_manage',
|
||||
'report_manage',
|
||||
'knowledge_base_manage',
|
||||
];
|
||||
|
||||
export const ROLES = ['agent', 'administrator'];
|
||||
|
||||
export const CONVERSATION_PERMISSIONS = [
|
||||
'conversation_manage',
|
||||
'conversation_unassigned_manage',
|
||||
'conversation_participating_manage',
|
||||
];
|
||||
|
||||
export const MANAGE_ALL_CONVERSATION_PERMISSIONS = 'conversation_manage';
|
||||
|
||||
export const CONVERSATION_UNASSIGNED_PERMISSIONS =
|
||||
'conversation_unassigned_manage';
|
||||
|
||||
export const CONVERSATION_PARTICIPATING_PERMISSIONS =
|
||||
'conversation_participating_manage';
|
||||
|
||||
export const CONTACT_PERMISSIONS = 'contact_manage';
|
||||
|
||||
export const REPORTS_PERMISSIONS = 'report_manage';
|
||||
|
||||
export const PORTAL_PERMISSIONS = 'knowledge_base_manage';
|
||||
|
||||
export const ASSIGNEE_TYPE_TAB_PERMISSIONS = {
|
||||
me: {
|
||||
count: 'mineCount',
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
},
|
||||
unassigned: {
|
||||
count: 'unAssignedCount',
|
||||
permissions: [
|
||||
...ROLES,
|
||||
MANAGE_ALL_CONVERSATION_PERMISSIONS,
|
||||
CONVERSATION_UNASSIGNED_PERMISSIONS,
|
||||
],
|
||||
},
|
||||
all: {
|
||||
count: 'allCount',
|
||||
permissions: [
|
||||
...ROLES,
|
||||
MANAGE_ALL_CONVERSATION_PERMISSIONS,
|
||||
CONVERSATION_PARTICIPATING_PERMISSIONS,
|
||||
],
|
||||
},
|
||||
};
|
||||
@@ -16,6 +16,7 @@ export const FEATURE_FLAGS = {
|
||||
TEAM_MANAGEMENT: 'team_management',
|
||||
VOICE_RECORDER: 'voice_recorder',
|
||||
AUDIT_LOGS: 'audit_logs',
|
||||
INSERT_ARTICLE_IN_REPLY: 'insert_article_in_reply',
|
||||
INBOX_VIEW: 'inbox_view',
|
||||
SLA: 'sla',
|
||||
RESPONSE_BOT: 'response_bot',
|
||||
@@ -31,5 +32,4 @@ export const FEATURE_FLAGS = {
|
||||
IP_LOOKUP: 'ip_lookup',
|
||||
LINEAR: 'linear_integration',
|
||||
CAPTAIN: 'captain_integration',
|
||||
CUSTOM_ROLES: 'custom_roles',
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
||||
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
||||
TRANSLATE_A_MESSAGE: 'Translated a message',
|
||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||
INSERTED_AN_EMOJI: 'Inserted an emoji',
|
||||
USED_MENTIONS: 'Used mentions',
|
||||
SEARCH_CONVERSATION: 'Searched conversations',
|
||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||
|
||||
@@ -5,11 +5,6 @@ import {
|
||||
getAlertAudio,
|
||||
initOnEvents,
|
||||
} from 'shared/helpers/AudioNotificationHelper';
|
||||
import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
import { getUserPermissions } from 'dashboard/helper/permissionsHelper.js';
|
||||
|
||||
const NOTIFICATION_TIME = 30000;
|
||||
|
||||
@@ -19,13 +14,12 @@ class DashboardAudioNotificationHelper {
|
||||
this.audioAlertType = 'none';
|
||||
this.playAlertOnlyWhenHidden = true;
|
||||
this.alertIfUnreadConversationExist = false;
|
||||
this.currentUser = null;
|
||||
this.currentUserId = null;
|
||||
this.audioAlertTone = 'ding';
|
||||
}
|
||||
|
||||
setInstanceValues = ({
|
||||
currentUser,
|
||||
currentUserId,
|
||||
alwaysPlayAudioAlert,
|
||||
alertIfUnreadConversationExist,
|
||||
audioAlertType,
|
||||
@@ -34,8 +28,7 @@ class DashboardAudioNotificationHelper {
|
||||
this.audioAlertType = audioAlertType;
|
||||
this.playAlertOnlyWhenHidden = !alwaysPlayAudioAlert;
|
||||
this.alertIfUnreadConversationExist = alertIfUnreadConversationExist;
|
||||
this.currentUser = currentUser;
|
||||
this.currentUserId = currentUser.id;
|
||||
this.currentUserId = currentUserId;
|
||||
this.audioAlertTone = audioAlertTone;
|
||||
initOnEvents.forEach(e => {
|
||||
document.addEventListener(e, this.onAudioListenEvent, false);
|
||||
@@ -119,20 +112,6 @@ class DashboardAudioNotificationHelper {
|
||||
return message?.sender_id === this.currentUserId;
|
||||
};
|
||||
|
||||
isUserHasConversationPermission = () => {
|
||||
const currentAccountId = window.WOOT.$store.getters.getCurrentAccountId;
|
||||
// Get the user permissions for the current account
|
||||
const userPermissions = getUserPermissions(
|
||||
this.currentUser,
|
||||
currentAccountId
|
||||
);
|
||||
// Check if the user has the required permissions
|
||||
const hasRequiredPermission = [...ROLES, ...CONVERSATION_PERMISSIONS].some(
|
||||
permission => userPermissions.includes(permission)
|
||||
);
|
||||
return hasRequiredPermission;
|
||||
};
|
||||
|
||||
shouldNotifyOnMessage = message => {
|
||||
if (this.audioAlertType === 'mine') {
|
||||
return this.isConversationAssignedToCurrentUser(message);
|
||||
@@ -141,11 +120,6 @@ class DashboardAudioNotificationHelper {
|
||||
};
|
||||
|
||||
onNewMessage = message => {
|
||||
// If the user does not have the permission to view the conversation, then dismiss the alert
|
||||
if (!this.isUserHasConversationPermission()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// If the message is sent by the current user or the
|
||||
// correct notification is not enabled, then dismiss the alert
|
||||
if (
|
||||
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* Default agent object representing 'None'
|
||||
* @type {Object}
|
||||
*/
|
||||
export const createNoneAgent = {
|
||||
confirmed: true,
|
||||
name: 'None',
|
||||
id: 0,
|
||||
role: 'agent',
|
||||
account_id: 0,
|
||||
email: 'None',
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters and sorts agents by availability status
|
||||
* @param {Array} agents - List of agents
|
||||
* @param {string} availability - Availability status to filter by
|
||||
* @returns {Array} Filtered and sorted list of agents
|
||||
*/
|
||||
export const getAgentsByAvailability = (agents, availability) => {
|
||||
return agents
|
||||
.filter(agent => agent.availability_status === availability)
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
};
|
||||
|
||||
/**
|
||||
* Sorts agents by availability status: online, busy, then offline
|
||||
* @param {Array} agents - List of agents
|
||||
* @returns {Array} Sorted list of agents
|
||||
*/
|
||||
export const getSortedAgentsByAvailability = agents => {
|
||||
const onlineAgents = getAgentsByAvailability(agents, 'online');
|
||||
const busyAgents = getAgentsByAvailability(agents, 'busy');
|
||||
const offlineAgents = getAgentsByAvailability(agents, 'offline');
|
||||
const filteredAgents = [...onlineAgents, ...busyAgents, ...offlineAgents];
|
||||
return filteredAgents;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the availability status of the current user based on the current account
|
||||
* @param {Array} agents - List of agents
|
||||
* @param {Object} currentUser - Current user object
|
||||
* @param {number} currentAccountId - ID of the current account
|
||||
* @returns {Array} Updated list of agents with dynamic presence
|
||||
*/
|
||||
// Here we are updating the availability status of the current user dynamically
|
||||
// based on the current account availability status
|
||||
export const getAgentsByUpdatedPresence = (
|
||||
agents,
|
||||
currentUser,
|
||||
currentAccountId
|
||||
) => {
|
||||
const agentsWithDynamicPresenceUpdate = agents.map(item =>
|
||||
item.id === currentUser.id
|
||||
? {
|
||||
...item,
|
||||
availability_status: currentUser.accounts.find(
|
||||
account => account.id === currentAccountId
|
||||
).availability_status,
|
||||
}
|
||||
: item
|
||||
);
|
||||
return agentsWithDynamicPresenceUpdate;
|
||||
};
|
||||
|
||||
/**
|
||||
* Combines the filtered agents with the 'None' agent option if applicable.
|
||||
*
|
||||
* @param {Array} filteredAgentsByAvailability - The list of agents sorted by availability.
|
||||
* @param {boolean} includeNoneAgent - Whether to include the 'None' agent option.
|
||||
* @param {boolean} isAgentSelected - Whether an agent is currently selected.
|
||||
* @returns {Array} The combined list of agents, potentially including the 'None' agent.
|
||||
*/
|
||||
export const getCombinedAgents = (
|
||||
filteredAgentsByAvailability,
|
||||
includeNoneAgent,
|
||||
isAgentSelected
|
||||
) => {
|
||||
return [
|
||||
...(includeNoneAgent && isAgentSelected ? [createNoneAgent] : []),
|
||||
...filteredAgentsByAvailability,
|
||||
];
|
||||
};
|
||||
@@ -3,16 +3,41 @@ import {
|
||||
OPERATOR_TYPES_3,
|
||||
OPERATOR_TYPES_4,
|
||||
} from 'dashboard/routes/dashboard/settings/automation/operators';
|
||||
import {
|
||||
DEFAULT_MESSAGE_CREATED_CONDITION,
|
||||
DEFAULT_CONVERSATION_OPENED_CONDITION,
|
||||
DEFAULT_OTHER_CONDITION,
|
||||
DEFAULT_ACTIONS,
|
||||
MESSAGE_CONDITION_VALUES,
|
||||
PRIORITY_CONDITION_VALUES,
|
||||
} from 'dashboard/constants/automation';
|
||||
import filterQueryGenerator from './filterQueryGenerator';
|
||||
import actionQueryGenerator from './actionQueryGenerator';
|
||||
const MESSAGE_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'incoming',
|
||||
name: 'Incoming Message',
|
||||
},
|
||||
{
|
||||
id: 'outgoing',
|
||||
name: 'Outgoing Message',
|
||||
},
|
||||
];
|
||||
|
||||
export const PRIORITY_CONDITION_VALUES = [
|
||||
{
|
||||
id: 'nil',
|
||||
name: 'None',
|
||||
},
|
||||
{
|
||||
id: 'low',
|
||||
name: 'Low',
|
||||
},
|
||||
{
|
||||
id: 'medium',
|
||||
name: 'Medium',
|
||||
},
|
||||
{
|
||||
id: 'high',
|
||||
name: 'High',
|
||||
},
|
||||
{
|
||||
id: 'urgent',
|
||||
name: 'Urgent',
|
||||
},
|
||||
];
|
||||
|
||||
export const getCustomAttributeInputType = key => {
|
||||
const customAttributeMap = {
|
||||
@@ -173,16 +198,45 @@ export const getFileName = (action, files = []) => {
|
||||
|
||||
export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
return [
|
||||
{
|
||||
attribute_key: 'message_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
if (eventName === 'conversation_opened') {
|
||||
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||
return [
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
}
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
return [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const getDefaultActions = () => {
|
||||
return DEFAULT_ACTIONS;
|
||||
return [
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: [],
|
||||
},
|
||||
];
|
||||
};
|
||||
|
||||
export const filterCustomAttributes = customAttributes => {
|
||||
@@ -243,100 +297,3 @@ export const generateCustomAttributes = (
|
||||
}
|
||||
return customAttributes;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get attributes for a given key from automation types.
|
||||
* @param {Object} automationTypes - Object containing automation types.
|
||||
* @param {string} key - The key to get attributes for.
|
||||
* @returns {Array} Array of condition objects for the given key.
|
||||
*/
|
||||
export const getAttributes = (automationTypes, key) => {
|
||||
return automationTypes[key].conditions;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the automation type for a given key.
|
||||
* @param {Object} automationTypes - Object containing automation types.
|
||||
* @param {Object} automation - The automation object.
|
||||
* @param {string} key - The key to get the automation type for.
|
||||
* @returns {Object} The automation type object.
|
||||
*/
|
||||
export const getAutomationType = (automationTypes, automation, key) => {
|
||||
return automationTypes[automation.event_name].conditions.find(
|
||||
condition => condition.key === key
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the input type for a given key.
|
||||
* @param {Array} allCustomAttributes - Array of all custom attributes.
|
||||
* @param {Object} automationTypes - Object containing automation types.
|
||||
* @param {Object} automation - The automation object.
|
||||
* @param {string} key - The key to get the input type for.
|
||||
* @returns {string} The input type.
|
||||
*/
|
||||
export const getInputType = (
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
key
|
||||
) => {
|
||||
const customAttribute = isACustomAttribute(allCustomAttributes, key);
|
||||
if (customAttribute) {
|
||||
return getCustomAttributeInputType(customAttribute.attribute_display_type);
|
||||
}
|
||||
const type = getAutomationType(automationTypes, automation, key);
|
||||
return type.inputType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get operators for a given key.
|
||||
* @param {Array} allCustomAttributes - Array of all custom attributes.
|
||||
* @param {Object} automationTypes - Object containing automation types.
|
||||
* @param {Object} automation - The automation object.
|
||||
* @param {string} mode - The mode ('edit' or other).
|
||||
* @param {string} key - The key to get operators for.
|
||||
* @returns {Array} Array of operators.
|
||||
*/
|
||||
export const getOperators = (
|
||||
allCustomAttributes,
|
||||
automationTypes,
|
||||
automation,
|
||||
mode,
|
||||
key
|
||||
) => {
|
||||
if (mode === 'edit') {
|
||||
const customAttribute = isACustomAttribute(allCustomAttributes, key);
|
||||
if (customAttribute) {
|
||||
return getOperatorTypes(customAttribute.attribute_display_type);
|
||||
}
|
||||
}
|
||||
const type = getAutomationType(automationTypes, automation, key);
|
||||
return type.filterOperators;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the custom attribute type for a given key.
|
||||
* @param {Object} automationTypes - Object containing automation types.
|
||||
* @param {Object} automation - The automation object.
|
||||
* @param {string} key - The key to get the custom attribute type for.
|
||||
* @returns {string} The custom attribute type.
|
||||
*/
|
||||
export const getCustomAttributeType = (automationTypes, automation, key) => {
|
||||
return automationTypes[automation.event_name].conditions.find(
|
||||
i => i.key === key
|
||||
).customAttributeType;
|
||||
};
|
||||
|
||||
/**
|
||||
* Determine if an action input should be shown.
|
||||
* @param {Array} automationActionTypes - Array of automation action type objects.
|
||||
* @param {string} action - The action to check.
|
||||
* @returns {boolean} True if the action input should be shown, false otherwise.
|
||||
*/
|
||||
export const showActionInput = (automationActionTypes, action) => {
|
||||
if (action === 'send_email_to_team' || action === 'send_message')
|
||||
return false;
|
||||
const type = automationActionTypes.find(i => i.key === action).inputType;
|
||||
return !!type;
|
||||
};
|
||||
|
||||
@@ -3,8 +3,6 @@ import {
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
/**
|
||||
@@ -283,92 +281,3 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Node Creation Helper Functions for
|
||||
* - mention
|
||||
* - canned response
|
||||
* - variable
|
||||
* - emoji
|
||||
*/
|
||||
|
||||
/**
|
||||
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
|
||||
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
|
||||
*/
|
||||
const createNode = (editorView, nodeType, content) => {
|
||||
const { state } = editorView;
|
||||
switch (nodeType) {
|
||||
case 'mention':
|
||||
return state.schema.nodes.mention.create({
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
});
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
return state.schema.text(content);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Object mapping types to their respective node creation functions.
|
||||
*/
|
||||
const nodeCreators = {
|
||||
mention: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'mention', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
cannedResponse: (editorView, content, from, to, variables) => {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
const node = createNode(editorView, 'cannedResponse', updatedMessage);
|
||||
return {
|
||||
node,
|
||||
from: node.textContent === updatedMessage ? from : from - 1,
|
||||
to,
|
||||
};
|
||||
},
|
||||
variable: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'variable', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
emoji: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'emoji', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||
* @param {string|Object} content - The content to be transformed into a node.
|
||||
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
|
||||
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
|
||||
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
|
||||
*/
|
||||
export const getContentNode = (
|
||||
editorView,
|
||||
type,
|
||||
content,
|
||||
{ from, to },
|
||||
variables
|
||||
) => {
|
||||
const creator = nodeCreators[type];
|
||||
return creator
|
||||
? creator(editorView, content, from, to, variables)
|
||||
: { node: null, from, to };
|
||||
};
|
||||
|
||||
@@ -9,15 +9,12 @@ const FEATURE_HELP_URLS = {
|
||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
inboxes: 'https://chwt.app/hc/inboxes',
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
macros: 'https://chwt.app/hc/macros',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -7,15 +7,6 @@ export const hasPermissions = (
|
||||
);
|
||||
};
|
||||
|
||||
export const getCurrentAccount = ({ accounts } = {}, accountId = null) => {
|
||||
return accounts.find(account => Number(account.id) === Number(accountId));
|
||||
};
|
||||
|
||||
export const getUserPermissions = (user, accountId) => {
|
||||
const currentAccount = getCurrentAccount(user, accountId) || {};
|
||||
return currentAccount.permissions || [];
|
||||
};
|
||||
|
||||
const isPermissionsPresentInRoute = route =>
|
||||
route.meta && route.meta.permissions;
|
||||
|
||||
@@ -41,32 +32,3 @@ export const buildPermissionsFromRouter = (routes = []) =>
|
||||
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
/**
|
||||
* Filters and transforms items based on user permissions.
|
||||
*
|
||||
* @param {Object} items - An object containing items to be filtered.
|
||||
* @param {Array} userPermissions - Array of permissions the user has.
|
||||
* @param {Function} getPermissions - Function to extract required permissions from an item.
|
||||
* @param {Function} [transformItem] - Optional function to transform each item after filtering.
|
||||
* @returns {Array} Filtered and transformed items.
|
||||
*/
|
||||
export const filterItemsByPermission = (
|
||||
items,
|
||||
userPermissions,
|
||||
getPermissions,
|
||||
transformItem = (key, item) => ({ key, ...item })
|
||||
) => {
|
||||
// Helper function to check if an item has the required permissions
|
||||
const hasRequiredPermissions = item => {
|
||||
const requiredPermissions = getPermissions(item);
|
||||
return (
|
||||
requiredPermissions.length === 0 ||
|
||||
hasPermissions(requiredPermissions, userPermissions)
|
||||
);
|
||||
};
|
||||
|
||||
return Object.entries(items)
|
||||
.filter(([, item]) => hasRequiredPermissions(item)) // Keep only items with required permissions
|
||||
.map(([key, item]) => transformItem(key, item)); // Transform each remaining item
|
||||
};
|
||||
|
||||
@@ -1,42 +1,15 @@
|
||||
import {
|
||||
hasPermissions,
|
||||
getUserPermissions,
|
||||
getCurrentAccount,
|
||||
} from './permissionsHelper';
|
||||
import { hasPermissions } from './permissionsHelper';
|
||||
|
||||
import {
|
||||
ROLES,
|
||||
CONVERSATION_PERMISSIONS,
|
||||
CONTACT_PERMISSIONS,
|
||||
REPORTS_PERMISSIONS,
|
||||
PORTAL_PERMISSIONS,
|
||||
} from 'dashboard/constants/permissions.js';
|
||||
// eslint-disable-next-line default-param-last
|
||||
export const getCurrentAccount = ({ accounts } = {}, accountId) => {
|
||||
return accounts.find(account => account.id === accountId);
|
||||
};
|
||||
|
||||
export const routeIsAccessibleFor = (route, userPermissions = []) => {
|
||||
const { meta: { permissions: routePermissions = [] } = {} } = route;
|
||||
return hasPermissions(routePermissions, userPermissions);
|
||||
};
|
||||
|
||||
export const defaultRedirectPage = (to, permissions) => {
|
||||
const { accountId } = to.params;
|
||||
|
||||
const permissionRoutes = [
|
||||
{
|
||||
permissions: [...ROLES, ...CONVERSATION_PERMISSIONS],
|
||||
path: 'dashboard',
|
||||
},
|
||||
{ permissions: [CONTACT_PERMISSIONS], path: 'contacts' },
|
||||
{ permissions: [REPORTS_PERMISSIONS], path: 'reports/overview' },
|
||||
{ permissions: [PORTAL_PERMISSIONS], path: 'portals' },
|
||||
];
|
||||
|
||||
const route = permissionRoutes.find(({ permissions: routePermissions }) =>
|
||||
hasPermissions(routePermissions, permissions)
|
||||
);
|
||||
|
||||
return `accounts/${accountId}/${route ? route.path : 'dashboard'}`;
|
||||
};
|
||||
|
||||
const validateActiveAccountRoutes = (to, user) => {
|
||||
// If the current account is active, then check for the route permissions
|
||||
const accountDashboardURL = `accounts/${to.params.accountId}/dashboard`;
|
||||
@@ -46,11 +19,9 @@ const validateActiveAccountRoutes = (to, user) => {
|
||||
return accountDashboardURL;
|
||||
}
|
||||
|
||||
const userPermissions = getUserPermissions(user, to.params.accountId);
|
||||
|
||||
const isAccessible = routeIsAccessibleFor(to, userPermissions);
|
||||
const isAccessible = routeIsAccessibleFor(to, user.permissions);
|
||||
// If the route is not accessible for the user, return to dashboard screen
|
||||
return isAccessible ? null : defaultRedirectPage(to, userPermissions);
|
||||
return isAccessible ? null : accountDashboardURL;
|
||||
};
|
||||
|
||||
export const validateLoggedInRoutes = (to, user) => {
|
||||
|
||||
@@ -26,7 +26,7 @@ const initializeAudioAlerts = user => {
|
||||
} = uiSettings || {};
|
||||
|
||||
DashboardAudioNotificationHelper.setInstanceValues({
|
||||
currentUser: user,
|
||||
currentUserId: user.id,
|
||||
audioAlertType: audioAlertType || 'none',
|
||||
audioAlertTone: audioAlertTone || 'ding',
|
||||
alwaysPlayAudioAlert: alwaysPlayAudioAlert || false,
|
||||
|
||||
@@ -1,131 +0,0 @@
|
||||
import {
|
||||
getAgentsByAvailability,
|
||||
getSortedAgentsByAvailability,
|
||||
getAgentsByUpdatedPresence,
|
||||
getCombinedAgents,
|
||||
createNoneAgent,
|
||||
} from '../agentHelper';
|
||||
import {
|
||||
allAgentsData,
|
||||
onlineAgentsData,
|
||||
busyAgentsData,
|
||||
offlineAgentsData,
|
||||
sortedByAvailability,
|
||||
formattedAgentsByPresenceOnline,
|
||||
formattedAgentsByPresenceOffline,
|
||||
} from 'dashboard/helper/specs/fixtures/agentFixtures';
|
||||
|
||||
describe('agentHelper', () => {
|
||||
describe('getAgentsByAvailability', () => {
|
||||
it('returns agents by availability', () => {
|
||||
expect(getAgentsByAvailability(allAgentsData, 'online')).toEqual(
|
||||
onlineAgentsData
|
||||
);
|
||||
expect(getAgentsByAvailability(allAgentsData, 'busy')).toEqual(
|
||||
busyAgentsData
|
||||
);
|
||||
expect(getAgentsByAvailability(allAgentsData, 'offline')).toEqual(
|
||||
offlineAgentsData
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSortedAgentsByAvailability', () => {
|
||||
it('returns sorted agents by availability', () => {
|
||||
expect(getSortedAgentsByAvailability(allAgentsData)).toEqual(
|
||||
sortedByAvailability
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty array when given an empty input', () => {
|
||||
expect(getSortedAgentsByAvailability([])).toEqual([]);
|
||||
});
|
||||
|
||||
it('maintains the order of agents with the same availability status', () => {
|
||||
const result = getSortedAgentsByAvailability(allAgentsData);
|
||||
expect(result[2].name).toBe('Honey Bee');
|
||||
expect(result[3].name).toBe('Samuel Keta');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentsByUpdatedPresence', () => {
|
||||
it('returns agents with updated presence', () => {
|
||||
const currentUser = {
|
||||
id: 1,
|
||||
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||
};
|
||||
const currentAccountId = 1;
|
||||
|
||||
expect(
|
||||
getAgentsByUpdatedPresence(
|
||||
formattedAgentsByPresenceOnline,
|
||||
currentUser,
|
||||
currentAccountId
|
||||
)
|
||||
).toEqual(formattedAgentsByPresenceOffline);
|
||||
});
|
||||
|
||||
it('does not modify other agents presence', () => {
|
||||
const currentUser = {
|
||||
id: 2,
|
||||
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||
};
|
||||
const currentAccountId = 1;
|
||||
|
||||
expect(
|
||||
getAgentsByUpdatedPresence(
|
||||
formattedAgentsByPresenceOnline,
|
||||
currentUser,
|
||||
currentAccountId
|
||||
)
|
||||
).toEqual(formattedAgentsByPresenceOnline);
|
||||
});
|
||||
|
||||
it('handles empty agent list', () => {
|
||||
const currentUser = {
|
||||
id: 1,
|
||||
accounts: [{ id: 1, availability_status: 'offline' }],
|
||||
};
|
||||
const currentAccountId = 1;
|
||||
|
||||
expect(
|
||||
getAgentsByUpdatedPresence([], currentUser, currentAccountId)
|
||||
).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCombinedAgents', () => {
|
||||
it('includes None agent when includeNoneAgent is true and isAgentSelected is true', () => {
|
||||
const result = getCombinedAgents(sortedByAvailability, true, true);
|
||||
expect(result).toEqual([createNoneAgent, ...sortedByAvailability]);
|
||||
expect(result.length).toBe(sortedByAvailability.length + 1);
|
||||
expect(result[0]).toEqual(createNoneAgent);
|
||||
});
|
||||
|
||||
it('excludes None agent when includeNoneAgent is false', () => {
|
||||
const result = getCombinedAgents(sortedByAvailability, false, true);
|
||||
expect(result).toEqual(sortedByAvailability);
|
||||
expect(result.length).toBe(sortedByAvailability.length);
|
||||
expect(result[0]).not.toEqual(createNoneAgent);
|
||||
});
|
||||
|
||||
it('excludes None agent when isAgentSelected is false', () => {
|
||||
const result = getCombinedAgents(sortedByAvailability, true, false);
|
||||
expect(result).toEqual(sortedByAvailability);
|
||||
expect(result.length).toBe(sortedByAvailability.length);
|
||||
expect(result[0]).not.toEqual(createNoneAgent);
|
||||
});
|
||||
|
||||
it('returns only filtered agents when both includeNoneAgent and isAgentSelected are false', () => {
|
||||
const result = getCombinedAgents(sortedByAvailability, false, false);
|
||||
expect(result).toEqual(sortedByAvailability);
|
||||
expect(result.length).toBe(sortedByAvailability.length);
|
||||
});
|
||||
|
||||
it('handles empty filteredAgentsByAvailability array', () => {
|
||||
const result = getCombinedAgents([], true, true);
|
||||
expect(result).toEqual([createNoneAgent]);
|
||||
expect(result.length).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,127 +0,0 @@
|
||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||
import { getContentNode } from '../editorHelper';
|
||||
import {
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||
MessageMarkdownTransformer: vi.fn(),
|
||||
messageSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
replaceVariablesInMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let editorView;
|
||||
|
||||
beforeEach(() => {
|
||||
editorView = {
|
||||
state: {
|
||||
schema: {
|
||||
nodes: {
|
||||
mention: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
text: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('getMentionNode', () => {
|
||||
it('should create a mention node', () => {
|
||||
const content = { id: 1, name: 'John Doe' };
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'mention', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCannedResponseNode', () => {
|
||||
it('should create a canned response node', () => {
|
||||
const content = 'Hello {{name}}';
|
||||
const variables = { name: 'John' };
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
const updatedMessage = 'Hello John';
|
||||
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||
}));
|
||||
|
||||
const { node } = getContentNode(
|
||||
editorView,
|
||||
'cannedResponse',
|
||||
content,
|
||||
{ from, to },
|
||||
variables
|
||||
);
|
||||
|
||||
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||
expect(node.textContent).toBe(updatedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableNode', () => {
|
||||
it('should create a variable node', () => {
|
||||
const content = 'name';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'variable', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmojiNode', () => {
|
||||
it('should create an emoji node', () => {
|
||||
const content = '😊';
|
||||
const from = 0;
|
||||
const to = 2;
|
||||
getContentNode(editorView, 'emoji', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
it('should return null for invalid type', () => {
|
||||
const content = 'invalid';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
const { node } = getContentNode(editorView, 'invalid', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,184 +0,0 @@
|
||||
export const allAgentsData = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
export const onlineAgentsData = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
];
|
||||
export const busyAgentsData = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
export const offlineAgentsData = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
export const sortedByAvailability = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abraham@chatwoot.com',
|
||||
id: 5,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'John K',
|
||||
confirmed: true,
|
||||
email: 'john@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'John Kennady',
|
||||
role: 'administrator',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Honey',
|
||||
confirmed: true,
|
||||
email: 'bee@chatwoot.com',
|
||||
id: 4,
|
||||
name: 'Honey Bee',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'busy',
|
||||
available_name: 'Samuel K',
|
||||
confirmed: true,
|
||||
email: 'samuel@chatwoot.com',
|
||||
id: 2,
|
||||
name: 'Samuel Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'James K',
|
||||
confirmed: true,
|
||||
email: 'james@chatwoot.com',
|
||||
id: 3,
|
||||
name: 'James Koti',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
export const formattedAgentsByPresenceOnline = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'online',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
export const formattedAgentsByPresenceOffline = [
|
||||
{
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
available_name: 'Abraham',
|
||||
confirmed: true,
|
||||
email: 'abr@chatwoot.com',
|
||||
id: 1,
|
||||
name: 'Abraham Keta',
|
||||
role: 'agent',
|
||||
},
|
||||
];
|
||||
@@ -1,32 +1,8 @@
|
||||
import {
|
||||
buildPermissionsFromRouter,
|
||||
getCurrentAccount,
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
filterItemsByPermission,
|
||||
} from '../permissionsHelper';
|
||||
|
||||
describe('#getCurrentAccount', () => {
|
||||
it('should return the current account', () => {
|
||||
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
|
||||
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getUserPermissions', () => {
|
||||
it('should return the correct permissions', () => {
|
||||
const user = {
|
||||
accounts: [
|
||||
{ id: 1, permissions: ['conversations_manage'] },
|
||||
{ id: 3, permissions: ['contacts_manage'] },
|
||||
],
|
||||
};
|
||||
expect(getUserPermissions(user, 1)).toEqual(['conversations_manage']);
|
||||
expect(getUserPermissions(user, '3')).toEqual(['contacts_manage']);
|
||||
expect(getUserPermissions(user, 2)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasPermissions', () => {
|
||||
it('returns true if permission is present', () => {
|
||||
expect(
|
||||
@@ -106,113 +82,3 @@ describe('buildPermissionsFromRouter', () => {
|
||||
}).toThrow("The route doesn't have the required permissions defined");
|
||||
});
|
||||
});
|
||||
|
||||
describe('filterItemsByPermission', () => {
|
||||
const items = {
|
||||
item1: { name: 'Item 1', permissions: ['agent', 'administrator'] },
|
||||
item2: {
|
||||
name: 'Item 2',
|
||||
permissions: [
|
||||
'conversation_manage',
|
||||
'conversation_unassigned_manage',
|
||||
'conversation_participating_manage',
|
||||
],
|
||||
},
|
||||
item3: { name: 'Item 3', permissions: ['contact_manage'] },
|
||||
item4: { name: 'Item 4', permissions: ['report_manage'] },
|
||||
item5: { name: 'Item 5', permissions: ['knowledge_base_manage'] },
|
||||
item6: {
|
||||
name: 'Item 6',
|
||||
permissions: [
|
||||
'agent',
|
||||
'administrator',
|
||||
'conversation_manage',
|
||||
'conversation_unassigned_manage',
|
||||
'conversation_participating_manage',
|
||||
'contact_manage',
|
||||
'report_manage',
|
||||
'knowledge_base_manage',
|
||||
],
|
||||
},
|
||||
item7: { name: 'Item 7', permissions: [] },
|
||||
};
|
||||
|
||||
const getPermissions = item => item.permissions;
|
||||
|
||||
it('filters items based on user permissions', () => {
|
||||
const userPermissions = ['agent', 'contact_manage', 'report_manage'];
|
||||
const result = filterItemsByPermission(
|
||||
items,
|
||||
userPermissions,
|
||||
getPermissions
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item1', name: 'Item 1' })
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item3', name: 'Item 3' })
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item4', name: 'Item 4' })
|
||||
);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item6', name: 'Item 6' })
|
||||
);
|
||||
});
|
||||
|
||||
it('includes items with empty permissions', () => {
|
||||
const userPermissions = [];
|
||||
const result = filterItemsByPermission(
|
||||
items,
|
||||
userPermissions,
|
||||
getPermissions
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item7', name: 'Item 7' })
|
||||
);
|
||||
});
|
||||
|
||||
it('uses custom transform function when provided', () => {
|
||||
const userPermissions = ['agent', 'contact_manage'];
|
||||
const customTransform = (key, item) => ({ id: key, title: item.name });
|
||||
const result = filterItemsByPermission(
|
||||
items,
|
||||
userPermissions,
|
||||
getPermissions,
|
||||
customTransform
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result).toContainEqual({ id: 'item1', title: 'Item 1' });
|
||||
expect(result).toContainEqual({ id: 'item3', title: 'Item 3' });
|
||||
expect(result).toContainEqual({ id: 'item6', title: 'Item 6' });
|
||||
});
|
||||
|
||||
it('handles empty items object', () => {
|
||||
const result = filterItemsByPermission({}, ['agent'], getPermissions);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('handles custom getPermissions function', () => {
|
||||
const customItems = {
|
||||
item1: { name: 'Item 1', requiredPerms: ['agent', 'administrator'] },
|
||||
item2: { name: 'Item 2', requiredPerms: ['contact_manage'] },
|
||||
};
|
||||
const customGetPermissions = item => item.requiredPerms;
|
||||
const result = filterItemsByPermission(
|
||||
customItems,
|
||||
['agent'],
|
||||
customGetPermissions
|
||||
);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result).toContainEqual(
|
||||
expect.objectContaining({ key: 'item1', name: 'Item 1' })
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
import {
|
||||
getConversationDashboardRoute,
|
||||
getCurrentAccount,
|
||||
isAConversationRoute,
|
||||
defaultRedirectPage,
|
||||
routeIsAccessibleFor,
|
||||
validateLoggedInRoutes,
|
||||
isAInboxViewRoute,
|
||||
} from '../routeHelpers';
|
||||
|
||||
describe('#getCurrentAccount', () => {
|
||||
it('should return the current account', () => {
|
||||
expect(getCurrentAccount({ accounts: [{ id: 1 }] }, 1)).toEqual({ id: 1 });
|
||||
expect(getCurrentAccount({ accounts: [] }, 1)).toEqual(undefined);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#routeIsAccessibleFor', () => {
|
||||
it('should return the correct access', () => {
|
||||
let route = { meta: { permissions: ['administrator'] } };
|
||||
@@ -15,57 +22,6 @@ describe('#routeIsAccessibleFor', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#defaultRedirectPage', () => {
|
||||
const to = {
|
||||
params: { accountId: '2' },
|
||||
fullPath: '/app/accounts/2/dashboard',
|
||||
name: 'home',
|
||||
};
|
||||
|
||||
it('should return dashboard route for users with conversation permissions', () => {
|
||||
const permissions = ['conversation_manage', 'agent'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||
});
|
||||
|
||||
it('should return contacts route for users with contact permissions', () => {
|
||||
const permissions = ['contact_manage'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/contacts');
|
||||
});
|
||||
|
||||
it('should return reports route for users with report permissions', () => {
|
||||
const permissions = ['report_manage'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe(
|
||||
'accounts/2/reports/overview'
|
||||
);
|
||||
});
|
||||
|
||||
it('should return portals route for users with portal permissions', () => {
|
||||
const permissions = ['knowledge_base_manage'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/portals');
|
||||
});
|
||||
|
||||
it('should return dashboard route as default for users with custom roles', () => {
|
||||
const permissions = ['custom_role'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||
});
|
||||
|
||||
it('should return dashboard route for users with administrator role', () => {
|
||||
const permissions = ['administrator'];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||
});
|
||||
|
||||
it('should return dashboard route for users with multiple permissions', () => {
|
||||
const permissions = [
|
||||
'contact_manage',
|
||||
'custom_role',
|
||||
'conversation_manage',
|
||||
'agent',
|
||||
'administrator',
|
||||
];
|
||||
expect(defaultRedirectPage(to, permissions)).toBe('accounts/2/dashboard');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#validateLoggedInRoutes', () => {
|
||||
describe('when account access is missing', () => {
|
||||
it('should return the login route', () => {
|
||||
|
||||
@@ -1,93 +1,52 @@
|
||||
import { uploadFile } from '../uploadHelper';
|
||||
import axios from 'axios';
|
||||
import { uploadExternalImage, uploadFile } from '../uploadHelper';
|
||||
|
||||
global.axios = axios;
|
||||
vi.mock('axios');
|
||||
|
||||
describe('Upload Helpers', () => {
|
||||
describe('#Upload Helpers', () => {
|
||||
afterEach(() => {
|
||||
// Cleaning up the mock after each test
|
||||
axios.post.mockReset();
|
||||
});
|
||||
|
||||
describe('uploadFile', () => {
|
||||
it('should send a POST request with correct data', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockResponse = {
|
||||
data: {
|
||||
file_url: 'https://example.com/fileUrl',
|
||||
blob_key: 'blobKey123',
|
||||
blob_id: 'blobId456',
|
||||
},
|
||||
};
|
||||
|
||||
axios.post.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await uploadFile(mockFile, '1602');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1602/upload',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
fileUrl: 'https://example.com/fileUrl',
|
||||
blobKey: 'blobKey123',
|
||||
blobId: 'blobId456',
|
||||
});
|
||||
it('should send a POST request with correct data', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockResponse = {
|
||||
data: {
|
||||
file_url: 'https://example.com/fileUrl',
|
||||
blob_key: 'blobKey123',
|
||||
blob_id: 'blobId456',
|
||||
},
|
||||
};
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockError = new Error('Failed to upload');
|
||||
axios.post.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
axios.post.mockRejectedValueOnce(mockError);
|
||||
const result = await uploadFile(mockFile, '1602');
|
||||
|
||||
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1602/upload',
|
||||
expect.any(FormData),
|
||||
{ headers: { 'Content-Type': 'multipart/form-data' } }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
fileUrl: 'https://example.com/fileUrl',
|
||||
blobKey: 'blobKey123',
|
||||
blobId: 'blobId456',
|
||||
});
|
||||
});
|
||||
|
||||
describe('uploadExternalImage', () => {
|
||||
it('should send a POST request with correct data', async () => {
|
||||
const mockUrl = 'https://example.com/image.jpg';
|
||||
const mockResponse = {
|
||||
data: {
|
||||
file_url: 'https://example.com/fileUrl',
|
||||
blob_key: 'blobKey123',
|
||||
blob_id: 'blobId456',
|
||||
},
|
||||
};
|
||||
|
||||
axios.post.mockResolvedValueOnce(mockResponse);
|
||||
|
||||
const result = await uploadExternalImage(mockUrl, '1602');
|
||||
|
||||
expect(axios.post).toHaveBeenCalledWith(
|
||||
'/api/v1/accounts/1602/upload',
|
||||
{ external_url: mockUrl },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
|
||||
expect(result).toEqual({
|
||||
fileUrl: 'https://example.com/fileUrl',
|
||||
blobKey: 'blobKey123',
|
||||
blobId: 'blobId456',
|
||||
});
|
||||
it('should handle errors', async () => {
|
||||
const mockFile = new File(['dummy content'], 'example.png', {
|
||||
type: 'image/png',
|
||||
});
|
||||
const mockError = new Error('Failed to upload');
|
||||
|
||||
it('should handle errors', async () => {
|
||||
const mockUrl = 'https://example.com/image.jpg';
|
||||
const mockError = new Error('Failed to upload');
|
||||
axios.post.mockRejectedValueOnce(mockError);
|
||||
|
||||
axios.post.mockRejectedValueOnce(mockError);
|
||||
|
||||
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
|
||||
'Failed to upload'
|
||||
);
|
||||
});
|
||||
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,47 +19,26 @@ const HEADERS = {
|
||||
* The function uses FormData to wrap the file and axios to send the request.
|
||||
*
|
||||
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
|
||||
* @param {string} accountId - The account ID.
|
||||
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
|
||||
*/
|
||||
export async function uploadFile(file, accountId) {
|
||||
// Create a new FormData instance.
|
||||
let formData = new FormData();
|
||||
|
||||
if (!accountId) {
|
||||
accountId = window.location.pathname.split('/')[3];
|
||||
}
|
||||
|
||||
// Append the file to the FormData instance under the key 'attachment'.
|
||||
let formData = new FormData();
|
||||
formData.append('attachment', file);
|
||||
|
||||
// Use axios to send a POST request to the upload endpoint.
|
||||
const { data } = await axios.post(
|
||||
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
||||
formData,
|
||||
{ headers: HEADERS }
|
||||
);
|
||||
|
||||
return {
|
||||
fileUrl: data.file_url,
|
||||
blobKey: data.blob_key,
|
||||
blobId: data.blob_id,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Uploads an image from an external URL.
|
||||
*
|
||||
* @param {string} url - The external URL of the image.
|
||||
* @param {string} accountId - The account ID.
|
||||
* @returns {Promise} A promise that resolves with the server's response.
|
||||
*/
|
||||
export async function uploadExternalImage(url, accountId) {
|
||||
if (!accountId) {
|
||||
accountId = window.location.pathname.split('/')[3];
|
||||
}
|
||||
|
||||
const { data } = await axios.post(
|
||||
`/api/${API_VERSION}/accounts/${accountId}/upload`,
|
||||
{ external_url: url },
|
||||
{ headers: { 'Content-Type': 'application/json' } }
|
||||
{
|
||||
headers: HEADERS,
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
|
||||
@@ -55,9 +55,6 @@
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Value is required"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard filters",
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
|
||||
@@ -18,8 +18,7 @@
|
||||
"STATUS": "Status",
|
||||
"ACTIONS": "Actions",
|
||||
"VERIFIED": "Verified",
|
||||
"VERIFICATION_PENDING": "Verification Pending",
|
||||
"AVAILABLE_CUSTOM_ROLE": "Available custom role permissions"
|
||||
"VERIFICATION_PENDING": "Verification Pending"
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add agent to your team",
|
||||
|
||||
@@ -3,8 +3,7 @@
|
||||
"HEADER": "Custom Attributes",
|
||||
"HEADER_BTN_TXT": "Add Custom Attribute",
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>",
|
||||
"ADD": {
|
||||
"TITLE": "Add Custom Attribute",
|
||||
"SUBMIT": "Create",
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
{
|
||||
"AUTOMATION": {
|
||||
"HEADER": "Automation",
|
||||
"DESCRIPTION": "Automation can replace and streamline existing processes that require manual effort, such as adding labels and assigning conversations to the most suitable agent. This allows the team to focus on their strengths while reducing time spent on routine tasks.",
|
||||
"LEARN_MORE": "Learn more about automation",
|
||||
"HEADER": "Automations",
|
||||
"HEADER_BTN_TXT": "Add Automation Rule",
|
||||
"LOADING": "Fetching automation rules",
|
||||
"SIDEBAR_TXT": "<p><b>Automation Rules</b> <p>Automation can replace and automate existing processes that require manual effort. You can do many things with automation, including adding labels and assigning conversation to the best agent. So the team focuses on what they do best and spends more little time on manual tasks.</p>",
|
||||
"ADD": {
|
||||
"TITLE": "Add Automation Rule",
|
||||
"SUBMIT": "Create",
|
||||
|
||||
@@ -30,9 +30,6 @@
|
||||
"is_lesser_than": "Is lesser than",
|
||||
"days_before": "Is x days before"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Value is required"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "Name",
|
||||
"EMAIL": "Email",
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
{
|
||||
"CUSTOM_ROLE": {
|
||||
"HEADER": "Custom Roles",
|
||||
"LEARN_MORE": "Learn more about custom roles",
|
||||
"DESCRIPTION": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"HEADER_BTN_TXT": "Add custom role",
|
||||
"LOADING": "Fetching custom roles...",
|
||||
"SEARCH_404": "There are no items matching this query.",
|
||||
"PAYWALL": {
|
||||
"TITLE": "Upgrade to create custom roles",
|
||||
"AVAILABLE_ON": "The custom role feature is only available in the Business and Enterprise plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade your plan to get access to advanced features like team management, automations, custom attributes, and more.",
|
||||
"UPGRADE_NOW": "Upgrade now",
|
||||
"CANCEL_ANYTIME": "You can change or cancel your plan anytime"
|
||||
},
|
||||
"ENTERPRISE_PAYWALL": {
|
||||
"AVAILABLE_ON": "The custom role feature is only available in the paid plans.",
|
||||
"UPGRADE_PROMPT": "Upgrade to a paid plan to access advanced features like audit logs, agent capacity, and more.",
|
||||
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
|
||||
},
|
||||
"LIST": {
|
||||
"404": "There are no custom roles available in this account.",
|
||||
"TITLE": "Manage custom roles",
|
||||
"DESC": "Custom roles are roles that are created by the account owner or admin. These roles can be assigned to agents to define their access and permissions within the account. Custom roles can be created with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Permissions",
|
||||
"Actions"
|
||||
]
|
||||
},
|
||||
"PERMISSIONS": {
|
||||
"CONVERSATION_MANAGE": "Manage all conversations",
|
||||
"CONVERSATION_UNASSIGNED_MANAGE": "Manage unassigned conversations and those assigned to them",
|
||||
"CONVERSATION_PARTICIPATING_MANAGE": "Manage participating conversations and those assigned to them",
|
||||
"CONTACT_MANAGE": "Manage contacts",
|
||||
"REPORT_MANAGE": "Manage reports",
|
||||
"KNOWLEDGE_BASE_MANAGE": "Manage knowledge base"
|
||||
},
|
||||
"FORM": {
|
||||
"NAME": {
|
||||
"LABEL": "Name",
|
||||
"PLACEHOLDER": "Please enter a name.",
|
||||
"ERROR": "Name is required."
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
"PLACEHOLDER": "Please enter a description.",
|
||||
"ERROR": "Description is required."
|
||||
},
|
||||
"PERMISSIONS": {
|
||||
"LABEL": "Permissions",
|
||||
"ERROR": "Permissions are required."
|
||||
},
|
||||
"CANCEL_BUTTON_TEXT": "Cancel",
|
||||
"API": {
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
}
|
||||
},
|
||||
"ADD": {
|
||||
"TITLE": "Add custom role",
|
||||
"DESC": " Custom roles allows you to create roles with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"SUBMIT": "Submit",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Custom role added successfully."
|
||||
}
|
||||
},
|
||||
"EDIT": {
|
||||
"BUTTON_TEXT": "Edit",
|
||||
"TITLE": "Edit custom role",
|
||||
"DESC": " Custom roles allows you to create roles with specific permissions and access levels to suit the requirements of the organization.",
|
||||
"SUBMIT": "Update",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Custom role updated successfully."
|
||||
}
|
||||
},
|
||||
"DELETE": {
|
||||
"BUTTON_TEXT": "Delete",
|
||||
"API": {
|
||||
"SUCCESS_MESSAGE": "Custom role deleted successfully.",
|
||||
"ERROR_MESSAGE": "Could not connect to Woot server. Please try again."
|
||||
},
|
||||
"CONFIRM": {
|
||||
"TITLE": "Confirm deletion",
|
||||
"MESSAGE": "Are you sure to delete ",
|
||||
"YES": "Yes, delete ",
|
||||
"NO": "No, keep "
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,7 +41,6 @@
|
||||
"UPLOADING": "Uploading...",
|
||||
"SUCCESS": "Image uploaded successfully",
|
||||
"ERROR": "Error while uploading image",
|
||||
"UN_AUTHORIZED_ERROR": "You are not authorized to upload images",
|
||||
"ERROR_FILE_SIZE": "Image size should be less than {size}MB",
|
||||
"ERROR_FILE_FORMAT": "Image format should be jpg, jpeg or png",
|
||||
"ERROR_FILE_DIMENSIONS": "Image dimensions should be less than 2000 x 2000"
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user