Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5bce6707d2 | ||
|
|
bb74c621b5 | ||
|
|
185a122ec7 | ||
|
|
05dece826d | ||
|
|
528b984a8d | ||
|
|
db0e654c03 | ||
|
|
e99e6a8443 | ||
|
|
d6fd3ce048 | ||
|
|
8a2f652b94 | ||
|
|
a3732c8f51 | ||
|
|
3a0e68030a | ||
|
|
f087461abc | ||
|
|
3a47b7e3d1 | ||
|
|
6dda1e8c8f | ||
|
|
098825c149 |
@@ -96,12 +96,12 @@ gem 'koala'
|
||||
# slack client
|
||||
gem 'slack-ruby-client', '~> 2.2.0'
|
||||
# for dialogflow integrations
|
||||
gem 'google-cloud-dialogflow-v2'
|
||||
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
|
||||
gem 'grpc'
|
||||
# Translate integrations
|
||||
# 'google-cloud-translate' gem depends on faraday 2.0 version
|
||||
# this dependency breaks the slack-ruby-client gem
|
||||
gem 'google-cloud-translate-v3'
|
||||
gem 'google-cloud-translate-v3', '>= 0.7.0'
|
||||
|
||||
##-- apm and error monitoring ---#
|
||||
# loaded only when environment variables are set.
|
||||
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
|
||||
|
||||
# need for google auth
|
||||
gem 'omniauth', '>= 2.1.2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.2'
|
||||
gem 'omniauth-google-oauth2', '>= 1.1.3'
|
||||
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
|
||||
|
||||
## Gems for reponse bot
|
||||
@@ -200,7 +200,7 @@ group :development do
|
||||
gem 'rack-mini-profiler', '>= 3.2.0', require: false
|
||||
gem 'stackprof'
|
||||
# Should install the associated chrome extension to view query logs
|
||||
gem 'meta_request', '>= 0.8.0'
|
||||
gem 'meta_request', '>= 0.8.3'
|
||||
end
|
||||
|
||||
group :test do
|
||||
|
||||
+18
-18
@@ -257,7 +257,7 @@ GEM
|
||||
faraday-net_http_persistent (2.1.0)
|
||||
faraday (~> 2.5)
|
||||
net-http-persistent (~> 4.0)
|
||||
faraday-retry (2.1.0)
|
||||
faraday-retry (2.2.1)
|
||||
faraday (~> 2.0)
|
||||
fcm (1.0.8)
|
||||
faraday (>= 1.0.0, < 3.0)
|
||||
@@ -271,7 +271,7 @@ GEM
|
||||
fugit (1.11.1)
|
||||
et-orbi (~> 1, >= 1.2.11)
|
||||
raabro (~> 1.4)
|
||||
gapic-common (0.18.0)
|
||||
gapic-common (0.20.0)
|
||||
faraday (>= 1.9, < 3.a)
|
||||
faraday-retry (>= 1.0, < 3.a)
|
||||
google-protobuf (~> 3.14)
|
||||
@@ -301,15 +301,15 @@ GEM
|
||||
google-cloud-core (1.6.0)
|
||||
google-cloud-env (~> 1.0)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-dialogflow-v2 (0.23.0)
|
||||
gapic-common (>= 0.18.0, < 2.a)
|
||||
google-cloud-dialogflow-v2 (0.31.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-location (>= 0.4, < 2.a)
|
||||
google-cloud-env (1.6.0)
|
||||
faraday (>= 0.17.3, < 3.0)
|
||||
google-cloud-errors (1.3.1)
|
||||
google-cloud-location (0.4.0)
|
||||
gapic-common (>= 0.17.1, < 2.a)
|
||||
google-cloud-location (0.6.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-cloud-storage (1.44.0)
|
||||
addressable (~> 2.8)
|
||||
@@ -319,17 +319,17 @@ GEM
|
||||
google-cloud-core (~> 1.6)
|
||||
googleauth (>= 0.16.2, < 2.a)
|
||||
mini_mime (~> 1.0)
|
||||
google-cloud-translate-v3 (0.6.0)
|
||||
gapic-common (>= 0.17.1, < 2.a)
|
||||
google-cloud-translate-v3 (0.10.0)
|
||||
gapic-common (>= 0.20.0, < 2.a)
|
||||
google-cloud-errors (~> 1.0)
|
||||
google-protobuf (3.25.3)
|
||||
google-protobuf (3.25.3-arm64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-darwin)
|
||||
google-protobuf (3.25.3-x86_64-linux)
|
||||
googleapis-common-protos (1.4.0)
|
||||
google-protobuf (~> 3.14)
|
||||
googleapis-common-protos-types (~> 1.2)
|
||||
grpc (~> 1.27)
|
||||
googleapis-common-protos (1.6.0)
|
||||
google-protobuf (>= 3.18, < 5.a)
|
||||
googleapis-common-protos-types (~> 1.7)
|
||||
grpc (~> 1.41)
|
||||
googleapis-common-protos-types (1.14.0)
|
||||
google-protobuf (~> 3.18)
|
||||
googleauth (1.5.2)
|
||||
@@ -457,7 +457,7 @@ GEM
|
||||
marcel (1.0.4)
|
||||
maxminddb (0.1.22)
|
||||
memoist (0.16.2)
|
||||
meta_request (0.8.2)
|
||||
meta_request (0.8.3)
|
||||
rack-contrib (>= 1.1, < 3)
|
||||
railties (>= 3.0.0, < 8)
|
||||
method_source (1.1.0)
|
||||
@@ -522,7 +522,7 @@ GEM
|
||||
hashie (>= 3.4.6)
|
||||
rack (>= 2.2.3)
|
||||
rack-protection
|
||||
omniauth-google-oauth2 (1.1.2)
|
||||
omniauth-google-oauth2 (1.1.3)
|
||||
jwt (>= 2.0)
|
||||
oauth2 (~> 2.0)
|
||||
omniauth (~> 2.0)
|
||||
@@ -881,9 +881,9 @@ DEPENDENCIES
|
||||
foreman
|
||||
geocoder
|
||||
gmail_xoauth
|
||||
google-cloud-dialogflow-v2
|
||||
google-cloud-dialogflow-v2 (>= 0.24.0)
|
||||
google-cloud-storage
|
||||
google-cloud-translate-v3
|
||||
google-cloud-translate-v3 (>= 0.7.0)
|
||||
groupdate
|
||||
grpc
|
||||
haikunator
|
||||
@@ -903,14 +903,14 @@ DEPENDENCIES
|
||||
listen
|
||||
lograge (~> 0.14.0)
|
||||
maxminddb
|
||||
meta_request (>= 0.8.0)
|
||||
meta_request (>= 0.8.3)
|
||||
mock_redis
|
||||
neighbor
|
||||
net-smtp (~> 0.3.4)
|
||||
newrelic-sidekiq-metrics (>= 1.6.2)
|
||||
newrelic_rpm
|
||||
omniauth (>= 2.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.2)
|
||||
omniauth-google-oauth2 (>= 1.1.3)
|
||||
omniauth-oauth2
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
pg
|
||||
|
||||
@@ -94,8 +94,8 @@ class AccountBuilder
|
||||
|
||||
def blocked_domains
|
||||
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
|
||||
domains.split("\n").map(&:strip) if domains.present?
|
||||
return [] if domains.blank?
|
||||
|
||||
[]
|
||||
domains.split("\n").map(&:strip)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,13 +1,68 @@
|
||||
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
|
||||
def create
|
||||
file_blob = ActiveStorage::Blob.create_and_upload!(
|
||||
key: nil,
|
||||
io: params[:attachment].tempfile,
|
||||
filename: params[:attachment].original_filename,
|
||||
content_type: params[:attachment].content_type
|
||||
)
|
||||
file_blob.save!
|
||||
result = if params[:attachment].present?
|
||||
create_from_file
|
||||
elsif params[:external_url].present?
|
||||
create_from_url
|
||||
else
|
||||
render_error('No file or URL provided', :unprocessable_entity)
|
||||
end
|
||||
|
||||
render_success(result) if result.is_a?(ActiveStorage::Blob)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def create_from_file
|
||||
attachment = params[:attachment]
|
||||
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
|
||||
end
|
||||
|
||||
def create_from_url
|
||||
uri = parse_uri(params[:external_url])
|
||||
return if performed?
|
||||
|
||||
fetch_and_process_file_from_uri(uri)
|
||||
end
|
||||
|
||||
def parse_uri(url)
|
||||
uri = URI.parse(url)
|
||||
validate_uri(uri)
|
||||
uri
|
||||
rescue URI::InvalidURIError, SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
nil
|
||||
end
|
||||
|
||||
def validate_uri(uri)
|
||||
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
|
||||
end
|
||||
|
||||
def fetch_and_process_file_from_uri(uri)
|
||||
uri.open do |file|
|
||||
create_and_save_blob(file, File.basename(uri.path), file.content_type)
|
||||
end
|
||||
rescue OpenURI::HTTPError => e
|
||||
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
|
||||
rescue SocketError
|
||||
render_error('Invalid URL provided', :unprocessable_entity)
|
||||
rescue StandardError
|
||||
render_error('An unexpected error occurred', :internal_server_error)
|
||||
end
|
||||
|
||||
def create_and_save_blob(io, filename, content_type)
|
||||
ActiveStorage::Blob.create_and_upload!(
|
||||
io: io,
|
||||
filename: filename,
|
||||
content_type: content_type
|
||||
)
|
||||
end
|
||||
|
||||
def render_success(file_blob)
|
||||
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
|
||||
end
|
||||
|
||||
def render_error(message, status)
|
||||
render json: { error: message }, status: status
|
||||
end
|
||||
end
|
||||
|
||||
@@ -117,35 +117,41 @@ export default {
|
||||
lastConversationIndex,
|
||||
};
|
||||
};
|
||||
const handlePreviousConversation = () => {
|
||||
const { allConversations, activeConversationIndex } =
|
||||
getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[0].click();
|
||||
}
|
||||
if (activeConversationIndex >= 1) {
|
||||
allConversations[activeConversationIndex - 1].click();
|
||||
}
|
||||
};
|
||||
const handleNextConversation = () => {
|
||||
const handleConversationNavigation = direction => {
|
||||
const {
|
||||
allConversations,
|
||||
activeConversationIndex,
|
||||
lastConversationIndex,
|
||||
} = getKeyboardListenerParams();
|
||||
if (activeConversationIndex === -1) {
|
||||
allConversations[lastConversationIndex].click();
|
||||
} else if (activeConversationIndex < lastConversationIndex) {
|
||||
allConversations[activeConversationIndex + 1].click();
|
||||
|
||||
// Determine the new index based on the direction
|
||||
const newIndex =
|
||||
direction === 'previous'
|
||||
? activeConversationIndex - 1
|
||||
: activeConversationIndex + 1;
|
||||
|
||||
// Check if the new index is within the valid range
|
||||
if (
|
||||
allConversations.length > 0 &&
|
||||
newIndex >= 0 &&
|
||||
newIndex <= lastConversationIndex
|
||||
) {
|
||||
// Click the conversation at the new index
|
||||
allConversations[newIndex].click();
|
||||
} else if (allConversations.length > 0) {
|
||||
// If the new index is out of range, click the first or last conversation based on the direction
|
||||
const fallbackIndex =
|
||||
direction === 'previous' ? 0 : lastConversationIndex;
|
||||
allConversations[fallbackIndex].click();
|
||||
}
|
||||
};
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyJ': {
|
||||
action: () => handlePreviousConversation(),
|
||||
action: () => handleConversationNavigation('previous'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
'Alt+KeyK': {
|
||||
action: () => handleNextConversation(),
|
||||
action: () => handleConversationNavigation('next'),
|
||||
allowOnFocusedInput: true,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -331,10 +331,12 @@ export default {
|
||||
::v-deep {
|
||||
.selector-wrap {
|
||||
@apply m-0 top-1;
|
||||
|
||||
.selector-name {
|
||||
@apply ml-0;
|
||||
}
|
||||
}
|
||||
|
||||
.name {
|
||||
@apply ml-0;
|
||||
}
|
||||
|
||||
@@ -1,38 +1,34 @@
|
||||
<script>
|
||||
export default {
|
||||
props: {
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 120,
|
||||
},
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showMore: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
textToBeDisplayed() {
|
||||
if (this.showMore || this.text.length <= this.limit) {
|
||||
return this.text;
|
||||
}
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
|
||||
return this.text.slice(0, this.limit) + '...';
|
||||
},
|
||||
buttonLabel() {
|
||||
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||
},
|
||||
const props = defineProps({
|
||||
text: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
methods: {
|
||||
toggleShowMore() {
|
||||
this.showMore = !this.showMore;
|
||||
},
|
||||
limit: {
|
||||
type: Number,
|
||||
default: 120,
|
||||
},
|
||||
});
|
||||
const { t } = useI18n();
|
||||
const showMore = ref(false);
|
||||
|
||||
const textToBeDisplayed = computed(() => {
|
||||
if (showMore.value || props.text.length <= props.limit) {
|
||||
return props.text;
|
||||
}
|
||||
|
||||
return props.text.slice(0, props.limit) + '...';
|
||||
});
|
||||
const buttonLabel = computed(() => {
|
||||
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
|
||||
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
|
||||
});
|
||||
|
||||
const toggleShowMore = () => {
|
||||
showMore.value = !showMore.value;
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -41,16 +37,10 @@ export default {
|
||||
{{ textToBeDisplayed }}
|
||||
<button
|
||||
v-if="text.length > limit"
|
||||
class="show-more--button"
|
||||
class="text-woot-500 !p-0 !border-0 align-top"
|
||||
@click="toggleShowMore"
|
||||
>
|
||||
{{ buttonLabel }}
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.show-more--button {
|
||||
color: var(--w-500);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -17,6 +17,8 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import TagAgents from '../conversation/TagAgents.vue';
|
||||
import CannedResponse from '../conversation/CannedResponse.vue';
|
||||
import VariableList from '../conversation/VariableList.vue';
|
||||
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
|
||||
|
||||
import {
|
||||
appendSignature,
|
||||
removeSignature,
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
scrollCursorIntoView,
|
||||
findNodeToInsertImage,
|
||||
setURLWithQueryAndSize,
|
||||
getContentNode,
|
||||
} from 'dashboard/helper/editorHelper';
|
||||
|
||||
const TYPING_INDICATOR_IDLE_TIME = 4000;
|
||||
@@ -35,10 +38,8 @@ import {
|
||||
} from 'shared/helpers/KeyboardHelpers';
|
||||
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import {
|
||||
replaceVariablesInMessage,
|
||||
createTypingIndicator,
|
||||
} from '@chatwoot/utils';
|
||||
|
||||
import { createTypingIndicator } from '@chatwoot/utils';
|
||||
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
|
||||
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
|
||||
import { uploadFile } from 'dashboard/helper/uploadHelper';
|
||||
@@ -71,7 +72,12 @@ const createState = (
|
||||
|
||||
export default {
|
||||
name: 'WootMessageEditor',
|
||||
components: { TagAgents, CannedResponse, VariableList },
|
||||
components: {
|
||||
TagAgents,
|
||||
CannedResponse,
|
||||
VariableList,
|
||||
KeyboardEmojiSelector,
|
||||
},
|
||||
mixins: [keyboardEventListenerMixins],
|
||||
props: {
|
||||
value: { type: String, default: '' },
|
||||
@@ -119,9 +125,11 @@ export default {
|
||||
showUserMentions: false,
|
||||
showCannedMenu: false,
|
||||
showVariables: false,
|
||||
showEmojiMenu: false,
|
||||
mentionSearchKey: '',
|
||||
cannedSearchTerm: '',
|
||||
variableSearchTerm: '',
|
||||
emojiSearchTerm: '',
|
||||
editorView: null,
|
||||
range: null,
|
||||
state: undefined,
|
||||
@@ -169,7 +177,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.mentionSearchKey = args.text.replace('@', '');
|
||||
this.mentionSearchKey = args.text;
|
||||
|
||||
return false;
|
||||
},
|
||||
@@ -198,7 +206,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.cannedSearchTerm = args.text.replace('/', '');
|
||||
this.cannedSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
@@ -226,7 +234,7 @@ export default {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
|
||||
this.variableSearchTerm = args.text.replace('{{', '');
|
||||
this.variableSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
@@ -238,6 +246,31 @@ export default {
|
||||
return event.keyCode === 13 && this.showVariables;
|
||||
},
|
||||
}),
|
||||
suggestionsPlugin({
|
||||
matcher: triggerCharacters(':', 1), // Trigger after ':' and at least 1 characters
|
||||
suggestionClass: '',
|
||||
onEnter: args => {
|
||||
this.showEmojiMenu = true;
|
||||
this.emojiSearchTerm = args.text || '';
|
||||
this.range = args.range;
|
||||
this.editorView = args.view;
|
||||
return false;
|
||||
},
|
||||
onChange: args => {
|
||||
this.editorView = args.view;
|
||||
this.range = args.range;
|
||||
this.emojiSearchTerm = args.text;
|
||||
return false;
|
||||
},
|
||||
onExit: () => {
|
||||
this.emojiSearchTerm = '';
|
||||
this.showEmojiMenu = false;
|
||||
return false;
|
||||
},
|
||||
onKeyDown: ({ event }) => {
|
||||
return event.keyCode === 13 && this.showEmojiMenu;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
sendWithSignature() {
|
||||
@@ -267,6 +300,8 @@ export default {
|
||||
},
|
||||
editorId() {
|
||||
this.showCannedMenu = false;
|
||||
this.showEmojiMenu = false;
|
||||
this.showVariables = false;
|
||||
this.cannedSearchTerm = '';
|
||||
this.reloadState(this.value);
|
||||
},
|
||||
@@ -517,57 +552,36 @@ export default {
|
||||
this.editorView.dispatch(tr.setSelection(selection));
|
||||
this.editorView.focus();
|
||||
},
|
||||
insertMentionNode(mentionItem) {
|
||||
/**
|
||||
* Inserts special content (mention, canned response, variable, emoji) into the editor.
|
||||
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
|
||||
* @param {Object|string} content - The content to insert, depending on the type.
|
||||
*/
|
||||
insertSpecialContent(type, content) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
const node = this.editorView.state.schema.nodes.mention.create({
|
||||
userId: mentionItem.id,
|
||||
userFullName: mentionItem.name,
|
||||
});
|
||||
|
||||
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
|
||||
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
|
||||
|
||||
return false;
|
||||
},
|
||||
insertCannedResponse(cannedItem) {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message: cannedItem,
|
||||
variables: this.variables,
|
||||
});
|
||||
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
return;
|
||||
}
|
||||
|
||||
let node = new MessageMarkdownTransformer(messageSchema).parse(
|
||||
updatedMessage
|
||||
let { node, from, to } = getContentNode(
|
||||
this.editorView,
|
||||
type,
|
||||
content,
|
||||
this.range,
|
||||
this.variables
|
||||
);
|
||||
|
||||
const from =
|
||||
node.textContent === updatedMessage
|
||||
? this.range.from
|
||||
: this.range.from - 1;
|
||||
|
||||
this.insertNodeIntoEditor(node, from, this.range.to);
|
||||
|
||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
|
||||
return false;
|
||||
},
|
||||
insertVariable(variable) {
|
||||
if (!this.editorView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = `{{${variable}}}`;
|
||||
let node = this.editorView.state.schema.text(content);
|
||||
const { from, to } = this.range;
|
||||
if (!node) return;
|
||||
|
||||
this.insertNodeIntoEditor(node, from, to);
|
||||
this.showVariables = false;
|
||||
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
|
||||
return false;
|
||||
|
||||
const event_map = {
|
||||
mention: CONVERSATION_EVENTS.USED_MENTIONS,
|
||||
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
|
||||
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
|
||||
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
|
||||
};
|
||||
|
||||
this.$track(event_map[type]);
|
||||
},
|
||||
openFileBrowser() {
|
||||
this.$refs.imageUpload.click();
|
||||
@@ -687,17 +701,22 @@ export default {
|
||||
<TagAgents
|
||||
v-if="showUserMentions && isPrivate"
|
||||
:search-key="mentionSearchKey"
|
||||
@click="insertMentionNode"
|
||||
@click="content => insertSpecialContent('mention', content)"
|
||||
/>
|
||||
<CannedResponse
|
||||
v-if="shouldShowCannedResponses"
|
||||
:search-key="cannedSearchTerm"
|
||||
@click="insertCannedResponse"
|
||||
@click="content => insertSpecialContent('cannedResponse', content)"
|
||||
/>
|
||||
<VariableList
|
||||
v-if="shouldShowVariables"
|
||||
:search-key="variableSearchTerm"
|
||||
@click="insertVariable"
|
||||
@click="content => insertSpecialContent('variable', content)"
|
||||
/>
|
||||
<KeyboardEmojiSelector
|
||||
v-if="showEmojiMenu"
|
||||
:search-key="emojiSearchTerm"
|
||||
@click="emoji => insertSpecialContent('emoji', emoji)"
|
||||
/>
|
||||
<input
|
||||
ref="imageUpload"
|
||||
|
||||
@@ -120,7 +120,6 @@ export default {
|
||||
if (fileUrl) {
|
||||
this.onImageUploadStart(fileUrl);
|
||||
}
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
|
||||
}
|
||||
@@ -173,6 +172,18 @@ export default {
|
||||
blur: () => {
|
||||
this.onBlur();
|
||||
},
|
||||
paste: (view, event) => {
|
||||
const data = event.clipboardData.files;
|
||||
if (data.length > 0) {
|
||||
data.forEach(file => {
|
||||
// Check if the file is an image
|
||||
if (file.type.includes('image')) {
|
||||
this.uploadImageToStorage(file);
|
||||
}
|
||||
});
|
||||
event.preventDefault();
|
||||
}
|
||||
},
|
||||
},
|
||||
});
|
||||
},
|
||||
|
||||
@@ -3,6 +3,7 @@ import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import * as ActiveStorage from 'activestorage';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import {
|
||||
ALLOWED_FILE_TYPES,
|
||||
@@ -17,6 +18,7 @@ import { mapGetters } from 'vuex';
|
||||
export default {
|
||||
name: 'ReplyBottomPanel',
|
||||
components: { FileUpload, VideoCallButton, AIAssistanceButton },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
mode: {
|
||||
type: String,
|
||||
@@ -34,6 +36,13 @@ export default {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
// inbox prop is used in /mixins/inboxMixin,
|
||||
// remove this props when refactoring to composable if not needed
|
||||
// eslint-disable-next-line vue/no-unused-properties
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
showFileUpload: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
@@ -102,22 +111,6 @@ export default {
|
||||
type: String,
|
||||
required: true,
|
||||
},
|
||||
isALineChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isATwilioWhatsAppChannel: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAWebWidgetInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
isAPIInbox: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -218,6 +211,7 @@ export default {
|
||||
return !this.isOnPrivateNote;
|
||||
},
|
||||
sendWithSignature() {
|
||||
// channelType is sourced from inboxMixin
|
||||
return this.fetchSignatureFlagFromUISettings(this.channelType);
|
||||
},
|
||||
signatureToggleTooltip() {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<script setup>
|
||||
import { shallowRef, computed, onMounted } from 'vue';
|
||||
import emojis from 'shared/components/emoji/emojisGroup.json';
|
||||
import MentionBox from '../mentions/MentionBox.vue';
|
||||
|
||||
const props = defineProps({
|
||||
searchKey: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
|
||||
const allEmojis = shallowRef([]);
|
||||
|
||||
const items = computed(() => {
|
||||
if (!props.searchKey) return [];
|
||||
const searchTerm = props.searchKey.toLowerCase();
|
||||
return allEmojis.value.filter(emoji =>
|
||||
emoji.searchString.includes(searchTerm)
|
||||
);
|
||||
});
|
||||
|
||||
function loadEmojis() {
|
||||
allEmojis.value = emojis.flatMap(group =>
|
||||
group.emojis.map(emoji => ({
|
||||
...emoji,
|
||||
searchString: `${emoji.slug} ${emoji.name}`.toLowerCase(),
|
||||
}))
|
||||
);
|
||||
}
|
||||
|
||||
function handleMentionClick(item = {}) {
|
||||
emit('click', item.emoji);
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEmojis();
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
type="emoji"
|
||||
:items="items"
|
||||
@mentionSelect="handleMentionClick"
|
||||
>
|
||||
<template #default="{ item, selected }">
|
||||
<span
|
||||
class="max-w-full inline-flex items-center gap-0.5 min-w-0 mb-0 text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 truncate"
|
||||
>
|
||||
{{ item.emoji }}
|
||||
<p
|
||||
class="relative mb-0 truncate bottom-px"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': selected,
|
||||
'font-normal': !selected,
|
||||
}"
|
||||
>
|
||||
:{{ item.slug }}
|
||||
</p>
|
||||
</span>
|
||||
</template>
|
||||
</MentionBox>
|
||||
</template>
|
||||
@@ -41,14 +41,11 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
:items="items"
|
||||
@mentionSelect="handleMentionClick"
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<strong>{{ item.label }}</strong> - {{ item.description }}
|
||||
</template>
|
||||
</MentionBox>
|
||||
/>
|
||||
</template>
|
||||
|
||||
+5
-1
@@ -338,7 +338,11 @@ export default {
|
||||
:show-query-operator="i !== appliedFilters.length - 1"
|
||||
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
|
||||
grouped-filters
|
||||
:error-message="validationErrors[`filter_${i}`]"
|
||||
:error-message="
|
||||
validationErrors[`filter_${i}`]
|
||||
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
|
||||
: ''
|
||||
"
|
||||
@resetFilter="resetFilter(i, appliedFilters[i])"
|
||||
@removeFilter="removeFilter(i)"
|
||||
/>
|
||||
|
||||
@@ -6,7 +6,7 @@ import MessagePreview from './MessagePreview.vue';
|
||||
import router from '../../../routes';
|
||||
import { frontendURL, conversationUrl } from '../../../helper/URLHelper';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import ConversationContextMenu from './contextMenu/Index.vue';
|
||||
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
|
||||
import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
@@ -24,6 +24,7 @@ export default {
|
||||
PriorityMark,
|
||||
SLACardLabel,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
activeLabel: {
|
||||
type: String,
|
||||
@@ -66,14 +67,6 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { inbox_id: inboxId } = props.chat;
|
||||
const { inboxBadge, inbox } = useInbox({ inboxId });
|
||||
return {
|
||||
inboxBadge,
|
||||
inbox,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
hovered: false,
|
||||
@@ -128,6 +121,12 @@ export default {
|
||||
return getLastMessage(this.chat);
|
||||
},
|
||||
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
const stateInbox = this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
return stateInbox;
|
||||
},
|
||||
|
||||
showInboxName() {
|
||||
return (
|
||||
!this.hideInboxName &&
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
<script>
|
||||
import { ref } from 'vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import agentMixin from '../../../mixins/agentMixin.js';
|
||||
import BackButton from '../BackButton.vue';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import InboxName from '../InboxName.vue';
|
||||
import MoreActions from './MoreActions.vue';
|
||||
import Thumbnail from '../Thumbnail.vue';
|
||||
@@ -24,7 +22,7 @@ export default {
|
||||
SLACardLabel,
|
||||
Linear,
|
||||
},
|
||||
mixins: [agentMixin],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
chat: {
|
||||
type: Object,
|
||||
@@ -44,20 +42,12 @@ export default {
|
||||
},
|
||||
},
|
||||
setup(props, { emit }) {
|
||||
const conversationHeaderActionsRef = ref(null);
|
||||
const { inbox, inboxBadge } = useInbox({ inboxId: props.chat.inbox_id });
|
||||
const keyboardEvents = {
|
||||
'Alt+KeyO': {
|
||||
action: () => emit('contactPanelToggle'),
|
||||
},
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
|
||||
|
||||
return {
|
||||
conversationHeaderActionsRef,
|
||||
inbox,
|
||||
inboxBadge,
|
||||
};
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
@@ -112,6 +102,10 @@ export default {
|
||||
: this.$t('CONVERSATION.HEADER.OPEN')
|
||||
} ${this.$t('CONVERSATION.HEADER.DETAILS')}`;
|
||||
},
|
||||
inbox() {
|
||||
const { inbox_id: inboxId } = this.chat;
|
||||
return this.$store.getters['inboxes/getInbox'](inboxId);
|
||||
},
|
||||
hasMultipleInboxes() {
|
||||
return this.$store.getters['inboxes/getInboxes'].length > 1;
|
||||
},
|
||||
@@ -181,7 +175,6 @@ export default {
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref="conversationHeaderActionsRef"
|
||||
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
|
||||
>
|
||||
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -14,9 +15,7 @@ import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// mixins
|
||||
import { useInbox, INBOX_FEATURES } from 'shared/composables/useInbox';
|
||||
|
||||
import aiMixin from 'dashboard/mixins/aiMixin';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
|
||||
// utils
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
@@ -41,7 +40,7 @@ export default {
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
},
|
||||
mixins: [aiMixin],
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
isContactPanelOpen: {
|
||||
type: Boolean,
|
||||
@@ -53,17 +52,6 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
inbox,
|
||||
isAWhatsAppChannel,
|
||||
isAPIInbox,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAFacebookInbox,
|
||||
isAnEmailChannel,
|
||||
inboxHasFeature,
|
||||
} = useInbox({ getCurrentChat: true });
|
||||
const conversationFooterRef = ref(null);
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
@@ -81,22 +69,24 @@ export default {
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents, conversationFooterRef);
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
conversationFooterRef,
|
||||
isPopOutReplyBox,
|
||||
closePopOutReplyBox,
|
||||
showPopOutReplyBox,
|
||||
inbox,
|
||||
isAWhatsAppChannel,
|
||||
isAPIInbox,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAFacebookInbox,
|
||||
isAnEmailChannel,
|
||||
inboxHasFeature,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -131,6 +121,9 @@ export default {
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
'conversationTypingStatus/getUserList'
|
||||
@@ -534,7 +527,6 @@ export default {
|
||||
/>
|
||||
</ul>
|
||||
<div
|
||||
ref="conversationFooterRef"
|
||||
class="conversation-footer"
|
||||
:class="{ 'modal-mask': isPopOutReplyBox }"
|
||||
>
|
||||
|
||||
@@ -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 {
|
||||
@@ -27,7 +26,7 @@ import {
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import { useInbox, INBOX_FEATURES } from 'shared/composables/useInbox';
|
||||
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';
|
||||
@@ -61,7 +60,7 @@ export default {
|
||||
MessageSignatureMissingAlert,
|
||||
ArticleSearchPopover,
|
||||
},
|
||||
mixins: [messageFormatterMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
|
||||
props: {
|
||||
popoutReplyBox: {
|
||||
type: Boolean,
|
||||
@@ -69,22 +68,6 @@ export default {
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
is360DialogWhatsAppChannel,
|
||||
isAPIInbox,
|
||||
isAWhatsAppChannel,
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isASmsInbox,
|
||||
isAWebWidgetInbox,
|
||||
isAnEmailChannel,
|
||||
isATelegramChannel,
|
||||
isALineChannel,
|
||||
channelType,
|
||||
isAWhatsAppCloudChannel,
|
||||
isATwilioWhatsAppChannel,
|
||||
inbox,
|
||||
} = useInbox({ getCurrentChat: true });
|
||||
const {
|
||||
uiSettings,
|
||||
updateUISettings,
|
||||
@@ -97,20 +80,6 @@ export default {
|
||||
updateUISettings,
|
||||
isEditorHotKeyEnabled,
|
||||
fetchSignatureFlagFromUISettings,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAPIInbox,
|
||||
isAWhatsAppChannel,
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isASmsInbox,
|
||||
isAWebWidgetInbox,
|
||||
isAnEmailChannel,
|
||||
isATelegramChannel,
|
||||
isALineChannel,
|
||||
channelType,
|
||||
isAWhatsAppCloudChannel,
|
||||
isATwilioWhatsAppChannel,
|
||||
inbox,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
@@ -220,6 +189,9 @@ export default {
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
messagePlaceHolder() {
|
||||
return this.isPrivate
|
||||
? this.$t('CONVERSATION.FOOTER.PRIVATE_MSG_INPUT')
|
||||
@@ -265,6 +237,9 @@ export default {
|
||||
if (this.isASmsInbox) {
|
||||
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
|
||||
}
|
||||
if (this.isAnEmailChannel) {
|
||||
return MESSAGE_MAX_LENGTH.EMAIL;
|
||||
}
|
||||
return MESSAGE_MAX_LENGTH.GENERAL;
|
||||
},
|
||||
showFileUpload() {
|
||||
@@ -1230,12 +1205,6 @@ export default {
|
||||
:message="message"
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
:is-a-line-channel="isALineChannel"
|
||||
:is-a-twilio-whatsapp-channel="isATwilioWhatsAppChannel"
|
||||
:is-a-web-widget-inbox="isAWebWidgetInbox"
|
||||
:is-an-email-channel="isAnEmailChannel"
|
||||
:is-api-inbox="isAPIInbox"
|
||||
:channel-type="channelType"
|
||||
@selectWhatsappTemplate="openWhatsappTemplateModal"
|
||||
@toggleEditor="toggleRichContentEditor"
|
||||
@replaceText="replaceText"
|
||||
|
||||
@@ -56,20 +56,14 @@ export default {
|
||||
};
|
||||
</script>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-root-v-if -->
|
||||
<template>
|
||||
<MentionBox
|
||||
v-if="items.length"
|
||||
type="variable"
|
||||
:items="items"
|
||||
@mentionSelect="handleVariableClick"
|
||||
>
|
||||
<template slot-scope="{ item }">
|
||||
<span class="text-capitalize variable--list-label">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
({{ item.label }})
|
||||
</template>
|
||||
</MentionBox>
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import { messageTimestamp } from 'shared/helpers/timeHelper';
|
||||
|
||||
export default {
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
sender: {
|
||||
type: Object,
|
||||
@@ -54,33 +55,10 @@ export default {
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const {
|
||||
inbox,
|
||||
isAWhatsAppChannel,
|
||||
isATwilioChannel,
|
||||
isAFacebookInbox,
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isALineChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAPIInbox,
|
||||
isAnEmailChannel,
|
||||
} = useInbox({ inboxId: props.inboxId });
|
||||
return {
|
||||
inbox,
|
||||
isAWhatsAppChannel,
|
||||
isATwilioChannel,
|
||||
isAFacebookInbox,
|
||||
isASmsInbox,
|
||||
isATelegramChannel,
|
||||
isALineChannel,
|
||||
isAWebWidgetInbox,
|
||||
isAPIInbox,
|
||||
isAnEmailChannel,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
isIncoming() {
|
||||
return MESSAGE_TYPE.INCOMING === this.messageType;
|
||||
},
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
<script>
|
||||
import DyteVideoCall from './integrations/Dyte.vue';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
|
||||
export default {
|
||||
components: { DyteVideoCall },
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
messageId: {
|
||||
type: [String, Number],
|
||||
@@ -18,20 +19,14 @@ export default {
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isAPIInbox, isAWebWidgetInbox } = useInbox({
|
||||
inboxId: props.inboxId,
|
||||
});
|
||||
return {
|
||||
isAPIInbox,
|
||||
isAWebWidgetInbox,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
showDyteIntegration() {
|
||||
const isEnabledOnTheInbox = this.isAPIInbox || this.isAWebWidgetInbox;
|
||||
return isEnabledOnTheInbox && this.contentAttributes.type === 'dyte';
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
},
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -90,22 +90,24 @@ const variableKey = (item = {}) => {
|
||||
}"
|
||||
@click="onListItemSelection(index)"
|
||||
>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ variableKey(item) }}
|
||||
</p>
|
||||
<slot :item="item" :index="index" :selected="index === selectedIndex">
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ item.description }}
|
||||
</p>
|
||||
<p
|
||||
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
|
||||
:class="{
|
||||
'text-woot-500 dark:text-woot-500': index === selectedIndex,
|
||||
}"
|
||||
>
|
||||
{{ variableKey(item) }}
|
||||
</p>
|
||||
</slot>
|
||||
</button>
|
||||
</woot-dropdown-item>
|
||||
</ul>
|
||||
|
||||
@@ -5,6 +5,7 @@ export const CONVERSATION_EVENTS = Object.freeze({
|
||||
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
|
||||
TRANSLATE_A_MESSAGE: 'Translated a message',
|
||||
INSERTED_A_VARIABLE: 'Inserted a variable',
|
||||
INSERTED_AN_EMOJI: 'Inserted an emoji',
|
||||
USED_MENTIONS: 'Used mentions',
|
||||
SEARCH_CONVERSATION: 'Searched conversations',
|
||||
APPLY_FILTER: 'Applied filters in the conversation list',
|
||||
|
||||
@@ -3,6 +3,8 @@ import {
|
||||
MessageMarkdownTransformer,
|
||||
MessageMarkdownSerializer,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
/**
|
||||
@@ -281,3 +283,92 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Content Node Creation Helper Functions for
|
||||
* - mention
|
||||
* - canned response
|
||||
* - variable
|
||||
* - emoji
|
||||
*/
|
||||
|
||||
/**
|
||||
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
|
||||
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
|
||||
*/
|
||||
const createNode = (editorView, nodeType, content) => {
|
||||
const { state } = editorView;
|
||||
switch (nodeType) {
|
||||
case 'mention':
|
||||
return state.schema.nodes.mention.create({
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
});
|
||||
case 'cannedResponse':
|
||||
return new MessageMarkdownTransformer(messageSchema).parse(content);
|
||||
case 'variable':
|
||||
return state.schema.text(`{{${content}}}`);
|
||||
case 'emoji':
|
||||
return state.schema.text(content);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Object mapping types to their respective node creation functions.
|
||||
*/
|
||||
const nodeCreators = {
|
||||
mention: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'mention', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
cannedResponse: (editorView, content, from, to, variables) => {
|
||||
const updatedMessage = replaceVariablesInMessage({
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
const node = createNode(editorView, 'cannedResponse', updatedMessage);
|
||||
return {
|
||||
node,
|
||||
from: node.textContent === updatedMessage ? from : from - 1,
|
||||
to,
|
||||
};
|
||||
},
|
||||
variable: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'variable', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
emoji: (editorView, content, from, to) => ({
|
||||
node: createNode(editorView, 'emoji', content),
|
||||
from,
|
||||
to,
|
||||
}),
|
||||
};
|
||||
|
||||
/**
|
||||
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
|
||||
* @param {Object} editorView - The editor view instance.
|
||||
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
|
||||
* @param {string|Object} content - The content to be transformed into a node.
|
||||
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
|
||||
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
|
||||
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
|
||||
*/
|
||||
export const getContentNode = (
|
||||
editorView,
|
||||
type,
|
||||
content,
|
||||
{ from, to },
|
||||
variables
|
||||
) => {
|
||||
const creator = nodeCreators[type];
|
||||
return creator
|
||||
? creator(editorView, content, from, to, variables)
|
||||
: { node: null, from, to };
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const FEATURE_HELP_URLS = {
|
||||
reports: 'https://chwt.app/hc/reports',
|
||||
sla: 'https://chwt.app/hc/sla',
|
||||
team_management: 'https://chwt.app/hc/teams',
|
||||
webhook: 'https://chwt.app/hc/webhooks',
|
||||
};
|
||||
|
||||
export function getHelpUrlForFeature(featureName) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { INBOX_TYPES } from 'shared/composables/useInbox';
|
||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||
|
||||
export const getInboxSource = (type, phoneNumber, inbox) => {
|
||||
switch (type) {
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
|
||||
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
|
||||
import { getContentNode } from '../editorHelper';
|
||||
import {
|
||||
MessageMarkdownTransformer,
|
||||
messageSchema,
|
||||
} from '@chatwoot/prosemirror-schema';
|
||||
import { replaceVariablesInMessage } from '@chatwoot/utils';
|
||||
|
||||
vi.mock('@chatwoot/prosemirror-schema', () => ({
|
||||
MessageMarkdownTransformer: vi.fn(),
|
||||
messageSchema: {},
|
||||
}));
|
||||
|
||||
vi.mock('@chatwoot/utils', () => ({
|
||||
replaceVariablesInMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('getContentNode', () => {
|
||||
let editorView;
|
||||
|
||||
beforeEach(() => {
|
||||
editorView = {
|
||||
state: {
|
||||
schema: {
|
||||
nodes: {
|
||||
mention: {
|
||||
create: vi.fn(),
|
||||
},
|
||||
},
|
||||
text: vi.fn(),
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
describe('getMentionNode', () => {
|
||||
it('should create a mention node', () => {
|
||||
const content = { id: 1, name: 'John Doe' };
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'mention', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
|
||||
{
|
||||
userId: content.id,
|
||||
userFullName: content.name,
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCannedResponseNode', () => {
|
||||
it('should create a canned response node', () => {
|
||||
const content = 'Hello {{name}}';
|
||||
const variables = { name: 'John' };
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
const updatedMessage = 'Hello John';
|
||||
|
||||
replaceVariablesInMessage.mockReturnValue(updatedMessage);
|
||||
MessageMarkdownTransformer.mockImplementation(() => ({
|
||||
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
|
||||
}));
|
||||
|
||||
const { node } = getContentNode(
|
||||
editorView,
|
||||
'cannedResponse',
|
||||
content,
|
||||
{ from, to },
|
||||
variables
|
||||
);
|
||||
|
||||
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
|
||||
message: content,
|
||||
variables,
|
||||
});
|
||||
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
|
||||
expect(node.textContent).toBe(updatedMessage);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getVariableNode', () => {
|
||||
it('should create a variable node', () => {
|
||||
const content = 'name';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
getContentNode(editorView, 'variable', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getEmojiNode', () => {
|
||||
it('should create an emoji node', () => {
|
||||
const content = '😊';
|
||||
const from = 0;
|
||||
const to = 2;
|
||||
getContentNode(editorView, 'emoji', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContentNode', () => {
|
||||
it('should return null for invalid type', () => {
|
||||
const content = 'invalid';
|
||||
const from = 0;
|
||||
const to = 10;
|
||||
const { node } = getContentNode(editorView, 'invalid', content, {
|
||||
from,
|
||||
to,
|
||||
});
|
||||
|
||||
expect(node).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -55,6 +55,9 @@
|
||||
"CREATED_AT": "Created at",
|
||||
"LAST_ACTIVITY": "Last activity"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Value is required"
|
||||
},
|
||||
"GROUPS": {
|
||||
"STANDARD_FILTERS": "Standard filters",
|
||||
"ADDITIONAL_FILTERS": "Additional filters",
|
||||
|
||||
@@ -3,7 +3,8 @@
|
||||
"HEADER": "Custom Attributes",
|
||||
"HEADER_BTN_TXT": "Add Custom Attribute",
|
||||
"LOADING": "Fetching custom attributes",
|
||||
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>",
|
||||
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
|
||||
"LEARN_MORE": "Learn more about custom attributes",
|
||||
"ADD": {
|
||||
"TITLE": "Add Custom Attribute",
|
||||
"SUBMIT": "Create",
|
||||
@@ -91,7 +92,12 @@
|
||||
"CONTACT": "Contact"
|
||||
},
|
||||
"LIST": {
|
||||
"TABLE_HEADER": ["Name", "Description", "Type", "Key"],
|
||||
"TABLE_HEADER": [
|
||||
"Name",
|
||||
"Description",
|
||||
"Type",
|
||||
"Key"
|
||||
],
|
||||
"BUTTONS": {
|
||||
"EDIT": "Edit",
|
||||
"DELETE": "Delete"
|
||||
|
||||
@@ -30,6 +30,9 @@
|
||||
"is_lesser_than": "Is lesser than",
|
||||
"days_before": "Is x days before"
|
||||
},
|
||||
"ERRORS": {
|
||||
"VALUE_REQUIRED": "Value is required"
|
||||
},
|
||||
"ATTRIBUTES": {
|
||||
"NAME": "Name",
|
||||
"EMAIL": "Email",
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
},
|
||||
"WEBHOOK": {
|
||||
"SUBSCRIBED_EVENTS": "Subscribed Events",
|
||||
"LEARN_MORE": "Learn more about webhooks",
|
||||
"FORM": {
|
||||
"CANCEL": "Cancel",
|
||||
"DESC": "Webhook events provide you the realtime information about what's happening in your Chatwoot account. Please enter a valid URL to configure a callback.",
|
||||
|
||||
+5
-1
@@ -308,7 +308,11 @@ export default {
|
||||
:dropdown-values="getDropdownValues(appliedFilters[i].attribute_key)"
|
||||
:show-query-operator="i !== appliedFilters.length - 1"
|
||||
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
|
||||
:error-message="validationErrors[`filter_${i}`]"
|
||||
:error-message="
|
||||
validationErrors[`filter_${i}`]
|
||||
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
|
||||
: ''
|
||||
"
|
||||
@resetFilter="resetFilter(i, appliedFilters[i])"
|
||||
@removeFilter="removeFilter(i)"
|
||||
/>
|
||||
|
||||
+5
-10
@@ -9,11 +9,12 @@ import CannedResponse from 'dashboard/components/widgets/conversation/CannedResp
|
||||
import MessageSignatureMissingAlert from 'dashboard/components/widgets/conversation/MessageSignatureMissingAlert';
|
||||
import InboxDropdownItem from 'dashboard/components/widgets/InboxDropdownItem.vue';
|
||||
import WhatsappTemplates from './WhatsappTemplates.vue';
|
||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||
import { ExceptionWithMessage } from 'shared/helpers/CustomErrors';
|
||||
import { getInboxSource } from 'dashboard/helper/inbox';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, requiredIf } from '@vuelidate/validators';
|
||||
import { useInbox, INBOX_TYPES } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import FileUpload from 'vue-upload-component';
|
||||
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
|
||||
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
|
||||
@@ -35,7 +36,7 @@ export default {
|
||||
AttachmentPreview,
|
||||
MessageSignatureMissingAlert,
|
||||
},
|
||||
mixins: [fileUploadMixin],
|
||||
mixins: [inboxMixin, fileUploadMixin],
|
||||
props: {
|
||||
contact: {
|
||||
type: Object,
|
||||
@@ -46,18 +47,12 @@ export default {
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { channelType } = useInbox({ inboxObj: props.targetInbox });
|
||||
setup() {
|
||||
const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } =
|
||||
useUISettings();
|
||||
const v$ = useVuelidate();
|
||||
|
||||
return {
|
||||
fetchSignatureFlagFromUISettings,
|
||||
setSignatureFlagForInbox,
|
||||
v$,
|
||||
channelType,
|
||||
};
|
||||
return { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox, v$ };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
@@ -22,17 +22,21 @@ defineProps({
|
||||
<template>
|
||||
<div class="flex flex-col w-full h-full gap-10 font-inter">
|
||||
<slot name="header" />
|
||||
<slot v-if="isLoading" name="loading">
|
||||
<woot-loading-state :message="loadingMessage" />
|
||||
</slot>
|
||||
<p
|
||||
v-else-if="noRecordsFound"
|
||||
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
|
||||
>
|
||||
{{ noRecordsMessage }}
|
||||
</p>
|
||||
<slot v-else name="body" />
|
||||
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
|
||||
<slot />
|
||||
<!-- Added to render any templates that should be rendered before body -->
|
||||
<div>
|
||||
<slot name="preBody" />
|
||||
<slot v-if="isLoading" name="loading">
|
||||
<woot-loading-state :message="loadingMessage" />
|
||||
</slot>
|
||||
<p
|
||||
v-else-if="noRecordsFound"
|
||||
class="flex-1 text-slate-700 dark:text-slate-100 flex items-center justify-center text-base"
|
||||
>
|
||||
{{ noRecordsMessage }}
|
||||
</p>
|
||||
<slot v-else name="body" />
|
||||
<!-- Do not delete the slot below. It is required to render anything that is not defined in the above slots. -->
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -12,6 +12,12 @@ export default {
|
||||
type: Function,
|
||||
default: () => {},
|
||||
},
|
||||
// Passes 0 or 1 based on the selected AttributeModel tab selected in the UI
|
||||
// Needs a better data type, todo: refactor this component later
|
||||
selectedAttributeModelTab: {
|
||||
type: Number,
|
||||
default: 0,
|
||||
},
|
||||
},
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
@@ -20,7 +26,10 @@ export default {
|
||||
return {
|
||||
displayName: '',
|
||||
description: '',
|
||||
attributeModel: 0,
|
||||
// Using the prop as default. There is no side effect here as the component
|
||||
// is destroyed completely when the modal is closed. The prop doesn't change
|
||||
// dynamically when the modal is active.
|
||||
attributeModel: this.selectedAttributeModelTab || 0,
|
||||
attributeType: 0,
|
||||
attributeKey: '',
|
||||
regexPattern: null,
|
||||
@@ -280,13 +289,16 @@ export default {
|
||||
padding: 0 var(--space-small) var(--space-small) 0;
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
.multiselect--wrap {
|
||||
margin-bottom: var(--space-normal);
|
||||
|
||||
.error-message {
|
||||
color: var(--r-400);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: var(--font-weight-normal);
|
||||
}
|
||||
|
||||
.invalid {
|
||||
::v-deep {
|
||||
.multiselect__tags {
|
||||
@@ -295,13 +307,16 @@ export default {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.multiselect {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.multiselect__content-wrapper {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.multiselect--active .multiselect__tags {
|
||||
border-radius: var(--border-radius-normal);
|
||||
}
|
||||
|
||||
+129
-196
@@ -1,195 +1,141 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
<script setup>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import EditAttribute from './EditAttribute.vue';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
const props = defineProps({
|
||||
attributeModel: {
|
||||
type: String,
|
||||
default: 'conversation_attribute',
|
||||
},
|
||||
});
|
||||
|
||||
export default {
|
||||
components: {
|
||||
EditAttribute,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
selectedTabIndex: 0,
|
||||
showEditPopup: false,
|
||||
showDeletePopup: false,
|
||||
selectedAttribute: {},
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
uiFlags: 'attributes/getUIFlags',
|
||||
}),
|
||||
attributes() {
|
||||
const attributeModel = this.selectedTabIndex
|
||||
? 'contact_attribute'
|
||||
: 'conversation_attribute';
|
||||
const { t } = useI18n();
|
||||
|
||||
return this.$store.getters['attributes/getAttributesByModel'](
|
||||
attributeModel
|
||||
);
|
||||
},
|
||||
tabs() {
|
||||
return [
|
||||
{
|
||||
key: 0,
|
||||
name: this.$t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
|
||||
},
|
||||
{
|
||||
key: 1,
|
||||
name: this.$t('ATTRIBUTES_MGMT.TABS.CONTACT'),
|
||||
},
|
||||
];
|
||||
},
|
||||
deleteConfirmText() {
|
||||
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${
|
||||
this.selectedAttribute.attribute_display_name
|
||||
}`;
|
||||
},
|
||||
deleteRejectText() {
|
||||
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO');
|
||||
},
|
||||
confirmDeleteTitle() {
|
||||
return this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||
attributeName: this.selectedAttribute.attribute_display_name,
|
||||
});
|
||||
},
|
||||
confirmPlaceHolderText() {
|
||||
return `${this.$t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||
attributeName: this.selectedAttribute.attribute_display_name,
|
||||
})}`;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.fetchAttributes(this.selectedTabIndex);
|
||||
},
|
||||
methods: {
|
||||
onClickTabChange(index) {
|
||||
this.selectedTabIndex = index;
|
||||
this.fetchAttributes(index);
|
||||
},
|
||||
fetchAttributes(index) {
|
||||
this.$store.dispatch('attributes/get', index);
|
||||
},
|
||||
async deleteAttributes({ id }) {
|
||||
try {
|
||||
await this.$store.dispatch('attributes/delete', id);
|
||||
useAlert(this.$t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message ||
|
||||
this.$t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
},
|
||||
openEditPopup(response) {
|
||||
this.showEditPopup = true;
|
||||
this.selectedAttribute = response;
|
||||
},
|
||||
hideEditPopup() {
|
||||
this.showEditPopup = false;
|
||||
},
|
||||
confirmDeletion() {
|
||||
this.deleteAttributes(this.selectedAttribute);
|
||||
this.closeDelete();
|
||||
},
|
||||
openDelete(value) {
|
||||
this.showDeletePopup = true;
|
||||
this.selectedAttribute = value;
|
||||
},
|
||||
closeDelete() {
|
||||
this.showDeletePopup = false;
|
||||
this.selectedAttribute = {};
|
||||
},
|
||||
},
|
||||
const showEditPopup = ref(false);
|
||||
const showDeletePopup = ref(false);
|
||||
const selectedAttribute = ref({});
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
|
||||
const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(props.attributeModel)
|
||||
);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
|
||||
const attributeDisplayName = computed(
|
||||
() => selectedAttribute.value.attribute_display_name
|
||||
);
|
||||
const deleteConfirmText = computed(
|
||||
() =>
|
||||
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.YES')} ${attributeDisplayName.value}`
|
||||
);
|
||||
const deleteRejectText = computed(() => t('ATTRIBUTES_MGMT.DELETE.CONFIRM.NO'));
|
||||
const confirmDeleteTitle = computed(() =>
|
||||
t('ATTRIBUTES_MGMT.DELETE.CONFIRM.TITLE', {
|
||||
attributeName: attributeDisplayName.value,
|
||||
})
|
||||
);
|
||||
const confirmPlaceHolderText = computed(
|
||||
() =>
|
||||
`${t('ATTRIBUTES_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
|
||||
attributeName: attributeDisplayName.value,
|
||||
})}`
|
||||
);
|
||||
|
||||
const deleteAttributes = async ({ id }) => {
|
||||
try {
|
||||
await store.dispatch('attributes/delete', id);
|
||||
useAlert(t('ATTRIBUTES_MGMT.DELETE.API.SUCCESS_MESSAGE'));
|
||||
} catch (error) {
|
||||
const errorMessage =
|
||||
error?.response?.message || t('ATTRIBUTES_MGMT.DELETE.API.ERROR_MESSAGE');
|
||||
useAlert(errorMessage);
|
||||
}
|
||||
};
|
||||
const openEditPopup = response => {
|
||||
showEditPopup.value = true;
|
||||
selectedAttribute.value = response;
|
||||
};
|
||||
const hideEditPopup = () => {
|
||||
showEditPopup.value = false;
|
||||
};
|
||||
|
||||
const closeDelete = () => {
|
||||
showDeletePopup.value = false;
|
||||
selectedAttribute.value = {};
|
||||
};
|
||||
const confirmDeletion = () => {
|
||||
deleteAttributes(selectedAttribute.value);
|
||||
closeDelete();
|
||||
};
|
||||
const openDelete = value => {
|
||||
showDeletePopup.value = true;
|
||||
selectedAttribute.value = value;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-row gap-4 p-8">
|
||||
<div class="w-full lg:w-3/5">
|
||||
<woot-tabs :index="selectedTabIndex" @change="onClickTabChange">
|
||||
<woot-tabs-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
|
||||
<div class="w-full">
|
||||
<p
|
||||
v-if="!uiFlags.isFetching && !attributes.length"
|
||||
class="flex items-center justify-center mt-12"
|
||||
<div class="flex flex-col">
|
||||
<table class="min-w-full overflow-x-auto">
|
||||
<thead>
|
||||
<th
|
||||
v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')"
|
||||
:key="tableHeader"
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 text-left font-semibold text-slate-700 dark:text-slate-300"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404') }}
|
||||
</p>
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.isFetching"
|
||||
:message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
/>
|
||||
<table
|
||||
v-if="!uiFlags.isFetching && attributes.length"
|
||||
class="w-full mt-2 table-fixed woot-table"
|
||||
>
|
||||
<thead>
|
||||
<th
|
||||
v-for="tableHeader in $t('ATTRIBUTES_MGMT.LIST.TABLE_HEADER')"
|
||||
:key="tableHeader"
|
||||
class="pl-0 max-w-[6.25rem] min-w-[5rem]"
|
||||
>
|
||||
{{ tableHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
|
||||
<td
|
||||
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_name }}
|
||||
</td>
|
||||
<td
|
||||
class="pl-0 max-w-[10rem] min-w-[6.25rem] overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_description }}
|
||||
</td>
|
||||
<td
|
||||
class="pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_type }}
|
||||
</td>
|
||||
<td
|
||||
class="attribute-key pl-0 max-w-[6.25rem] min-w-[5rem] overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_key }}
|
||||
</td>
|
||||
<td class="button-wrapper">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class-names="grey-btn"
|
||||
icon="edit"
|
||||
@click="openEditPopup(attribute)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
class-names="grey-btn"
|
||||
@click="openDelete(attribute)"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="hidden w-1/3 lg:block">
|
||||
<span v-dompurify-html="$t('ATTRIBUTES_MGMT.SIDEBAR_TXT')" />
|
||||
</div>
|
||||
{{ tableHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
<tr v-for="attribute in attributes" :key="attribute.attribute_key">
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_name }}
|
||||
</td>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
{{ attribute.attribute_description }}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_display_type }}
|
||||
</td>
|
||||
<td
|
||||
class="py-4 ltr:pr-4 rtl:pl-4 attribute-key overflow-hidden whitespace-nowrap text-ellipsis"
|
||||
>
|
||||
{{ attribute.attribute_key }}
|
||||
</td>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.EDIT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
class-names="grey-btn"
|
||||
icon="edit"
|
||||
@click="openEditPopup(attribute)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('ATTRIBUTES_MGMT.LIST.BUTTONS.DELETE')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
class-names="grey-btn"
|
||||
@click="openDelete(attribute)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
|
||||
<EditAttribute
|
||||
:selected-attribute="selectedAttribute"
|
||||
@@ -216,17 +162,4 @@ export default {
|
||||
.attribute-key {
|
||||
font-family: monospace;
|
||||
}
|
||||
|
||||
::v-deep {
|
||||
.tabs--container {
|
||||
.tabs {
|
||||
@apply p-0;
|
||||
}
|
||||
}
|
||||
|
||||
.tabs-title a {
|
||||
font-weight: var(--font-weight-medium);
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,40 +1,112 @@
|
||||
<script>
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
|
||||
import AddAttribute from './AddAttribute.vue';
|
||||
import CustomAttribute from './CustomAttribute.vue';
|
||||
export default {
|
||||
components: {
|
||||
AddAttribute,
|
||||
CustomAttribute,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
showAddPopup: false,
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
openAddPopup() {
|
||||
this.showAddPopup = true;
|
||||
import SettingsLayout from '../SettingsLayout.vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useStoreGetters, useStore } from 'dashboard/composables/store';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const getters = useStoreGetters();
|
||||
const store = useStore();
|
||||
|
||||
const showAddPopup = ref(false);
|
||||
const selectedTabIndex = ref(0);
|
||||
const uiFlags = computed(() => getters['attributes/getUIFlags'].value);
|
||||
|
||||
const openAddPopup = () => {
|
||||
showAddPopup.value = true;
|
||||
};
|
||||
const hideAddPopup = () => {
|
||||
showAddPopup.value = false;
|
||||
};
|
||||
|
||||
const tabs = computed(() => {
|
||||
return [
|
||||
{
|
||||
key: 0,
|
||||
name: t('ATTRIBUTES_MGMT.TABS.CONVERSATION'),
|
||||
},
|
||||
hideAddPopup() {
|
||||
this.showAddPopup = false;
|
||||
{
|
||||
key: 1,
|
||||
name: t('ATTRIBUTES_MGMT.TABS.CONTACT'),
|
||||
},
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
store.dispatch('attributes/get');
|
||||
});
|
||||
|
||||
const attributeModel = computed(() =>
|
||||
selectedTabIndex.value ? 'contact_attribute' : 'conversation_attribute'
|
||||
);
|
||||
|
||||
const attributes = computed(() =>
|
||||
getters['attributes/getAttributesByModel'].value(attributeModel.value)
|
||||
);
|
||||
|
||||
const onClickTabChange = index => {
|
||||
selectedTabIndex.value = index;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 overflow-auto">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
@click="openAddPopup()"
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.isFetching"
|
||||
:loading-message="$t('ATTRIBUTES_MGMT.LOADING')"
|
||||
:no-records-found="!attributes.length"
|
||||
:no-records-message="$t('ATTRIBUTES_MGMT.LIST.EMPTY_RESULT.404')"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
:title="$t('ATTRIBUTES_MGMT.HEADER')"
|
||||
:description="$t('ATTRIBUTES_MGMT.DESCRIPTION')"
|
||||
:link-text="$t('ATTRIBUTES_MGMT.LEARN_MORE')"
|
||||
feature-name="custom_attributes"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
class="button nice rounded-md"
|
||||
icon="add-circle"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #preBody>
|
||||
<woot-tabs
|
||||
class="font-medium [&_.tabs]:p-0 mb-4"
|
||||
:index="selectedTabIndex"
|
||||
@change="onClickTabChange"
|
||||
>
|
||||
<woot-tabs-item
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
:name="tab.name"
|
||||
:show-badge="false"
|
||||
/>
|
||||
</woot-tabs>
|
||||
</template>
|
||||
<template #body>
|
||||
<CustomAttribute
|
||||
:key="attributeModel"
|
||||
:attribute-model="attributeModel"
|
||||
/>
|
||||
</template>
|
||||
<woot-modal
|
||||
v-if="showAddPopup"
|
||||
:show.sync="showAddPopup"
|
||||
:on-close="hideAddPopup"
|
||||
>
|
||||
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
<CustomAttribute />
|
||||
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
|
||||
<AddAttribute :on-close="hideAddPopup" />
|
||||
<AddAttribute
|
||||
:on-close="hideAddPopup"
|
||||
:selected-attribute-model-tab="selectedTabIndex"
|
||||
/>
|
||||
</woot-modal>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
@@ -1,17 +1,12 @@
|
||||
import { frontendURL } from '../../../../helper/URLHelper';
|
||||
const SettingsContent = () => import('../Wrapper.vue');
|
||||
const SettingsWrapper = () => import('../SettingsWrapper.vue');
|
||||
const AttributesHome = () => import('./Index.vue');
|
||||
|
||||
export default {
|
||||
routes: [
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/settings/custom-attributes'),
|
||||
component: SettingsContent,
|
||||
props: {
|
||||
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
|
||||
icon: 'code',
|
||||
showNewButton: false,
|
||||
},
|
||||
component: SettingsWrapper,
|
||||
children: [
|
||||
{
|
||||
path: '',
|
||||
|
||||
@@ -5,7 +5,7 @@ import { useAlert } from 'dashboard/composables';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
|
||||
import SettingsSection from '../../../../components/SettingsSection.vue';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import FacebookReauthorize from './facebook/Reauthorize.vue';
|
||||
import PreChatFormSettings from './PreChatForm/Settings.vue';
|
||||
import WeeklyAvailability from './components/WeeklyAvailability.vue';
|
||||
@@ -33,42 +33,9 @@ export default {
|
||||
SenderNameExamplePreview,
|
||||
MicrosoftReauthorize,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const {
|
||||
isAMicrosoftInbox,
|
||||
isAGoogleInbox,
|
||||
isAPIInbox,
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isAWebWidgetInbox,
|
||||
isATwilioChannel,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
isATwilioSMSChannel,
|
||||
isASmsInbox,
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWhatsAppChannel,
|
||||
} = useInbox({ inboxId: this.currentInboxId });
|
||||
return {
|
||||
v$: useVuelidate(),
|
||||
isAMicrosoftInbox,
|
||||
isAGoogleInbox,
|
||||
isAPIInbox,
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isAWebWidgetInbox,
|
||||
isATwilioChannel,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
isATwilioSMSChannel,
|
||||
isASmsInbox,
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAWhatsAppChannel,
|
||||
};
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
+2
-11
@@ -1,7 +1,7 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import SettingsSection from 'dashboard/components/SettingsSection.vue';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import BusinessDay from './BusinessDay.vue';
|
||||
@@ -23,22 +23,13 @@ export default {
|
||||
BusinessDay,
|
||||
WootMessageEditor,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const { isATwilioChannel, isATwitterInbox, isAFacebookInbox } = useInbox({
|
||||
inboxObj: props.inbox,
|
||||
});
|
||||
return {
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isATwilioChannel,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isBusinessHoursEnabled: false,
|
||||
|
||||
+4
-19
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useInbox } from 'shared/composables/useInbox';
|
||||
import inboxMixin from 'shared/mixins/inboxMixin';
|
||||
import SettingsSection from '../../../../../components/SettingsSection.vue';
|
||||
import ImapSettings from '../ImapSettings.vue';
|
||||
import SmtpSettings from '../SmtpSettings.vue';
|
||||
@@ -13,30 +13,15 @@ export default {
|
||||
ImapSettings,
|
||||
SmtpSettings,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
props: {
|
||||
inbox: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
},
|
||||
setup(props) {
|
||||
const {
|
||||
isAPIInbox,
|
||||
isAWebWidgetInbox,
|
||||
isATwilioChannel,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
} = useInbox({
|
||||
inboxObj: props.inbox,
|
||||
});
|
||||
return {
|
||||
v$: useVuelidate(),
|
||||
isAPIInbox,
|
||||
isAWebWidgetInbox,
|
||||
isATwilioChannel,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
};
|
||||
setup() {
|
||||
return { v$: useVuelidate() };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
|
||||
+60
-66
@@ -3,16 +3,18 @@ import { mapGetters } from 'vuex';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import NewWebhook from './NewWebHook.vue';
|
||||
import EditWebhook from './EditWebHook.vue';
|
||||
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
|
||||
import WebhookRow from './WebhookRow.vue';
|
||||
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
|
||||
import SettingsLayout from '../../SettingsLayout.vue';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
SettingsLayout,
|
||||
BaseSettingsHeader,
|
||||
NewWebhook,
|
||||
EditWebhook,
|
||||
WebhookRow,
|
||||
},
|
||||
mixins: [globalConfigMixin],
|
||||
data() {
|
||||
return {
|
||||
loading: {},
|
||||
@@ -26,8 +28,10 @@ export default {
|
||||
...mapGetters({
|
||||
records: 'webhooks/getWebhooks',
|
||||
uiFlags: 'webhooks/getUIFlags',
|
||||
globalConfig: 'globalConfig/get',
|
||||
}),
|
||||
integration() {
|
||||
return this.$store.getters['integrations/getIntegration']('webhook');
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.$store.dispatch('webhooks/get');
|
||||
@@ -75,69 +79,59 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-1 p-4 overflow-auto">
|
||||
<woot-button
|
||||
color-scheme="success"
|
||||
class-names="button--fixed-top"
|
||||
icon="add-circle"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
|
||||
<div class="flex flex-row gap-4">
|
||||
<div class="w-full lg:w-3/5">
|
||||
<p
|
||||
v-if="!uiFlags.fetchingList && !records.length"
|
||||
class="flex flex-col items-center justify-center h-full"
|
||||
<SettingsLayout
|
||||
:is-loading="uiFlags.fetchingList"
|
||||
:loading-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
|
||||
:no-records-message="$t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404')"
|
||||
:no-records-found="!records.length"
|
||||
>
|
||||
<template #header>
|
||||
<BaseSettingsHeader
|
||||
v-if="integration.name"
|
||||
:title="integration.name"
|
||||
:description="integration.description"
|
||||
:link-text="$t('INTEGRATION_SETTINGS.WEBHOOK.LEARN_MORE')"
|
||||
feature-name="webhook"
|
||||
:back-button-label="$t('INTEGRATION_SETTINGS.HEADER')"
|
||||
>
|
||||
<template #actions>
|
||||
<woot-button
|
||||
class="button nice rounded-md"
|
||||
icon="add-circle"
|
||||
@click="openAddPopup"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.HEADER_BTN_TXT') }}
|
||||
</woot-button>
|
||||
</template>
|
||||
</BaseSettingsHeader>
|
||||
</template>
|
||||
<template #body>
|
||||
<table class="min-w-full divide-y divide-slate-75 dark:divide-slate-700">
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in $t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.LIST.TABLE_HEADER'
|
||||
)"
|
||||
:key="thHeader"
|
||||
class="py-4 pr-4 text-left font-semibold text-slate-700 dark:text-slate-300 last:text-right last:pr-4"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody
|
||||
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
|
||||
>
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.LIST.404') }}
|
||||
</p>
|
||||
<woot-loading-state
|
||||
v-if="uiFlags.fetchingList"
|
||||
:message="$t('INTEGRATION_SETTINGS.WEBHOOK.LOADING')"
|
||||
/>
|
||||
|
||||
<table
|
||||
v-if="!uiFlags.fetchingList && records.length"
|
||||
class="woot-table"
|
||||
>
|
||||
<thead>
|
||||
<th
|
||||
v-for="thHeader in $t(
|
||||
'INTEGRATION_SETTINGS.WEBHOOK.LIST.TABLE_HEADER'
|
||||
)"
|
||||
:key="thHeader"
|
||||
class="last:text-right"
|
||||
>
|
||||
{{ thHeader }}
|
||||
</th>
|
||||
</thead>
|
||||
<tbody>
|
||||
<WebhookRow
|
||||
v-for="(webHookItem, index) in records"
|
||||
:key="webHookItem.id"
|
||||
:index="index"
|
||||
:webhook="webHookItem"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div class="hidden w-1/3 lg:block">
|
||||
<span
|
||||
v-dompurify-html="
|
||||
useInstallationName(
|
||||
$t('INTEGRATION_SETTINGS.WEBHOOK.SIDEBAR_TXT'),
|
||||
globalConfig.installationName
|
||||
)
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<WebhookRow
|
||||
v-for="(webHookItem, index) in records"
|
||||
:key="webHookItem.id"
|
||||
:index="index"
|
||||
:webhook="webHookItem"
|
||||
@edit="openEditPopup"
|
||||
@delete="openDeletePopup"
|
||||
/>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
|
||||
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
|
||||
</woot-modal>
|
||||
@@ -163,5 +157,5 @@ export default {
|
||||
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
|
||||
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
|
||||
/>
|
||||
</div>
|
||||
</SettingsLayout>
|
||||
</template>
|
||||
|
||||
+4
-10
@@ -1,8 +1,8 @@
|
||||
<script>
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required, url, minLength } from '@vuelidate/validators';
|
||||
import webhookMixin from './webhookMixin';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { getEventNamei18n } from './webhookHelper';
|
||||
|
||||
const { EXAMPLE_WEBHOOK_URL } = wootConstants;
|
||||
|
||||
@@ -18,7 +18,6 @@ const SUPPORTED_WEBHOOK_EVENTS = [
|
||||
];
|
||||
|
||||
export default {
|
||||
mixins: [webhookMixin],
|
||||
props: {
|
||||
value: {
|
||||
type: Object,
|
||||
@@ -70,6 +69,7 @@ export default {
|
||||
subscriptions: this.subscriptions,
|
||||
});
|
||||
},
|
||||
getEventNamei18n,
|
||||
},
|
||||
};
|
||||
</script>
|
||||
@@ -105,10 +105,10 @@ export default {
|
||||
type="checkbox"
|
||||
:value="event"
|
||||
name="subscriptions"
|
||||
class="checkbox"
|
||||
class="mr-2"
|
||||
/>
|
||||
<label :for="event" class="text-sm">
|
||||
{{ `${getEventLabel(event)} (${event})` }}
|
||||
{{ `${$t(getEventNamei18n(event))} (${event})` }}
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -129,9 +129,3 @@ export default {
|
||||
</div>
|
||||
</form>
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.checkbox {
|
||||
@apply mr-2;
|
||||
}
|
||||
</style>
|
||||
|
||||
+40
-41
@@ -1,59 +1,58 @@
|
||||
<script>
|
||||
import webhookMixin from './webhookMixin';
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { getEventNamei18n } from './webhookHelper';
|
||||
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
|
||||
export default {
|
||||
components: { ShowMore },
|
||||
mixins: [webhookMixin],
|
||||
props: {
|
||||
webhook: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
const props = defineProps({
|
||||
webhook: {
|
||||
type: Object,
|
||||
required: true,
|
||||
},
|
||||
computed: {
|
||||
subscribedEvents() {
|
||||
const { subscriptions } = this.webhook;
|
||||
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
|
||||
},
|
||||
index: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
};
|
||||
});
|
||||
const { t } = useI18n();
|
||||
const subscribedEvents = computed(() => {
|
||||
const { subscriptions } = props.webhook;
|
||||
return subscriptions.map(event => t(getEventNamei18n(event))).join(', ');
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<tr class="space-x-2">
|
||||
<td class="max-w-2xl">
|
||||
<tr>
|
||||
<td class="py-4 ltr:pr-4 rtl:pl-4">
|
||||
<div class="font-medium break-words text-slate-700 dark:text-slate-100">
|
||||
{{ webhook.url }}
|
||||
</div>
|
||||
<span class="text-xs text-slate-500 dark:text-slate-400">
|
||||
<div class="text-sm text-slate-500 dark:text-slate-400 block mt-1">
|
||||
<span class="font-medium">
|
||||
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
|
||||
</span>
|
||||
<ShowMore :text="subscribedEvents" :limit="60" />
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="min-w-[7rem] flex gap-1 justify-end flex-shrink-0">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="$emit('edit', webhook)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
@click="$emit('delete', webhook, index)"
|
||||
/>
|
||||
<td class="py-4 min-w-xs">
|
||||
<div class="flex gap-1 justify-end">
|
||||
<woot-button
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
size="tiny"
|
||||
color-scheme="secondary"
|
||||
icon="edit"
|
||||
@click="$emit('edit', webhook)"
|
||||
/>
|
||||
<woot-button
|
||||
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.BUTTON_TEXT')"
|
||||
variant="smooth"
|
||||
color-scheme="alert"
|
||||
size="tiny"
|
||||
icon="dismiss-circle"
|
||||
@click="$emit('delete', webhook, index)"
|
||||
/>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { getEventNamei18n } from '../webhookHelper';
|
||||
|
||||
describe('#getEventNamei18n', () => {
|
||||
it('returns correct i18n translation text', () => {
|
||||
expect(getEventNamei18n('message_created')).toEqual(
|
||||
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
|
||||
);
|
||||
});
|
||||
});
|
||||
-26
@@ -1,26 +0,0 @@
|
||||
import { createWrapper } from '@vue/test-utils';
|
||||
import webhookMixin from '../webhookMixin';
|
||||
import Vue from 'vue';
|
||||
|
||||
describe('webhookMixin', () => {
|
||||
describe('#getEventLabel', () => {
|
||||
it('returns correct i18n translation:', () => {
|
||||
const Component = {
|
||||
render() {},
|
||||
title: 'WebhookComponent',
|
||||
mixins: [webhookMixin],
|
||||
methods: {
|
||||
$t(text) {
|
||||
return text;
|
||||
},
|
||||
},
|
||||
};
|
||||
const Constructor = Vue.extend(Component);
|
||||
const vm = new Constructor().$mount();
|
||||
const wrapper = createWrapper(vm);
|
||||
expect(wrapper.vm.getEventLabel('message_created')).toEqual(
|
||||
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.MESSAGE_CREATED`
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
+4
@@ -0,0 +1,4 @@
|
||||
export const getEventNamei18n = event => {
|
||||
const eventName = event.toUpperCase();
|
||||
return `INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`;
|
||||
};
|
||||
-10
@@ -1,10 +0,0 @@
|
||||
export default {
|
||||
methods: {
|
||||
getEventLabel(event) {
|
||||
const eventName = event.toUpperCase();
|
||||
return this.$t(
|
||||
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
|
||||
);
|
||||
},
|
||||
},
|
||||
};
|
||||
+8
-8
@@ -30,6 +30,14 @@ export default {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'webhook',
|
||||
component: Webhook,
|
||||
name: 'settings_integrations_webhook',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -49,14 +57,6 @@ export default {
|
||||
};
|
||||
},
|
||||
children: [
|
||||
{
|
||||
path: 'webhook',
|
||||
component: Webhook,
|
||||
name: 'settings_integrations_webhook',
|
||||
meta: {
|
||||
permissions: ['administrator'],
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'slack',
|
||||
name: 'settings_integrations_slack',
|
||||
|
||||
@@ -187,30 +187,25 @@ describe('#actions', () => {
|
||||
|
||||
describe('attachImage', () => {
|
||||
it('should upload the file and return the fileUrl', async () => {
|
||||
// Given
|
||||
const mockFile = new Blob(['test'], { type: 'image/png' });
|
||||
mockFile.name = 'test.png';
|
||||
|
||||
const mockFileUrl = 'https://test.com/test.png';
|
||||
uploadFile.mockResolvedValueOnce({ fileUrl: mockFileUrl });
|
||||
|
||||
// When
|
||||
const result = await actions.attachImage({}, { file: mockFile });
|
||||
|
||||
// Then
|
||||
expect(uploadFile).toHaveBeenCalledWith(mockFile);
|
||||
expect(result).toBe(mockFileUrl);
|
||||
});
|
||||
|
||||
it('should throw an error if the upload fails', async () => {
|
||||
// Given
|
||||
const mockFile = new Blob(['test'], { type: 'image/png' });
|
||||
mockFile.name = 'test.png';
|
||||
|
||||
const mockError = new Error('Upload failed');
|
||||
uploadFile.mockRejectedValueOnce(mockError);
|
||||
|
||||
// When & Then
|
||||
await expect(actions.attachImage({}, { file: mockFile })).rejects.toThrow(
|
||||
'Upload failed'
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import * as types from '../mutation-types';
|
||||
import { INBOX_TYPES } from 'shared/composables/useInbox';
|
||||
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
|
||||
import InboxesAPI from '../../api/inboxes';
|
||||
import WebChannel from '../../api/channel/webChannel';
|
||||
import FBChannel from '../../api/channel/fbChannel';
|
||||
|
||||
@@ -1,148 +0,0 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { useInbox } from '../useInbox';
|
||||
|
||||
// Mock the useStore composable
|
||||
vi.mock('dashboard/composables/store', () => ({
|
||||
useStore: () => ({
|
||||
getters: {
|
||||
'inboxes/getInbox': vi.fn(id => ({
|
||||
id,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
})),
|
||||
getSelectedChat: {
|
||||
id: 1,
|
||||
inbox_id: 1,
|
||||
channel_type: 'Channel::WebWidget',
|
||||
},
|
||||
},
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('useInbox', () => {
|
||||
const createInbox = (channelType, additionalConfig = {}) => ({
|
||||
channel_type: channelType,
|
||||
...additionalConfig,
|
||||
});
|
||||
|
||||
it('returns the correct channel type', () => {
|
||||
const inbox = createInbox('Channel::WebWidget');
|
||||
const { channelType } = useInbox({ inboxObj: inbox });
|
||||
expect(channelType.value).toBe('Channel::WebWidget');
|
||||
});
|
||||
|
||||
it('isAPIInbox returns true if channel type is API', () => {
|
||||
const inbox = createInbox('Channel::Api');
|
||||
const { isAPIInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isAPIInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwitterInbox returns true if channel type is twitter', () => {
|
||||
const inbox = createInbox('Channel::TwitterProfile');
|
||||
const { isATwitterInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isATwitterInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
it('isAFacebookInbox returns true if channel type is Facebook', () => {
|
||||
const inbox = createInbox('Channel::FacebookPage');
|
||||
const { isAFacebookInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isAFacebookInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWebWidgetInbox returns true if channel type is WebWidget', () => {
|
||||
const inbox = createInbox('Channel::WebWidget');
|
||||
const { isAWebWidgetInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isAWebWidgetInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is sms', () => {
|
||||
const inbox = createInbox('Channel::Sms');
|
||||
const { isASmsInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isASmsInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is twilio sms', () => {
|
||||
const inbox = createInbox('Channel::TwilioSms', { medium: 'sms' });
|
||||
const { isASmsInbox } = useInbox({ inboxObj: inbox });
|
||||
expect(isASmsInbox.value).toBe(true);
|
||||
});
|
||||
|
||||
describe('isATwilioSMSChannel', () => {
|
||||
it('returns true if channel type is Twilio and medium is SMS', () => {
|
||||
const inbox = createInbox('Channel::TwilioSms', { medium: 'sms' });
|
||||
const { isATwilioSMSChannel } = useInbox({ inboxObj: inbox });
|
||||
expect(isATwilioSMSChannel.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if channel type is Twilio but medium is not SMS', () => {
|
||||
const inbox = createInbox('Channel::TwilioSms', { medium: 'whatsapp' });
|
||||
const { isATwilioSMSChannel } = useInbox({ inboxObj: inbox });
|
||||
expect(isATwilioSMSChannel.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if channel type is not Twilio but medium is SMS', () => {
|
||||
const inbox = createInbox('Channel::NotTwilio', { medium: 'sms' });
|
||||
const { isATwilioSMSChannel } = useInbox({ inboxObj: inbox });
|
||||
expect(isATwilioSMSChannel.value).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Badges', () => {
|
||||
it('inboxBadge returns correct badge for Telegram', () => {
|
||||
const inbox = createInbox('Channel::Telegram');
|
||||
const { inboxBadge } = useInbox({ inboxObj: inbox });
|
||||
expect(inboxBadge.value).toBe('Channel::Telegram');
|
||||
});
|
||||
|
||||
it('inboxBadge returns correct badge for WhatsApp channel', () => {
|
||||
const inbox = createInbox('Channel::Whatsapp');
|
||||
const { inboxBadge } = useInbox({ inboxObj: inbox });
|
||||
expect(inboxBadge.value).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the twitterBadge when isATwitterInbox is true', () => {
|
||||
const inbox = createInbox('Channel::TwitterProfile');
|
||||
const { inboxBadge } = useInbox({ inboxObj: inbox });
|
||||
expect(inboxBadge.value).toBe('twitter-dm');
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhatsApp channel', () => {
|
||||
it('returns correct whatsAppAPIProvider', () => {
|
||||
const inbox = createInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const { whatsAppAPIProvider } = useInbox({ inboxObj: inbox });
|
||||
expect(whatsAppAPIProvider.value).toBe('whatsapp_cloud');
|
||||
});
|
||||
|
||||
it('isAWhatsAppCloudChannel returns true if channel type is WhatsApp and provider is whatsapp_cloud', () => {
|
||||
const inbox = createInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const { isAWhatsAppCloudChannel } = useInbox({ inboxObj: inbox });
|
||||
expect(isAWhatsAppCloudChannel.value).toBe(true);
|
||||
});
|
||||
|
||||
it('is360DialogWhatsAppChannel returns true if channel type is WhatsApp and provider is default', () => {
|
||||
const inbox = createInbox('Channel::Whatsapp', { provider: 'default' });
|
||||
const { is360DialogWhatsAppChannel } = useInbox({ inboxObj: inbox });
|
||||
expect(is360DialogWhatsAppChannel.value).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#inboxHasFeature', () => {
|
||||
it('detects the correct feature', () => {
|
||||
const inbox = createInbox('Channel::Telegram');
|
||||
const { inboxHasFeature } = useInbox({ inboxObj: inbox });
|
||||
expect(inboxHasFeature('replyTo')).toBe(true);
|
||||
expect(inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for feature not included', () => {
|
||||
const inbox = createInbox('Channel::Sms');
|
||||
const { inboxHasFeature } = useInbox({ inboxObj: inbox });
|
||||
expect(inboxHasFeature('replyTo')).toBe(false);
|
||||
expect(inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { filterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
|
||||
import { filterAttributeGroups as conversationFilterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
|
||||
import { filterAttributeGroups as contactFilterAttributeGroups } from 'dashboard/routes/dashboard/contacts/contactFilterItems';
|
||||
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
|
||||
|
||||
const customAttributeInputType = key => {
|
||||
@@ -42,6 +43,11 @@ export const useFilter = ({ filteri18nKey, attributeModel }) => {
|
||||
const { t: $t } = useI18n();
|
||||
const { getters } = useStore();
|
||||
|
||||
const filterAttributeGroups =
|
||||
attributeModel === 'contact_attribute'
|
||||
? contactFilterAttributeGroups
|
||||
: conversationFilterAttributeGroups;
|
||||
|
||||
const setFilterAttributes = () => {
|
||||
const allCustomAttributes =
|
||||
getters['attributes/getAttributesByModel'](attributeModel);
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
|
||||
export const INBOX_TYPES = {
|
||||
WEB: 'Channel::WebWidget',
|
||||
FB: 'Channel::FacebookPage',
|
||||
TWITTER: 'Channel::TwitterProfile',
|
||||
TWILIO: 'Channel::TwilioSms',
|
||||
WHATSAPP: 'Channel::Whatsapp',
|
||||
API: 'Channel::Api',
|
||||
EMAIL: 'Channel::Email',
|
||||
TELEGRAM: 'Channel::Telegram',
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
};
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
REPLY_TO_OUTGOING: 'replyToOutgoing',
|
||||
};
|
||||
|
||||
export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
|
||||
// useInbox(inboxId) {}
|
||||
// useChatInbox {
|
||||
// chat = getCurrentChat
|
||||
// return useInbox(chat.inboxId)
|
||||
// }
|
||||
|
||||
export function useInbox({ inboxObj, inboxId, chat, getCurrentChat }) {
|
||||
const store = useStore();
|
||||
const inbox = ref(null);
|
||||
if (inboxObj) {
|
||||
inbox.value = inboxObj;
|
||||
}
|
||||
if (inboxId) {
|
||||
inbox.value = store.getters['inboxes/getInbox'](inboxId);
|
||||
}
|
||||
if (getCurrentChat) {
|
||||
inbox.value = store.getters.getSelectedChat;
|
||||
}
|
||||
const channelType = computed(() => inbox.value.channel_type);
|
||||
const whatsAppAPIProvider = computed(() => inbox.value.provider || '');
|
||||
|
||||
const isAPIInbox = computed(() => channelType.value === INBOX_TYPES.API);
|
||||
const isATwitterInbox = computed(
|
||||
() => channelType.value === INBOX_TYPES.TWITTER
|
||||
);
|
||||
const isAFacebookInbox = computed(() => channelType.value === INBOX_TYPES.FB);
|
||||
const isAWebWidgetInbox = computed(
|
||||
() => channelType.value === INBOX_TYPES.WEB
|
||||
);
|
||||
const isATwilioChannel = computed(
|
||||
() => channelType.value === INBOX_TYPES.TWILIO
|
||||
);
|
||||
const isALineChannel = computed(() => channelType.value === INBOX_TYPES.LINE);
|
||||
const isAnEmailChannel = computed(
|
||||
() => channelType.value === INBOX_TYPES.EMAIL
|
||||
);
|
||||
const isATelegramChannel = computed(
|
||||
() => channelType.value === INBOX_TYPES.TELEGRAM
|
||||
);
|
||||
|
||||
const isATwilioSMSChannel = computed(() => {
|
||||
const medium = inbox.value.medium || '';
|
||||
return isATwilioChannel.value && medium === 'sms';
|
||||
});
|
||||
|
||||
const isAMicrosoftInbox = computed(
|
||||
() => isAnEmailChannel.value && inbox.value.provider === 'microsoft'
|
||||
);
|
||||
|
||||
const isAGoogleInbox = computed(
|
||||
() => isAnEmailChannel.value && inbox.value.provider === 'google'
|
||||
);
|
||||
|
||||
const isASmsInbox = computed(
|
||||
() => channelType.value === INBOX_TYPES.SMS || isATwilioSMSChannel.value
|
||||
);
|
||||
|
||||
const isATwilioWhatsAppChannel = computed(() => {
|
||||
const medium = inbox.value.medium || '';
|
||||
return isATwilioChannel.value && medium === 'whatsapp';
|
||||
});
|
||||
|
||||
const isAWhatsAppCloudChannel = computed(
|
||||
() =>
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'whatsapp_cloud'
|
||||
);
|
||||
|
||||
const is360DialogWhatsAppChannel = computed(
|
||||
() =>
|
||||
channelType.value === INBOX_TYPES.WHATSAPP &&
|
||||
whatsAppAPIProvider.value === 'default'
|
||||
);
|
||||
|
||||
const chatAdditionalAttributes = computed(() => {
|
||||
return chat?.additional_attributes || {};
|
||||
});
|
||||
|
||||
const isTwitterInboxTweet = computed(
|
||||
() => chatAdditionalAttributes.value.type === 'tweet'
|
||||
);
|
||||
|
||||
const twilioBadge = computed(
|
||||
() => `${isATwilioSMSChannel.value ? 'sms' : 'whatsapp'}`
|
||||
);
|
||||
|
||||
const twitterBadge = computed(
|
||||
() => `${isTwitterInboxTweet.value ? 'twitter-tweet' : 'twitter-dm'}`
|
||||
);
|
||||
|
||||
const facebookBadge = computed(
|
||||
() => chatAdditionalAttributes.value.type || 'facebook'
|
||||
);
|
||||
|
||||
const isAWhatsAppChannel = computed(
|
||||
() =>
|
||||
channelType.value === INBOX_TYPES.WHATSAPP ||
|
||||
isATwilioWhatsAppChannel.value
|
||||
);
|
||||
|
||||
const inboxBadge = computed(() => {
|
||||
if (isATwitterInbox.value) return twitterBadge.value;
|
||||
if (isAFacebookInbox.value) return facebookBadge.value;
|
||||
if (isATwilioChannel.value) return twilioBadge.value;
|
||||
if (isAWhatsAppChannel.value) return 'whatsapp';
|
||||
return channelType.value;
|
||||
});
|
||||
|
||||
function inboxHasFeature(feature) {
|
||||
return INBOX_FEATURE_MAP[feature]?.includes(channelType.value) ?? false;
|
||||
}
|
||||
|
||||
return {
|
||||
channelType,
|
||||
whatsAppAPIProvider,
|
||||
isAMicrosoftInbox,
|
||||
isAGoogleInbox,
|
||||
isAPIInbox,
|
||||
inbox,
|
||||
isATwitterInbox,
|
||||
isAFacebookInbox,
|
||||
isAWebWidgetInbox,
|
||||
isATwilioChannel,
|
||||
isALineChannel,
|
||||
isAnEmailChannel,
|
||||
isATelegramChannel,
|
||||
isATwilioSMSChannel,
|
||||
isASmsInbox,
|
||||
isATwilioWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
chatAdditionalAttributes,
|
||||
isTwitterInboxTweet,
|
||||
twilioBadge,
|
||||
twitterBadge,
|
||||
facebookBadge,
|
||||
inboxBadge,
|
||||
isAWhatsAppChannel,
|
||||
inboxHasFeature,
|
||||
};
|
||||
}
|
||||
@@ -7,4 +7,5 @@ export const MESSAGE_MAX_LENGTH = {
|
||||
FACEBOOK: 1000,
|
||||
TWILIO_SMS: 320,
|
||||
TWILIO_WHATSAPP: 1600,
|
||||
EMAIL: 25000,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
export const INBOX_TYPES = {
|
||||
WEB: 'Channel::WebWidget',
|
||||
FB: 'Channel::FacebookPage',
|
||||
TWITTER: 'Channel::TwitterProfile',
|
||||
TWILIO: 'Channel::TwilioSms',
|
||||
WHATSAPP: 'Channel::Whatsapp',
|
||||
API: 'Channel::Api',
|
||||
EMAIL: 'Channel::Email',
|
||||
TELEGRAM: 'Channel::Telegram',
|
||||
LINE: 'Channel::Line',
|
||||
SMS: 'Channel::Sms',
|
||||
};
|
||||
|
||||
export const INBOX_FEATURES = {
|
||||
REPLY_TO: 'replyTo',
|
||||
REPLY_TO_OUTGOING: 'replyToOutgoing',
|
||||
};
|
||||
|
||||
// This is a single source of truth for inbox features
|
||||
// This is used to check if a feature is available for a particular inbox or not
|
||||
export const INBOX_FEATURE_MAP = {
|
||||
[INBOX_FEATURES.REPLY_TO]: [
|
||||
INBOX_TYPES.FB,
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
[INBOX_FEATURES.REPLY_TO_OUTGOING]: [
|
||||
INBOX_TYPES.WEB,
|
||||
INBOX_TYPES.TWITTER,
|
||||
INBOX_TYPES.WHATSAPP,
|
||||
INBOX_TYPES.TELEGRAM,
|
||||
INBOX_TYPES.API,
|
||||
],
|
||||
};
|
||||
|
||||
export default {
|
||||
computed: {
|
||||
channelType() {
|
||||
return this.inbox.channel_type;
|
||||
},
|
||||
whatsAppAPIProvider() {
|
||||
return this.inbox.provider || '';
|
||||
},
|
||||
isAMicrosoftInbox() {
|
||||
return this.isAnEmailChannel && this.inbox.provider === 'microsoft';
|
||||
},
|
||||
isAGoogleInbox() {
|
||||
return this.isAnEmailChannel && this.inbox.provider === 'google';
|
||||
},
|
||||
isAPIInbox() {
|
||||
return this.channelType === INBOX_TYPES.API;
|
||||
},
|
||||
isATwitterInbox() {
|
||||
return this.channelType === INBOX_TYPES.TWITTER;
|
||||
},
|
||||
isAFacebookInbox() {
|
||||
return this.channelType === INBOX_TYPES.FB;
|
||||
},
|
||||
isAWebWidgetInbox() {
|
||||
return this.channelType === INBOX_TYPES.WEB;
|
||||
},
|
||||
isATwilioChannel() {
|
||||
return this.channelType === INBOX_TYPES.TWILIO;
|
||||
},
|
||||
isALineChannel() {
|
||||
return this.channelType === INBOX_TYPES.LINE;
|
||||
},
|
||||
isAnEmailChannel() {
|
||||
return this.channelType === INBOX_TYPES.EMAIL;
|
||||
},
|
||||
isATelegramChannel() {
|
||||
return this.channelType === INBOX_TYPES.TELEGRAM;
|
||||
},
|
||||
isATwilioSMSChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'sms';
|
||||
},
|
||||
isASmsInbox() {
|
||||
return this.channelType === INBOX_TYPES.SMS || this.isATwilioSMSChannel;
|
||||
},
|
||||
isATwilioWhatsAppChannel() {
|
||||
const { medium: medium = '' } = this.inbox;
|
||||
return this.isATwilioChannel && medium === 'whatsapp';
|
||||
},
|
||||
isAWhatsAppCloudChannel() {
|
||||
return (
|
||||
this.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
this.whatsAppAPIProvider === 'whatsapp_cloud'
|
||||
);
|
||||
},
|
||||
is360DialogWhatsAppChannel() {
|
||||
return (
|
||||
this.channelType === INBOX_TYPES.WHATSAPP &&
|
||||
this.whatsAppAPIProvider === 'default'
|
||||
);
|
||||
},
|
||||
chatAdditionalAttributes() {
|
||||
const { additional_attributes: additionalAttributes } = this.chat || {};
|
||||
return additionalAttributes || {};
|
||||
},
|
||||
isTwitterInboxTweet() {
|
||||
return this.chatAdditionalAttributes.type === 'tweet';
|
||||
},
|
||||
twilioBadge() {
|
||||
return `${this.isATwilioSMSChannel ? 'sms' : 'whatsapp'}`;
|
||||
},
|
||||
twitterBadge() {
|
||||
return `${this.isTwitterInboxTweet ? 'twitter-tweet' : 'twitter-dm'}`;
|
||||
},
|
||||
facebookBadge() {
|
||||
return this.chatAdditionalAttributes.type || 'facebook';
|
||||
},
|
||||
inboxBadge() {
|
||||
let badgeKey = '';
|
||||
if (this.isATwitterInbox) {
|
||||
badgeKey = this.twitterBadge;
|
||||
} else if (this.isAFacebookInbox) {
|
||||
badgeKey = this.facebookBadge;
|
||||
} else if (this.isATwilioChannel) {
|
||||
badgeKey = this.twilioBadge;
|
||||
} else if (this.isAWhatsAppChannel) {
|
||||
badgeKey = 'whatsapp';
|
||||
}
|
||||
return badgeKey || this.channelType;
|
||||
},
|
||||
isAWhatsAppChannel() {
|
||||
return (
|
||||
this.channelType === INBOX_TYPES.WHATSAPP ||
|
||||
this.isATwilioWhatsAppChannel
|
||||
);
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
inboxHasFeature(feature) {
|
||||
return INBOX_FEATURE_MAP[feature]?.includes(this.channelType) ?? false;
|
||||
},
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,286 @@
|
||||
import { shallowMount } from '@vue/test-utils';
|
||||
import inboxMixin from '../inboxMixin';
|
||||
|
||||
function getComponentConfigForInbox(channelType, additionalConfig = {}) {
|
||||
return {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
inbox: {
|
||||
channel_type: channelType,
|
||||
...additionalConfig,
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function getComponentConfigForChat(chat) {
|
||||
return {
|
||||
render() {},
|
||||
mixins: [inboxMixin],
|
||||
data() {
|
||||
return {
|
||||
chat,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe('inboxMixin', () => {
|
||||
it('returns the correct channel type', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::WebWidget');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.channelType).toBe('Channel::WebWidget');
|
||||
});
|
||||
|
||||
it('isAPIInbox returns true if channel type is API', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Api');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAPIInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwitterInbox returns true if channel type is twitter', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwitterInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isAFacebookInbox returns true if channel type is Facebook', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::FacebookPage');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAFacebookInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWebWidgetInbox returns true if channel type is Facebook', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::WebWidget');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWebWidgetInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is sms', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Sms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isASmsInbox returns true if channel type is twilio sms', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isASmsInbox).toBe(true);
|
||||
});
|
||||
|
||||
it('isALineChannel returns true if channel type is Line', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Line');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isALineChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATelegramChannel returns true if channel type is Telegram', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATelegramChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isATwilioChannel returns true if channel type is Twilio', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(true);
|
||||
});
|
||||
|
||||
describe('isATwilioSMSChannel', () => {
|
||||
it('returns true if channel type is Twilio and medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if channel type is Twilio but medium is not SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'whatsapp',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if channel type is not Twilio but medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if neither channel type is Twilio nor medium is SMS', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::NotTwilio', {
|
||||
medium: 'not_sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false if channel type is Twilio but medium is empty', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: undefined,
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
it('isAnEmailChannel returns true if channel type is email', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Email');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAnEmailChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isTwitterInboxTweet returns true if Twitter channel type is tweet', () => {
|
||||
const Component = getComponentConfigForChat({
|
||||
channel_type: 'Channel::TwitterProfile',
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
|
||||
});
|
||||
|
||||
it('twilioBadge returns string sms if channel type is Twilio and medium is sms', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioSMSChannel).toBe(true);
|
||||
expect(wrapper.vm.twilioBadge).toBe('sms');
|
||||
});
|
||||
|
||||
it('twitterBadge returns string twitter-tweet if Twitter channel type is tweet', () => {
|
||||
const Component = getComponentConfigForChat({
|
||||
id: 1,
|
||||
additional_attributes: {
|
||||
type: 'tweet',
|
||||
},
|
||||
});
|
||||
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isTwitterInboxTweet).toBe(true);
|
||||
expect(wrapper.vm.twitterBadge).toBe('twitter-tweet');
|
||||
});
|
||||
|
||||
describe('Badges', () => {
|
||||
it('inboxBadge returns string Channel::Telegram if isATwilioChannel and isATwitterInbox is false', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isATwilioChannel).toBe(false);
|
||||
expect(wrapper.vm.isATwitterInbox).toBe(false);
|
||||
expect(wrapper.vm.channelType).toBe('Channel::Telegram');
|
||||
});
|
||||
|
||||
it('inboxBadge returns correct badge for WhatsApp channel', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the twitterBadge when isATwitterInbox is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwitterProfile');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('twitter-dm');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the facebookBadge when isAFacebookInbox is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::FacebookPage');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('facebook');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the twilioBadge when isATwilioChannel is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'sms',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('sms');
|
||||
});
|
||||
|
||||
it('inboxBadge returns "whatsapp" when isAWhatsAppChannel is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('whatsapp');
|
||||
});
|
||||
|
||||
it('inboxBadge returns the channelType when no specific condition is true', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Email');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxBadge).toBe('Channel::Email');
|
||||
});
|
||||
});
|
||||
|
||||
describe('#inboxHasFeature', () => {
|
||||
it('detects the correct feature', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Telegram');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(true);
|
||||
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
|
||||
it('returns false for feature not included', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Sms');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.inboxHasFeature('replyTo')).toBe(false);
|
||||
expect(wrapper.vm.inboxHasFeature('feature-does-not-exist')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('WhatsApp channel', () => {
|
||||
it('returns correct whatsAppAPIProvider', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.whatsAppAPIProvider).toBe('whatsapp_cloud');
|
||||
});
|
||||
|
||||
it('returns empty whatsAppAPIProvider if nothing is present', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: undefined,
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.whatsAppAPIProvider).toBe('');
|
||||
});
|
||||
|
||||
it('isAWhatsAppCloudChannel returns true if channel type is WhatsApp and provider is whatsapp_cloud', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'whatsapp_cloud',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppCloudChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('is360DialogWhatsAppChannel returns true if channel type is WhatsApp and provider is default', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp', {
|
||||
provider: 'default',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.is360DialogWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::Whatsapp');
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
});
|
||||
|
||||
it('isAWhatsAppChannel returns true if channel type is Twilio and medium is WhatsApp', () => {
|
||||
const Component = getComponentConfigForInbox('Channel::TwilioSms', {
|
||||
medium: 'whatsapp',
|
||||
});
|
||||
const wrapper = shallowMount(Component);
|
||||
expect(wrapper.vm.isAWhatsAppChannel).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -4,7 +4,7 @@ import { setHeader } from 'widget/helpers/axios';
|
||||
import addHours from 'date-fns/addHours';
|
||||
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
|
||||
import configMixin from './mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { getLocale } from './helpers/urlParamsHelper';
|
||||
import { isEmptyObject } from 'widget/helpers/utils';
|
||||
import Spinner from 'shared/components/Spinner.vue';
|
||||
@@ -27,7 +27,11 @@ export default {
|
||||
components: {
|
||||
Spinner,
|
||||
},
|
||||
mixins: [availabilityMixin, configMixin, routerMixin, darkModeMixin],
|
||||
mixins: [configMixin, routerMixin, darkModeMixin],
|
||||
setup() {
|
||||
const { isInBusinessHours } = useAvailability();
|
||||
return { isInBusinessHours };
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isMobile: false,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script>
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import { useNextAvailabilityTime } from 'widget/composables/useNextAvailabilityTime';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import HeaderActions from './HeaderActions.vue';
|
||||
import routerMixin from 'widget/mixins/routerMixin';
|
||||
@@ -12,7 +12,7 @@ export default {
|
||||
FluentIcon,
|
||||
HeaderActions,
|
||||
},
|
||||
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
|
||||
mixins: [routerMixin, darkMixin],
|
||||
props: {
|
||||
avatarUrl: {
|
||||
type: String,
|
||||
@@ -30,21 +30,25 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
default: () => {},
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
isOnline() {
|
||||
const { workingHoursEnabled } = this.channelConfig;
|
||||
const anyAgentOnline = this.availableAgents.length > 0;
|
||||
|
||||
if (workingHoursEnabled) {
|
||||
return this.isInBetweenTheWorkingHours;
|
||||
}
|
||||
return anyAgentOnline;
|
||||
},
|
||||
setup() {
|
||||
const {
|
||||
isOnline,
|
||||
channelConfig,
|
||||
isInBetweenTheWorkingHours,
|
||||
replyWaitMessage,
|
||||
} = useAvailability();
|
||||
const { setTimeSlot } = useNextAvailabilityTime();
|
||||
return {
|
||||
isOnline,
|
||||
channelConfig,
|
||||
isInBetweenTheWorkingHours,
|
||||
replyWaitMessage,
|
||||
setTimeSlot,
|
||||
};
|
||||
},
|
||||
mounted() {
|
||||
this.setTimeSlot();
|
||||
},
|
||||
methods: {
|
||||
onBackButtonClick() {
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
<script>
|
||||
import { mapGetters } from 'vuex';
|
||||
import { getContrastingTextColor } from '@chatwoot/utils';
|
||||
import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
|
||||
import { useNextAvailabilityTime } from 'widget/composables/useNextAvailabilityTime';
|
||||
import AvailableAgents from 'widget/components/AvailableAgents.vue';
|
||||
import configMixin from 'widget/mixins/configMixin';
|
||||
import availabilityMixin from 'widget/mixins/availability';
|
||||
import { useAvailability } from 'widget/composables/useAvailability';
|
||||
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
|
||||
import { IFrameHelper } from 'widget/helpers/utils';
|
||||
import { CHATWOOT_ON_START_CONVERSATION } from '../constants/sdkEvents';
|
||||
@@ -15,7 +15,7 @@ export default {
|
||||
AvailableAgents,
|
||||
FluentIcon,
|
||||
},
|
||||
mixins: [configMixin, nextAvailabilityTime, availabilityMixin],
|
||||
mixins: [configMixin],
|
||||
props: {
|
||||
availableAgents: {
|
||||
type: Array,
|
||||
@@ -26,7 +26,11 @@ export default {
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
|
||||
setup() {
|
||||
const { isInBetweenTheWorkingHours, replyWaitMessage } = useAvailability();
|
||||
const { setTimeSlot } = useNextAvailabilityTime();
|
||||
return { setTimeSlot, isInBetweenTheWorkingHours, replyWaitMessage };
|
||||
},
|
||||
computed: {
|
||||
...mapGetters({
|
||||
widgetColor: 'appConfig/getWidgetColor',
|
||||
@@ -44,6 +48,9 @@ export default {
|
||||
return anyAgentOnline;
|
||||
},
|
||||
},
|
||||
mounted() {
|
||||
this.setTimeSlot();
|
||||
},
|
||||
methods: {
|
||||
startConversation() {
|
||||
this.$emit('startConversation');
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import { ref } from 'vue';
|
||||
import { useAvailability } from '../useAvailability';
|
||||
import * as useNextAvailabilityTime from '../useNextAvailabilityTime';
|
||||
import * as useI18n from 'dashboard/composables/useI18n';
|
||||
import * as useMapGetter from 'dashboard/composables/store';
|
||||
|
||||
vi.mock('../useNextAvailabilityTime');
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
vi.mock('dashboard/composables/store');
|
||||
|
||||
describe('useAvailability', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
useNextAvailabilityTime.useNextAvailabilityTime.mockReturnValue({
|
||||
timeLeftToBackInOnline: ref('2 hours'),
|
||||
});
|
||||
useI18n.useI18n.mockReturnValue({
|
||||
t: vi.fn(key => key),
|
||||
});
|
||||
useMapGetter.useMapGetter.mockReturnValue(ref([]));
|
||||
|
||||
global.chatwootWebChannel = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: [
|
||||
{
|
||||
day_of_week: 3,
|
||||
closed_all_day: false,
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 17,
|
||||
close_minutes: 35,
|
||||
open_all_day: false,
|
||||
},
|
||||
{
|
||||
day_of_week: 4,
|
||||
closed_all_day: false,
|
||||
open_hour: 8,
|
||||
open_minutes: 30,
|
||||
close_hour: 17,
|
||||
close_minutes: 30,
|
||||
open_all_day: false,
|
||||
},
|
||||
],
|
||||
utcOffset: '-07:00',
|
||||
replyTime: 'in_a_few_hours',
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('returns valid isInBetweenTheWorkingHours if in different timezone', () => {
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 06:04:46 GMT+0530'));
|
||||
const { isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns valid isInBetweenTheWorkingHours if in same timezone', () => {
|
||||
global.chatwootWebChannel.utcOffset = '+05:30';
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false if closed all day', () => {
|
||||
global.chatwootWebChannel.utcOffset = '-07:00';
|
||||
global.chatwootWebChannel.workingHours = [
|
||||
{ day_of_week: 3, closed_all_day: true },
|
||||
];
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(false);
|
||||
});
|
||||
|
||||
it('returns true if open all day', () => {
|
||||
global.chatwootWebChannel.utcOffset = '-07:00';
|
||||
global.chatwootWebChannel.workingHours = [
|
||||
{ day_of_week: 3, open_all_day: true },
|
||||
];
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(true);
|
||||
});
|
||||
|
||||
it('returns correct replyTimeStatus', () => {
|
||||
const { replyTimeStatus } = useAvailability();
|
||||
expect(replyTimeStatus.value).toBe('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
});
|
||||
|
||||
it('returns correct replyWaitMessage when online', () => {
|
||||
global.chatwootWebChannel.workingHoursEnabled = true;
|
||||
global.chatwootWebChannel.utcOffset = '+05:30';
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { replyWaitMessage, isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(true);
|
||||
expect(replyWaitMessage.value).toBe('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
});
|
||||
|
||||
it('returns correct replyWaitMessage when offline', () => {
|
||||
global.chatwootWebChannel.workingHoursEnabled = true;
|
||||
global.chatwootWebChannel.workingHours = [
|
||||
{ day_of_week: 3, closed_all_day: true },
|
||||
];
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { replyWaitMessage } = useAvailability();
|
||||
expect(replyWaitMessage.value).toBe('REPLY_TIME.BACK_IN 2 hours');
|
||||
});
|
||||
|
||||
it('returns correct isInBusinessHours', () => {
|
||||
global.chatwootWebChannel.workingHoursEnabled = true;
|
||||
global.chatwootWebChannel.utcOffset = '+05:30';
|
||||
vi.setSystemTime(new Date('Thu Apr 14 2022 09:01:46 GMT+0530'));
|
||||
const { isInBusinessHours, isInBetweenTheWorkingHours } = useAvailability();
|
||||
expect(isInBetweenTheWorkingHours.value).toBe(true);
|
||||
expect(isInBusinessHours.value).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,275 @@
|
||||
import { useNextAvailabilityTime } from '../useNextAvailabilityTime';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { generateRelativeTime } from 'shared/helpers/DateHelper';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
|
||||
vi.mock('dashboard/composables/useI18n');
|
||||
|
||||
describe('useNextAvailabilityTime', () => {
|
||||
const chatwootWebChannel = {
|
||||
workingHoursEnabled: true,
|
||||
workingHours: [
|
||||
{
|
||||
day_of_week: 0,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 1,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 2,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 3,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 4,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 5,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
{
|
||||
day_of_week: 6,
|
||||
open_hour: 9,
|
||||
closed_all_day: false,
|
||||
open_minutes: 0,
|
||||
close_hour: 17,
|
||||
},
|
||||
],
|
||||
timezone: 'UTC',
|
||||
locale: 'en',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
window.chatwootWebChannel = chatwootWebChannel;
|
||||
useI18n.mockReturnValue({
|
||||
t: vi.fn(key => {
|
||||
if (key === 'DAY_NAMES') {
|
||||
return [
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
];
|
||||
}
|
||||
return key;
|
||||
}),
|
||||
});
|
||||
generateRelativeTime.mockImplementation((value, unit) => {
|
||||
if (unit === 'hour') return `in ${value} hour${value > 1 ? 's' : ''}`;
|
||||
if (unit === 'minutes')
|
||||
return `in ${value} minute${value > 1 ? 's' : ''}`;
|
||||
if (unit === 'days') return value === 1 ? 'tomorrow' : `in ${value} days`;
|
||||
return `in ${value} ${unit}`;
|
||||
});
|
||||
utcToZonedTime.mockImplementation(date => new Date(date));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
delete window.chatwootWebChannel;
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should return day names', () => {
|
||||
const { dayNames } = useNextAvailabilityTime();
|
||||
expect(dayNames).toEqual([
|
||||
'Sunday',
|
||||
'Monday',
|
||||
'Tuesday',
|
||||
'Wednesday',
|
||||
'Thursday',
|
||||
'Friday',
|
||||
'Saturday',
|
||||
]);
|
||||
});
|
||||
|
||||
it('should return channelConfig', () => {
|
||||
const { channelConfig } = useNextAvailabilityTime();
|
||||
expect(channelConfig.value).toEqual(chatwootWebChannel);
|
||||
});
|
||||
|
||||
it('should return workingHours', () => {
|
||||
const { workingHours } = useNextAvailabilityTime();
|
||||
expect(workingHours.value).toEqual(chatwootWebChannel.workingHours);
|
||||
});
|
||||
|
||||
it('should return currentDayWorkingHours', () => {
|
||||
const { currentDayWorkingHours } = useNextAvailabilityTime();
|
||||
const currentDay = new Date().getDay();
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === currentDay
|
||||
);
|
||||
expect(currentDayWorkingHours.value).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return nextDayWorkingHours', () => {
|
||||
const { nextDayWorkingHours } = useNextAvailabilityTime();
|
||||
const currentDay = new Date().getDay();
|
||||
const nextDay = currentDay === 6 ? 0 : currentDay + 1;
|
||||
const expectedWorkingHours = chatwootWebChannel.workingHours.find(
|
||||
slot => slot.day_of_week === nextDay
|
||||
);
|
||||
expect(nextDayWorkingHours.value).toEqual(expectedWorkingHours);
|
||||
});
|
||||
|
||||
it('should return presentHour', () => {
|
||||
const { presentHour } = useNextAvailabilityTime();
|
||||
expect(presentHour.value).toBe(new Date().getUTCHours());
|
||||
});
|
||||
|
||||
it('should return presentMinute', () => {
|
||||
const { presentMinute } = useNextAvailabilityTime();
|
||||
expect(presentMinute.value).toBe(new Date().getUTCMinutes());
|
||||
});
|
||||
|
||||
it('should return currentDay', () => {
|
||||
const { currentDay } = useNextAvailabilityTime();
|
||||
const date = new Date();
|
||||
const day = date.getUTCDay();
|
||||
expect(currentDay.value).toBe(day);
|
||||
});
|
||||
|
||||
it('should return currentDayTimings', () => {
|
||||
const { currentDayTimings, currentDayWorkingHours } =
|
||||
useNextAvailabilityTime();
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = currentDayWorkingHours.value;
|
||||
expect(currentDayTimings.value).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return nextDayTimings', () => {
|
||||
const { nextDayTimings, nextDayWorkingHours } = useNextAvailabilityTime();
|
||||
const { open_hour: openHour, open_minutes: openMinute } =
|
||||
nextDayWorkingHours.value;
|
||||
expect(nextDayTimings.value).toEqual({
|
||||
openHour,
|
||||
openMinute,
|
||||
});
|
||||
});
|
||||
|
||||
it('should return dayDiff', () => {
|
||||
const { dayDiff, currentDay, nextDayWorkingHours } =
|
||||
useNextAvailabilityTime();
|
||||
const nextDay = nextDayWorkingHours.value.day_of_week;
|
||||
const totalDays = 6;
|
||||
const expectedDayDiff =
|
||||
nextDay > currentDay.value
|
||||
? nextDay - currentDay.value - 1
|
||||
: totalDays - currentDay.value + nextDay;
|
||||
expect(dayDiff.value).toEqual(expectedDayDiff);
|
||||
});
|
||||
|
||||
it('should return dayNameOfNextWorkingDay', () => {
|
||||
const { dayNameOfNextWorkingDay, nextDayWorkingHours, dayNames } =
|
||||
useNextAvailabilityTime();
|
||||
const nextDay = nextDayWorkingHours.value.day_of_week;
|
||||
const expectedDayName = dayNames[nextDay];
|
||||
expect(dayNameOfNextWorkingDay.value).toEqual(expectedDayName);
|
||||
});
|
||||
|
||||
it('should return hoursAndMinutesBackInOnline', () => {
|
||||
const { hoursAndMinutesBackInOnline } = useNextAvailabilityTime();
|
||||
expect(hoursAndMinutesBackInOnline.value).toBeDefined();
|
||||
expect(typeof hoursAndMinutesBackInOnline.value.hoursLeft).toBe('number');
|
||||
expect(typeof hoursAndMinutesBackInOnline.value.minutesLeft).toBe('number');
|
||||
});
|
||||
|
||||
// Time-based tests
|
||||
describe('time-based tests', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('should return in 30 minutes', () => {
|
||||
vi.setSystemTime(new Date('2022-04-14T17:30:00.000Z')); // Thursday
|
||||
chatwootWebChannel.workingHours[4].open_hour = 18;
|
||||
chatwootWebChannel.workingHours[4].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 23;
|
||||
|
||||
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
|
||||
setTimeSlot();
|
||||
expect(timeLeftToBackInOnline.value).toBe('in 30 minutes');
|
||||
});
|
||||
|
||||
it('should return in 2 hours', () => {
|
||||
vi.setSystemTime(new Date('2022-04-14T17:00:00.000Z')); // Thursday
|
||||
chatwootWebChannel.workingHours[4].open_hour = 19;
|
||||
chatwootWebChannel.workingHours[4].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 23;
|
||||
|
||||
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
|
||||
setTimeSlot();
|
||||
expect(timeLeftToBackInOnline.value).toBe('in 2 hours');
|
||||
});
|
||||
|
||||
it('should return at 10:00 AM', () => {
|
||||
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
|
||||
chatwootWebChannel.workingHours[5].open_hour = 10; // Friday
|
||||
chatwootWebChannel.workingHours[5].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[5].close_hour = 18;
|
||||
|
||||
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
|
||||
setTimeSlot();
|
||||
expect(timeLeftToBackInOnline.value).toBe('at 10:00 AM');
|
||||
});
|
||||
|
||||
it('should return tomorrow', () => {
|
||||
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
|
||||
chatwootWebChannel.workingHours[5].open_hour = 9; // Friday
|
||||
chatwootWebChannel.workingHours[5].open_minutes = 0;
|
||||
chatwootWebChannel.workingHours[5].close_hour = 17;
|
||||
|
||||
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
|
||||
setTimeSlot();
|
||||
expect(timeLeftToBackInOnline.value).toBe('tomorrow');
|
||||
});
|
||||
|
||||
it('should return on Saturday', () => {
|
||||
vi.setSystemTime(new Date('2022-04-14T22:00:00.000Z')); // Thursday
|
||||
chatwootWebChannel.workingHours[4].open_hour = 9;
|
||||
chatwootWebChannel.workingHours[4].close_hour = 17;
|
||||
chatwootWebChannel.workingHours[5].closed_all_day = true; // Friday closed
|
||||
|
||||
const { timeLeftToBackInOnline, setTimeSlot } = useNextAvailabilityTime();
|
||||
setTimeSlot();
|
||||
expect(timeLeftToBackInOnline.value).toBe('on Saturday');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,122 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { isTimeAfter } from 'shared/helpers/DateHelper';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
import { useNextAvailabilityTime } from './useNextAvailabilityTime';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
export const useAvailability = () => {
|
||||
const availableAgents = useMapGetter('agent/availableAgents');
|
||||
|
||||
const { t } = useI18n();
|
||||
const { timeLeftToBackInOnline } = useNextAvailabilityTime();
|
||||
const channelConfig = ref(window.chatwootWebChannel);
|
||||
|
||||
const replyTimeStatus = computed(() => {
|
||||
switch (channelConfig.value.replyTime) {
|
||||
case 'in_a_few_minutes':
|
||||
return t('REPLY_TIME.IN_A_FEW_MINUTES');
|
||||
case 'in_a_few_hours':
|
||||
return t('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
case 'in_a_day':
|
||||
return t('REPLY_TIME.IN_A_DAY');
|
||||
default:
|
||||
return t('REPLY_TIME.IN_A_FEW_HOURS');
|
||||
}
|
||||
});
|
||||
|
||||
function getDateWithOffset(utcOffset) {
|
||||
return utcToZonedTime(new Date().toISOString(), utcOffset);
|
||||
}
|
||||
|
||||
const currentDayAvailability = computed(() => {
|
||||
const { utcOffset, workingHours } = channelConfig.value;
|
||||
const dayOfTheWeek = getDateWithOffset(utcOffset).getDay();
|
||||
const [workingHourConfig = {}] = workingHours.filter(
|
||||
workingHour => workingHour.day_of_week === dayOfTheWeek
|
||||
);
|
||||
return {
|
||||
closedAllDay: workingHourConfig.closed_all_day,
|
||||
openHour: workingHourConfig.open_hour,
|
||||
openMinute: workingHourConfig.open_minutes,
|
||||
closeHour: workingHourConfig.close_hour,
|
||||
closeMinute: workingHourConfig.close_minutes,
|
||||
openAllDay: workingHourConfig.open_all_day,
|
||||
};
|
||||
});
|
||||
|
||||
const isInBetweenTheWorkingHours = computed(() => {
|
||||
const {
|
||||
openHour,
|
||||
openMinute,
|
||||
closeHour,
|
||||
closeMinute,
|
||||
closedAllDay,
|
||||
openAllDay,
|
||||
} = currentDayAvailability.value;
|
||||
|
||||
if (openAllDay) return true;
|
||||
if (closedAllDay) return false;
|
||||
|
||||
const { utcOffset } = channelConfig.value;
|
||||
const today = getDateWithOffset(utcOffset);
|
||||
const currentHours = today.getHours();
|
||||
const currentMinutes = today.getMinutes();
|
||||
|
||||
const isAfterStartTime = isTimeAfter(
|
||||
currentHours,
|
||||
currentMinutes,
|
||||
openHour,
|
||||
openMinute
|
||||
);
|
||||
const isBeforeEndTime = isTimeAfter(
|
||||
closeHour,
|
||||
closeMinute,
|
||||
currentHours,
|
||||
currentMinutes
|
||||
);
|
||||
return isAfterStartTime && isBeforeEndTime;
|
||||
});
|
||||
|
||||
const isOnline = computed(() => {
|
||||
const { workingHoursEnabled } = channelConfig.value;
|
||||
const anyAgentOnline = availableAgents.value.length > 0;
|
||||
|
||||
if (workingHoursEnabled) {
|
||||
return isInBetweenTheWorkingHours.value;
|
||||
}
|
||||
return anyAgentOnline;
|
||||
});
|
||||
|
||||
const replyWaitMessage = computed(() => {
|
||||
const { workingHoursEnabled } = channelConfig.value;
|
||||
if (workingHoursEnabled) {
|
||||
return isOnline.value
|
||||
? replyTimeStatus.value
|
||||
: `${t('REPLY_TIME.BACK_IN')} ${timeLeftToBackInOnline.value}`;
|
||||
}
|
||||
return isOnline.value
|
||||
? replyTimeStatus.value
|
||||
: t('TEAM_AVAILABILITY.OFFLINE');
|
||||
});
|
||||
|
||||
const outOfOfficeMessage = computed(
|
||||
() => channelConfig.value.outOfOfficeMessage
|
||||
);
|
||||
|
||||
const isInBusinessHours = computed(() => {
|
||||
const { workingHoursEnabled } = channelConfig.value;
|
||||
return workingHoursEnabled ? isInBetweenTheWorkingHours.value : true;
|
||||
});
|
||||
|
||||
return {
|
||||
channelConfig,
|
||||
replyTimeStatus,
|
||||
currentDayAvailability,
|
||||
isInBetweenTheWorkingHours,
|
||||
isOnline,
|
||||
replyWaitMessage,
|
||||
outOfOfficeMessage,
|
||||
isInBusinessHours,
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,245 @@
|
||||
import { ref, computed } from 'vue';
|
||||
import {
|
||||
timeSlotParse,
|
||||
defaultTimeSlot,
|
||||
} from 'dashboard/routes/dashboard/settings/inbox/helpers/businessHour.js';
|
||||
import { utcToZonedTime } from 'date-fns-tz';
|
||||
import { generateRelativeTime } from 'shared/helpers/DateHelper';
|
||||
import { useI18n } from 'dashboard/composables/useI18n';
|
||||
|
||||
const MINUTE_ROUNDING_FACTOR = 5;
|
||||
|
||||
export const useNextAvailabilityTime = () => {
|
||||
const { t } = useI18n();
|
||||
const dayNames = t('DAY_NAMES');
|
||||
const timeSlots = ref([...defaultTimeSlot]);
|
||||
const timeSlot = ref({});
|
||||
|
||||
const channelConfig = computed(() => window.chatwootWebChannel);
|
||||
const workingHours = computed(() => channelConfig.value.workingHours);
|
||||
const timeZoneValue = computed(() => channelConfig.value.timezone);
|
||||
const languageCode = computed(() => window.chatwootWebChannel.locale);
|
||||
|
||||
const newDateWithTimeZone = computed(() =>
|
||||
utcToZonedTime(new Date(), timeZoneValue.value)
|
||||
);
|
||||
const presentHour = computed(() => newDateWithTimeZone.value.getHours());
|
||||
const presentMinute = computed(() => newDateWithTimeZone.value.getMinutes());
|
||||
|
||||
const currentDay = computed(() => {
|
||||
const date = newDateWithTimeZone.value;
|
||||
const day = date.getDay();
|
||||
const getCurrentDay = Object.keys(dayNames).find(
|
||||
key => dayNames[key] === dayNames[day]
|
||||
);
|
||||
return Number(getCurrentDay);
|
||||
});
|
||||
|
||||
const currentDayWorkingHours = computed(() =>
|
||||
workingHours.value.find(slot => slot.day_of_week === currentDay.value)
|
||||
);
|
||||
|
||||
function getNextDay(day) {
|
||||
return (day + 1) % 7;
|
||||
}
|
||||
|
||||
function getNextWorkingHour(day) {
|
||||
const workingHour = workingHours.value.find(
|
||||
slot => slot.day_of_week === day
|
||||
);
|
||||
if (workingHour && !workingHour.closed_all_day) {
|
||||
return workingHour;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const nextDayWorkingHours = computed(() => {
|
||||
let nextDay = getNextDay(currentDay.value);
|
||||
let nextWorkingHour = getNextWorkingHour(nextDay);
|
||||
|
||||
while (!nextWorkingHour) {
|
||||
nextDay = getNextDay(nextDay);
|
||||
nextWorkingHour = getNextWorkingHour(nextDay);
|
||||
}
|
||||
return nextWorkingHour;
|
||||
});
|
||||
|
||||
const currentDayTimings = computed(() => {
|
||||
const {
|
||||
open_hour: openHour,
|
||||
open_minutes: openMinute,
|
||||
close_hour: closeHour,
|
||||
} = currentDayWorkingHours.value ?? {};
|
||||
return { openHour, openMinute, closeHour };
|
||||
});
|
||||
|
||||
const nextDayTimings = computed(() => {
|
||||
const { open_hour: openHour, open_minutes: openMinute } =
|
||||
nextDayWorkingHours.value ?? {};
|
||||
return { openHour, openMinute };
|
||||
});
|
||||
|
||||
const dayDiff = computed(() => {
|
||||
const nextDay = nextDayWorkingHours.value.day_of_week;
|
||||
const totalDays = 6;
|
||||
return nextDay > currentDay.value
|
||||
? nextDay - currentDay.value - 1
|
||||
: totalDays - currentDay.value + nextDay;
|
||||
});
|
||||
|
||||
const dayNameOfNextWorkingDay = computed(
|
||||
() => dayNames[nextDayWorkingHours.value.day_of_week]
|
||||
);
|
||||
|
||||
function getHoursAndMinutesUntilNextDayOpen(
|
||||
openHour,
|
||||
openMinutes,
|
||||
closeHour
|
||||
) {
|
||||
if (closeHour < openHour) {
|
||||
openHour += 24;
|
||||
}
|
||||
let diffMinutes =
|
||||
openHour * 60 +
|
||||
openMinutes -
|
||||
(presentHour.value * 60 + presentMinute.value);
|
||||
diffMinutes = diffMinutes < 0 ? diffMinutes + 24 * 60 : diffMinutes;
|
||||
const [hoursLeft, minutesLeft] = [
|
||||
Math.floor(diffMinutes / 60),
|
||||
diffMinutes % 60,
|
||||
];
|
||||
|
||||
return { hoursLeft, minutesLeft };
|
||||
}
|
||||
|
||||
const hoursAndMinutesBackInOnline = computed(() => {
|
||||
if (presentHour.value >= currentDayTimings.value.closeHour) {
|
||||
return getHoursAndMinutesUntilNextDayOpen(
|
||||
nextDayWorkingHours.value.open_all_day
|
||||
? 0
|
||||
: nextDayTimings.value.openHour,
|
||||
nextDayTimings.value.openMinute,
|
||||
currentDayTimings.value.closeHour
|
||||
);
|
||||
}
|
||||
return getHoursAndMinutesUntilNextDayOpen(
|
||||
currentDayTimings.value.openHour,
|
||||
currentDayTimings.value.openMinute,
|
||||
currentDayTimings.value.closeHour
|
||||
);
|
||||
});
|
||||
|
||||
const exactTimeInAmPm = computed(
|
||||
() =>
|
||||
`${
|
||||
timeSlot.value.day === currentDay.value
|
||||
? `at ${timeSlot.value.from}`
|
||||
: ''
|
||||
}`
|
||||
);
|
||||
|
||||
const hoursAndMinutesLeft = computed(() => {
|
||||
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
|
||||
const timeLeftChars = [];
|
||||
|
||||
if (hoursLeft > 0) {
|
||||
const roundedUpHoursLeft = minutesLeft > 0 ? hoursLeft + 1 : hoursLeft;
|
||||
const hourRelative = generateRelativeTime(
|
||||
roundedUpHoursLeft,
|
||||
'hour',
|
||||
languageCode.value
|
||||
);
|
||||
timeLeftChars.push(`${hourRelative}`);
|
||||
}
|
||||
|
||||
if (minutesLeft > 0 && hoursLeft === 0) {
|
||||
const roundedUpMinLeft =
|
||||
Math.ceil(minutesLeft / MINUTE_ROUNDING_FACTOR) *
|
||||
MINUTE_ROUNDING_FACTOR;
|
||||
const minRelative = generateRelativeTime(
|
||||
roundedUpMinLeft,
|
||||
'minutes',
|
||||
languageCode.value
|
||||
);
|
||||
timeLeftChars.push(`${minRelative}`);
|
||||
}
|
||||
|
||||
return timeLeftChars.join(' ');
|
||||
});
|
||||
|
||||
const hoursAndMinutesToBack = computed(() => {
|
||||
const { hoursLeft, minutesLeft } = hoursAndMinutesBackInOnline.value;
|
||||
if (hoursLeft >= 3) {
|
||||
return exactTimeInAmPm.value;
|
||||
}
|
||||
if (hoursLeft > 0 || minutesLeft > 0) {
|
||||
return hoursAndMinutesLeft.value;
|
||||
}
|
||||
return 'in some time';
|
||||
});
|
||||
|
||||
const timeLeftToBackInOnline = computed(() => {
|
||||
if (
|
||||
hoursAndMinutesBackInOnline.value.hoursLeft >= 24 ||
|
||||
(timeSlot.value.day !== currentDay.value && dayDiff.value === 0)
|
||||
) {
|
||||
const hourRelative = generateRelativeTime(
|
||||
dayDiff.value + 1,
|
||||
'days',
|
||||
languageCode.value
|
||||
);
|
||||
return `${hourRelative}`;
|
||||
}
|
||||
if (
|
||||
dayDiff.value >= 1 &&
|
||||
presentHour.value >= currentDayTimings.value.closeHour
|
||||
) {
|
||||
return `on ${dayNameOfNextWorkingDay.value}`;
|
||||
}
|
||||
return hoursAndMinutesToBack.value;
|
||||
});
|
||||
|
||||
function setTimeSlot() {
|
||||
const workingTimeSlots = workingHours.value;
|
||||
|
||||
const currentSlot =
|
||||
presentHour.value >= currentDayTimings.value.closeHour
|
||||
? nextDayWorkingHours.value
|
||||
: currentDayWorkingHours.value;
|
||||
|
||||
const slots = timeSlotParse(workingTimeSlots).length
|
||||
? timeSlotParse(workingTimeSlots)
|
||||
: defaultTimeSlot;
|
||||
timeSlots.value = slots;
|
||||
|
||||
timeSlot.value = timeSlots.value.find(
|
||||
slot => slot.day === currentSlot.day_of_week
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
dayNames,
|
||||
timeSlots,
|
||||
timeSlot,
|
||||
channelConfig,
|
||||
workingHours,
|
||||
newDateWithTimeZone,
|
||||
presentHour,
|
||||
presentMinute,
|
||||
currentDay,
|
||||
timeZoneValue,
|
||||
languageCode,
|
||||
currentDayWorkingHours,
|
||||
nextDayWorkingHours,
|
||||
currentDayTimings,
|
||||
nextDayTimings,
|
||||
dayDiff,
|
||||
dayNameOfNextWorkingDay,
|
||||
hoursAndMinutesBackInOnline,
|
||||
exactTimeInAmPm,
|
||||
hoursAndMinutesLeft,
|
||||
hoursAndMinutesToBack,
|
||||
timeLeftToBackInOnline,
|
||||
setTimeSlot,
|
||||
};
|
||||
};
|
||||
@@ -26,7 +26,7 @@ export default {
|
||||
if (workingHoursEnabled) {
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`;
|
||||
: `${this.$t('REPLY_TIME.BACK_IN')} ${this.timeLeftToBackInOnline}`; // timeLeftToBackInOnline should be pulled in from the useNextAvailabilityTime composable
|
||||
}
|
||||
return this.isOnline
|
||||
? this.replyTimeStatus
|
||||
|
||||
+1
-1
@@ -31,7 +31,7 @@
|
||||
"dependencies": {
|
||||
"@braid/vue-formulate": "^2.5.2",
|
||||
"@chatwoot/ninja-keys": "1.2.3",
|
||||
"@chatwoot/prosemirror-schema": "1.0.11",
|
||||
"@chatwoot/prosemirror-schema": "1.0.16",
|
||||
"@chatwoot/utils": "^0.0.25",
|
||||
"@hcaptcha/vue-hcaptcha": "^0.3.2",
|
||||
"@june-so/analytics-next": "^2.0.0",
|
||||
|
||||
@@ -1,42 +1,112 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
|
||||
describe 'POST /api/v1/account/1/upload/' do
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:account) { create(:account) }
|
||||
let(:user) { create(:user, account: account) }
|
||||
let(:upload_url) { "/api/v1/accounts/#{account.id}/upload/" }
|
||||
|
||||
it 'uploads the image when authorized' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
describe 'POST /api/v1/accounts/:account_id/upload/' do
|
||||
context 'when uploading a file' do
|
||||
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: user.create_new_auth_token,
|
||||
params: { attachment: file }
|
||||
it 'uploads the image when authorized' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { attachment: file }
|
||||
|
||||
expect(response).to have_http_status(:success)
|
||||
blob = response.parsed_body
|
||||
expect(response).to have_http_status(:success)
|
||||
blob = response.parsed_body
|
||||
expect(blob['errors']).to be_nil
|
||||
expect(blob['file_url']).to be_present
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
end
|
||||
|
||||
expect(blob['errors']).to be_nil
|
||||
it 'does not upload when unauthorized' do
|
||||
post upload_url,
|
||||
headers: {},
|
||||
params: { attachment: file }
|
||||
|
||||
expect(blob['file_url']).to be_present
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
blob = response.parsed_body
|
||||
expect(blob['errors']).to be_present
|
||||
expect(blob['file_url']).to be_nil
|
||||
expect(blob['blob_key']).to be_nil
|
||||
expect(blob['blob_id']).to be_nil
|
||||
end
|
||||
end
|
||||
|
||||
it 'does not upload when un-authorized' do
|
||||
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
|
||||
context 'when uploading from a URL' do
|
||||
let(:valid_external_url) { 'http://example.com/image.jpg' }
|
||||
|
||||
post "/api/v1/accounts/#{account.id}/upload/",
|
||||
headers: {},
|
||||
params: { attachment: file }
|
||||
before do
|
||||
stub_request(:get, valid_external_url)
|
||||
.to_return(status: 200, body: File.new(Rails.root.join('spec/assets/avatar.png')), headers: { 'Content-Type' => 'image/png' })
|
||||
end
|
||||
|
||||
expect(response).to have_http_status(:unauthorized)
|
||||
blob = response.parsed_body
|
||||
it 'uploads the image from URL when authorized' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: valid_external_url }
|
||||
|
||||
expect(blob['errors']).to be_present
|
||||
expect(response).to have_http_status(:success)
|
||||
blob = response.parsed_body
|
||||
expect(blob['error']).to be_nil
|
||||
expect(blob['file_url']).to be_present
|
||||
expect(blob['blob_key']).to be_present
|
||||
expect(blob['blob_id']).to be_present
|
||||
end
|
||||
|
||||
expect(blob['file_url']).to be_nil
|
||||
expect(blob['blob_key']).to be_nil
|
||||
expect(blob['blob_id']).to be_nil
|
||||
it 'handles invalid URL format' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'not_a_url' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'handles URL with unsupported protocol' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'ftp://example.com/image.jpg' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'handles unreachable URLs' do
|
||||
stub_request(:get, 'http://nonexistent.example.com')
|
||||
.to_raise(SocketError.new('Failed to open TCP connection'))
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://nonexistent.example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('Invalid URL provided')
|
||||
end
|
||||
|
||||
it 'handles HTTP errors' do
|
||||
stub_request(:get, 'http://error.example.com')
|
||||
.to_return(status: 404)
|
||||
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: { external_url: 'http://error.example.com' }
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to start_with('Failed to fetch file from URL')
|
||||
end
|
||||
end
|
||||
|
||||
it 'returns an error when no file or URL is provided' do
|
||||
post upload_url,
|
||||
headers: user.create_new_auth_token,
|
||||
params: {}
|
||||
|
||||
expect(response).to have_http_status(:unprocessable_entity)
|
||||
expect(response.parsed_body['error']).to eq('No file or URL provided')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -69,7 +69,8 @@ RSpec.describe 'Custom Roles API', type: :request do
|
||||
|
||||
describe 'POST #create' do
|
||||
let(:valid_params) do
|
||||
{ custom_role: { name: 'Support', description: 'Support role', permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) } }
|
||||
{ custom_role: { name: 'Support', description: 'Support role',
|
||||
permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) } }
|
||||
end
|
||||
|
||||
context 'when it is an authenticated administrator' do
|
||||
|
||||
@@ -3,6 +3,6 @@ FactoryBot.define do
|
||||
account
|
||||
name { Faker::Name.name }
|
||||
description { Faker::Lorem.sentence }
|
||||
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) }
|
||||
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -2914,10 +2914,10 @@
|
||||
hotkeys-js "3.8.7"
|
||||
lit "2.2.6"
|
||||
|
||||
"@chatwoot/prosemirror-schema@1.0.11":
|
||||
version "1.0.11"
|
||||
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.11.tgz#b66201be8b09cd4c6370a60607cc5d4f9d924bdb"
|
||||
integrity sha512-OAUa1CuHtetEPh/ZhRkMLVQHY2/eesCRb2IRdVzjh0z+4spp3fG826y8FbOEsa7m4hisNirnGF/mDH+NMmYetw==
|
||||
"@chatwoot/prosemirror-schema@1.0.16":
|
||||
version "1.0.16"
|
||||
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.16.tgz#63cfd4cff0f138466a95fedba5d237409a0b418e"
|
||||
integrity sha512-qFPHPrfkG14vB0C/8+kF6huAvz4c2h6s07WKznZqUZ5CgJYd+fESQ49CScOYwOTO6luV4ghILv9kAVRjiaenOQ==
|
||||
dependencies:
|
||||
markdown-it-sup "^1.0.0"
|
||||
prosemirror-commands "^1.1.4"
|
||||
|
||||
Reference in New Issue
Block a user