Compare commits
96
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6c17211d29 | ||
|
|
bc6420019f | ||
|
|
f82ec3b885 | ||
|
|
32c25047c4 | ||
|
|
7f8d718da3 | ||
|
|
3489783cb8 | ||
|
|
53d68868c6 | ||
|
|
b61ad6e41a | ||
|
|
41c5e7d3f1 | ||
|
|
abc511d00f | ||
|
|
a48f98de9d | ||
|
|
7c2353c7d9 | ||
|
|
c80de24ac1 | ||
|
|
dadd572f9d | ||
|
|
776579ba5b | ||
|
|
4aa4e2549f | ||
|
|
eb6de74269 | ||
|
|
d19a9c38d7 | ||
|
|
c63a6ed8ec | ||
|
|
429d281501 | ||
|
|
6571baf211 | ||
|
|
44227de97e | ||
|
|
77b718c22c | ||
|
|
04b67eb431 | ||
|
|
a92d7fd7d5 | ||
|
|
e9e515c436 | ||
|
|
bb947ca50d | ||
|
|
8473e72a7e | ||
|
|
292435e0e7 | ||
|
|
d3dacccb1d | ||
|
|
0190b6c36d | ||
|
|
855b9c8f25 | ||
|
|
bb78639696 | ||
|
|
f38101527f | ||
|
|
4310a547ca | ||
|
|
06a362318c | ||
|
|
dd8abe975c | ||
|
|
207933ed70 | ||
|
|
b998f04826 | ||
|
|
7d6466022a | ||
|
|
7ff939efd5 | ||
|
|
b33d59d804 | ||
|
|
c26490e9c1 | ||
|
|
66db9a0cc1 | ||
|
|
b1da3dc7cf | ||
|
|
4c6572c2c9 | ||
|
|
452096f4b2 | ||
|
|
96d60674aa | ||
|
|
0b0e266455 | ||
|
|
6e12ba04ab | ||
|
|
ffc0183809 | ||
|
|
dcefd58240 | ||
|
|
6196a6d99a | ||
|
|
4a63d1d896 | ||
|
|
d5f34bf9d0 | ||
|
|
3558878ae2 | ||
|
|
d1d1398d80 | ||
|
|
40bd79f1ca | ||
|
|
74bbbd25b9 | ||
|
|
ae938b2154 | ||
|
|
80a90d9d8c | ||
|
|
646cfb97e7 | ||
|
|
89acbd8d09 | ||
|
|
b03a839809 | ||
|
|
56e93d152d | ||
|
|
c344f2b9cf | ||
|
|
aea68f1ecf | ||
|
|
e393bcf125 | ||
|
|
e330c2f6c0 | ||
|
|
cb4ad28a13 | ||
|
|
91b713f6f5 | ||
|
|
59b9c55967 | ||
|
|
736f16b170 | ||
|
|
28d4432152 | ||
|
|
e0b67bb552 | ||
|
|
0ba086a7ef | ||
|
|
8ea412bc85 | ||
|
|
a2b2567371 | ||
|
|
11155b755d | ||
|
|
862ef37e7f | ||
|
|
9a1d5519ec | ||
|
|
e17f0ea753 | ||
|
|
7ce0b4fad5 | ||
|
|
cfd6f25426 | ||
|
|
fe8e58a80b | ||
|
|
f7580f864c | ||
|
|
85aeaf2aee | ||
|
|
e93af2681e | ||
|
|
cb2b75ca17 | ||
|
|
608592db0c | ||
|
|
ab1f803354 | ||
|
|
b81c722ac8 | ||
|
|
b40ad399f6 | ||
|
|
dcd18072b4 | ||
|
|
bda2d1d9a4 | ||
|
|
83231b9678 |
@@ -25,13 +25,5 @@ jobs:
|
||||
with:
|
||||
issue-inactive-days: '30'
|
||||
issue-lock-reason: 'resolved'
|
||||
issue-comment: >
|
||||
This issue has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
pr-inactive-days: '30'
|
||||
pr-lock-reason: 'resolved'
|
||||
pr-comment: >
|
||||
This pull request has been automatically locked since there
|
||||
has not been any recent activity after it was closed.
|
||||
Please open a new issue for related bugs.
|
||||
|
||||
@@ -111,12 +111,12 @@ gem 'elastic-apm', require: false
|
||||
gem 'newrelic_rpm', require: false
|
||||
gem 'newrelic-sidekiq-metrics', '>= 1.6.2', require: false
|
||||
gem 'scout_apm', require: false
|
||||
gem 'sentry-rails', '>= 5.18.2', require: false
|
||||
gem 'sentry-rails', '>= 5.19.0', require: false
|
||||
gem 'sentry-ruby', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.18.2', require: false
|
||||
gem 'sentry-sidekiq', '>= 5.19.0', require: false
|
||||
|
||||
##-- background job processing --##
|
||||
gem 'sidekiq', '>= 7.3.0'
|
||||
gem 'sidekiq', '>= 7.3.1'
|
||||
# We want cron jobs
|
||||
gem 'sidekiq-cron', '>= 1.12.0'
|
||||
|
||||
@@ -228,7 +228,7 @@ group :development, :test do
|
||||
gem 'mock_redis'
|
||||
gem 'pry-rails'
|
||||
gem 'rspec_junit_formatter'
|
||||
gem 'rspec-rails', '>= 6.1.3'
|
||||
gem 'rspec-rails', '>= 6.1.4'
|
||||
gem 'rubocop', require: false
|
||||
gem 'rubocop-performance', require: false
|
||||
gem 'rubocop-rails', require: false
|
||||
|
||||
+19
-19
@@ -169,7 +169,7 @@ GEM
|
||||
climate_control (1.2.0)
|
||||
coderay (1.1.3)
|
||||
commonmarker (0.23.10)
|
||||
concurrent-ruby (1.3.3)
|
||||
concurrent-ruby (1.3.4)
|
||||
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.7)
|
||||
et-orbi (1.2.11)
|
||||
tzinfo
|
||||
execjs (2.8.1)
|
||||
facebook-messenger (2.0.1)
|
||||
@@ -268,8 +268,8 @@ GEM
|
||||
rake
|
||||
flag_shih_tzu (0.3.23)
|
||||
foreman (0.87.2)
|
||||
fugit (1.9.0)
|
||||
et-orbi (~> 1, >= 1.2.7)
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.18.0)
|
||||
faraday (>= 1.9, < 3.a)
|
||||
@@ -467,7 +467,7 @@ GEM
|
||||
mini_magick (4.12.0)
|
||||
mini_mime (1.1.5)
|
||||
mini_portile2 (2.8.7)
|
||||
minitest (5.24.1)
|
||||
minitest (5.25.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.12)
|
||||
net-imap (0.4.14)
|
||||
date
|
||||
net-protocol
|
||||
net-pop (0.1.2)
|
||||
@@ -634,7 +634,7 @@ GEM
|
||||
retriable (3.1.2)
|
||||
reverse_markdown (2.1.1)
|
||||
nokogiri
|
||||
rexml (3.3.4)
|
||||
rexml (3.3.6)
|
||||
strscan
|
||||
rspec-core (3.13.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
@@ -644,7 +644,7 @@ GEM
|
||||
rspec-mocks (3.13.1)
|
||||
diff-lcs (>= 1.2.0, < 2.0)
|
||||
rspec-support (~> 3.13.0)
|
||||
rspec-rails (6.1.3)
|
||||
rspec-rails (6.1.4)
|
||||
actionpack (>= 6.1)
|
||||
activesupport (>= 6.1)
|
||||
railties (>= 6.1)
|
||||
@@ -710,19 +710,19 @@ GEM
|
||||
activesupport (>= 4)
|
||||
selectize-rails (0.12.6)
|
||||
semantic_range (3.0.0)
|
||||
sentry-rails (5.18.2)
|
||||
sentry-rails (5.19.0)
|
||||
railties (>= 5.0)
|
||||
sentry-ruby (~> 5.18.2)
|
||||
sentry-ruby (5.18.2)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sentry-ruby (5.19.0)
|
||||
bigdecimal
|
||||
concurrent-ruby (~> 1.0, >= 1.0.2)
|
||||
sentry-sidekiq (5.18.2)
|
||||
sentry-ruby (~> 5.18.2)
|
||||
sentry-sidekiq (5.19.0)
|
||||
sentry-ruby (~> 5.19.0)
|
||||
sidekiq (>= 3.0)
|
||||
sexp_processor (4.17.0)
|
||||
shoulda-matchers (5.3.0)
|
||||
activesupport (>= 5.2.0)
|
||||
sidekiq (7.3.0)
|
||||
sidekiq (7.3.1)
|
||||
concurrent-ruby (< 2)
|
||||
connection_pool (>= 2.3.0)
|
||||
logger
|
||||
@@ -796,7 +796,7 @@ GEM
|
||||
uniform_notifier (1.16.0)
|
||||
uri (0.13.0)
|
||||
uri_template (0.7.0)
|
||||
valid_email2 (4.0.6)
|
||||
valid_email2 (5.2.6)
|
||||
activemodel (>= 3.2)
|
||||
mail (~> 2.5)
|
||||
version_gem (1.1.4)
|
||||
@@ -930,7 +930,7 @@ DEPENDENCIES
|
||||
responders (>= 3.1.1)
|
||||
rest-client
|
||||
reverse_markdown
|
||||
rspec-rails (>= 6.1.3)
|
||||
rspec-rails (>= 6.1.4)
|
||||
rspec_junit_formatter
|
||||
rubocop
|
||||
rubocop-performance
|
||||
@@ -939,11 +939,11 @@ DEPENDENCIES
|
||||
scout_apm
|
||||
scss_lint
|
||||
seed_dump
|
||||
sentry-rails (>= 5.18.2)
|
||||
sentry-rails (>= 5.19.0)
|
||||
sentry-ruby
|
||||
sentry-sidekiq (>= 5.18.2)
|
||||
sentry-sidekiq (>= 5.19.0)
|
||||
shoulda-matchers
|
||||
sidekiq (>= 7.3.0)
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
simplecov (= 0.17.1)
|
||||
slack-ruby-client (~> 2.2.0)
|
||||
|
||||
@@ -32,11 +32,13 @@ 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?)
|
||||
raise InvalidEmail.new({ valid: address.valid?, disposable: address.disposable? })
|
||||
end
|
||||
end
|
||||
|
||||
@@ -79,4 +81,21 @@ 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', '')
|
||||
domains.split("\n").map(&:strip) if domains.present?
|
||||
|
||||
[]
|
||||
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(:role, :availability, :auto_offline).compact)
|
||||
@agent.current_account_user.update!(agent_params.slice(*account_user_attributes).compact)
|
||||
end
|
||||
|
||||
def destroy
|
||||
@@ -67,8 +67,16 @@ 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(:name, :email, :name, :role, :availability, :auto_offline)
|
||||
params.require(:agent).permit(allowed_agent_params)
|
||||
end
|
||||
|
||||
def new_agent_params
|
||||
@@ -101,3 +109,5 @@ 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')
|
||||
|
||||
@@ -11,7 +11,17 @@ class Api::V1::Accounts::Integrations::HooksController < Api::V1::Accounts::Base
|
||||
end
|
||||
|
||||
def process_event
|
||||
render json: { message: @hook.process_event(params[: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
|
||||
end
|
||||
|
||||
def destroy
|
||||
|
||||
@@ -10,7 +10,6 @@ import PaymentPendingBanner from './components/app/PaymentPendingBanner.vue';
|
||||
import PendingEmailVerificationBanner from './components/app/PendingEmailVerificationBanner.vue';
|
||||
import vueActionCable from './helper/actionCable';
|
||||
import WootSnackbarBox from './components/SnackbarContainer.vue';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import { setColorTheme } from './helper/themeHelper';
|
||||
import { isOnOnboardingView } from 'v3/helpers/RouteHelper';
|
||||
import {
|
||||
@@ -32,9 +31,6 @@ export default {
|
||||
UpgradeBanner,
|
||||
PendingEmailVerificationBanner,
|
||||
},
|
||||
|
||||
mixins: [rtlMixin],
|
||||
|
||||
data() {
|
||||
return {
|
||||
showAddAccountModal: false,
|
||||
@@ -46,6 +42,7 @@ export default {
|
||||
computed: {
|
||||
...mapGetters({
|
||||
getAccount: 'accounts/getAccount',
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentUser: 'getCurrentUser',
|
||||
authUIFlags: 'getAuthUIFlags',
|
||||
accountUIFlags: 'accounts/getUIFlags',
|
||||
@@ -102,7 +99,6 @@ export default {
|
||||
this.getAccount(this.currentAccountId);
|
||||
const { pubsub_token: pubsubToken } = this.currentUser || {};
|
||||
this.setLocale(locale);
|
||||
this.updateRTLDirectionView(locale);
|
||||
this.latestChatwootVersion = latestChatwootVersion;
|
||||
vueActionCable.init(pubsubToken);
|
||||
this.reconnectService = new ReconnectService(this.$store, router);
|
||||
@@ -124,8 +120,8 @@ export default {
|
||||
v-if="!authUIFlags.isFetching && !accountUIFlags.isFetchingItem"
|
||||
id="app"
|
||||
class="flex-grow-0 w-full h-full min-h-0 app-wrapper"
|
||||
:class="{ 'app-rtl--wrapper': isRTLView }"
|
||||
:dir="isRTLView ? 'rtl' : 'ltr'"
|
||||
:class="{ 'app-rtl--wrapper': isRTL }"
|
||||
:dir="isRTL ? 'rtl' : 'ltr'"
|
||||
>
|
||||
<UpdateBanner :latest-chatwoot-version="latestChatwootVersion" />
|
||||
<template v-if="currentAccountId">
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import VirtualList from 'vue-virtual-scroll-list';
|
||||
|
||||
import ChatListHeader from './ChatListHeader.vue';
|
||||
import ConversationAdvancedFilter from './widgets/conversation/ConversationAdvancedFilter.vue';
|
||||
import ChatTypeTabs from './widgets/ChatTypeTabs.vue';
|
||||
import ConversationItem from './ConversationItem.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import conversationMixin from '../mixins/conversations';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import advancedFilterTypes from './widgets/conversation/advancedFilterItems';
|
||||
import filterQueryGenerator from '../helper/filterQueryGenerator.js';
|
||||
@@ -41,7 +41,7 @@ export default {
|
||||
IntersectionObserver,
|
||||
VirtualList,
|
||||
},
|
||||
mixins: [conversationMixin, keyboardEventListenerMixins, filterMixin],
|
||||
mixins: [filterMixin],
|
||||
provide() {
|
||||
return {
|
||||
// Actions to be performed on virtual list item and context menu.
|
||||
@@ -89,8 +89,63 @@ export default {
|
||||
setup() {
|
||||
const { uiSettings } = useUISettings();
|
||||
|
||||
const conversationListRef = ref(null);
|
||||
|
||||
const getKeyboardListenerParams = () => {
|
||||
const allConversations = conversationListRef.value.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
);
|
||||
const activeConversation = conversationListRef.value.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
return {
|
||||
allConversations,
|
||||
activeConversation,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
};
|
||||
};
|
||||
const handlePreviousConversation = () => {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
};
|
||||
const handleNextConversation = () => {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
}
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyJ': {
|
||||
action: () => handlePreviousConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => handleNextConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
conversationListRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -113,7 +168,7 @@ export default {
|
||||
isContextMenuOpen: false,
|
||||
appliedFilter: [],
|
||||
infiniteLoaderOptions: {
|
||||
root: this.$refs.conversationList,
|
||||
root: this.$refs.conversationListRef,
|
||||
rootMargin: '100px 0px 100px 0px',
|
||||
},
|
||||
|
||||
@@ -490,58 +545,6 @@ export default {
|
||||
}))
|
||||
);
|
||||
},
|
||||
getKeyboardListenerParams() {
|
||||
const allConversations = this.$refs.conversationList.querySelectorAll(
|
||||
'div.conversations-list div.conversation'
|
||||
);
|
||||
const activeConversation = this.$refs.conversationList.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
return {
|
||||
allConversations,
|
||||
activeConversation,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
};
|
||||
},
|
||||
handlePreviousConversation() {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
},
|
||||
handleNextConversation() {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = this.getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
}
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyJ': {
|
||||
action: () => this.handlePreviousConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => this.handleNextConversation(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
resetAndFetchData() {
|
||||
this.appliedFilter = [];
|
||||
this.resetBulkActions();
|
||||
@@ -902,7 +905,6 @@ export default {
|
||||
v-if="!hasAppliedFiltersOrActiveFolders"
|
||||
:items="assigneeTabItems"
|
||||
:active-tab="activeAssigneeTab"
|
||||
class="tab--chat-type"
|
||||
@chatTabChange="updateAssigneeTab"
|
||||
/>
|
||||
|
||||
@@ -927,7 +929,7 @@ export default {
|
||||
@assignTeam="onAssignTeamsForBulk"
|
||||
/>
|
||||
<div
|
||||
ref="conversationList"
|
||||
ref="conversationListRef"
|
||||
class="flex-1 conversations-list"
|
||||
:class="{ 'overflow-hidden': isContextMenuOpen }"
|
||||
>
|
||||
@@ -990,14 +992,4 @@ export default {
|
||||
.conversations-list {
|
||||
@apply overflow-hidden hover:overflow-y-auto;
|
||||
}
|
||||
|
||||
.tab--chat-type {
|
||||
@apply py-0 px-4;
|
||||
|
||||
::v-deep {
|
||||
.tabs {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,7 +5,7 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import MultiselectDropdown from 'shared/components/ui/MultiselectDropdown.vue';
|
||||
import HelperTextPopup from 'dashboard/components/ui/HelperTextPopup.vue';
|
||||
import { isValidURL } from '../helper/URLHelper';
|
||||
import customAttributeMixin from '../mixins/customAttributeMixin';
|
||||
import { getRegexp } from 'shared/helpers/Validators';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
|
||||
const DATE_FORMAT = 'yyyy-MM-dd';
|
||||
@@ -15,7 +15,6 @@ export default {
|
||||
MultiselectDropdown,
|
||||
HelperTextPopup,
|
||||
},
|
||||
mixins: [customAttributeMixin],
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
description: { type: String, default: '' },
|
||||
@@ -128,8 +127,7 @@ export default {
|
||||
required,
|
||||
regexValidation: value => {
|
||||
return !(
|
||||
this.attributeRegex &&
|
||||
!this.getRegexp(this.attributeRegex).test(value)
|
||||
this.attributeRegex && !getRegexp(this.attributeRegex).test(value)
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
|
||||
const EMPTY_SUBSCRIPTION_INFO = {
|
||||
status: null,
|
||||
@@ -11,10 +11,13 @@ const EMPTY_SUBSCRIPTION_INFO = {
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
mixins: [accountMixin],
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const { accountId } = useAccount();
|
||||
|
||||
return {
|
||||
accountId,
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
|
||||
@@ -1,12 +1,10 @@
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
mixins: [accountMixin],
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
<script>
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import { differenceInDays } from 'date-fns';
|
||||
|
||||
export default {
|
||||
components: { Banner },
|
||||
mixins: [accountMixin],
|
||||
setup() {
|
||||
const { accountId } = useAccount();
|
||||
return {
|
||||
accountId,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return { conversationMeta: {} };
|
||||
},
|
||||
|
||||
@@ -1,145 +1,139 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { useToggle } from '@vueuse/core';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useEmitter } from 'dashboard/composables/emitter';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
import WootDropdownItem from 'shared/components/ui/dropdown/DropdownItem.vue';
|
||||
import WootDropdownMenu from 'shared/components/ui/dropdown/DropdownMenu.vue';
|
||||
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import {
|
||||
CMD_REOPEN_CONVERSATION,
|
||||
CMD_RESOLVE_CONVERSATION,
|
||||
} from '../../routes/dashboard/commands/commandBarBusEvents';
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
WootDropdownItem,
|
||||
WootDropdownMenu,
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
|
||||
const arrowDownButtonRef = ref(null);
|
||||
const isLoading = ref(false);
|
||||
|
||||
const [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
const closeDropdown = () => toggleDropdown(false);
|
||||
const openDropdown = () => toggleDropdown(true);
|
||||
|
||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
||||
|
||||
const isOpen = computed(
|
||||
() => currentChat.value.status === wootConstants.STATUS_TYPE.OPEN
|
||||
);
|
||||
const isPending = computed(
|
||||
() => currentChat.value.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
const isResolved = computed(
|
||||
() => currentChat.value.status === wootConstants.STATUS_TYPE.RESOLVED
|
||||
);
|
||||
const isSnoozed = computed(
|
||||
() => currentChat.value.status === wootConstants.STATUS_TYPE.SNOOZED
|
||||
);
|
||||
|
||||
const buttonClass = computed(() => {
|
||||
if (isPending.value) return 'primary';
|
||||
if (isOpen.value) return 'success';
|
||||
if (isResolved.value) return 'warning';
|
||||
return '';
|
||||
});
|
||||
|
||||
const showAdditionalActions = computed(
|
||||
() => !isPending.value && !isSnoozed.value
|
||||
);
|
||||
|
||||
const showOpenButton = computed(() => {
|
||||
return isPending.value || isSnoozed.value;
|
||||
});
|
||||
|
||||
const getConversationParams = () => {
|
||||
const allConversations = document.querySelectorAll(
|
||||
'.conversations-list .conversation'
|
||||
);
|
||||
|
||||
const activeConversation = document.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
|
||||
return {
|
||||
all: allConversations,
|
||||
activeIndex: activeConversationIndex,
|
||||
lastIndex: lastConversationIndex,
|
||||
};
|
||||
};
|
||||
|
||||
const openSnoozeModal = () => {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
};
|
||||
|
||||
const toggleStatus = (status, snoozedUntil) => {
|
||||
closeDropdown();
|
||||
isLoading.value = true;
|
||||
store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value.id,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(t('CONVERSATION.CHANGE_STATUS'));
|
||||
isLoading.value = false;
|
||||
});
|
||||
};
|
||||
|
||||
const onCmdOpenConversation = () => {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.OPEN);
|
||||
};
|
||||
|
||||
const onCmdResolveConversation = () => {
|
||||
toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyM': {
|
||||
action: () => arrowDownButtonRef.value?.$el.click(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
data() {
|
||||
return {
|
||||
isLoading: false,
|
||||
showActionsDropdown: false,
|
||||
STATUS_TYPE: wootConstants.STATUS_TYPE,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ currentChat: 'getSelectedChat' }),
|
||||
isOpen() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
isPending() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.PENDING;
|
||||
},
|
||||
isResolved() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.RESOLVED;
|
||||
},
|
||||
isSnoozed() {
|
||||
return this.currentChat.status === wootConstants.STATUS_TYPE.SNOOZED;
|
||||
},
|
||||
buttonClass() {
|
||||
if (this.isPending) return 'primary';
|
||||
if (this.isOpen) return 'success';
|
||||
if (this.isResolved) return 'warning';
|
||||
return '';
|
||||
},
|
||||
showAdditionalActions() {
|
||||
return !this.isPending && !this.isSnoozed;
|
||||
'Alt+KeyE': {
|
||||
action: async () => {
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$emitter.on(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
this.$emitter.on(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
destroyed() {
|
||||
this.$emitter.off(CMD_REOPEN_CONVERSATION, this.onCmdOpenConversation);
|
||||
this.$emitter.off(CMD_RESOLVE_CONVERSATION, this.onCmdResolveConversation);
|
||||
},
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyM': {
|
||||
action: () => this.$refs.arrowDownButton?.$el.click(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyE': this.resolveOrToast,
|
||||
'$mod+Alt+KeyE': async event => {
|
||||
const { all, activeIndex, lastIndex } = this.getConversationParams();
|
||||
await this.resolveOrToast();
|
||||
'$mod+Alt+KeyE': {
|
||||
action: async event => {
|
||||
const { all, activeIndex, lastIndex } = getConversationParams();
|
||||
await toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
|
||||
if (activeIndex < lastIndex) {
|
||||
all[activeIndex + 1].click();
|
||||
} else if (all.length > 1) {
|
||||
all[0].click();
|
||||
document.querySelector('.conversations-list').scrollTop = 0;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
},
|
||||
};
|
||||
},
|
||||
getConversationParams() {
|
||||
const allConversations = document.querySelectorAll(
|
||||
'.conversations-list .conversation'
|
||||
);
|
||||
|
||||
const activeConversation = document.querySelector(
|
||||
'div.conversations-list div.conversation.active'
|
||||
);
|
||||
const activeConversationIndex = [...allConversations].indexOf(
|
||||
activeConversation
|
||||
);
|
||||
const lastConversationIndex = allConversations.length - 1;
|
||||
|
||||
return {
|
||||
all: allConversations,
|
||||
activeIndex: activeConversationIndex,
|
||||
lastIndex: lastConversationIndex,
|
||||
};
|
||||
},
|
||||
async resolveOrToast() {
|
||||
try {
|
||||
await this.toggleStatus(wootConstants.STATUS_TYPE.RESOLVED);
|
||||
} catch (error) {
|
||||
// error
|
||||
if (activeIndex < lastIndex) {
|
||||
all[activeIndex + 1].click();
|
||||
} else if (all.length > 1) {
|
||||
all[0].click();
|
||||
document.querySelector('.conversations-list').scrollTop = 0;
|
||||
}
|
||||
},
|
||||
onCmdOpenConversation() {
|
||||
this.toggleStatus(this.STATUS_TYPE.OPEN);
|
||||
},
|
||||
onCmdResolveConversation() {
|
||||
this.toggleStatus(this.STATUS_TYPE.RESOLVED);
|
||||
},
|
||||
showOpenButton() {
|
||||
return this.isResolved || this.isSnoozed;
|
||||
},
|
||||
closeDropdown() {
|
||||
this.showActionsDropdown = false;
|
||||
},
|
||||
openDropdown() {
|
||||
this.showActionsDropdown = true;
|
||||
},
|
||||
toggleStatus(status, snoozedUntil) {
|
||||
this.closeDropdown();
|
||||
this.isLoading = true;
|
||||
this.$store
|
||||
.dispatch('toggleStatus', {
|
||||
conversationId: this.currentChat.id,
|
||||
status,
|
||||
snoozedUntil,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_STATUS'));
|
||||
this.isLoading = false;
|
||||
});
|
||||
},
|
||||
openSnoozeModal() {
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'snooze_conversation' });
|
||||
event.preventDefault();
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
useEmitter(CMD_REOPEN_CONVERSATION, onCmdOpenConversation);
|
||||
useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -165,7 +159,7 @@ export default {
|
||||
:is-loading="isLoading"
|
||||
@click="onCmdOpenConversation"
|
||||
>
|
||||
{{ $t('CONVERSATION.HEADER.REOPEN_ACTION') }}
|
||||
{{ t('CONVERSATION.HEADER.REOPEN_ACTION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-else-if="showOpenButton"
|
||||
@@ -175,11 +169,11 @@ export default {
|
||||
:is-loading="isLoading"
|
||||
@click="onCmdOpenConversation"
|
||||
>
|
||||
{{ $t('CONVERSATION.HEADER.OPEN_ACTION') }}
|
||||
{{ t('CONVERSATION.HEADER.OPEN_ACTION') }}
|
||||
</woot-button>
|
||||
<woot-button
|
||||
v-if="showAdditionalActions"
|
||||
ref="arrowDownButton"
|
||||
ref="arrowDownButtonRef"
|
||||
:color-scheme="buttonClass"
|
||||
:disabled="isLoading"
|
||||
icon="chevron-down"
|
||||
@@ -190,7 +184,7 @@ export default {
|
||||
<div
|
||||
v-if="showActionsDropdown"
|
||||
v-on-clickaway="closeDropdown"
|
||||
class="dropdown-pane dropdown-pane--open"
|
||||
class="dropdown-pane dropdown-pane--open left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem]"
|
||||
>
|
||||
<WootDropdownMenu class="mb-0">
|
||||
<WootDropdownItem v-if="!isPending">
|
||||
@@ -201,7 +195,7 @@ export default {
|
||||
icon="snooze"
|
||||
@click="() => openSnoozeModal()"
|
||||
>
|
||||
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
|
||||
{{ t('CONVERSATION.RESOLVE_DROPDOWN.SNOOZE_UNTIL') }}
|
||||
</woot-button>
|
||||
</WootDropdownItem>
|
||||
<WootDropdownItem v-if="!isPending">
|
||||
@@ -210,9 +204,9 @@ export default {
|
||||
color-scheme="secondary"
|
||||
size="small"
|
||||
icon="book-clock"
|
||||
@click="() => toggleStatus(STATUS_TYPE.PENDING)"
|
||||
@click="() => toggleStatus(wootConstants.STATUS_TYPE.PENDING)"
|
||||
>
|
||||
{{ $t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
|
||||
{{ t('CONVERSATION.RESOLVE_DROPDOWN.MARK_PENDING') }}
|
||||
</woot-button>
|
||||
</WootDropdownItem>
|
||||
</WootDropdownMenu>
|
||||
@@ -222,8 +216,6 @@ export default {
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.dropdown-pane {
|
||||
@apply left-auto top-[2.625rem] mt-0.5 right-0 max-w-[12.5rem] min-w-[9.75rem];
|
||||
|
||||
.dropdown-menu__item {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
@@ -1,19 +1,22 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getSidebarItems } from './config/default-sidebar';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useRoute, useRouter } from 'dashboard/composables/route';
|
||||
|
||||
import PrimarySidebar from './sidebarComponents/Primary.vue';
|
||||
import SecondarySidebar from './sidebarComponents/Secondary.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import router, { routesWithPermissions } from '../../routes';
|
||||
import { hasPermissions } from '../../helper/permissionsHelper';
|
||||
import { routesWithPermissions } from '../../routes';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../../helper/permissionsHelper';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
PrimarySidebar,
|
||||
SecondarySidebar,
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
showSecondarySidebar: {
|
||||
type: Boolean,
|
||||
@@ -24,6 +27,50 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const toggleKeyShortcutModal = () => {
|
||||
emit('openKeyShortcutModal');
|
||||
};
|
||||
const closeKeyShortcutModal = () => {
|
||||
emit('closeKeyShortcutModal');
|
||||
};
|
||||
const isCurrentRouteSameAsNavigation = routeName => {
|
||||
return route.name === routeName;
|
||||
};
|
||||
const navigateToRoute = routeName => {
|
||||
if (!isCurrentRouteSameAsNavigation(routeName)) {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'$mod+Slash': {
|
||||
action: toggleKeyShortcutModal,
|
||||
},
|
||||
'$mod+Escape': {
|
||||
action: closeKeyShortcutModal,
|
||||
},
|
||||
'Alt+KeyC': {
|
||||
action: () => navigateToRoute('home'),
|
||||
},
|
||||
'Alt+KeyV': {
|
||||
action: () => navigateToRoute('contacts_dashboard'),
|
||||
},
|
||||
'Alt+KeyR': {
|
||||
action: () => navigateToRoute('account_overview_reports'),
|
||||
},
|
||||
'Alt+KeyS': {
|
||||
action: () => navigateToRoute('agent_list'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
toggleKeyShortcutModal,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showOptionsMenu: false,
|
||||
@@ -66,7 +113,10 @@ export default {
|
||||
return getSidebarItems(this.accountId);
|
||||
},
|
||||
primaryMenuItems() {
|
||||
const userPermissions = this.currentUser.permissions;
|
||||
const userPermissions = getUserPermissions(
|
||||
this.currentUser,
|
||||
this.accountId
|
||||
);
|
||||
const menuItems = this.sideMenuConfig.primaryMenu;
|
||||
return menuItems.filter(menuItem => {
|
||||
const isAvailableForTheUser = hasPermissions(
|
||||
@@ -131,38 +181,6 @@ export default {
|
||||
this.$store.dispatch('customViews/get', this.activeCustomView);
|
||||
}
|
||||
},
|
||||
toggleKeyShortcutModal() {
|
||||
this.$emit('openKeyShortcutModal');
|
||||
},
|
||||
closeKeyShortcutModal() {
|
||||
this.$emit('closeKeyShortcutModal');
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+Slash': this.toggleKeyShortcutModal,
|
||||
'$mod+Escape': this.closeKeyShortcutModal,
|
||||
'Alt+KeyC': {
|
||||
action: () => this.navigateToRoute('home'),
|
||||
},
|
||||
'Alt+KeyV': {
|
||||
action: () => this.navigateToRoute('contacts_dashboard'),
|
||||
},
|
||||
'Alt+KeyR': {
|
||||
action: () => this.navigateToRoute('account_overview_reports'),
|
||||
},
|
||||
'Alt+KeyS': {
|
||||
action: () => this.navigateToRoute('agent_list'),
|
||||
},
|
||||
};
|
||||
},
|
||||
navigateToRoute(routeName) {
|
||||
if (!this.isCurrentRouteSameAsNavigation(routeName)) {
|
||||
router.push({ name: routeName });
|
||||
}
|
||||
},
|
||||
isCurrentRouteSameAsNavigation(routeName) {
|
||||
return this.$route.name === routeName;
|
||||
},
|
||||
toggleSupportChatWindow() {
|
||||
window.$chatwoot.toggle();
|
||||
},
|
||||
|
||||
@@ -173,11 +173,10 @@ const settings = accountId => ({
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
toState: frontendURL(`accounts/${accountId}/settings/audit-log/list`),
|
||||
toState: frontendURL(`accounts/${accountId}/settings/audit-logs/list`),
|
||||
toStateName: 'auditlogs_list',
|
||||
isEnterpriseOnly: true,
|
||||
featureFlag: FEATURE_FLAGS.AUDIT_LOGS,
|
||||
beta: true,
|
||||
},
|
||||
{
|
||||
icon: 'document-list-clock',
|
||||
|
||||
@@ -4,7 +4,10 @@ import SecondaryNavItem from './SecondaryNavItem.vue';
|
||||
import AccountContext from './AccountContext.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
import { hasPermissions } from '../../../helper/permissionsHelper';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../../../helper/permissionsHelper';
|
||||
import { routesWithPermissions } from '../../../routes';
|
||||
|
||||
export default {
|
||||
@@ -59,7 +62,10 @@ export default {
|
||||
accessibleMenuItems() {
|
||||
const menuItemsFilteredByPermissions = this.menuConfig.menuItems.filter(
|
||||
menuItem => {
|
||||
const { permissions: userPermissions = [] } = this.currentUser;
|
||||
const userPermissions = getUserPermissions(
|
||||
this.currentUser,
|
||||
this.accountId
|
||||
);
|
||||
return hasPermissions(
|
||||
routesWithPermissions[menuItem.toStateName],
|
||||
userPermissions
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import {
|
||||
getInboxClassByType,
|
||||
getInboxWarningIconClass,
|
||||
@@ -16,7 +16,6 @@ import Policy from '../../policy.vue';
|
||||
|
||||
export default {
|
||||
components: { SecondaryChildNavItem, Policy },
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
menuItem: {
|
||||
type: Object,
|
||||
@@ -25,8 +24,10 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
const { isEnterprise } = useConfig();
|
||||
return {
|
||||
isAdmin,
|
||||
isEnterprise,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { computed } from 'vue';
|
||||
import { hasPermissions } from '../helper/permissionsHelper';
|
||||
import {
|
||||
getUserPermissions,
|
||||
hasPermissions,
|
||||
} from '../helper/permissionsHelper';
|
||||
|
||||
const props = defineProps({
|
||||
permissions: {
|
||||
type: Array,
|
||||
@@ -10,12 +14,17 @@ const props = defineProps({
|
||||
});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const user = getters.getCurrentUser.value;
|
||||
const hasPermission = computed(() =>
|
||||
hasPermissions(props.permissions, user.permissions)
|
||||
);
|
||||
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);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-root-v-if -->
|
||||
<template>
|
||||
<div v-if="hasPermission">
|
||||
<slot />
|
||||
|
||||
@@ -101,6 +101,8 @@ const shouldShowEmptyState = computed(() => {
|
||||
:key="item.id"
|
||||
:is-active="isFilterActive(item.id)"
|
||||
:button-text="item.name"
|
||||
:icon="item.icon"
|
||||
:icon-color="item.iconColor"
|
||||
@click="$emit('click', item)"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
@@ -8,6 +8,14 @@ defineProps({
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
iconColor: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -20,6 +28,12 @@ defineProps({
|
||||
@focus="$emit('focus')"
|
||||
>
|
||||
<div class="inline-flex items-center gap-3 overflow-hidden">
|
||||
<fluent-icon
|
||||
v-if="icon"
|
||||
:icon="icon"
|
||||
size="18"
|
||||
:style="{ color: iconColor }"
|
||||
/>
|
||||
<span
|
||||
class="text-sm font-medium truncate text-slate-900 dark:text-slate-50"
|
||||
>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
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 aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/routes/dashboard/commands/commandBarBusEvents';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { CMD_AI_ASSIST } from 'dashboard/helper/commandbar/events';
|
||||
import AIAssistanceCTAButton from './AIAssistanceCTAButton.vue';
|
||||
|
||||
export default {
|
||||
@@ -15,22 +16,46 @@ export default {
|
||||
AICTAModal,
|
||||
AIAssistanceCTAButton,
|
||||
},
|
||||
mixins: [aiMixin, keyboardEventListenerMixins],
|
||||
setup() {
|
||||
setup(props, { emit }) {
|
||||
const { uiSettings, updateUISettings } = useUISettings();
|
||||
|
||||
const { isAIIntegrationEnabled, draftMessage, recordAnalytics } = useAI();
|
||||
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const initialMessage = ref('');
|
||||
|
||||
const initializeMessage = draftMsg => {
|
||||
initialMessage.value = draftMsg;
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'$mod+KeyZ': {
|
||||
action: () => {
|
||||
if (initialMessage.value) {
|
||||
emit('replaceText', initialMessage.value);
|
||||
initialMessage.value = '';
|
||||
}
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
isAdmin,
|
||||
initialMessage,
|
||||
initializeMessage,
|
||||
recordAnalytics,
|
||||
isAIIntegrationEnabled,
|
||||
draftMessage,
|
||||
};
|
||||
},
|
||||
data: () => ({
|
||||
showAIAssistanceModal: false,
|
||||
showAICtaModal: false,
|
||||
aiOption: '',
|
||||
initialMessage: '',
|
||||
}),
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -56,22 +81,10 @@ export default {
|
||||
|
||||
mounted() {
|
||||
this.$emitter.on(CMD_AI_ASSIST, this.onAIAssist);
|
||||
this.initialMessage = this.draftMessage;
|
||||
this.initializeMessage(this.draftMessage);
|
||||
},
|
||||
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'$mod+KeyZ': {
|
||||
action: () => {
|
||||
if (this.initialMessage) {
|
||||
this.$emit('replaceText', this.initialMessage);
|
||||
this.initialMessage = '';
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
hideAIAssistanceModal() {
|
||||
this.recordAnalytics('DISMISS_AI_SUGGESTION', {
|
||||
aiOption: this.aiOption,
|
||||
@@ -85,7 +98,7 @@ export default {
|
||||
is_open_ai_cta_modal_dismissed: true,
|
||||
});
|
||||
}
|
||||
this.initialMessage = this.draftMessage;
|
||||
this.initializeMessage(this.draftMessage);
|
||||
const ninja = document.querySelector('ninja-keys');
|
||||
ninja.open({ parent: 'ai_assist' });
|
||||
},
|
||||
@@ -107,7 +120,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="!isFetchingAppIntegrations">
|
||||
<div>
|
||||
<div v-if="isAIIntegrationEnabled" class="relative">
|
||||
<AIAssistanceCTAButton
|
||||
v-if="shouldShowAIAssistCTAButton"
|
||||
|
||||
@@ -1,20 +1,23 @@
|
||||
<script>
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
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 aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
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$ };
|
||||
return { updateUISettings, v$, recordAnalytics };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -1,49 +1,52 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: wootConstants.ASSIGNEE_TYPE.ME,
|
||||
},
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
computed: {
|
||||
activeTabIndex() {
|
||||
return this.items.findIndex(item => item.key === this.activeTab);
|
||||
},
|
||||
activeTab: {
|
||||
type: String,
|
||||
default: wootConstants.ASSIGNEE_TYPE.ME,
|
||||
},
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyN': {
|
||||
action: () => {
|
||||
if (this.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||
this.onTabChange(0);
|
||||
} else {
|
||||
this.onTabChange(this.activeTabIndex + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
onTabChange(selectedTabIndex) {
|
||||
if (this.items[selectedTabIndex].key !== this.activeTab) {
|
||||
this.$emit('chatTabChange', this.items[selectedTabIndex].key);
|
||||
});
|
||||
|
||||
const emit = defineEmits(['chatTabChange']);
|
||||
|
||||
const activeTabIndex = computed(() => {
|
||||
return props.items.findIndex(item => item.key === props.activeTab);
|
||||
});
|
||||
|
||||
const onTabChange = selectedTabIndex => {
|
||||
if (props.items[selectedTabIndex].key !== props.activeTab) {
|
||||
emit('chatTabChange', props.items[selectedTabIndex].key);
|
||||
}
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyN': {
|
||||
action: () => {
|
||||
if (props.activeTab === wootConstants.ASSIGNEE_TYPE.ALL) {
|
||||
onTabChange(0);
|
||||
} else {
|
||||
onTabChange(activeTabIndex.value + 1);
|
||||
}
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-tabs :index="activeTabIndex" @change="onTabChange">
|
||||
<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"
|
||||
@@ -52,3 +55,13 @@ export default {
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.tab--chat-type {
|
||||
::v-deep {
|
||||
.tabs {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,83 +1,65 @@
|
||||
<script>
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import AddLabel from 'shared/components/ui/dropdown/AddLabel.vue';
|
||||
import LabelDropdown from 'shared/components/ui/label/LabelDropdown.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
AddLabel,
|
||||
LabelDropdown,
|
||||
const props = defineProps({
|
||||
allLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
savedLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
const emit = defineEmits(['add', 'remove']);
|
||||
|
||||
props: {
|
||||
allLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
savedLabels: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const showSearchDropdownLabel = ref(false);
|
||||
|
||||
const selectedLabels = computed(() => {
|
||||
return props.savedLabels.map(label => label.title);
|
||||
});
|
||||
|
||||
const addItem = label => {
|
||||
emit('add', label);
|
||||
};
|
||||
|
||||
const removeItem = label => {
|
||||
emit('remove', label);
|
||||
};
|
||||
|
||||
const toggleLabels = () => {
|
||||
showSearchDropdownLabel.value = !showSearchDropdownLabel.value;
|
||||
};
|
||||
|
||||
const closeDropdownLabel = () => {
|
||||
showSearchDropdownLabel.value = false;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
toggleLabels();
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
return {
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
|
||||
data() {
|
||||
return {
|
||||
showSearchDropdownLabel: false,
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
selectedLabels() {
|
||||
return this.savedLabels.map(label => label.title);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
addItem(label) {
|
||||
this.$emit('add', label);
|
||||
},
|
||||
|
||||
removeItem(label) {
|
||||
this.$emit('remove', label);
|
||||
},
|
||||
|
||||
toggleLabels() {
|
||||
this.showSearchDropdownLabel = !this.showSearchDropdownLabel;
|
||||
},
|
||||
|
||||
closeDropdownLabel() {
|
||||
this.showSearchDropdownLabel = false;
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
KeyL: {
|
||||
action: e => {
|
||||
this.toggleLabels();
|
||||
e.preventDefault();
|
||||
},
|
||||
},
|
||||
Escape: {
|
||||
action: () => this.closeDropdownLabel(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
Escape: {
|
||||
action: () => closeDropdownLabel(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-on-clickaway="closeDropdownLabel" class="label-wrap">
|
||||
<div v-on-clickaway="closeDropdownLabel" class="relative leading-6">
|
||||
<AddLabel @add="toggleLabels" />
|
||||
<woot-label
|
||||
v-for="label in savedLabels"
|
||||
@@ -89,10 +71,10 @@ export default {
|
||||
variant="smooth"
|
||||
@click="removeItem"
|
||||
/>
|
||||
<div class="dropdown-wrap">
|
||||
<div class="absolute w-full top-7">
|
||||
<div
|
||||
:class="{ 'dropdown-pane--open': showSearchDropdownLabel }"
|
||||
class="dropdown-pane"
|
||||
class="!box-border !w-full dropdown-pane"
|
||||
>
|
||||
<LabelDropdown
|
||||
v-if="showSearchDropdownLabel"
|
||||
@@ -106,28 +88,3 @@ export default {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.title-icon {
|
||||
margin-right: var(--space-smaller);
|
||||
}
|
||||
|
||||
.label-wrap {
|
||||
position: relative;
|
||||
line-height: var(--space-medium);
|
||||
|
||||
.dropdown-wrap {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
margin-right: var(--space-medium);
|
||||
top: var(--space-medium);
|
||||
width: 100%;
|
||||
left: -1px;
|
||||
|
||||
.dropdown-pane {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
@@ -18,7 +18,7 @@ import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [keyboardEventListenerMixins, inboxMixin],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -116,6 +116,24 @@ export default {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
useUISettings();
|
||||
|
||||
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'
|
||||
);
|
||||
uploadTriggerButton.click();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
setSignatureFlagForInbox,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
@@ -125,6 +143,7 @@ export default {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
uiFlags: 'integrations/getUIFlags',
|
||||
}),
|
||||
isNote() {
|
||||
return this.mode === REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -207,21 +226,14 @@ export default {
|
||||
);
|
||||
return isFeatEnabled && this.portalSlug;
|
||||
},
|
||||
isFetchingAppIntegrations() {
|
||||
return this.uiFlags.isFetching;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
ActiveStorage.start();
|
||||
},
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyA': {
|
||||
action: () => {
|
||||
this.$refs.upload.$children[1].$el.click();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
toggleMessageSignature() {
|
||||
this.setSignatureFlagForInbox(this.channelType, !this.sendWithSignature);
|
||||
},
|
||||
@@ -249,7 +261,6 @@ export default {
|
||||
@click="toggleEmojiPicker"
|
||||
/>
|
||||
<FileUpload
|
||||
ref="upload"
|
||||
v-tooltip.top-end="$t('CONVERSATION.REPLYBOX.TIP_ATTACH_ICON')"
|
||||
input-id="conversationAttachment"
|
||||
:size="4096 * 4096"
|
||||
@@ -330,6 +341,7 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
/>
|
||||
<AIAssistanceButton
|
||||
v-if="!isFetchingAppIntegrations"
|
||||
:conversation-id="conversationId"
|
||||
:is-private-note="isOnPrivateNote"
|
||||
:message="message"
|
||||
@@ -337,7 +349,7 @@ export default {
|
||||
/>
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="$refs.upload && $refs.upload.dropActive"
|
||||
v-show="$refs.uploadRef && $refs.uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-slate-900 dark:text-slate-50 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
|
||||
@@ -1,18 +1,13 @@
|
||||
<script>
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { REPLY_EDITOR_MODES, CHAR_LENGTH_WARNING } from './constants';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
export default {
|
||||
name: 'ReplyTopPanel',
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
default: REPLY_EDITOR_MODES.REPLY,
|
||||
},
|
||||
setReplyMode: {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
isMessageLengthReachingThreshold: {
|
||||
type: Boolean,
|
||||
default: () => false,
|
||||
@@ -26,6 +21,33 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const setReplyMode = mode => {
|
||||
emit('setReplyMode', mode);
|
||||
};
|
||||
const handleReplyClick = () => {
|
||||
setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||
};
|
||||
const handleNoteClick = () => {
|
||||
setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyP': {
|
||||
action: () => handleNoteClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => handleReplyClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
handleReplyClick,
|
||||
handleNoteClick,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
replyButtonClass() {
|
||||
return {
|
||||
@@ -46,26 +68,6 @@ export default {
|
||||
: `${this.charactersRemaining} ${CHAR_LENGTH_WARNING.UNDER_50}`;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyP': {
|
||||
action: () => this.handleNoteClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyL': {
|
||||
action: () => this.handleReplyClick(),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
handleReplyClick() {
|
||||
this.setReplyMode(REPLY_EDITOR_MODES.REPLY);
|
||||
},
|
||||
handleNoteClick() {
|
||||
this.setReplyMode(REPLY_EDITOR_MODES.NOTE);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
import conversationMixin from '../../../mixins/conversations';
|
||||
import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName.vue';
|
||||
@@ -24,7 +24,7 @@ export default {
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
},
|
||||
mixins: [inboxMixin, conversationMixin],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
activeLabel: {
|
||||
type: String,
|
||||
@@ -118,7 +118,7 @@ export default {
|
||||
},
|
||||
|
||||
lastMessageInChat() {
|
||||
return this.lastMessage(this.chat);
|
||||
return getLastMessage(this.chat);
|
||||
},
|
||||
|
||||
inbox() {
|
||||
@@ -316,7 +316,11 @@ export default {
|
||||
{{ unreadCount > 9 ? '9+' : unreadCount }}
|
||||
</span>
|
||||
</div>
|
||||
<CardLabels :conversation-id="chat.id" class="mt-0.5 mx-2 mb-0">
|
||||
<CardLabels
|
||||
:conversation-id="chat.id"
|
||||
:conversation-labels="chat.labels"
|
||||
class="mt-0.5 mx-2 mb-0"
|
||||
>
|
||||
<template v-if="hasSlaPolicyId" #before>
|
||||
<SLACardLabel :chat="chat" class="ltr:mr-1 rtl:ml-1" />
|
||||
</template>
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
@@ -23,7 +22,7 @@ export default {
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [inboxMixin, agentMixin, keyboardEventListenerMixins],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
@@ -42,6 +41,14 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: () => emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
@@ -117,16 +124,6 @@ export default {
|
||||
);
|
||||
},
|
||||
},
|
||||
|
||||
methods: {
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
'Alt+KeyO': {
|
||||
action: () => this.$emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import accountMixin from 'dashboard/mixins/account';
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import OnboardingView from '../OnboardingView.vue';
|
||||
import EmptyStateMessage from './EmptyStateMessage.vue';
|
||||
|
||||
@@ -10,7 +10,6 @@ export default {
|
||||
OnboardingView,
|
||||
EmptyStateMessage,
|
||||
},
|
||||
mixins: [accountMixin],
|
||||
props: {
|
||||
isOnExpandedLayout: {
|
||||
type: Boolean,
|
||||
@@ -19,8 +18,12 @@ export default {
|
||||
},
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
const { accountScopedUrl } = useAccount();
|
||||
|
||||
return {
|
||||
isAdmin,
|
||||
accountScopedUrl,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
@@ -44,7 +47,7 @@ export default {
|
||||
return this.$t('CONVERSATION.404');
|
||||
},
|
||||
newInboxURL() {
|
||||
return this.addAccountScoping('settings/inboxes/new');
|
||||
return this.accountScopedUrl('settings/inboxes/new');
|
||||
},
|
||||
emptyClassName() {
|
||||
if (
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script>
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import BubbleActions from './bubble/Actions.vue';
|
||||
import BubbleContact from './bubble/Contact.vue';
|
||||
import BubbleFile from './bubble/File.vue';
|
||||
@@ -39,7 +39,6 @@ export default {
|
||||
InstagramStoryReply,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
data: {
|
||||
type: Object,
|
||||
@@ -74,6 +73,12 @@ export default {
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { formatMessage } = useMessageFormatter();
|
||||
return {
|
||||
formatMessage,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showContextMenu: false,
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script>
|
||||
import { MESSAGE_TYPE } from 'widget/helpers/constants';
|
||||
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
|
||||
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
|
||||
import { ATTACHMENT_ICONS } from 'shared/constants/messages';
|
||||
|
||||
export default {
|
||||
name: 'MessagePreview',
|
||||
mixins: [messageFormatterMixin],
|
||||
props: {
|
||||
message: {
|
||||
type: Object,
|
||||
@@ -20,6 +19,12 @@ export default {
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { getPlainText } = useMessageFormatter();
|
||||
return {
|
||||
getPlainText,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
messageByAgent() {
|
||||
const { message_type: messageType } = this.message;
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
<script>
|
||||
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';
|
||||
import Message from './Message.vue';
|
||||
@@ -9,18 +15,17 @@ import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// mixins
|
||||
import conversationMixin, {
|
||||
filterDuplicateSourceMessages,
|
||||
} from '../../../mixins/conversations';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import configMixin from 'shared/mixins/configMixin';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
// utils
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
filterDuplicateSourceMessages,
|
||||
getReadMessages,
|
||||
getUnreadMessages,
|
||||
} from 'dashboard/helper/conversationHelper';
|
||||
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
@@ -35,13 +40,7 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
},
|
||||
mixins: [
|
||||
conversationMixin,
|
||||
inboxMixin,
|
||||
keyboardEventListenerMixins,
|
||||
configMixin,
|
||||
aiMixin,
|
||||
],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
@@ -52,7 +51,44 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const closePopOutReplyBox = () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
};
|
||||
|
||||
const showPopOutReplyBox = () => {
|
||||
isPopOutReplyBox.value = !isPopOutReplyBox.value;
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: closePopOutReplyBox,
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
closePopOutReplyBox,
|
||||
showPopOutReplyBox,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoadingPrevious: true,
|
||||
@@ -60,7 +96,6 @@ export default {
|
||||
conversationPanel: null,
|
||||
hasUserScrolled: false,
|
||||
isProgrammaticScroll: false,
|
||||
isPopoutReplyBox: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
};
|
||||
@@ -116,14 +151,14 @@ export default {
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
getReadMessages() {
|
||||
return this.readMessages(
|
||||
readMessages() {
|
||||
return getReadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
},
|
||||
getUnReadMessages() {
|
||||
return this.unReadMessages(
|
||||
unReadMessages() {
|
||||
return getUnreadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
@@ -303,19 +338,6 @@ export default {
|
||||
});
|
||||
this.makeMessagesRead();
|
||||
},
|
||||
showPopoutReplyBox() {
|
||||
this.isPopoutReplyBox = !this.isPopoutReplyBox;
|
||||
},
|
||||
closePopoutReplyBox() {
|
||||
this.isPopoutReplyBox = false;
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => this.closePopoutReplyBox(),
|
||||
},
|
||||
};
|
||||
},
|
||||
addScrollListener() {
|
||||
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
||||
this.setScrollParams();
|
||||
@@ -459,7 +481,7 @@ export default {
|
||||
</li>
|
||||
</transition>
|
||||
<Message
|
||||
v-for="message in getReadMessages"
|
||||
v-for="message in readMessages"
|
||||
:key="message.id"
|
||||
class="message--read ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
@@ -484,7 +506,7 @@ export default {
|
||||
</span>
|
||||
</li>
|
||||
<Message
|
||||
v-for="message in getUnReadMessages"
|
||||
v-for="message in unReadMessages"
|
||||
:key="message.id"
|
||||
class="message--unread ph-no-capture"
|
||||
data-clarity-mask="True"
|
||||
@@ -506,7 +528,7 @@ export default {
|
||||
</ul>
|
||||
<div
|
||||
class="conversation-footer"
|
||||
:class="{ 'modal-mask': isPopoutReplyBox }"
|
||||
:class="{ 'modal-mask': isPopOutReplyBox }"
|
||||
>
|
||||
<div
|
||||
v-if="isAnyoneTyping"
|
||||
@@ -525,8 +547,8 @@ export default {
|
||||
</div>
|
||||
<ReplyBox
|
||||
:conversation-id="currentChat.id"
|
||||
:popout-reply-box.sync="isPopoutReplyBox"
|
||||
@click="showPopoutReplyBox"
|
||||
:popout-reply-box.sync="isPopOutReplyBox"
|
||||
@click="showPopOutReplyBox"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
CMD_MUTE_CONVERSATION,
|
||||
CMD_SEND_TRANSCRIPT,
|
||||
CMD_UNMUTE_CONVERSATION,
|
||||
} from '../../../routes/dashboard/commands/commandBarBusEvents';
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
<script setup>
|
||||
defineProps({
|
||||
imageSrc: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
imageAlt: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
title: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
to: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
linkText: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="h-full w-full bg-white dark:bg-slate-900 border border-slate-100 dark:border-white/10 rounded-lg p-4 flex flex-col"
|
||||
>
|
||||
<img :src="imageSrc" :alt="imageAlt" class="h-36 w-auto mx-auto" />
|
||||
<div class="mt-auto">
|
||||
<p
|
||||
class="text-base text-slate-800 dark:text-slate-100 font-interDisplay font-semibold tracking-[0.3px]"
|
||||
>
|
||||
{{ title }}
|
||||
</p>
|
||||
<p class="text-slate-600 dark:text-slate-400 text-sm">
|
||||
{{ description }}
|
||||
</p>
|
||||
<router-link
|
||||
v-if="to"
|
||||
:to="{ name: to }"
|
||||
class="no-underline text-woot-500 text-sm font-medium"
|
||||
>
|
||||
<span>{{ linkText }}</span>
|
||||
<span>{{ `→` }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,159 +1,80 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import accountMixin from '../../../mixins/account';
|
||||
<script setup>
|
||||
import OnboardingFeatureCard from './OnboardingFeatureCard.vue';
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
export default {
|
||||
mixins: [accountMixin],
|
||||
computed: {
|
||||
...mapGetters({ globalConfig: 'globalConfig/get' }),
|
||||
newInboxURL() {
|
||||
return this.addAccountScoping('settings/inboxes/new');
|
||||
},
|
||||
newAgentURL() {
|
||||
return this.addAccountScoping('settings/agents/list');
|
||||
},
|
||||
newLabelsURL() {
|
||||
return this.addAccountScoping('settings/labels/list');
|
||||
},
|
||||
},
|
||||
};
|
||||
const getters = useStoreGetters();
|
||||
const { t } = useI18n();
|
||||
const globalConfig = computed(() => getters['globalConfig/get'].value);
|
||||
const currentUser = computed(() => getters.getCurrentUser.value);
|
||||
|
||||
const greetingMessage = computed(() => {
|
||||
const hours = new Date().getHours();
|
||||
let translationKey;
|
||||
if (hours < 12) {
|
||||
translationKey = 'ONBOARDING.GREETING_MORNING';
|
||||
} else if (hours < 18) {
|
||||
translationKey = 'ONBOARDING.GREETING_AFTERNOON';
|
||||
} else {
|
||||
translationKey = 'ONBOARDING.GREETING_EVENING';
|
||||
}
|
||||
return t(translationKey, {
|
||||
name: currentUser.value.name,
|
||||
installationName: globalConfig.value.installationName,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding-wrap">
|
||||
<div class="onboarding">
|
||||
<div class="scroll-wrap">
|
||||
<div class="features-item">
|
||||
<h1 class="text-2xl text-slate-900 dark:text-slate-50">
|
||||
<span>{{
|
||||
$t('ONBOARDING.TITLE', {
|
||||
installationName: globalConfig.installationName,
|
||||
})
|
||||
}}</span>
|
||||
</h1>
|
||||
<p class="intro-body">
|
||||
{{
|
||||
$t('ONBOARDING.DESCRIPTION', {
|
||||
installationName: globalConfig.installationName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
<p v-if="globalConfig.installationName === 'Chatwoot'">
|
||||
<a
|
||||
href="https://www.chatwoot.com/changelog"
|
||||
target="_blank"
|
||||
rel="noopener nofollow noreferrer"
|
||||
class="onboarding--link"
|
||||
>
|
||||
{{ $t('ONBOARDING.READ_LATEST_UPDATES') }}
|
||||
</a>
|
||||
<span>🎉</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="features-item">
|
||||
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||
<span class="emoji">💬</span>
|
||||
<span class="conversation--title">{{
|
||||
$t('ONBOARDING.ALL_CONVERSATION.TITLE')
|
||||
}}</span>
|
||||
</h2>
|
||||
<p class="intro-body">
|
||||
{{ $t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="features-item">
|
||||
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||
<span class="emoji">👥</span>
|
||||
{{ $t('ONBOARDING.TEAM_MEMBERS.TITLE') }}
|
||||
</h2>
|
||||
<p class="intro-body">
|
||||
{{ $t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION') }}
|
||||
</p>
|
||||
<router-link :to="newAgentURL" class="onboarding--link">
|
||||
{{ $t('ONBOARDING.TEAM_MEMBERS.NEW_LINK') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="features-item">
|
||||
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||
<span class="emoji">📥</span>{{ $t('ONBOARDING.INBOXES.TITLE') }}
|
||||
</h2>
|
||||
<p class="intro-body">
|
||||
{{ $t('ONBOARDING.INBOXES.DESCRIPTION') }}
|
||||
</p>
|
||||
<router-link :to="newInboxURL" class="onboarding--link">
|
||||
{{ $t('ONBOARDING.INBOXES.NEW_LINK') }}
|
||||
</router-link>
|
||||
</div>
|
||||
<div class="features-item">
|
||||
<h2 class="text-lg text-black-900 dark:text-slate-200">
|
||||
<span class="emoji">🔖</span>{{ $t('ONBOARDING.LABELS.TITLE') }}
|
||||
</h2>
|
||||
<p class="intro-body">
|
||||
{{ $t('ONBOARDING.LABELS.DESCRIPTION') }}
|
||||
</p>
|
||||
<router-link :to="newLabelsURL" class="onboarding--link">
|
||||
{{ $t('ONBOARDING.LABELS.NEW_LINK') }}
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="min-h-screen max-w-4xl mx-auto grid grid-cols-1 xl:grid-cols-2 grid-rows-[auto_1fr_1fr] gap-4 p-8 w-full font-inter overflow-auto"
|
||||
>
|
||||
<div class="col-span-full self-start">
|
||||
<p
|
||||
class="text-xl font-semibold text-slate-900 dark:text-white font-interDisplay tracking-[0.3px]"
|
||||
>
|
||||
{{ greetingMessage }}
|
||||
</p>
|
||||
<p class="text-slate-600 dark:text-slate-400 max-w-2xl text-base">
|
||||
{{
|
||||
$t('ONBOARDING.DESCRIPTION', {
|
||||
installationName: globalConfig.installationName,
|
||||
})
|
||||
}}
|
||||
</p>
|
||||
</div>
|
||||
<OnboardingFeatureCard
|
||||
image-src="/dashboard/images/onboarding/omnichannel-inbox.png"
|
||||
image-alt="Omnichannel"
|
||||
to="settings_inbox_new"
|
||||
:title="$t('ONBOARDING.ALL_CONVERSATION.TITLE')"
|
||||
:description="$t('ONBOARDING.ALL_CONVERSATION.DESCRIPTION')"
|
||||
:link-text="$t('ONBOARDING.ALL_CONVERSATION.NEW_LINK')"
|
||||
/>
|
||||
<OnboardingFeatureCard
|
||||
image-src="/dashboard/images/onboarding/teams.png"
|
||||
image-alt="Teams"
|
||||
to="settings_teams_new"
|
||||
:title="$t('ONBOARDING.TEAM_MEMBERS.TITLE')"
|
||||
:description="$t('ONBOARDING.TEAM_MEMBERS.DESCRIPTION')"
|
||||
:link-text="$t('ONBOARDING.TEAM_MEMBERS.NEW_LINK')"
|
||||
/>
|
||||
<OnboardingFeatureCard
|
||||
image-src="/dashboard/images/onboarding/canned-responses.png"
|
||||
image-alt="Canned responses"
|
||||
to="canned_list"
|
||||
:title="$t('ONBOARDING.CANNED_RESPONSES.TITLE')"
|
||||
:description="$t('ONBOARDING.CANNED_RESPONSES.DESCRIPTION')"
|
||||
:link-text="$t('ONBOARDING.CANNED_RESPONSES.NEW_LINK')"
|
||||
/>
|
||||
<OnboardingFeatureCard
|
||||
image-src="/dashboard/images/onboarding/labels.png"
|
||||
image-alt="Labels"
|
||||
to="labels_list"
|
||||
:title="$t('ONBOARDING.LABELS.TITLE')"
|
||||
:description="$t('ONBOARDING.LABELS.DESCRIPTION')"
|
||||
:link-text="$t('ONBOARDING.LABELS.NEW_LINK')"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.onboarding-wrap {
|
||||
display: flex;
|
||||
font-size: var(--font-size-small);
|
||||
justify-content: center;
|
||||
overflow: auto;
|
||||
text-align: left;
|
||||
}
|
||||
.onboarding {
|
||||
height: 100vh;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.scroll-wrap {
|
||||
padding: var(--space-larger) 8.5rem;
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.features-item {
|
||||
margin-bottom: var(--space-large);
|
||||
}
|
||||
|
||||
.conversation--title {
|
||||
margin-left: var(--space-minus-smaller);
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-size: var(--font-size-big);
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-one);
|
||||
}
|
||||
|
||||
.block-title {
|
||||
font-weight: var(--font-weight-bold);
|
||||
margin-bottom: var(--space-smaller);
|
||||
margin-left: var(--space-minus-large);
|
||||
}
|
||||
|
||||
.intro-body {
|
||||
margin-bottom: var(--space-small);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.onboarding--link {
|
||||
color: var(--w-500);
|
||||
font-weight: var(--font-weight-medium);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.emoji {
|
||||
width: var(--space-large);
|
||||
display: inline-block;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,7 +17,6 @@ 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 {
|
||||
@@ -31,7 +30,6 @@ import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce } from '@chatwoot/utils';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import rtlMixin from 'shared/mixins/rtlMixin';
|
||||
import fileUploadMixin from 'dashboard/mixins/fileUploadMixin';
|
||||
import {
|
||||
appendSignature,
|
||||
@@ -62,13 +60,7 @@ export default {
|
||||
MessageSignatureMissingAlert,
|
||||
ArticleSearchPopover,
|
||||
},
|
||||
mixins: [
|
||||
inboxMixin,
|
||||
messageFormatterMixin,
|
||||
rtlMixin,
|
||||
fileUploadMixin,
|
||||
keyboardEventListenerMixins,
|
||||
],
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
popoutReplyBox: {
|
||||
type: Boolean,
|
||||
@@ -121,6 +113,7 @@ export default {
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
isRTL: 'accounts/isRTL',
|
||||
currentChat: 'getSelectedChat',
|
||||
messageSignature: 'getMessageSignature',
|
||||
currentUser: 'getCurrentUser',
|
||||
@@ -307,7 +300,7 @@ export default {
|
||||
if (this.isOnExpandedLayout || this.popoutReplyBox) {
|
||||
return 'emoji-dialog--expanded';
|
||||
}
|
||||
if (this.isRTLView) {
|
||||
if (this.isRTL) {
|
||||
return 'emoji-dialog--rtl';
|
||||
}
|
||||
return '';
|
||||
@@ -1091,10 +1084,10 @@ export default {
|
||||
/>
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
:set-reply-mode="setReplyMode"
|
||||
:is-message-length-reaching-threshold="isMessageLengthReachingThreshold"
|
||||
:characters-remaining="charactersRemaining"
|
||||
:popout-reply-box="popoutReplyBox"
|
||||
@setReplyMode="setReplyMode"
|
||||
@click="$emit('click')"
|
||||
/>
|
||||
<ArticleSearchPopover
|
||||
|
||||
@@ -1,124 +1,118 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import mentionSelectionKeyboardMixin from '../mentions/mentionSelectionKeyboardMixin';
|
||||
export default {
|
||||
mixins: [mentionSelectionKeyboardMixin],
|
||||
props: {
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return { selectedIndex: 0 };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({ agents: 'agents/getVerifiedAgents' }),
|
||||
items() {
|
||||
if (!this.searchKey) {
|
||||
return this.agents;
|
||||
}
|
||||
<script setup>
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
|
||||
return this.agents.filter(agent =>
|
||||
agent.name
|
||||
.toLocaleLowerCase()
|
||||
.includes(this.searchKey.toLocaleLowerCase())
|
||||
);
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
items(newListOfAgents) {
|
||||
if (newListOfAgents.length < this.selectedIndex + 1) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
methods: {
|
||||
adjustScroll() {
|
||||
this.$nextTick(() => {
|
||||
this.$el.scrollTop = 50 * this.selectedIndex;
|
||||
});
|
||||
},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
onAgentSelect(index) {
|
||||
this.selectedIndex = index;
|
||||
this.onSelect();
|
||||
},
|
||||
onSelect() {
|
||||
this.$emit('click', this.items[this.selectedIndex]);
|
||||
},
|
||||
},
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const agents = computed(() => getters['agents/getVerifiedAgents'].value);
|
||||
|
||||
const tagAgentsRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) {
|
||||
return agents.value;
|
||||
}
|
||||
return agents.value.filter(agent =>
|
||||
agent.name.toLowerCase().includes(props.searchKey.toLowerCase())
|
||||
);
|
||||
});
|
||||
|
||||
const adjustScroll = () => {
|
||||
nextTick(() => {
|
||||
if (tagAgentsRef.value) {
|
||||
tagAgentsRef.value.scrollTop = 50 * selectedIndex.value;
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('click', items.value[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(items, newListOfAgents => {
|
||||
if (newListOfAgents.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
});
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onAgentSelect = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
class="vertical dropdown menu mention--box"
|
||||
:class="{ 'with-bottom-border': items.length <= 4 }"
|
||||
>
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="agent.id"
|
||||
:class="{ active: index === selectedIndex }"
|
||||
class="last:mb-2 items-center rounded-md flex p-2"
|
||||
@click="onAgentSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
<div>
|
||||
<ul
|
||||
v-if="items.length"
|
||||
ref="tagAgentsRef"
|
||||
class="vertical dropdown menu mention--box bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800"
|
||||
:class="{
|
||||
'border-b-[0.5rem] border-solid border-white dark:!border-slate-700':
|
||||
items.length <= 4,
|
||||
}"
|
||||
>
|
||||
<div class="mr-2">
|
||||
<woot-thumbnail
|
||||
:src="agent.thumbnail"
|
||||
:username="agent.name"
|
||||
size="32px"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
<li
|
||||
v-for="(agent, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
:key="agent.id"
|
||||
:class="{
|
||||
'bg-slate-50 dark:bg-slate-800': index === selectedIndex,
|
||||
'last:mb-0': items.length <= 4,
|
||||
}"
|
||||
class="flex items-center p-2 rounded-md last:mb-2"
|
||||
@click="onAgentSelect(index)"
|
||||
@mouseover="onHover(index)"
|
||||
>
|
||||
<h5
|
||||
class="mention--user-name mb-0 text-sm text-slate-900 dark:text-slate-100 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ agent.name }}
|
||||
</h5>
|
||||
<div
|
||||
class="mention--email overflow-hidden whitespace-nowrap text-ellipsis text-slate-700 dark:text-slate-300 text-xs"
|
||||
>
|
||||
{{ agent.email }}
|
||||
<div class="mr-2">
|
||||
<woot-thumbnail
|
||||
:src="agent.thumbnail"
|
||||
:username="agent.name"
|
||||
size="32px"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div
|
||||
class="flex-1 max-w-full overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
<h5
|
||||
class="mb-0 overflow-hidden text-sm text-slate-800 dark:text-slate-100 whitespace-nowrap text-ellipsis"
|
||||
:class="{
|
||||
'text-slate-900 dark:text-slate-100': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ agent.name }}
|
||||
</h5>
|
||||
<div
|
||||
class="overflow-hidden text-xs whitespace-nowrap text-ellipsis text-slate-700 dark:text-slate-300"
|
||||
:class="{
|
||||
'text-slate-800 dark:text-slate-200': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ agent.email }}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.mention--box {
|
||||
@apply bg-white text-sm dark:bg-slate-700 rounded-md overflow-auto absolute w-full z-20 pt-2 px-2 pb-0 shadow-md left-0 leading-[1.2] bottom-full max-h-[12.5rem] border-t border-solid border-slate-75 dark:border-slate-800;
|
||||
|
||||
&.with-bottom-border {
|
||||
@apply border-b-[0.5rem] border-solid border-white dark:border-slate-600;
|
||||
|
||||
li {
|
||||
&:last-child {
|
||||
@apply mb-0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
li {
|
||||
&.active {
|
||||
@apply bg-slate-50 dark:bg-slate-800;
|
||||
|
||||
.mention--user-name {
|
||||
@apply text-slate-900 dark:text-slate-100;
|
||||
}
|
||||
.mention--email {
|
||||
@apply text-slate-800 dark:text-slate-200;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { mapGetters } from 'vuex';
|
||||
import { hasPressedCommand } from 'shared/helpers/KeyboardHelpers';
|
||||
import GalleryView from '../components/GalleryView.vue';
|
||||
import { timeStampAppendedURL } from 'dashboard/helper/URLHelper';
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
@@ -42,6 +43,9 @@ export default {
|
||||
isAudio() {
|
||||
return this.attachment.file_type === ALLOWED_FILE_TYPES.AUDIO;
|
||||
},
|
||||
timeStampURL() {
|
||||
return timeStampAppendedURL(this.dataUrl);
|
||||
},
|
||||
attachmentTypeClasses() {
|
||||
return {
|
||||
image: this.isImage,
|
||||
@@ -108,7 +112,7 @@ export default {
|
||||
@click="onClick"
|
||||
/>
|
||||
<audio v-else-if="isAudio" controls class="skip-context-menu mb-0.5">
|
||||
<source :src="`${dataUrl}?t=${Date.now()}`" />
|
||||
<source :src="timeStampURL" />
|
||||
</audio>
|
||||
<GalleryView
|
||||
v-if="show"
|
||||
|
||||
+186
-183
@@ -1,10 +1,30 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
allAttachments: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const ALLOWED_FILE_TYPES = {
|
||||
IMAGE: 'image',
|
||||
VIDEO: 'video',
|
||||
@@ -15,194 +35,177 @@ const ALLOWED_FILE_TYPES = {
|
||||
const MAX_ZOOM_LEVEL = 2;
|
||||
const MIN_ZOOM_LEVEL = 1;
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Thumbnail,
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
required: true,
|
||||
},
|
||||
attachment: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
allAttachments: {
|
||||
type: Array,
|
||||
required: true,
|
||||
const zoomScale = ref(1);
|
||||
const activeAttachment = ref({});
|
||||
const activeFileType = ref('');
|
||||
const activeImageIndex = ref(
|
||||
props.allAttachments.findIndex(
|
||||
attachment => attachment.message_id === props.attachment.message_id
|
||||
) || 0
|
||||
);
|
||||
const activeImageRotation = ref(0);
|
||||
|
||||
const currentUser = computed(() => getters.getCurrentUser.value);
|
||||
|
||||
const hasMoreThanOneAttachment = computed(
|
||||
() => props.allAttachments.length > 1
|
||||
);
|
||||
|
||||
const readableTime = computed(() => {
|
||||
const { created_at: createdAt } = activeAttachment.value;
|
||||
if (!createdAt) return '';
|
||||
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
|
||||
});
|
||||
|
||||
const isImage = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.IMAGE
|
||||
);
|
||||
const isVideo = computed(
|
||||
() =>
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
activeFileType.value === ALLOWED_FILE_TYPES.IG_REEL
|
||||
);
|
||||
const isAudio = computed(
|
||||
() => activeFileType.value === ALLOWED_FILE_TYPES.AUDIO
|
||||
);
|
||||
|
||||
const senderDetails = computed(() => {
|
||||
const {
|
||||
name,
|
||||
available_name: availableName,
|
||||
avatar_url,
|
||||
thumbnail,
|
||||
id,
|
||||
} = activeAttachment.value?.sender || props.attachment?.sender || {};
|
||||
const currentUserID = currentUser.value?.id;
|
||||
return {
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
});
|
||||
|
||||
const fileNameFromDataUrl = computed(() => {
|
||||
const { data_url: dataUrl } = activeAttachment.value;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return decodeURIComponent(fileName || '');
|
||||
});
|
||||
|
||||
const imageRotationStyle = computed(() => ({
|
||||
transform: `rotate(${activeImageRotation.value}deg) scale(${zoomScale.value})`,
|
||||
cursor: zoomScale.value < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
}));
|
||||
|
||||
const onClose = () => {
|
||||
emit('close');
|
||||
};
|
||||
|
||||
const setImageAndVideoSrc = attachment => {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
activeAttachment.value = attachment;
|
||||
activeFileType.value = type;
|
||||
};
|
||||
|
||||
const onClickChangeAttachment = (attachment, index) => {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
activeImageIndex.value = index;
|
||||
setImageAndVideoSrc(attachment);
|
||||
activeImageRotation.value = 0;
|
||||
zoomScale.value = 1;
|
||||
};
|
||||
|
||||
const onClickDownload = () => {
|
||||
const { file_type: type, data_url: url } = activeAttachment.value;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `attachment.${type}`;
|
||||
link.click();
|
||||
};
|
||||
|
||||
const onRotate = type => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotation = type === 'clockwise' ? 90 : -90;
|
||||
|
||||
// Reset rotation if it is 360
|
||||
if (Math.abs(activeImageRotation.value) === 360) {
|
||||
activeImageRotation.value = rotation;
|
||||
} else {
|
||||
activeImageRotation.value += rotation;
|
||||
}
|
||||
};
|
||||
|
||||
const onZoom = scale => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newZoomScale = zoomScale.value + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
zoomScale.value = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
zoomScale.value = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// If within bounds, update the zoom scale
|
||||
zoomScale.value = newZoomScale;
|
||||
};
|
||||
|
||||
const onClickZoomImage = () => {
|
||||
onZoom(0.1);
|
||||
};
|
||||
|
||||
const onWheelImageZoom = e => {
|
||||
if (!isImage.value) {
|
||||
return;
|
||||
}
|
||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
onZoom(scale);
|
||||
};
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
onClose();
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
zoomScale: 1,
|
||||
activeAttachment: {},
|
||||
activeFileType: '',
|
||||
activeImageIndex:
|
||||
this.allAttachments.findIndex(
|
||||
attachment => attachment.message_id === this.attachment.message_id
|
||||
) || 0,
|
||||
activeImageRotation: 0,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentUser: 'getCurrentUser',
|
||||
}),
|
||||
hasMoreThanOneAttachment() {
|
||||
return this.allAttachments.length > 1;
|
||||
},
|
||||
readableTime() {
|
||||
const { created_at: createdAt } = this.activeAttachment;
|
||||
if (!createdAt) return '';
|
||||
return messageTimestamp(createdAt, 'LLL d yyyy, h:mm a') || '';
|
||||
},
|
||||
isImage() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.IMAGE;
|
||||
},
|
||||
isVideo() {
|
||||
return (
|
||||
this.activeFileType === ALLOWED_FILE_TYPES.VIDEO ||
|
||||
this.activeFileType === ALLOWED_FILE_TYPES.IG_REEL
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
onClickChangeAttachment(
|
||||
props.allAttachments[activeImageIndex.value - 1],
|
||||
activeImageIndex.value - 1
|
||||
);
|
||||
},
|
||||
isAudio() {
|
||||
return this.activeFileType === ALLOWED_FILE_TYPES.AUDIO;
|
||||
},
|
||||
senderDetails() {
|
||||
const {
|
||||
name,
|
||||
available_name: availableName,
|
||||
avatar_url,
|
||||
thumbnail,
|
||||
id,
|
||||
} = this.activeAttachment?.sender || this.attachment?.sender || {};
|
||||
const currentUserID = this.currentUser?.id;
|
||||
return {
|
||||
name: currentUserID === id ? 'You' : name || availableName || '',
|
||||
avatar: thumbnail || avatar_url || '',
|
||||
};
|
||||
},
|
||||
fileNameFromDataUrl() {
|
||||
const { data_url: dataUrl } = this.activeAttachment;
|
||||
if (!dataUrl) return '';
|
||||
const fileName = dataUrl?.split('/').pop();
|
||||
return decodeURIComponent(fileName || '');
|
||||
},
|
||||
imageRotationStyle() {
|
||||
return {
|
||||
transform: `rotate(${this.activeImageRotation}deg) scale(${this.zoomScale})`,
|
||||
cursor: this.zoomScale < MAX_ZOOM_LEVEL ? 'zoom-in' : 'zoom-out',
|
||||
};
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setImageAndVideoSrc(this.attachment);
|
||||
},
|
||||
methods: {
|
||||
onClose() {
|
||||
this.$emit('close');
|
||||
},
|
||||
onClickChangeAttachment(attachment, index) {
|
||||
if (!attachment) {
|
||||
return;
|
||||
}
|
||||
this.activeImageIndex = index;
|
||||
this.setImageAndVideoSrc(attachment);
|
||||
this.activeImageRotation = 0;
|
||||
this.zoomScale = 1;
|
||||
},
|
||||
setImageAndVideoSrc(attachment) {
|
||||
const { file_type: type } = attachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
this.activeAttachment = attachment;
|
||||
this.activeFileType = type;
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
Escape: {
|
||||
action: () => {
|
||||
this.onClose();
|
||||
},
|
||||
},
|
||||
ArrowLeft: {
|
||||
action: () => {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex - 1],
|
||||
this.activeImageIndex - 1
|
||||
);
|
||||
},
|
||||
},
|
||||
ArrowRight: {
|
||||
action: () => {
|
||||
this.onClickChangeAttachment(
|
||||
this.allAttachments[this.activeImageIndex + 1],
|
||||
this.activeImageIndex + 1
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
onClickDownload() {
|
||||
const { file_type: type, data_url: url } = this.activeAttachment;
|
||||
if (!Object.values(ALLOWED_FILE_TYPES).includes(type)) {
|
||||
return;
|
||||
}
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = `attachment.${type}`;
|
||||
link.click();
|
||||
},
|
||||
onRotate(type) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rotation = type === 'clockwise' ? 90 : -90;
|
||||
|
||||
// Reset rotation if it is 360
|
||||
if (Math.abs(this.activeImageRotation) === 360) {
|
||||
this.activeImageRotation = rotation;
|
||||
} else {
|
||||
this.activeImageRotation += rotation;
|
||||
}
|
||||
},
|
||||
onClickZoomImage() {
|
||||
this.onZoom(0.1);
|
||||
},
|
||||
onZoom(scale) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newZoomScale = this.zoomScale + scale;
|
||||
// Check if the new zoom scale is within the allowed range
|
||||
if (newZoomScale > MAX_ZOOM_LEVEL) {
|
||||
// Set zoom to max but do not reset to default
|
||||
this.zoomScale = MAX_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
if (newZoomScale < MIN_ZOOM_LEVEL) {
|
||||
// Set zoom to min but do not reset to default
|
||||
this.zoomScale = MIN_ZOOM_LEVEL;
|
||||
return;
|
||||
}
|
||||
// If within bounds, update the zoom scale
|
||||
this.zoomScale = newZoomScale;
|
||||
},
|
||||
|
||||
onWheelImageZoom(e) {
|
||||
if (!this.isImage) {
|
||||
return;
|
||||
}
|
||||
const scale = e.deltaY > 0 ? -0.1 : 0.1;
|
||||
this.onZoom(scale);
|
||||
ArrowRight: {
|
||||
action: () => {
|
||||
onClickChangeAttachment(
|
||||
props.allAttachments[activeImageIndex.value + 1],
|
||||
activeImageIndex.value + 1
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
onMounted(() => {
|
||||
setImageAndVideoSrc(props.attachment);
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable vue/no-mutating-props -->
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<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: {
|
||||
@@ -11,7 +14,6 @@ export default {
|
||||
MenuItemWithSubmenu,
|
||||
AgentLoadingPlaceholder,
|
||||
},
|
||||
mixins: [agentMixin],
|
||||
props: {
|
||||
chatId: {
|
||||
type: Number,
|
||||
@@ -112,13 +114,19 @@ 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 = this.getAgentsByUpdatedPresence(agents);
|
||||
const filteredAgents = this.sortedAgentsByAvailability(
|
||||
const agentsByUpdatedPresence = getAgentsByUpdatedPresence(
|
||||
agents,
|
||||
this.currentUser,
|
||||
this.currentAccountId
|
||||
);
|
||||
const filteredAgents = getSortedAgentsByAvailability(
|
||||
agentsByUpdatedPresence
|
||||
);
|
||||
return filteredAgents;
|
||||
|
||||
+13
-3
@@ -2,7 +2,9 @@
|
||||
// components
|
||||
import WootButton from '../../../ui/WootButton.vue';
|
||||
import Avatar from '../../Avatar.vue';
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
|
||||
// composables
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
|
||||
// store & api
|
||||
import { mapGetters } from 'vuex';
|
||||
@@ -18,7 +20,6 @@ export default {
|
||||
Avatar,
|
||||
WootButton,
|
||||
},
|
||||
mixins: [aiMixin],
|
||||
props: {
|
||||
suggestedLabels: {
|
||||
type: Array,
|
||||
@@ -30,6 +31,11 @@ export default {
|
||||
default: () => [],
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { isAIIntegrationEnabled } = useAI();
|
||||
|
||||
return { isAIIntegrationEnabled };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isDismissed: false,
|
||||
@@ -41,7 +47,11 @@ 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');
|
||||
@@ -162,7 +172,7 @@ export default {
|
||||
delay: { show: 600, hide: 0 },
|
||||
hideOnClick: true,
|
||||
}"
|
||||
class="label-suggestion--option"
|
||||
class="label-suggestion--option !px-0"
|
||||
@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/routes/dashboard/commands/commandBarBusEvents';
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import AgentSelector from './AgentSelector.vue';
|
||||
import UpdateActions from './UpdateActions.vue';
|
||||
|
||||
+19
-16
@@ -1,23 +1,26 @@
|
||||
<script>
|
||||
import conversationLabelMixin from 'dashboard/mixins/conversation/labelMixin';
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
export default {
|
||||
mixins: [conversationLabelMixin],
|
||||
props: {
|
||||
// conversationId prop is used in /conversation/labelMixin,
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
conversationId: {
|
||||
type: Number,
|
||||
conversationLabels: {
|
||||
type: Array,
|
||||
required: true,
|
||||
},
|
||||
// conversationLabels prop is used in /conversation/labelMixin,
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
conversationLabels: {
|
||||
type: String,
|
||||
required: false,
|
||||
default: '',
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const accountLabels = useMapGetter('labels/getLabels');
|
||||
|
||||
const activeLabels = computed(() => {
|
||||
return accountLabels.value.filter(({ title }) =>
|
||||
props.conversationLabels.includes(title)
|
||||
);
|
||||
});
|
||||
|
||||
return {
|
||||
activeLabels,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -80,7 +83,7 @@ export default {
|
||||
<slot name="before" />
|
||||
<woot-label
|
||||
v-for="(label, index) in activeLabels"
|
||||
:key="label.id"
|
||||
:key="label ? label.id : index"
|
||||
:title="label.title"
|
||||
:description="label.description"
|
||||
:color="label.color"
|
||||
|
||||
@@ -62,6 +62,8 @@ const onSearch = async value => {
|
||||
issues.value = response.data.map(issue => ({
|
||||
id: issue.id,
|
||||
name: `${issue.identifier} ${issue.title}`,
|
||||
icon: 'status',
|
||||
iconColor: issue.state.color,
|
||||
}));
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
|
||||
@@ -48,11 +48,7 @@ const loadLinkedIssue = async () => {
|
||||
const issues = response.data;
|
||||
linkedIssue.value = issues && issues.length ? issues[0] : null;
|
||||
} catch (error) {
|
||||
const errorMessage = parseLinearAPIErrorResponse(
|
||||
error,
|
||||
t('INTEGRATION_SETTINGS.LINEAR.LOADING_ERROR')
|
||||
);
|
||||
useAlert(errorMessage);
|
||||
// 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.
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,71 +1,81 @@
|
||||
<script>
|
||||
import mentionSelectionKeyboardMixin from './mentionSelectionKeyboardMixin';
|
||||
export default {
|
||||
mixins: [mentionSelectionKeyboardMixin],
|
||||
props: {
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'canned',
|
||||
},
|
||||
<script setup>
|
||||
import { ref, watch, computed } from 'vue';
|
||||
import { useKeyboardNavigableList } from 'dashboard/composables/useKeyboardNavigableList';
|
||||
|
||||
const props = defineProps({
|
||||
items: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedIndex: 0,
|
||||
};
|
||||
},
|
||||
watch: {
|
||||
items(newItems) {
|
||||
if (newItems.length < this.selectedIndex + 1) {
|
||||
this.selectedIndex = 0;
|
||||
}
|
||||
},
|
||||
selectedIndex() {
|
||||
const container = this.$refs.mentionsListContainer;
|
||||
const item = container.querySelector(
|
||||
`#mention-item-${this.selectedIndex}`
|
||||
);
|
||||
if (item) {
|
||||
const itemTop = item.offsetTop;
|
||||
const itemBottom = itemTop + item.offsetHeight;
|
||||
const containerTop = container.scrollTop;
|
||||
const containerBottom = containerTop + container.offsetHeight;
|
||||
if (itemTop < containerTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom + 34 > containerBottom) {
|
||||
container.scrollTop = itemBottom - container.offsetHeight + 34;
|
||||
}
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
adjustScroll() {},
|
||||
onHover(index) {
|
||||
this.selectedIndex = index;
|
||||
},
|
||||
onListItemSelection(index) {
|
||||
this.selectedIndex = index;
|
||||
this.onSelect();
|
||||
},
|
||||
onSelect() {
|
||||
this.$emit('mentionSelect', this.items[this.selectedIndex]);
|
||||
},
|
||||
variableKey(item = {}) {
|
||||
return this.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
default: 'canned',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['mentionSelect']);
|
||||
|
||||
const mentionsListContainerRef = ref(null);
|
||||
const selectedIndex = ref(0);
|
||||
|
||||
const adjustScroll = () => {
|
||||
const container = mentionsListContainerRef.value;
|
||||
const item = container.querySelector(`#mention-item-${selectedIndex.value}`);
|
||||
if (item) {
|
||||
const itemTop = item.offsetTop;
|
||||
const itemBottom = itemTop + item.offsetHeight;
|
||||
const containerTop = container.scrollTop;
|
||||
const containerBottom = containerTop + container.offsetHeight;
|
||||
if (itemTop < containerTop) {
|
||||
container.scrollTop = itemTop;
|
||||
} else if (itemBottom + 34 > containerBottom) {
|
||||
container.scrollTop = itemBottom - container.offsetHeight + 34;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onSelect = () => {
|
||||
emit('mentionSelect', props.items[selectedIndex.value]);
|
||||
};
|
||||
|
||||
useKeyboardNavigableList({
|
||||
items: computed(() => props.items),
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.items,
|
||||
newItems => {
|
||||
if (newItems.length < selectedIndex.value + 1) {
|
||||
selectedIndex.value = 0;
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
watch(selectedIndex, adjustScroll);
|
||||
|
||||
const onHover = index => {
|
||||
selectedIndex.value = index;
|
||||
};
|
||||
|
||||
const onListItemSelection = index => {
|
||||
selectedIndex.value = index;
|
||||
onSelect();
|
||||
};
|
||||
|
||||
const variableKey = (item = {}) => {
|
||||
return props.type === 'variable' ? `{{${item.label}}}` : `/${item.label}`;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
ref="mentionsListContainer"
|
||||
ref="mentionsListContainerRef"
|
||||
class="bg-white dark:bg-slate-800 rounded-md overflow-auto absolute w-full z-20 pb-0 shadow-md left-0 bottom-full max-h-[9.75rem] border border-solid border-slate-100 dark:border-slate-700 mention--box"
|
||||
>
|
||||
<ul class="vertical dropdown menu">
|
||||
<ul class="mb-0 vertical dropdown menu">
|
||||
<woot-dropdown-item
|
||||
v-for="(item, index) in items"
|
||||
:id="`mention-item-${index}`"
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
export default {
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
methods: {
|
||||
moveSelectionUp() {
|
||||
if (!this.selectedIndex) {
|
||||
this.selectedIndex = this.items.length - 1;
|
||||
} else {
|
||||
this.selectedIndex -= 1;
|
||||
}
|
||||
this.adjustScroll();
|
||||
},
|
||||
moveSelectionDown() {
|
||||
if (this.selectedIndex === this.items.length - 1) {
|
||||
this.selectedIndex = 0;
|
||||
} else {
|
||||
this.selectedIndex += 1;
|
||||
}
|
||||
this.adjustScroll();
|
||||
},
|
||||
getKeyboardEvents() {
|
||||
return {
|
||||
ArrowUp: {
|
||||
action: e => {
|
||||
this.moveSelectionUp();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Control+KeyP': {
|
||||
action: e => {
|
||||
this.moveSelectionUp();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
ArrowDown: {
|
||||
action: e => {
|
||||
this.moveSelectionDown();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Control+KeyN': {
|
||||
action: e => {
|
||||
this.moveSelectionDown();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
Enter: {
|
||||
action: e => {
|
||||
this.onSelect();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
-69
@@ -1,69 +0,0 @@
|
||||
import { shallowMount, createLocalVue } from '@vue/test-utils';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
|
||||
const localVue = createLocalVue();
|
||||
|
||||
const buildComponent = ({ data = {}, methods = {} }) => ({
|
||||
render() {},
|
||||
data() {
|
||||
return { ...data, selectedIndex: 0, items: [1, 2, 3] };
|
||||
},
|
||||
methods: { ...methods, onSelect: vi.fn(), adjustScroll: vi.fn() },
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
});
|
||||
|
||||
describe('mentionSelectionKeyboardMixin', () => {
|
||||
let wrapper;
|
||||
|
||||
beforeEach(() => {
|
||||
const Component = buildComponent({});
|
||||
wrapper = shallowMount(Component, { localVue });
|
||||
});
|
||||
|
||||
it('ArrowUp and Control+KeyP update selectedIndex correctly', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.ArrowUp) {
|
||||
keyboardEvents.ArrowUp.action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(2);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
wrapper.setData({ selectedIndex: 1 });
|
||||
if (keyboardEvents && keyboardEvents['Control+KeyP']) {
|
||||
keyboardEvents['Control+KeyP'].action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(0);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('ArrowDown and Control+KeyN update selectedIndex correctly', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.ArrowDown) {
|
||||
keyboardEvents.ArrowDown.action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(1);
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
|
||||
wrapper.setData({ selectedIndex: 1 });
|
||||
if (keyboardEvents && keyboardEvents['Control+KeyN']) {
|
||||
keyboardEvents['Control+KeyN'].action({ preventDefault });
|
||||
expect(wrapper.vm.selectedIndex).toBe(2);
|
||||
expect(preventDefault).toHaveBeenCalledTimes(2);
|
||||
}
|
||||
});
|
||||
|
||||
it('Enter key triggers onSelect method', () => {
|
||||
const preventDefault = vi.fn();
|
||||
const keyboardEvents = wrapper.vm.getKeyboardEvents();
|
||||
|
||||
if (keyboardEvents && keyboardEvents.Enter) {
|
||||
keyboardEvents.Enter.action({ preventDefault });
|
||||
expect(wrapper.vm.onSelect).toHaveBeenCalled();
|
||||
expect(preventDefault).toHaveBeenCalled();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,28 +1,43 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { SHORTCUT_KEYS } from './constants';
|
||||
import {
|
||||
LAYOUT_QWERTZ,
|
||||
keysToModifyInQWERTZ,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import Hotkey from 'dashboard/components/base/Hotkey.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
Hotkey,
|
||||
},
|
||||
props: {
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
shortcutKeys: SHORTCUT_KEYS,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
title(item) {
|
||||
return this.$t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
|
||||
},
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const shortcutKeys = SHORTCUT_KEYS;
|
||||
const currentLayout = ref(null);
|
||||
|
||||
const title = item => t(`KEYBOARD_SHORTCUTS.TITLE.${item.label}`);
|
||||
|
||||
// Added this function to check if the keySet needs a shift key
|
||||
// This is used to display the shift key in the modal
|
||||
// If the current layout is QWERTZ and the keySet contains a key that needs a shift key
|
||||
// If layout is QWERTZ then we add the Shift+keysToModify to fix an known issue
|
||||
// https://github.com/chatwoot/chatwoot/issues/9492
|
||||
const needsShiftKey = keySet => {
|
||||
return (
|
||||
currentLayout.value === LAYOUT_QWERTZ &&
|
||||
keySet.some(key => keysToModifyInQWERTZ.has(key))
|
||||
);
|
||||
};
|
||||
|
||||
onMounted(async () => {
|
||||
currentLayout.value = await useDetectKeyboardLayout();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -94,6 +109,12 @@ export default {
|
||||
{{ title(shortcutKey) }}
|
||||
</h5>
|
||||
<div class="flex items-center gap-2 mb-1 ml-2">
|
||||
<Hotkey
|
||||
v-if="needsShiftKey(shortcutKey.keySet)"
|
||||
custom-class="min-h-[28px] min-w-[36px] key"
|
||||
>
|
||||
{{ 'Shift' }}
|
||||
</Hotkey>
|
||||
<Hotkey
|
||||
:class="{ 'min-w-[60px]': shortcutKey.firstKey !== 'Up' }"
|
||||
custom-class="min-h-[28px] normal-case key"
|
||||
|
||||
@@ -4,71 +4,83 @@ export const SHORTCUT_KEYS = [
|
||||
label: 'NAVIGATE_DROPDOWN',
|
||||
firstKey: 'Up',
|
||||
secondKey: 'Down',
|
||||
keySet: ['ArrowUp', 'ArrowDown'],
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
label: 'RESOLVE_CONVERSATION',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'E',
|
||||
keySet: ['Alt+KeyE'],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
label: 'GO_TO_CONVERSATION_DASHBOARD',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'C',
|
||||
keySet: ['Alt+KeyC'],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
label: 'ADD_ATTACHMENT',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'A',
|
||||
keySet: ['Alt+KeyA'],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
label: 'GO_TO_CONTACTS_DASHBOARD',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'V',
|
||||
keySet: ['Alt+KeyV'],
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
label: 'TOGGLE_SIDEBAR',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'O',
|
||||
keySet: ['Alt+KeyO'],
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
label: 'GO_TO_REPORTS_SIDEBAR',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'R',
|
||||
keySet: ['Alt+KeyR'],
|
||||
},
|
||||
{
|
||||
id: 8,
|
||||
label: 'MOVE_TO_NEXT_TAB',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'N',
|
||||
keySet: ['Alt+KeyN'],
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
label: 'GO_TO_SETTINGS',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'S',
|
||||
keySet: ['Alt+KeyS'],
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
label: 'SWITCH_TO_PRIVATE_NOTE',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'P',
|
||||
keySet: ['Alt+KeyP'],
|
||||
},
|
||||
{
|
||||
id: 13,
|
||||
id: 12,
|
||||
label: 'SWITCH_TO_REPLY',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'L',
|
||||
keySet: ['Alt+KeyL'],
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
id: 13,
|
||||
label: 'TOGGLE_SNOOZE_DROPDOWN',
|
||||
firstKey: 'Alt / ⌥',
|
||||
secondKey: 'M',
|
||||
keySet: ['Alt+KeyM'],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
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',
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,102 @@
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,204 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,188 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,408 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
+47
-44
@@ -1,3 +1,8 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRouter } from 'dashboard/composables/route';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import {
|
||||
ICON_ACCOUNT_SETTINGS,
|
||||
ICON_AGENT_REPORTS,
|
||||
@@ -14,11 +19,9 @@ import {
|
||||
ICON_TEAM_REPORTS,
|
||||
ICON_USER_PROFILE,
|
||||
ICON_CONVERSATION_REPORTS,
|
||||
} from './CommandBarIcons';
|
||||
import { frontendURL } from '../../../helper/URLHelper';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAdmin } from 'dashboard/composables/useAdmin';
|
||||
import { FEATURE_FLAGS } from '../../../featureFlags';
|
||||
} from 'dashboard/helper/commandbar/icons';
|
||||
import { frontendURL } from 'dashboard/helper/URLHelper';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
const GO_TO_COMMANDS = [
|
||||
{
|
||||
@@ -172,45 +175,45 @@ const GO_TO_COMMANDS = [
|
||||
},
|
||||
];
|
||||
|
||||
export default {
|
||||
setup() {
|
||||
const { isAdmin } = useAdmin();
|
||||
return {
|
||||
isAdmin,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
accountId: 'getCurrentAccountId',
|
||||
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
|
||||
}),
|
||||
goToCommandHotKeys() {
|
||||
let commands = GO_TO_COMMANDS.filter(cmd => {
|
||||
if (cmd.featureFlag) {
|
||||
return this.isFeatureEnabledonAccount(
|
||||
this.accountId,
|
||||
cmd.featureFlag
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
export function useGoToCommandHotKeys() {
|
||||
const { t } = useI18n();
|
||||
const router = useRouter();
|
||||
const { isAdmin } = useAdmin();
|
||||
|
||||
if (!this.isAdmin) {
|
||||
commands = commands.filter(command => command.role.includes('agent'));
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const isFeatureEnabledOnAccount = useMapGetter(
|
||||
'accounts/isFeatureEnabledonAccount'
|
||||
);
|
||||
|
||||
const openRoute = url => {
|
||||
router.push(frontendURL(url));
|
||||
};
|
||||
|
||||
const goToCommandHotKeys = computed(() => {
|
||||
let commands = GO_TO_COMMANDS.filter(cmd => {
|
||||
if (cmd.featureFlag) {
|
||||
return isFeatureEnabledOnAccount.value(
|
||||
currentAccountId.value,
|
||||
cmd.featureFlag
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
|
||||
return commands.map(command => ({
|
||||
id: command.id,
|
||||
section: this.$t(command.section),
|
||||
title: this.$t(command.title),
|
||||
icon: command.icon,
|
||||
handler: () => this.openRoute(command.path(this.accountId)),
|
||||
}));
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
openRoute(url) {
|
||||
this.$router.push(frontendURL(url));
|
||||
},
|
||||
},
|
||||
};
|
||||
if (!isAdmin.value) {
|
||||
commands = commands.filter(command => command.role.includes('agent'));
|
||||
}
|
||||
|
||||
return commands.map(command => ({
|
||||
id: command.id,
|
||||
section: t(command.section),
|
||||
title: t(command.title),
|
||||
icon: command.icon,
|
||||
handler: () => openRoute(command.path(currentAccountId.value)),
|
||||
}));
|
||||
});
|
||||
|
||||
return {
|
||||
goToCommandHotKeys,
|
||||
};
|
||||
}
|
||||
+38
-32
@@ -1,13 +1,19 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useRoute } from 'dashboard/composables/route';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import { CMD_SNOOZE_NOTIFICATION } from './commandBarBusEvents';
|
||||
import { ICON_SNOOZE_NOTIFICATION } from './CommandBarIcons';
|
||||
import { CMD_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/events';
|
||||
import { ICON_SNOOZE_NOTIFICATION } from 'dashboard/helper/commandbar/icons';
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
|
||||
import { isAInboxViewRoute } from 'dashboard/helper/routeHelpers';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
const createSnoozeHandler = option => () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, option);
|
||||
|
||||
const INBOX_SNOOZE_EVENTS = [
|
||||
{
|
||||
id: 'snooze_notification',
|
||||
@@ -21,8 +27,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
parent: 'snooze_notification',
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
|
||||
handler: createSnoozeHandler(SNOOZE_OPTIONS.AN_HOUR_FROM_NOW),
|
||||
},
|
||||
{
|
||||
id: SNOOZE_OPTIONS.UNTIL_TOMORROW,
|
||||
@@ -30,8 +35,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_TOMORROW),
|
||||
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_TOMORROW),
|
||||
},
|
||||
{
|
||||
id: SNOOZE_OPTIONS.UNTIL_NEXT_WEEK,
|
||||
@@ -39,8 +43,7 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
|
||||
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_WEEK),
|
||||
},
|
||||
{
|
||||
id: SNOOZE_OPTIONS.UNTIL_NEXT_MONTH,
|
||||
@@ -48,35 +51,38 @@ const INBOX_SNOOZE_EVENTS = [
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
|
||||
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_NEXT_MONTH),
|
||||
},
|
||||
{
|
||||
id: SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME,
|
||||
title: 'COMMAND_BAR.COMMANDS.CUSTOM',
|
||||
title: 'COMMAND_BAR.COMMANDS.UNTIL_CUSTOM_TIME',
|
||||
section: 'COMMAND_BAR.SECTIONS.SNOOZE_NOTIFICATION',
|
||||
parent: 'snooze_notification',
|
||||
icon: ICON_SNOOZE_NOTIFICATION,
|
||||
handler: () =>
|
||||
emitter.emit(CMD_SNOOZE_NOTIFICATION, SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
||||
handler: createSnoozeHandler(SNOOZE_OPTIONS.UNTIL_CUSTOM_TIME),
|
||||
},
|
||||
];
|
||||
export default {
|
||||
computed: {
|
||||
inboxHotKeys() {
|
||||
if (isAInboxViewRoute(this.$route.name)) {
|
||||
return this.prepareActions(INBOX_SNOOZE_EVENTS);
|
||||
}
|
||||
return [];
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
prepareActions(actions) {
|
||||
return actions.map(action => ({
|
||||
...action,
|
||||
title: this.$t(action.title),
|
||||
section: this.$t(action.section),
|
||||
}));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export function useInboxHotKeys() {
|
||||
const { t } = useI18n();
|
||||
const route = useRoute();
|
||||
|
||||
const prepareActions = actions => {
|
||||
return actions.map(action => ({
|
||||
...action,
|
||||
title: t(action.title),
|
||||
section: action.section ? t(action.section) : undefined,
|
||||
}));
|
||||
};
|
||||
|
||||
const inboxHotKeys = computed(() => {
|
||||
if (isAInboxViewRoute(route.name)) {
|
||||
return prepareActions(INBOX_SNOOZE_EVENTS);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
|
||||
return {
|
||||
inboxHotKeys,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -0,0 +1,37 @@
|
||||
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 },
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,119 @@
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,37 @@
|
||||
import { ref } from 'vue';
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useAccount } from '../useAccount';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
|
||||
describe('useAccount', () => {
|
||||
beforeEach(() => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
getCurrentAccountId: ref(123),
|
||||
});
|
||||
});
|
||||
|
||||
it('returns accountId as a computed property', () => {
|
||||
const { accountId } = useAccount();
|
||||
expect(accountId.value).toBe(123);
|
||||
});
|
||||
|
||||
it('generates account-scoped URLs correctly', () => {
|
||||
const { accountScopedUrl } = useAccount();
|
||||
const result = accountScopedUrl('settings/inbox/new');
|
||||
expect(result).toBe('/app/accounts/123/settings/inbox/new');
|
||||
});
|
||||
|
||||
it('handles URLs with leading slash', () => {
|
||||
const { accountScopedUrl } = useAccount();
|
||||
const result = accountScopedUrl('users');
|
||||
expect(result).toBe('/app/accounts/123/users');
|
||||
});
|
||||
|
||||
it('handles empty URL', () => {
|
||||
const { accountScopedUrl } = useAccount();
|
||||
const result = accountScopedUrl('');
|
||||
expect(result).toBe('/app/accounts/123/');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,91 @@
|
||||
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]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,295 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useConfig } from '../useConfig';
|
||||
|
||||
describe('useConfig', () => {
|
||||
const originalChatwootConfig = window.chatwootConfig;
|
||||
|
||||
beforeEach(() => {
|
||||
window.chatwootConfig = {
|
||||
hostURL: 'https://example.com',
|
||||
vapidPublicKey: 'vapid-key',
|
||||
enabledLanguages: ['en', 'fr'],
|
||||
isEnterprise: 'true',
|
||||
enterprisePlanName: 'enterprise',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
window.chatwootConfig = originalChatwootConfig;
|
||||
});
|
||||
|
||||
it('returns the correct configuration values', () => {
|
||||
const config = useConfig();
|
||||
|
||||
expect(config.hostURL).toBe('https://example.com');
|
||||
expect(config.vapidPublicKey).toBe('vapid-key');
|
||||
expect(config.enabledLanguages).toEqual(['en', 'fr']);
|
||||
expect(config.isEnterprise).toBe(true);
|
||||
expect(config.enterprisePlanName).toBe('enterprise');
|
||||
});
|
||||
|
||||
it('handles missing configuration values', () => {
|
||||
window.chatwootConfig = {};
|
||||
const config = useConfig();
|
||||
|
||||
expect(config.hostURL).toBeUndefined();
|
||||
expect(config.vapidPublicKey).toBeUndefined();
|
||||
expect(config.enabledLanguages).toBeUndefined();
|
||||
expect(config.isEnterprise).toBe(false);
|
||||
expect(config.enterprisePlanName).toBeUndefined();
|
||||
});
|
||||
|
||||
it('handles undefined window.chatwootConfig', () => {
|
||||
window.chatwootConfig = undefined;
|
||||
const config = useConfig();
|
||||
|
||||
expect(config.hostURL).toBeUndefined();
|
||||
expect(config.vapidPublicKey).toBeUndefined();
|
||||
expect(config.enabledLanguages).toBeUndefined();
|
||||
expect(config.isEnterprise).toBe(false);
|
||||
expect(config.enterprisePlanName).toBeUndefined();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,87 @@
|
||||
import { useConversationLabels } from '../useConversationLabels';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
|
||||
describe('useConversationLabels', () => {
|
||||
let store;
|
||||
let getters;
|
||||
|
||||
beforeEach(() => {
|
||||
store = {
|
||||
getters: {
|
||||
'conversationLabels/getConversationLabels': vi.fn(),
|
||||
},
|
||||
dispatch: vi.fn(),
|
||||
};
|
||||
getters = {
|
||||
getSelectedChat: { value: { id: 1 } },
|
||||
'labels/getLabels': {
|
||||
value: [
|
||||
{ id: 1, title: 'Label 1' },
|
||||
{ id: 2, title: 'Label 2' },
|
||||
{ id: 3, title: 'Label 3' },
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
useStore.mockReturnValue(store);
|
||||
useStoreGetters.mockReturnValue(getters);
|
||||
});
|
||||
|
||||
it('should return the correct computed properties', () => {
|
||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
||||
'Label 1',
|
||||
'Label 2',
|
||||
]);
|
||||
const { accountLabels, savedLabels, activeLabels, inactiveLabels } =
|
||||
useConversationLabels();
|
||||
|
||||
expect(accountLabels.value).toEqual(getters['labels/getLabels'].value);
|
||||
expect(savedLabels.value).toEqual(['Label 1', 'Label 2']);
|
||||
expect(activeLabels.value).toEqual([
|
||||
{ id: 1, title: 'Label 1' },
|
||||
{ id: 2, title: 'Label 2' },
|
||||
]);
|
||||
expect(inactiveLabels.value).toEqual([{ id: 3, title: 'Label 3' }]);
|
||||
});
|
||||
|
||||
it('should update labels correctly', async () => {
|
||||
const { onUpdateLabels } = useConversationLabels();
|
||||
await onUpdateLabels(['Label 1', 'Label 3']);
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
||||
conversationId: 1,
|
||||
labels: ['Label 1', 'Label 3'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should add a label to the conversation', () => {
|
||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
||||
'Label 1',
|
||||
]);
|
||||
const { addLabelToConversation } = useConversationLabels();
|
||||
|
||||
addLabelToConversation({ title: 'Label 2' });
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
||||
conversationId: 1,
|
||||
labels: ['Label 1', 'Label 2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('should remove a label from the conversation', () => {
|
||||
store.getters['conversationLabels/getConversationLabels'].mockReturnValue([
|
||||
'Label 1',
|
||||
'Label 2',
|
||||
]);
|
||||
const { removeLabelFromConversation } = useConversationLabels();
|
||||
|
||||
removeLabelFromConversation('Label 2');
|
||||
|
||||
expect(store.dispatch).toHaveBeenCalledWith('conversationLabels/update', {
|
||||
conversationId: 1,
|
||||
labels: ['Label 1'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import {
|
||||
LAYOUT_QWERTY,
|
||||
LAYOUT_QWERTZ,
|
||||
LAYOUT_AZERTY,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
describe('useDetectKeyboardLayout', () => {
|
||||
beforeEach(() => {
|
||||
window.cw_keyboard_layout = null;
|
||||
});
|
||||
|
||||
it('returns cached layout if available', async () => {
|
||||
window.cw_keyboard_layout = LAYOUT_QWERTY;
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
});
|
||||
|
||||
it('should detect QWERTY layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
});
|
||||
|
||||
it('should detect QWERTZ layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'z'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTZ);
|
||||
});
|
||||
|
||||
it('should detect AZERTY layout using modern method', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'a'],
|
||||
['KeyW', 'z'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_AZERTY);
|
||||
});
|
||||
|
||||
it('should use legacy method if navigator.keyboard is not available', async () => {
|
||||
navigator.keyboard = undefined;
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect([LAYOUT_QWERTY, LAYOUT_QWERTZ, LAYOUT_AZERTY]).toContain(layout);
|
||||
});
|
||||
|
||||
it('should cache the detected layout', async () => {
|
||||
navigator.keyboard = {
|
||||
getLayoutMap: vi.fn().mockResolvedValue(
|
||||
new Map([
|
||||
['KeyQ', 'q'],
|
||||
['KeyW', 'w'],
|
||||
['KeyE', 'e'],
|
||||
['KeyR', 'r'],
|
||||
['KeyT', 't'],
|
||||
['KeyY', 'y'],
|
||||
])
|
||||
),
|
||||
};
|
||||
|
||||
const layout = await useDetectKeyboardLayout();
|
||||
expect(layout).toBe(LAYOUT_QWERTY);
|
||||
|
||||
const layoutAgain = await useDetectKeyboardLayout();
|
||||
expect(layoutAgain).toBe(LAYOUT_QWERTY);
|
||||
expect(navigator.keyboard.getLayoutMap).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
|
||||
describe('useKeyboardEvents', () => {
|
||||
it('should be defined', () => {
|
||||
expect(useKeyboardEvents).toBeDefined();
|
||||
});
|
||||
|
||||
it('should return a function', () => {
|
||||
expect(useKeyboardEvents).toBeInstanceOf(Function);
|
||||
});
|
||||
|
||||
it('should set up listeners on mount and remove them on unmount', async () => {
|
||||
const events = {
|
||||
'ALT+KeyL': () => {},
|
||||
};
|
||||
|
||||
const mountedMock = vi.fn();
|
||||
const unmountedMock = vi.fn();
|
||||
useKeyboardEvents(events);
|
||||
mountedMock();
|
||||
unmountedMock();
|
||||
|
||||
expect(mountedMock).toHaveBeenCalled();
|
||||
expect(unmountedMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,260 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { ref } from 'vue';
|
||||
import { useKeyboardNavigableList } from '../useKeyboardNavigableList';
|
||||
import { useKeyboardEvents } from '../useKeyboardEvents';
|
||||
|
||||
// Mock the useKeyboardEvents function
|
||||
vi.mock('../useKeyboardEvents', () => ({
|
||||
useKeyboardEvents: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('useKeyboardNavigableList', () => {
|
||||
let items;
|
||||
let onSelect;
|
||||
let adjustScroll;
|
||||
let selectedIndex;
|
||||
|
||||
const createMockEvent = () => ({ preventDefault: vi.fn() });
|
||||
|
||||
beforeEach(() => {
|
||||
items = ref(['item1', 'item2', 'item3']);
|
||||
onSelect = vi.fn();
|
||||
adjustScroll = vi.fn();
|
||||
selectedIndex = ref(0);
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return moveSelectionUp and moveSelectionDown functions', () => {
|
||||
const result = useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
expect(result).toHaveProperty('moveSelectionUp');
|
||||
expect(result).toHaveProperty('moveSelectionDown');
|
||||
expect(typeof result.moveSelectionUp).toBe('function');
|
||||
expect(typeof result.moveSelectionDown).toBe('function');
|
||||
});
|
||||
|
||||
it('should move selection up correctly', () => {
|
||||
const { moveSelectionUp } = useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(0);
|
||||
|
||||
moveSelectionUp();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
});
|
||||
|
||||
it('should move selection down correctly', () => {
|
||||
const { moveSelectionDown } = useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(0);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
});
|
||||
|
||||
it('should call adjustScroll after moving selection', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
expect(adjustScroll).toHaveBeenCalledTimes(1);
|
||||
|
||||
moveSelectionDown();
|
||||
expect(adjustScroll).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('should include Enter key handler when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
|
||||
expect(keyboardEvents).toHaveProperty('Enter');
|
||||
expect(keyboardEvents.Enter.allowOnFocusedInput).toBe(true);
|
||||
});
|
||||
|
||||
it('should not include Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
|
||||
expect(keyboardEvents).not.toHaveProperty('Enter');
|
||||
});
|
||||
|
||||
it('should not trigger onSelect when items are empty', () => {
|
||||
const { moveSelectionUp, moveSelectionDown } = useKeyboardNavigableList({
|
||||
items: ref([]),
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
moveSelectionUp();
|
||||
moveSelectionDown();
|
||||
expect(onSelect).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should call useKeyboardEvents with correct parameters', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
expect(useKeyboardEvents).toHaveBeenCalledWith(expect.any(Object));
|
||||
});
|
||||
|
||||
// Keyboard event handlers
|
||||
it('should handle ArrowUp key', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.ArrowUp.action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Control+KeyP', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents['Control+KeyP'].action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(2);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle ArrowDown key', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.ArrowDown.action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Control+KeyN', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents['Control+KeyN'].action(mockEvent);
|
||||
|
||||
expect(selectedIndex.value).toBe(1);
|
||||
expect(adjustScroll).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should handle Enter key when onSelect is provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const mockEvent = createMockEvent();
|
||||
keyboardEvents.Enter.action(mockEvent);
|
||||
|
||||
expect(onSelect).toHaveBeenCalled();
|
||||
expect(mockEvent.preventDefault).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should not have Enter key handler when onSelect is not provided', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
|
||||
const keyboardEventsWithoutSelect = useKeyboardEvents.mock.calls[0][0];
|
||||
expect(keyboardEventsWithoutSelect).not.toHaveProperty('Enter');
|
||||
});
|
||||
|
||||
it('should set allowOnFocusedInput to true for all key handlers', () => {
|
||||
useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
});
|
||||
const keyboardEvents = useKeyboardEvents.mock.calls[0][0];
|
||||
const keyHandlers = [
|
||||
'ArrowUp',
|
||||
'Control+KeyP',
|
||||
'ArrowDown',
|
||||
'Control+KeyN',
|
||||
'Enter',
|
||||
];
|
||||
keyHandlers.forEach(key => {
|
||||
if (keyboardEvents[key]) {
|
||||
expect(keyboardEvents[key].allowOnFocusedInput).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
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';
|
||||
|
||||
vi.mock('dashboard/composables/store');
|
||||
vi.mock('dashboard/helper/automationHelper.js');
|
||||
|
||||
describe('useMacros', () => {
|
||||
const mockLabels = [
|
||||
{
|
||||
id: 6,
|
||||
title: 'sales',
|
||||
description: 'sales team',
|
||||
color: '#8EA20F',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: 'billing',
|
||||
description: 'billing',
|
||||
color: '#4077DA',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
title: 'snoozed',
|
||||
description: 'Items marked for later',
|
||||
color: '#D12F42',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'mobile-app',
|
||||
description: 'tech team',
|
||||
color: '#2DB1CC',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 14,
|
||||
title: 'human-resources-department-with-long-title',
|
||||
description: 'Test',
|
||||
color: '#FF6E09',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
{
|
||||
id: 22,
|
||||
title: 'priority',
|
||||
description: 'For important sales leads',
|
||||
color: '#7E7CED',
|
||||
show_on_sidebar: true,
|
||||
},
|
||||
];
|
||||
const mockTeams = [
|
||||
{
|
||||
id: 1,
|
||||
name: '⚙️ sales team',
|
||||
description: 'This is our internal sales team',
|
||||
allow_auto_assign: true,
|
||||
account_id: 1,
|
||||
is_member: true,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: '🤷♂️ fayaz',
|
||||
description: 'Test',
|
||||
allow_auto_assign: true,
|
||||
account_id: 1,
|
||||
is_member: true,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: '🇮🇳 apac sales',
|
||||
description: 'Sales team for France Territory',
|
||||
allow_auto_assign: true,
|
||||
account_id: 1,
|
||||
is_member: true,
|
||||
},
|
||||
];
|
||||
const mockAgents = [
|
||||
{
|
||||
id: 1,
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'john@doe.com',
|
||||
available_name: 'John Doe',
|
||||
name: 'John Doe',
|
||||
role: 'agent',
|
||||
thumbnail: 'https://example.com/image.png',
|
||||
},
|
||||
{
|
||||
id: 9,
|
||||
account_id: 1,
|
||||
availability_status: 'offline',
|
||||
auto_offline: true,
|
||||
confirmed: true,
|
||||
email: 'clark@kent.com',
|
||||
available_name: 'Clark Kent',
|
||||
name: 'Clark Kent',
|
||||
role: 'agent',
|
||||
thumbnail: '',
|
||||
},
|
||||
];
|
||||
|
||||
beforeEach(() => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: mockLabels },
|
||||
'teams/getTeams': { value: mockTeams },
|
||||
'agents/getAgents': { value: mockAgents },
|
||||
});
|
||||
});
|
||||
|
||||
it('initializes computed properties correctly', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toHaveLength(mockLabels.length);
|
||||
expect(getMacroDropdownValues('assign_team')).toHaveLength(
|
||||
mockTeams.length
|
||||
);
|
||||
expect(getMacroDropdownValues('assign_agent')).toHaveLength(
|
||||
mockAgents.length + 1
|
||||
); // +1 for "Self"
|
||||
});
|
||||
|
||||
it('returns teams for assign_team and send_email_to_team types', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual(mockTeams);
|
||||
expect(getMacroDropdownValues('send_email_to_team')).toEqual(mockTeams);
|
||||
});
|
||||
|
||||
it('returns agents with "Self" option for assign_agent type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const result = getMacroDropdownValues('assign_agent');
|
||||
expect(result[0]).toEqual({ id: 'self', name: 'Self' });
|
||||
expect(result.slice(1)).toEqual(mockAgents);
|
||||
});
|
||||
|
||||
it('returns formatted labels for add_label and remove_label types', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
const expectedLabels = mockLabels.map(i => ({
|
||||
id: i.title,
|
||||
name: i.title,
|
||||
}));
|
||||
expect(getMacroDropdownValues('add_label')).toEqual(expectedLabels);
|
||||
expect(getMacroDropdownValues('remove_label')).toEqual(expectedLabels);
|
||||
});
|
||||
|
||||
it('returns PRIORITY_CONDITION_VALUES for change_priority type', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('change_priority')).toEqual(
|
||||
PRIORITY_CONDITION_VALUES
|
||||
);
|
||||
});
|
||||
|
||||
it('returns an empty array for unknown types', () => {
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('unknown_type')).toEqual([]);
|
||||
});
|
||||
|
||||
it('handles empty data correctly', () => {
|
||||
useStoreGetters.mockReturnValue({
|
||||
'labels/getLabels': { value: [] },
|
||||
'teams/getTeams': { value: [] },
|
||||
'agents/getAgents': { value: [] },
|
||||
});
|
||||
|
||||
const { getMacroDropdownValues } = useMacros();
|
||||
expect(getMacroDropdownValues('add_label')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_team')).toEqual([]);
|
||||
expect(getMacroDropdownValues('assign_agent')).toEqual([
|
||||
{ id: 'self', name: 'Self' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,61 @@
|
||||
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');
|
||||
});
|
||||
});
|
||||
@@ -17,7 +17,7 @@ export const useStoreGetters = () => {
|
||||
);
|
||||
};
|
||||
|
||||
export const mapGetter = key => {
|
||||
export const useMapGetter = key => {
|
||||
const store = useStore();
|
||||
return computed(() => store.getters[key]);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
/**
|
||||
* Composable for account-related operations.
|
||||
* @returns {Object} An object containing account-related properties and methods.
|
||||
*/
|
||||
export function useAccount() {
|
||||
const getters = useStoreGetters();
|
||||
|
||||
/**
|
||||
* Computed property for the current account ID.
|
||||
* @type {import('vue').ComputedRef<number>}
|
||||
*/
|
||||
const accountId = computed(() => getters.getCurrentAccountId.value);
|
||||
|
||||
/**
|
||||
* Generates an account-scoped URL.
|
||||
* @param {string} url - The URL to be scoped to the account.
|
||||
* @returns {string} The account-scoped URL.
|
||||
*/
|
||||
const accountScopedUrl = url => {
|
||||
return `/app/accounts/${accountId.value}/${url}`;
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
accountScopedUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,349 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* A function that provides access to various configuration values.
|
||||
* @returns {Object} An object containing configuration values.
|
||||
*/
|
||||
export function useConfig() {
|
||||
const config = window.chatwootConfig || {};
|
||||
|
||||
/**
|
||||
* The host URL of the Chatwoot instance.
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
const hostURL = config.hostURL;
|
||||
|
||||
/**
|
||||
* The VAPID public key for web push notifications.
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
const vapidPublicKey = config.vapidPublicKey;
|
||||
|
||||
/**
|
||||
* An array of enabled languages in the Chatwoot instance.
|
||||
* @type {string[]|undefined}
|
||||
*/
|
||||
const enabledLanguages = config.enabledLanguages;
|
||||
|
||||
/**
|
||||
* Indicates whether the current instance is an enterprise version.
|
||||
* @type {boolean}
|
||||
*/
|
||||
const isEnterprise = config.isEnterprise === 'true';
|
||||
|
||||
/**
|
||||
* The name of the enterprise plan, if applicable.
|
||||
* Returns "community" or "enterprise"
|
||||
* @type {string|undefined}
|
||||
*/
|
||||
const enterprisePlanName = config.enterprisePlanName;
|
||||
|
||||
return {
|
||||
hostURL,
|
||||
vapidPublicKey,
|
||||
enabledLanguages,
|
||||
isEnterprise,
|
||||
enterprisePlanName,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStore, useStoreGetters } from 'dashboard/composables/store';
|
||||
|
||||
/**
|
||||
* Composable for managing conversation labels
|
||||
* @returns {Object} An object containing methods and computed properties for conversation labels
|
||||
*/
|
||||
export function useConversationLabels() {
|
||||
const store = useStore();
|
||||
const getters = useStoreGetters();
|
||||
|
||||
/**
|
||||
* The currently selected chat
|
||||
* @type {import('vue').ComputedRef<Object>}
|
||||
*/
|
||||
const currentChat = computed(() => getters.getSelectedChat.value);
|
||||
|
||||
/**
|
||||
* The ID of the current conversation
|
||||
* @type {import('vue').ComputedRef<number|null>}
|
||||
*/
|
||||
const conversationId = computed(() => currentChat.value?.id);
|
||||
|
||||
/**
|
||||
* All labels available for the account
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const accountLabels = computed(() => getters['labels/getLabels'].value);
|
||||
|
||||
/**
|
||||
* Labels currently saved to the conversation
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const savedLabels = computed(() => {
|
||||
return store.getters['conversationLabels/getConversationLabels'](
|
||||
conversationId.value
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Labels currently active on the conversation
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const activeLabels = computed(() =>
|
||||
accountLabels.value.filter(({ title }) => savedLabels.value.includes(title))
|
||||
);
|
||||
|
||||
/**
|
||||
* Labels available but not active on the conversation
|
||||
* @type {import('vue').ComputedRef<Array>}
|
||||
*/
|
||||
const inactiveLabels = computed(() =>
|
||||
accountLabels.value.filter(
|
||||
({ title }) => !savedLabels.value.includes(title)
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* Updates the labels for the current conversation
|
||||
* @param {string[]} selectedLabels - Array of label titles to be set for the conversation
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
const onUpdateLabels = async selectedLabels => {
|
||||
await store.dispatch('conversationLabels/update', {
|
||||
conversationId: conversationId.value,
|
||||
labels: selectedLabels,
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Adds a label to the current conversation
|
||||
* @param {Object} value - The label object to be added
|
||||
* @param {string} value.title - The title of the label to be added
|
||||
*/
|
||||
const addLabelToConversation = value => {
|
||||
const result = activeLabels.value.map(item => item.title);
|
||||
result.push(value.title);
|
||||
onUpdateLabels(result);
|
||||
};
|
||||
|
||||
/**
|
||||
* Removes a label from the current conversation
|
||||
* @param {string} value - The title of the label to be removed
|
||||
*/
|
||||
const removeLabelFromConversation = value => {
|
||||
const result = activeLabels.value
|
||||
.map(label => label.title)
|
||||
.filter(label => label !== value);
|
||||
onUpdateLabels(result);
|
||||
};
|
||||
|
||||
return {
|
||||
accountLabels,
|
||||
savedLabels,
|
||||
activeLabels,
|
||||
inactiveLabels,
|
||||
addLabelToConversation,
|
||||
removeLabelFromConversation,
|
||||
onUpdateLabels,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
LAYOUT_QWERTY,
|
||||
LAYOUT_QWERTZ,
|
||||
LAYOUT_AZERTY,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
|
||||
/**
|
||||
* Detects the keyboard layout using a legacy method by creating a hidden input and dispatching a key event.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
async function detectLegacy() {
|
||||
const input = document.createElement('input');
|
||||
input.style.position = 'fixed';
|
||||
input.style.top = '-100px';
|
||||
document.body.appendChild(input);
|
||||
input.focus();
|
||||
|
||||
return new Promise(resolve => {
|
||||
const keyboardEvent = new KeyboardEvent('keypress', {
|
||||
key: 'y',
|
||||
keyCode: 89,
|
||||
which: 89,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
});
|
||||
|
||||
const handler = e => {
|
||||
document.body.removeChild(input);
|
||||
document.removeEventListener('keypress', handler);
|
||||
if (e.key === 'z') {
|
||||
resolve(LAYOUT_QWERTY);
|
||||
} else if (e.key === 'y') {
|
||||
resolve(LAYOUT_QWERTZ);
|
||||
} else {
|
||||
resolve(LAYOUT_AZERTY);
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('keypress', handler);
|
||||
input.dispatchEvent(keyboardEvent);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the keyboard layout using the modern navigator.keyboard API.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
async function detect() {
|
||||
const map = await navigator.keyboard.getLayoutMap();
|
||||
const q = map.get('KeyQ');
|
||||
const w = map.get('KeyW');
|
||||
const e = map.get('KeyE');
|
||||
const r = map.get('KeyR');
|
||||
const t = map.get('KeyT');
|
||||
const y = map.get('KeyY');
|
||||
|
||||
return [q, w, e, r, t, y].join('').toUpperCase();
|
||||
}
|
||||
|
||||
/**
|
||||
* Uses either the modern or legacy method to detect the keyboard layout, caching the result.
|
||||
* @returns {Promise<string>} A promise that resolves to the detected keyboard layout.
|
||||
*/
|
||||
export async function useDetectKeyboardLayout() {
|
||||
const cachedLayout = window.cw_keyboard_layout;
|
||||
if (cachedLayout) {
|
||||
return cachedLayout;
|
||||
}
|
||||
|
||||
const layout = navigator.keyboard ? await detect() : await detectLegacy();
|
||||
window.cw_keyboard_layout = layout;
|
||||
return layout;
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,90 @@
|
||||
import {
|
||||
isActiveElementTypeable,
|
||||
isEscape,
|
||||
keysToModifyInQWERTZ,
|
||||
LAYOUT_QWERTZ,
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import { useDetectKeyboardLayout } from 'dashboard/composables/useDetectKeyboardLayout';
|
||||
import { createKeybindingsHandler } from 'tinykeys';
|
||||
import { onUnmounted, onMounted } from 'vue';
|
||||
|
||||
/**
|
||||
* Determines if the keyboard event should be ignored based on the element type and handler settings.
|
||||
* @param {Event} e - The event object.
|
||||
* @param {Object|Function} handler - The handler configuration or function.
|
||||
* @returns {boolean} - True if the event should be ignored, false otherwise.
|
||||
*/
|
||||
const shouldIgnoreEvent = (e, handler) => {
|
||||
const isTypeable = isActiveElementTypeable(e);
|
||||
const allowOnFocusedInput =
|
||||
typeof handler === 'function' ? false : handler.allowOnFocusedInput;
|
||||
|
||||
if (isTypeable) {
|
||||
if (isEscape(e)) {
|
||||
e.target.blur();
|
||||
}
|
||||
return !allowOnFocusedInput;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps the event handler to include custom logic before executing the handler.
|
||||
* @param {Function} handler - The original event handler.
|
||||
* @returns {Function} - The wrapped handler.
|
||||
*/
|
||||
const keydownWrapper = handler => {
|
||||
return e => {
|
||||
if (shouldIgnoreEvent(e, handler)) return;
|
||||
// extract the action to perform from the handler
|
||||
|
||||
const actionToPerform =
|
||||
typeof handler === 'function' ? handler : handler.action;
|
||||
actionToPerform(e);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps all provided keyboard events in handlers that respect the current keyboard layout.
|
||||
* @param {Object} events - The object containing event names and their handlers.
|
||||
* @returns {Object} - The object with event names possibly modified based on the keyboard layout and wrapped handlers.
|
||||
*/
|
||||
async function wrapEventsInKeybindingsHandler(events) {
|
||||
const wrappedEvents = {};
|
||||
const currentLayout = await useDetectKeyboardLayout();
|
||||
|
||||
Object.keys(events).forEach(originalEventName => {
|
||||
const modifiedEventName =
|
||||
currentLayout === LAYOUT_QWERTZ &&
|
||||
keysToModifyInQWERTZ.has(originalEventName)
|
||||
? `Shift+${originalEventName}`
|
||||
: originalEventName;
|
||||
|
||||
wrappedEvents[modifiedEventName] = keydownWrapper(
|
||||
events[originalEventName]
|
||||
);
|
||||
});
|
||||
return wrappedEvents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vue composable to handle keyboard events with support for different keyboard layouts.
|
||||
* @param {Object} keyboardEvents - The keyboard events to handle.
|
||||
*/
|
||||
export async function useKeyboardEvents(keyboardEvents) {
|
||||
let abortController = new AbortController();
|
||||
|
||||
onMounted(async () => {
|
||||
if (!keyboardEvents) return;
|
||||
const wrappedEvents = await wrapEventsInKeybindingsHandler(keyboardEvents);
|
||||
const keydownHandler = createKeybindingsHandler(wrappedEvents);
|
||||
|
||||
document.addEventListener('keydown', keydownHandler, {
|
||||
signal: abortController.signal,
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
abortController.abort();
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
/**
|
||||
* This composable provides keyboard navigation functionality for list-like UI components
|
||||
* such as dropdowns, autocomplete suggestions, or any list of selectable items.
|
||||
*
|
||||
* TODO - Things that can be improved in the future
|
||||
* - The scrolling should be handled by the component instead of the consumer of this composable
|
||||
* it can be done if we know the item height.
|
||||
* - The focus should be trapped within the list.
|
||||
* - onSelect should be callback instead of a function that is passed
|
||||
*/
|
||||
import { useKeyboardEvents } from './useKeyboardEvents';
|
||||
|
||||
/**
|
||||
* Wrap the action in a function that calls the action and prevents the default event behavior.
|
||||
* @param {Function} action - The action to be called.
|
||||
* @returns {{action: Function, allowOnFocusedInput: boolean}} An object containing the action and a flag to allow the event on focused input.
|
||||
*/
|
||||
const createAction = action => ({
|
||||
action: e => {
|
||||
action();
|
||||
e.preventDefault();
|
||||
},
|
||||
allowOnFocusedInput: true,
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates keyboard event handlers for navigation.
|
||||
* @param {Function} moveSelectionUp - Function to move selection up.
|
||||
* @param {Function} moveSelectionDown - Function to move selection down.
|
||||
* @param {Function} [onSelect] - Optional function to handle selection.
|
||||
* @param {import('vue').Ref<Array>} items - A ref to the array of selectable items.
|
||||
* @returns {Object.<string, {action: Function, allowOnFocusedInput: boolean}>}
|
||||
*/
|
||||
const createKeyboardEvents = (
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
onSelect,
|
||||
items
|
||||
) => {
|
||||
const events = {
|
||||
ArrowUp: createAction(moveSelectionUp),
|
||||
'Control+KeyP': createAction(moveSelectionUp),
|
||||
ArrowDown: createAction(moveSelectionDown),
|
||||
'Control+KeyN': createAction(moveSelectionDown),
|
||||
};
|
||||
|
||||
// Adds an event handler for the Enter key if the onSelect function is provided.
|
||||
if (typeof onSelect === 'function') {
|
||||
events.Enter = createAction(() => items.value?.length > 0 && onSelect());
|
||||
}
|
||||
|
||||
return events;
|
||||
};
|
||||
|
||||
/**
|
||||
* Updates the selection index based on the current index, total number of items, and direction of movement.
|
||||
*
|
||||
* @param {number} currentIndex - The current index of the selected item.
|
||||
* @param {number} itemsLength - The total number of items in the list.
|
||||
* @param {string} direction - The direction of movement, either 'up' or 'down'.
|
||||
* @returns {number} The new index after moving in the specified direction.
|
||||
*/
|
||||
const updateSelectionIndex = (currentIndex, itemsLength, direction) => {
|
||||
// If the selected index is the first item, move to the last item
|
||||
// If the selected index is the last item, move to the first item
|
||||
if (direction === 'up') {
|
||||
return currentIndex === 0 ? itemsLength - 1 : currentIndex - 1;
|
||||
}
|
||||
return currentIndex === itemsLength - 1 ? 0 : currentIndex + 1;
|
||||
};
|
||||
|
||||
/**
|
||||
* A composable for handling keyboard navigation in mention selection scenarios.
|
||||
*
|
||||
* @param {Object} options - The options for the composable.
|
||||
* @param {import('vue').Ref<HTMLElement>} options.elementRef - A ref to the DOM element that will receive keyboard events.
|
||||
* @param {import('vue').Ref<Array>} options.items - A ref to the array of selectable items.
|
||||
* @param {Function} [options.onSelect] - An optional function to be called when an item is selected.
|
||||
* @param {Function} options.adjustScroll - A function to adjust the scroll position after selection changes.
|
||||
* @param {import('vue').Ref<number>} options.selectedIndex - A ref to the currently selected index.
|
||||
* @returns {{
|
||||
* moveSelectionUp: Function,
|
||||
* moveSelectionDown: Function
|
||||
* }} An object containing functions to move the selection up and down.
|
||||
*/
|
||||
export function useKeyboardNavigableList({
|
||||
items,
|
||||
onSelect,
|
||||
adjustScroll,
|
||||
selectedIndex,
|
||||
}) {
|
||||
const moveSelection = direction => {
|
||||
selectedIndex.value = updateSelectionIndex(
|
||||
selectedIndex.value,
|
||||
items.value.length,
|
||||
direction
|
||||
);
|
||||
adjustScroll();
|
||||
};
|
||||
|
||||
const moveSelectionUp = () => moveSelection('up');
|
||||
const moveSelectionDown = () => moveSelection('down');
|
||||
|
||||
const keyboardEvents = createKeyboardEvents(
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
onSelect,
|
||||
items
|
||||
);
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
return {
|
||||
moveSelectionUp,
|
||||
moveSelectionDown,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { computed } from 'vue';
|
||||
import { useStoreGetters } from 'dashboard/composables/store';
|
||||
import { PRIORITY_CONDITION_VALUES } from 'dashboard/constants/automation';
|
||||
|
||||
/**
|
||||
* Composable for handling macro-related functionality
|
||||
* @returns {Object} An object containing the getMacroDropdownValues function
|
||||
*/
|
||||
export const useMacros = () => {
|
||||
const getters = useStoreGetters();
|
||||
|
||||
const labels = computed(() => getters['labels/getLabels'].value);
|
||||
const teams = computed(() => getters['teams/getTeams'].value);
|
||||
const agents = computed(() => getters['agents/getAgents'].value);
|
||||
|
||||
/**
|
||||
* Get dropdown values based on the specified type
|
||||
* @param {string} type - The type of dropdown values to retrieve
|
||||
* @returns {Array} An array of dropdown values
|
||||
*/
|
||||
const getMacroDropdownValues = type => {
|
||||
switch (type) {
|
||||
case 'assign_team':
|
||||
case 'send_email_to_team':
|
||||
return teams.value;
|
||||
case 'assign_agent':
|
||||
return [{ id: 'self', name: 'Self' }, ...agents.value];
|
||||
case 'add_label':
|
||||
case 'remove_label':
|
||||
return labels.value.map(i => ({
|
||||
id: i.title,
|
||||
name: i.title,
|
||||
}));
|
||||
case 'change_priority':
|
||||
return PRIORITY_CONDITION_VALUES;
|
||||
default:
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
getMacroDropdownValues,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
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',
|
||||
},
|
||||
];
|
||||
@@ -108,3 +108,12 @@ export const hasValidAvatarUrl = avatarUrl => {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const timeStampAppendedURL = dataUrl => {
|
||||
const url = new URL(dataUrl);
|
||||
if (!url.searchParams.has('t')) {
|
||||
url.searchParams.append('t', Date.now());
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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,
|
||||
];
|
||||
};
|
||||
@@ -30,6 +30,7 @@ const translationKeys = {
|
||||
'accountuser:create': `AUDIT_LOGS.ACCOUNT_USER.ADD`,
|
||||
'accountuser:update:self': `AUDIT_LOGS.ACCOUNT_USER.EDIT.SELF`,
|
||||
'accountuser:update:other': `AUDIT_LOGS.ACCOUNT_USER.EDIT.OTHER`,
|
||||
'accountuser:update:deleted': `AUDIT_LOGS.ACCOUNT_USER.EDIT.DELETED`,
|
||||
'inboxmember:create': `AUDIT_LOGS.INBOX_MEMBER.ADD`,
|
||||
'inboxmember:destroy': `AUDIT_LOGS.INBOX_MEMBER.REMOVE`,
|
||||
'teammember:create': `AUDIT_LOGS.TEAM_MEMBER.ADD`,
|
||||
@@ -89,9 +90,9 @@ function handleAccountUserCreate(auditLogItem, translationPayload, agentList) {
|
||||
}
|
||||
|
||||
function handleAccountUserUpdate(auditLogItem, translationPayload, agentList) {
|
||||
if (auditLogItem.user_id !== auditLogItem.auditable.user_id) {
|
||||
if (auditLogItem.user_id !== auditLogItem.auditable?.user_id) {
|
||||
translationPayload.user = getAgentName(
|
||||
auditLogItem.auditable.user_id,
|
||||
auditLogItem.auditable?.user_id,
|
||||
agentList
|
||||
);
|
||||
}
|
||||
@@ -187,16 +188,25 @@ export function generateTranslationPayload(auditLogItem, agentList) {
|
||||
return translationPayload;
|
||||
}
|
||||
|
||||
function getAccountUserUpdateSuffix(auditLogItem) {
|
||||
if (auditLogItem.auditable === null) {
|
||||
// If the user is deleted, we don't need to check if the user is the same as the auditLogItem.user_id
|
||||
// Else we can use the deleted translation key
|
||||
// It doesn't need the agent name
|
||||
return ':deleted';
|
||||
}
|
||||
return auditLogItem.user_id === auditLogItem.auditable.user_id
|
||||
? ':self'
|
||||
: ':other';
|
||||
}
|
||||
|
||||
export const generateLogActionKey = auditLogItem => {
|
||||
const auditableType = auditLogItem.auditable_type.toLowerCase();
|
||||
const action = auditLogItem.action.toLowerCase();
|
||||
let logActionKey = `${auditableType}:${action}`;
|
||||
|
||||
if (auditableType === 'accountuser' && action === 'update') {
|
||||
logActionKey +=
|
||||
auditLogItem.user_id === auditLogItem.auditable.user_id
|
||||
? ':self'
|
||||
: ':other';
|
||||
logActionKey += getAccountUserUpdateSuffix(auditLogItem);
|
||||
}
|
||||
|
||||
return translationKeys[logActionKey] || '';
|
||||
|
||||
@@ -3,41 +3,16 @@ 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 = {
|
||||
@@ -198,45 +173,16 @@ export const getFileName = (action, files = []) => {
|
||||
|
||||
export const getDefaultConditions = eventName => {
|
||||
if (eventName === 'message_created') {
|
||||
return [
|
||||
{
|
||||
attribute_key: 'message_type',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
return DEFAULT_MESSAGE_CREATED_CONDITION;
|
||||
}
|
||||
if (eventName === 'conversation_opened') {
|
||||
return [
|
||||
{
|
||||
attribute_key: 'browser_language',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
return DEFAULT_CONVERSATION_OPENED_CONDITION;
|
||||
}
|
||||
return [
|
||||
{
|
||||
attribute_key: 'status',
|
||||
filter_operator: 'equal_to',
|
||||
values: '',
|
||||
query_operator: 'and',
|
||||
custom_attribute_type: '',
|
||||
},
|
||||
];
|
||||
return DEFAULT_OTHER_CONDITION;
|
||||
};
|
||||
|
||||
export const getDefaultActions = () => {
|
||||
return [
|
||||
{
|
||||
action_name: 'assign_agent',
|
||||
action_params: [],
|
||||
},
|
||||
];
|
||||
return DEFAULT_ACTIONS;
|
||||
};
|
||||
|
||||
export const filterCustomAttributes = customAttributes => {
|
||||
@@ -297,3 +243,100 @@ 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
-2
@@ -8,7 +8,7 @@ import {
|
||||
CMD_SEND_TRANSCRIPT,
|
||||
CMD_SNOOZE_CONVERSATION,
|
||||
CMD_UNMUTE_CONVERSATION,
|
||||
} from './commandBarBusEvents';
|
||||
} from 'dashboard/helper/commandbar/events';
|
||||
|
||||
import {
|
||||
ICON_MUTE_CONVERSATION,
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
ICON_SEND_TRANSCRIPT,
|
||||
ICON_SNOOZE_CONVERSATION,
|
||||
ICON_UNMUTE_CONVERSATION,
|
||||
} from './CommandBarIcons';
|
||||
} from 'dashboard/helper/commandbar/icons';
|
||||
|
||||
const SNOOZE_OPTIONS = wootConstants.SNOOZE_OPTIONS;
|
||||
|
||||
@@ -46,6 +46,7 @@ export const SNOOZE_CONVERSATION_ACTIONS = [
|
||||
{
|
||||
id: 'snooze_conversation',
|
||||
title: 'COMMAND_BAR.COMMANDS.SNOOZE_CONVERSATION',
|
||||
section: 'COMMAND_BAR.SECTIONS.CONVERSATION',
|
||||
icon: ICON_SNOOZE_CONVERSATION,
|
||||
children: Object.values(SNOOZE_OPTIONS),
|
||||
},
|
||||
@@ -0,0 +1,94 @@
|
||||
/**
|
||||
* Determines the last non-activity message between store and API messages.
|
||||
* @param {Object} messageInStore - The last non-activity message from the store.
|
||||
* @param {Object} messageFromAPI - The last non-activity message from the API.
|
||||
* @returns {Object} The latest non-activity message.
|
||||
*/
|
||||
const getLastNonActivityMessage = (messageInStore, messageFromAPI) => {
|
||||
// If both API value and store value for last non activity message
|
||||
// are available, then return the latest one.
|
||||
if (messageInStore && messageFromAPI) {
|
||||
return messageInStore.created_at >= messageFromAPI.created_at
|
||||
? messageInStore
|
||||
: messageFromAPI;
|
||||
}
|
||||
// Otherwise, return whichever is available
|
||||
return messageInStore || messageFromAPI;
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters out duplicate source messages from an array of messages.
|
||||
* @param {Array} messages - The array of messages to filter.
|
||||
* @returns {Array} An array of messages without duplicates.
|
||||
*/
|
||||
export const filterDuplicateSourceMessages = (messages = []) => {
|
||||
const messagesWithoutDuplicates = [];
|
||||
// We cannot use Map or any short hand method as it returns the last message with the duplicate ID
|
||||
// We should return the message with smaller id when there is a duplicate
|
||||
messages.forEach(m1 => {
|
||||
if (m1.source_id) {
|
||||
const index = messagesWithoutDuplicates.findIndex(
|
||||
m2 => m1.source_id === m2.source_id
|
||||
);
|
||||
|
||||
if (index < 0) {
|
||||
messagesWithoutDuplicates.push(m1);
|
||||
}
|
||||
} else {
|
||||
messagesWithoutDuplicates.push(m1);
|
||||
}
|
||||
});
|
||||
return messagesWithoutDuplicates;
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves the last message from a conversation, prioritizing non-activity messages.
|
||||
* @param {Object} m - The conversation object containing messages.
|
||||
* @returns {Object} The last message of the conversation.
|
||||
*/
|
||||
export const getLastMessage = m => {
|
||||
const lastMessageIncludingActivity = m.messages[m.messages.length - 1];
|
||||
|
||||
const nonActivityMessages = m.messages.filter(
|
||||
message => message.message_type !== 2
|
||||
);
|
||||
const lastNonActivityMessageInStore =
|
||||
nonActivityMessages[nonActivityMessages.length - 1];
|
||||
|
||||
const lastNonActivityMessageFromAPI = m.last_non_activity_message;
|
||||
|
||||
// If API value and store value for last non activity message
|
||||
// is empty, then return the last activity message
|
||||
if (!lastNonActivityMessageInStore && !lastNonActivityMessageFromAPI) {
|
||||
return lastMessageIncludingActivity;
|
||||
}
|
||||
|
||||
return getLastNonActivityMessage(
|
||||
lastNonActivityMessageInStore,
|
||||
lastNonActivityMessageFromAPI
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters messages that have been read by the agent.
|
||||
* @param {Array} messages - The array of messages to filter.
|
||||
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
|
||||
* @returns {Array} An array of read messages.
|
||||
*/
|
||||
export const getReadMessages = (messages, agentLastSeenAt) => {
|
||||
return messages.filter(
|
||||
message => message.created_at * 1000 <= agentLastSeenAt * 1000
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Filters messages that have not been read by the agent.
|
||||
* @param {Array} messages - The array of messages to filter.
|
||||
* @param {number} agentLastSeenAt - The timestamp of when the agent last saw the messages.
|
||||
* @returns {Array} An array of unread messages.
|
||||
*/
|
||||
export const getUnreadMessages = (messages, agentLastSeenAt) => {
|
||||
return messages.filter(
|
||||
message => message.created_at * 1000 > agentLastSeenAt * 1000
|
||||
);
|
||||
};
|
||||
@@ -1,18 +1,21 @@
|
||||
const FEATURE_HELP_URLS = {
|
||||
agent_bots: 'https://chwt.app/hc/agent-bots',
|
||||
agents: 'https://chwt.app/hc/agents',
|
||||
audit_logs: 'https://chwt.app/hc/audit-logs',
|
||||
campaigns: 'https://chwt.app/hc/campaigns',
|
||||
canned_responses: 'https://chwt.app/hc/canned',
|
||||
channel_email: 'https://chwt.app/hc/email',
|
||||
channel_facebook: 'https://chwt.app/hc/fb',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
agent_bots: 'https://chwt.app/hc/agent-bots',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
labels: 'https://chwt.app/hc/labels',
|
||||
custom_attributes: 'https://chwt.app/hc/custom-attributes',
|
||||
canned_responses: 'https://chwt.app/hc/canned',
|
||||
integrations: 'https://chwt.app/hc/integrations',
|
||||
campaigns: 'https://chwt.app/hc/campaigns',
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
message_reply_to: 'https://chwt.app/hc/reply-to',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
|
||||
help_center: 'https://chwt.app/hc/help-center',
|
||||
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',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -7,6 +7,15 @@ 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;
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user