Compare commits

..
103 changed files with 1543 additions and 2246 deletions
+4 -4
View File
@@ -96,12 +96,12 @@ gem 'koala'
# slack client
gem 'slack-ruby-client', '~> 2.2.0'
# for dialogflow integrations
gem 'google-cloud-dialogflow-v2', '>= 0.24.0'
gem 'google-cloud-dialogflow-v2'
gem 'grpc'
# Translate integrations
# 'google-cloud-translate' gem depends on faraday 2.0 version
# this dependency breaks the slack-ruby-client gem
gem 'google-cloud-translate-v3', '>= 0.7.0'
gem 'google-cloud-translate-v3'
##-- apm and error monitoring ---#
# loaded only when environment variables are set.
@@ -165,7 +165,7 @@ gem 'audited', '~> 5.4', '>= 5.4.1'
# need for google auth
gem 'omniauth', '>= 2.1.2'
gem 'omniauth-google-oauth2', '>= 1.1.3'
gem 'omniauth-google-oauth2', '>= 1.1.2'
gem 'omniauth-rails_csrf_protection', '~> 1.0', '>= 1.0.2'
## Gems for reponse bot
@@ -200,7 +200,7 @@ group :development do
gem 'rack-mini-profiler', '>= 3.2.0', require: false
gem 'stackprof'
# Should install the associated chrome extension to view query logs
gem 'meta_request', '>= 0.8.3'
gem 'meta_request', '>= 0.8.0'
end
group :test do
+18 -18
View File
@@ -257,7 +257,7 @@ GEM
faraday-net_http_persistent (2.1.0)
faraday (~> 2.5)
net-http-persistent (~> 4.0)
faraday-retry (2.2.1)
faraday-retry (2.1.0)
faraday (~> 2.0)
fcm (1.0.8)
faraday (>= 1.0.0, < 3.0)
@@ -271,7 +271,7 @@ GEM
fugit (1.11.1)
et-orbi (~> 1, >= 1.2.11)
raabro (~> 1.4)
gapic-common (0.20.0)
gapic-common (0.18.0)
faraday (>= 1.9, < 3.a)
faraday-retry (>= 1.0, < 3.a)
google-protobuf (~> 3.14)
@@ -301,15 +301,15 @@ GEM
google-cloud-core (1.6.0)
google-cloud-env (~> 1.0)
google-cloud-errors (~> 1.0)
google-cloud-dialogflow-v2 (0.31.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-dialogflow-v2 (0.23.0)
gapic-common (>= 0.18.0, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-location (>= 0.4, < 2.a)
google-cloud-env (1.6.0)
faraday (>= 0.17.3, < 3.0)
google-cloud-errors (1.3.1)
google-cloud-location (0.6.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-location (0.4.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-cloud-storage (1.44.0)
addressable (~> 2.8)
@@ -319,17 +319,17 @@ GEM
google-cloud-core (~> 1.6)
googleauth (>= 0.16.2, < 2.a)
mini_mime (~> 1.0)
google-cloud-translate-v3 (0.10.0)
gapic-common (>= 0.20.0, < 2.a)
google-cloud-translate-v3 (0.6.0)
gapic-common (>= 0.17.1, < 2.a)
google-cloud-errors (~> 1.0)
google-protobuf (3.25.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.6.0)
google-protobuf (>= 3.18, < 5.a)
googleapis-common-protos-types (~> 1.7)
grpc (~> 1.41)
googleapis-common-protos (1.4.0)
google-protobuf (~> 3.14)
googleapis-common-protos-types (~> 1.2)
grpc (~> 1.27)
googleapis-common-protos-types (1.14.0)
google-protobuf (~> 3.18)
googleauth (1.5.2)
@@ -457,7 +457,7 @@ GEM
marcel (1.0.4)
maxminddb (0.1.22)
memoist (0.16.2)
meta_request (0.8.3)
meta_request (0.8.2)
rack-contrib (>= 1.1, < 3)
railties (>= 3.0.0, < 8)
method_source (1.1.0)
@@ -522,7 +522,7 @@ GEM
hashie (>= 3.4.6)
rack (>= 2.2.3)
rack-protection
omniauth-google-oauth2 (1.1.3)
omniauth-google-oauth2 (1.1.2)
jwt (>= 2.0)
oauth2 (~> 2.0)
omniauth (~> 2.0)
@@ -881,9 +881,9 @@ DEPENDENCIES
foreman
geocoder
gmail_xoauth
google-cloud-dialogflow-v2 (>= 0.24.0)
google-cloud-dialogflow-v2
google-cloud-storage
google-cloud-translate-v3 (>= 0.7.0)
google-cloud-translate-v3
groupdate
grpc
haikunator
@@ -903,14 +903,14 @@ DEPENDENCIES
listen
lograge (~> 0.14.0)
maxminddb
meta_request (>= 0.8.3)
meta_request (>= 0.8.0)
mock_redis
neighbor
net-smtp (~> 0.3.4)
newrelic-sidekiq-metrics (>= 1.6.2)
newrelic_rpm
omniauth (>= 2.1.2)
omniauth-google-oauth2 (>= 1.1.3)
omniauth-google-oauth2 (>= 1.1.2)
omniauth-oauth2
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
pg
+2 -2
View File
@@ -94,8 +94,8 @@ class AccountBuilder
def blocked_domains
domains = GlobalConfigService.load('BLOCKED_EMAIL_DOMAINS', '')
return [] if domains.blank?
domains.split("\n").map(&:strip) if domains.present?
domains.split("\n").map(&:strip)
[]
end
end
@@ -1,68 +1,13 @@
class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create
result = if params[:attachment].present?
create_from_file
elsif params[:external_url].present?
create_from_url
else
render_error('No file or URL provided', :unprocessable_entity)
end
render_success(result) if result.is_a?(ActiveStorage::Blob)
end
private
def create_from_file
attachment = params[:attachment]
create_and_save_blob(attachment.tempfile, attachment.original_filename, attachment.content_type)
end
def create_from_url
uri = parse_uri(params[:external_url])
return if performed?
fetch_and_process_file_from_uri(uri)
end
def parse_uri(url)
uri = URI.parse(url)
validate_uri(uri)
uri
rescue URI::InvalidURIError, SocketError
render_error('Invalid URL provided', :unprocessable_entity)
nil
end
def validate_uri(uri)
raise URI::InvalidURIError unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
end
def fetch_and_process_file_from_uri(uri)
uri.open do |file|
create_and_save_blob(file, File.basename(uri.path), file.content_type)
end
rescue OpenURI::HTTPError => e
render_error("Failed to fetch file from URL: #{e.message}", :unprocessable_entity)
rescue SocketError
render_error('Invalid URL provided', :unprocessable_entity)
rescue StandardError
render_error('An unexpected error occurred', :internal_server_error)
end
def create_and_save_blob(io, filename, content_type)
ActiveStorage::Blob.create_and_upload!(
io: io,
filename: filename,
content_type: content_type
file_blob = ActiveStorage::Blob.create_and_upload!(
key: nil,
io: params[:attachment].tempfile,
filename: params[:attachment].original_filename,
content_type: params[:attachment].content_type
)
end
file_blob.save!
def render_success(file_blob)
render json: { file_url: url_for(file_blob), blob_key: file_blob.key, blob_id: file_blob.id }
end
def render_error(message, status)
render json: { error: message }, status: status
end
end
@@ -117,41 +117,35 @@ export default {
lastConversationIndex,
};
};
const handleConversationNavigation = direction => {
const handlePreviousConversation = () => {
const { allConversations, activeConversationIndex } =
getKeyboardListenerParams();
if (activeConversationIndex === -1) {
allConversations[0].click();
}
if (activeConversationIndex >= 1) {
allConversations[activeConversationIndex - 1].click();
}
};
const handleNextConversation = () => {
const {
allConversations,
activeConversationIndex,
lastConversationIndex,
} = getKeyboardListenerParams();
// Determine the new index based on the direction
const newIndex =
direction === 'previous'
? activeConversationIndex - 1
: activeConversationIndex + 1;
// Check if the new index is within the valid range
if (
allConversations.length > 0 &&
newIndex >= 0 &&
newIndex <= lastConversationIndex
) {
// Click the conversation at the new index
allConversations[newIndex].click();
} else if (allConversations.length > 0) {
// If the new index is out of range, click the first or last conversation based on the direction
const fallbackIndex =
direction === 'previous' ? 0 : lastConversationIndex;
allConversations[fallbackIndex].click();
if (activeConversationIndex === -1) {
allConversations[lastConversationIndex].click();
} else if (activeConversationIndex < lastConversationIndex) {
allConversations[activeConversationIndex + 1].click();
}
};
const keyboardEvents = {
'Alt+KeyJ': {
action: () => handleConversationNavigation('previous'),
action: () => handlePreviousConversation(),
allowOnFocusedInput: true,
},
'Alt+KeyK': {
action: () => handleConversationNavigation('next'),
action: () => handleNextConversation(),
allowOnFocusedInput: true,
},
};
@@ -331,12 +331,10 @@ export default {
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
@@ -1,34 +1,38 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
text: {
type: String,
default: '',
<script>
export default {
props: {
text: {
type: String,
default: '',
},
limit: {
type: Number,
default: 120,
},
},
limit: {
type: Number,
default: 120,
data() {
return {
showMore: false,
};
},
});
const { t } = useI18n();
const showMore = ref(false);
computed: {
textToBeDisplayed() {
if (this.showMore || this.text.length <= this.limit) {
return this.text;
}
const textToBeDisplayed = computed(() => {
if (showMore.value || props.text.length <= props.limit) {
return props.text;
}
return props.text.slice(0, props.limit) + '...';
});
const buttonLabel = computed(() => {
const i18nKey = !showMore.value ? 'SHOW_MORE' : 'SHOW_LESS';
return t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
});
const toggleShowMore = () => {
showMore.value = !showMore.value;
return this.text.slice(0, this.limit) + '...';
},
buttonLabel() {
const i18nKey = !this.showMore ? 'SHOW_MORE' : 'SHOW_LESS';
return this.$t(`COMPONENTS.SHOW_MORE_BLOCK.${i18nKey}`);
},
},
methods: {
toggleShowMore() {
this.showMore = !this.showMore;
},
},
};
</script>
@@ -37,10 +41,16 @@ const toggleShowMore = () => {
{{ textToBeDisplayed }}
<button
v-if="text.length > limit"
class="text-woot-500 !p-0 !border-0 align-top"
class="show-more--button"
@click="toggleShowMore"
>
{{ buttonLabel }}
</button>
</span>
</template>
<style scoped>
.show-more--button {
color: var(--w-500);
}
</style>
@@ -17,8 +17,6 @@ import { BUS_EVENTS } from 'shared/constants/busEvents';
import TagAgents from '../conversation/TagAgents.vue';
import CannedResponse from '../conversation/CannedResponse.vue';
import VariableList from '../conversation/VariableList.vue';
import KeyboardEmojiSelector from './keyboardEmojiSelector.vue';
import {
appendSignature,
removeSignature,
@@ -26,7 +24,6 @@ import {
scrollCursorIntoView,
findNodeToInsertImage,
setURLWithQueryAndSize,
getContentNode,
} from 'dashboard/helper/editorHelper';
const TYPING_INDICATOR_IDLE_TIME = 4000;
@@ -38,8 +35,10 @@ import {
} from 'shared/helpers/KeyboardHelpers';
import keyboardEventListenerMixins from 'shared/mixins/keyboardEventListenerMixins';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { createTypingIndicator } from '@chatwoot/utils';
import {
replaceVariablesInMessage,
createTypingIndicator,
} from '@chatwoot/utils';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
@@ -72,12 +71,7 @@ const createState = (
export default {
name: 'WootMessageEditor',
components: {
TagAgents,
CannedResponse,
VariableList,
KeyboardEmojiSelector,
},
components: { TagAgents, CannedResponse, VariableList },
mixins: [keyboardEventListenerMixins],
props: {
value: { type: String, default: '' },
@@ -125,11 +119,9 @@ export default {
showUserMentions: false,
showCannedMenu: false,
showVariables: false,
showEmojiMenu: false,
mentionSearchKey: '',
cannedSearchTerm: '',
variableSearchTerm: '',
emojiSearchTerm: '',
editorView: null,
range: null,
state: undefined,
@@ -177,7 +169,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.mentionSearchKey = args.text;
this.mentionSearchKey = args.text.replace('@', '');
return false;
},
@@ -206,7 +198,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.cannedSearchTerm = args.text;
this.cannedSearchTerm = args.text.replace('/', '');
return false;
},
onExit: () => {
@@ -234,7 +226,7 @@ export default {
this.editorView = args.view;
this.range = args.range;
this.variableSearchTerm = args.text;
this.variableSearchTerm = args.text.replace('{{', '');
return false;
},
onExit: () => {
@@ -246,31 +238,6 @@ 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() {
@@ -300,8 +267,6 @@ export default {
},
editorId() {
this.showCannedMenu = false;
this.showEmojiMenu = false;
this.showVariables = false;
this.cannedSearchTerm = '';
this.reloadState(this.value);
},
@@ -552,36 +517,57 @@ export default {
this.editorView.dispatch(tr.setSelection(selection));
this.editorView.focus();
},
/**
* Inserts special content (mention, canned response, variable, emoji) into the editor.
* @param {string} type - The type of special content to insert. Possible values: 'mention', 'canned_response', 'variable', 'emoji'.
* @param {Object|string} content - The content to insert, depending on the type.
*/
insertSpecialContent(type, content) {
insertMentionNode(mentionItem) {
if (!this.editorView) {
return;
return null;
}
const node = this.editorView.state.schema.nodes.mention.create({
userId: mentionItem.id,
userFullName: mentionItem.name,
});
this.insertNodeIntoEditor(node, this.range.from, this.range.to);
this.$track(CONVERSATION_EVENTS.USED_MENTIONS);
return false;
},
insertCannedResponse(cannedItem) {
const updatedMessage = replaceVariablesInMessage({
message: cannedItem,
variables: this.variables,
});
if (!this.editorView) {
return null;
}
let { node, from, to } = getContentNode(
this.editorView,
type,
content,
this.range,
this.variables
let node = new MessageMarkdownTransformer(messageSchema).parse(
updatedMessage
);
if (!node) return;
const from =
node.textContent === updatedMessage
? this.range.from
: this.range.from - 1;
this.insertNodeIntoEditor(node, from, this.range.to);
this.$track(CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE);
return false;
},
insertVariable(variable) {
if (!this.editorView) {
return null;
}
const content = `{{${variable}}}`;
let node = this.editorView.state.schema.text(content);
const { from, to } = this.range;
this.insertNodeIntoEditor(node, from, to);
const event_map = {
mention: CONVERSATION_EVENTS.USED_MENTIONS,
cannedResponse: CONVERSATION_EVENTS.INSERTED_A_CANNED_RESPONSE,
variable: CONVERSATION_EVENTS.INSERTED_A_VARIABLE,
emoji: CONVERSATION_EVENTS.INSERTED_AN_EMOJI,
};
this.$track(event_map[type]);
this.showVariables = false;
this.$track(CONVERSATION_EVENTS.INSERTED_A_VARIABLE);
return false;
},
openFileBrowser() {
this.$refs.imageUpload.click();
@@ -701,22 +687,17 @@ export default {
<TagAgents
v-if="showUserMentions && isPrivate"
:search-key="mentionSearchKey"
@click="content => insertSpecialContent('mention', content)"
@click="insertMentionNode"
/>
<CannedResponse
v-if="shouldShowCannedResponses"
:search-key="cannedSearchTerm"
@click="content => insertSpecialContent('cannedResponse', content)"
@click="insertCannedResponse"
/>
<VariableList
v-if="shouldShowVariables"
:search-key="variableSearchTerm"
@click="content => insertSpecialContent('variable', content)"
/>
<KeyboardEmojiSelector
v-if="showEmojiMenu"
:search-key="emojiSearchTerm"
@click="emoji => insertSpecialContent('emoji', emoji)"
@click="insertVariable"
/>
<input
ref="imageUpload"
@@ -8,7 +8,6 @@ import {
EditorState,
Selection,
} from '@chatwoot/prosemirror-schema';
import imagePastePlugin from '@chatwoot/prosemirror-schema/src/plugins/image';
import { checkFileSizeLimit } from 'shared/helpers/FileHelper';
import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
@@ -56,7 +55,7 @@ export default {
return {
editorView: null,
state: undefined,
plugins: [imagePastePlugin(this.handleImageUpload)],
plugins: [],
};
},
computed: {
@@ -77,7 +76,6 @@ export default {
this.reloadState();
},
},
created() {
this.state = createState(
this.value,
@@ -97,24 +95,6 @@ export default {
openFileBrowser() {
this.$refs.imageUploadInput.click();
},
async handleImageUpload(url) {
try {
const fileUrl = await this.$store.dispatch(
'articles/uploadExternalImage',
{
portalSlug: this.$route.params.portalSlug,
url,
}
);
return fileUrl;
} catch (error) {
useAlert(
this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.UN_AUTHORIZED_ERROR')
);
return '';
}
},
onFileChange() {
const file = this.$refs.imageUploadInput.files[0];
@@ -140,6 +120,7 @@ export default {
if (fileUrl) {
this.onImageUploadStart(fileUrl);
}
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.SUCCESS'));
} catch (error) {
useAlert(this.$t('HELP_CENTER.ARTICLE_EDITOR.IMAGE_UPLOAD.ERROR'));
}
@@ -192,18 +173,6 @@ export default {
blur: () => {
this.onBlur();
},
paste: (view, event) => {
const data = event.clipboardData.files;
if (data.length > 0) {
data.forEach(file => {
// Check if the file is an image
if (file.type.includes('image')) {
this.uploadImageToStorage(file);
}
});
event.preventDefault();
}
},
},
});
},
@@ -3,7 +3,6 @@ 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,
@@ -18,7 +17,6 @@ import { mapGetters } from 'vuex';
export default {
name: 'ReplyBottomPanel',
components: { FileUpload, VideoCallButton, AIAssistanceButton },
mixins: [inboxMixin],
props: {
mode: {
type: String,
@@ -36,13 +34,6 @@ 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,
@@ -111,6 +102,22 @@ 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 } =
@@ -211,7 +218,6 @@ export default {
return !this.isOnPrivateNote;
},
sendWithSignature() {
// channelType is sourced from inboxMixin
return this.fetchSignatureFlagFromUISettings(this.channelType);
},
signatureToggleTooltip() {
@@ -1,68 +0,0 @@
<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,11 +41,14 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
:items="items"
@mentionSelect="handleMentionClick"
/>
>
<template slot-scope="{ item }">
<strong>{{ item.label }}</strong> - {{ item.description }}
</template>
</MentionBox>
</template>
@@ -338,11 +338,7 @@ export default {
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
:error-message="validationErrors[`filter_${i}`]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -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 inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import ConversationContextMenu from './contextMenu/Index.vue';
import TimeAgo from 'dashboard/components/ui/TimeAgo.vue';
import CardLabels from './conversationCardComponents/CardLabels.vue';
@@ -24,7 +24,6 @@ export default {
PriorityMark,
SLACardLabel,
},
mixins: [inboxMixin],
props: {
activeLabel: {
type: String,
@@ -67,6 +66,14 @@ export default {
default: false,
},
},
setup(props) {
const { inbox_id: inboxId } = props.chat;
const { inboxBadge, inbox } = useInbox({ inboxId });
return {
inboxBadge,
inbox,
};
},
data() {
return {
hovered: false,
@@ -121,12 +128,6 @@ 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,8 +1,10 @@
<script>
import { ref } from 'vue';
import { mapGetters } from 'vuex';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import agentMixin from '../../../mixins/agentMixin.js';
import BackButton from '../BackButton.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import InboxName from '../InboxName.vue';
import MoreActions from './MoreActions.vue';
import Thumbnail from '../Thumbnail.vue';
@@ -22,7 +24,7 @@ export default {
SLACardLabel,
Linear,
},
mixins: [inboxMixin],
mixins: [agentMixin],
props: {
chat: {
type: Object,
@@ -42,12 +44,20 @@ 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);
useKeyboardEvents(keyboardEvents, conversationHeaderActionsRef);
return {
conversationHeaderActionsRef,
inbox,
inboxBadge,
};
},
computed: {
...mapGetters({
@@ -102,10 +112,6 @@ 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;
},
@@ -175,6 +181,7 @@ export default {
</div>
<div
ref="conversationHeaderActionsRef"
class="flex items-center gap-2 overflow-hidden text-xs conversation--header--actions text-ellipsis whitespace-nowrap"
>
<InboxName v-if="hasMultipleInboxes" :inbox="inbox" />
@@ -3,7 +3,6 @@ import { ref } from 'vue';
// composable
import { useConfig } from 'dashboard/composables/useConfig';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useAI } from 'dashboard/composables/useAI';
// components
import ReplyBox from './ReplyBox.vue';
@@ -15,7 +14,9 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { mapGetters } from 'vuex';
// mixins
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { useInbox, INBOX_FEATURES } from 'shared/composables/useInbox';
import aiMixin from 'dashboard/mixins/aiMixin';
// utils
import { getTypingUsersText } from '../../../helper/commons';
@@ -40,7 +41,7 @@ export default {
Banner,
ConversationLabelSuggestion,
},
mixins: [inboxMixin],
mixins: [aiMixin],
props: {
isContactPanelOpen: {
type: Boolean,
@@ -52,6 +53,17 @@ 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();
@@ -69,24 +81,22 @@ export default {
},
};
useKeyboardEvents(keyboardEvents);
const {
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
} = useAI();
useKeyboardEvents(keyboardEvents, conversationFooterRef);
return {
isEnterprise,
conversationFooterRef,
isPopOutReplyBox,
closePopOutReplyBox,
showPopOutReplyBox,
isAIIntegrationEnabled,
isLabelSuggestionFeatureEnabled,
fetchIntegrationsIfRequired,
fetchLabelSuggestions,
inbox,
isAWhatsAppChannel,
isAPIInbox,
is360DialogWhatsAppChannel,
isAWebWidgetInbox,
isAFacebookInbox,
isAnEmailChannel,
inboxHasFeature,
};
},
data() {
@@ -121,9 +131,6 @@ 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'
@@ -527,6 +534,7 @@ export default {
/>
</ul>
<div
ref="conversationFooterRef"
class="conversation-footer"
:class="{ 'modal-mask': isPopOutReplyBox }"
>
@@ -17,6 +17,7 @@ import Banner from 'dashboard/components/ui/Banner.vue';
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import WootAudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
import messageFormatterMixin from 'shared/mixins/messageFormatterMixin';
import { AUDIO_FORMATS } from 'shared/constants/messages';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import {
@@ -26,7 +27,7 @@ import {
} from '@chatwoot/utils';
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
import { useInbox, INBOX_FEATURES } from 'shared/composables/useInbox';
import { trimContent, debounce } from '@chatwoot/utils';
import wootConstants from 'dashboard/constants/globals';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
@@ -60,7 +61,7 @@ export default {
MessageSignatureMissingAlert,
ArticleSearchPopover,
},
mixins: [inboxMixin, fileUploadMixin, keyboardEventListenerMixins],
mixins: [messageFormatterMixin, fileUploadMixin, keyboardEventListenerMixins],
props: {
popoutReplyBox: {
type: Boolean,
@@ -68,6 +69,22 @@ export default {
},
},
setup() {
const {
is360DialogWhatsAppChannel,
isAPIInbox,
isAWhatsAppChannel,
isATwitterInbox,
isAFacebookInbox,
isASmsInbox,
isAWebWidgetInbox,
isAnEmailChannel,
isATelegramChannel,
isALineChannel,
channelType,
isAWhatsAppCloudChannel,
isATwilioWhatsAppChannel,
inbox,
} = useInbox({ getCurrentChat: true });
const {
uiSettings,
updateUISettings,
@@ -80,6 +97,20 @@ export default {
updateUISettings,
isEditorHotKeyEnabled,
fetchSignatureFlagFromUISettings,
is360DialogWhatsAppChannel,
isAPIInbox,
isAWhatsAppChannel,
isATwitterInbox,
isAFacebookInbox,
isASmsInbox,
isAWebWidgetInbox,
isAnEmailChannel,
isATelegramChannel,
isALineChannel,
channelType,
isAWhatsAppCloudChannel,
isATwilioWhatsAppChannel,
inbox,
};
},
data() {
@@ -189,9 +220,6 @@ 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')
@@ -237,9 +265,6 @@ export default {
if (this.isASmsInbox) {
return MESSAGE_MAX_LENGTH.TWILIO_SMS;
}
if (this.isAnEmailChannel) {
return MESSAGE_MAX_LENGTH.EMAIL;
}
return MESSAGE_MAX_LENGTH.GENERAL;
},
showFileUpload() {
@@ -1205,6 +1230,12 @@ 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,14 +56,20 @@ export default {
};
</script>
<!-- eslint-disable-next-line vue/no-root-v-if -->
<template>
<MentionBox
v-if="items.length"
type="variable"
:items="items"
@mentionSelect="handleVariableClick"
/>
>
<template slot-scope="{ item }">
<span class="text-capitalize variable--list-label">
{{ item.description }}
</span>
({{ item.label }})
</template>
</MentionBox>
</template>
<style scoped>
@@ -1,10 +1,9 @@
<script>
import { MESSAGE_TYPE, MESSAGE_STATUS } from 'shared/constants/messages';
import inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import { messageTimestamp } from 'shared/helpers/timeHelper';
export default {
mixins: [inboxMixin],
props: {
sender: {
type: Object,
@@ -55,10 +54,33 @@ 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,10 +1,9 @@
<script>
import DyteVideoCall from './integrations/Dyte.vue';
import inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
export default {
components: { DyteVideoCall },
mixins: [inboxMixin],
props: {
messageId: {
type: [String, Number],
@@ -19,14 +18,20 @@ 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,24 +90,22 @@ const variableKey = (item = {}) => {
}"
@click="onListItemSelection(index)"
>
<slot :item="item" :index="index" :selected="index === selectedIndex">
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</slot>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-sm font-medium text-slate-900 dark:text-slate-100 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ item.description }}
</p>
<p
class="max-w-full min-w-0 mb-0 overflow-hidden text-xs text-slate-500 dark:text-slate-300 group-hover:text-woot-500 dark:group-hover:text-woot-500 text-ellipsis whitespace-nowrap"
:class="{
'text-woot-500 dark:text-woot-500': index === selectedIndex,
}"
>
{{ variableKey(item) }}
</p>
</button>
</woot-dropdown-item>
</ul>
@@ -1,68 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { usePortal } from '../usePortal';
import { useAccount } from 'dashboard/composables/useAccount';
import { useRoute } from 'dashboard/composables/route';
import { frontendURL } from 'dashboard/helper/URLHelper';
import allLocales from 'shared/constants/locales.js';
vi.mock('dashboard/composables/useAccount');
vi.mock('dashboard/composables/route');
vi.mock('dashboard/helper/URLHelper');
describe('usePortal', () => {
beforeEach(() => {
vi.mocked(useAccount).mockReturnValue({ accountId: { value: 123 } });
});
it('returns the correct properties', () => {
vi.mocked(useRoute).mockReturnValue({
params: { portalSlug: 'test-portal', locale: 'en' },
});
const portal = usePortal();
expect(portal).toHaveProperty('accountId');
expect(portal).toHaveProperty('portalSlug');
expect(portal).toHaveProperty('locale');
expect(portal).toHaveProperty('articleUrl');
expect(portal).toHaveProperty('localeName');
});
it('computes portalSlug and locale correctly', () => {
vi.mocked(useRoute).mockReturnValue({
params: { portalSlug: 'test-portal', locale: 'fr' },
});
const { portalSlug, locale } = usePortal();
expect(portalSlug.value).toBe('test-portal');
expect(locale.value).toBe('fr');
});
it('generates correct article URL', () => {
vi.mocked(useAccount).mockReturnValue({ accountId: { value: 456 } });
vi.mocked(useRoute).mockReturnValue({
params: { portalSlug: 'help-center', locale: 'es' },
});
vi.mocked(frontendURL).mockReturnValue('https://example.com/article');
const { articleUrl } = usePortal();
const url = articleUrl(789);
expect(frontendURL).toHaveBeenCalledWith(
'accounts/456/portals/help-center/es/articles/789'
);
expect(url).toBe('https://example.com/article');
});
it('returns correct locale name', () => {
vi.mocked(useRoute).mockReturnValue({
params: { portalSlug: 'test-portal', locale: 'ja' },
});
const { localeName } = usePortal();
const name = localeName('ja');
expect(name).toBe(allLocales.ja);
});
});
@@ -1,53 +0,0 @@
import { useAccount } from 'dashboard/composables/useAccount';
import { frontendURL } from 'dashboard/helper/URLHelper';
import allLocales from 'shared/constants/locales.js';
import { useRoute } from 'dashboard/composables/route';
import { computed } from 'vue';
/**
* @typedef {Object} PortalComposable
* @property {import('vue').ComputedRef<number>} accountId - The current account ID.
* @property {import('vue').ComputedRef<string>} portalSlug - The slug of the current portal.
* @property {import('vue').ComputedRef<string>} locale - The current locale code.
* @property {function(number): string} articleUrl - A function to generate the URL for an article.
* @property {function(string): string} localeName - A function to get the localized name of a locale.
*/
/**
* A composable for managing portal-related data and utilities.
* @returns {PortalComposable} An object containing portal-related properties and functions.
*/
export const usePortal = () => {
const { accountId } = useAccount();
const route = useRoute();
const portalSlug = computed(() => route.params.portalSlug);
const locale = computed(() => route.params.locale);
/**
* Generates the URL for an article.
* @param {number} id - The ID of the article.
* @returns {string} The full URL for the article.
*/
const articleUrl = id => {
return frontendURL(
`accounts/${accountId.value}/portals/${portalSlug.value}/${locale.value}/articles/${id}`
);
};
/**
* Gets the localized name of a locale.
* @param {string} code - The locale code.
* @returns {string} The localized name of the locale.
*/
const localeName = code => {
return allLocales[code];
};
return {
accountId,
portalSlug,
locale,
articleUrl,
localeName,
};
};
@@ -5,7 +5,6 @@ export const CONVERSATION_EVENTS = Object.freeze({
INSERTED_A_CANNED_RESPONSE: 'Inserted a canned response',
TRANSLATE_A_MESSAGE: 'Translated a message',
INSERTED_A_VARIABLE: 'Inserted a variable',
INSERTED_AN_EMOJI: 'Inserted an emoji',
USED_MENTIONS: 'Used mentions',
SEARCH_CONVERSATION: 'Searched conversations',
APPLY_FILTER: 'Applied filters in the conversation list',
@@ -3,8 +3,6 @@ import {
MessageMarkdownTransformer,
MessageMarkdownSerializer,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
import * as Sentry from '@sentry/browser';
/**
@@ -283,92 +281,3 @@ export function setURLWithQueryAndSize(selectedImageNode, size, editorView) {
}
}
}
/**
* Content Node Creation Helper Functions for
* - mention
* - canned response
* - variable
* - emoji
*/
/**
* Centralized node creation function that handles the creation of different types of nodes based on the specified type.
* @param {Object} editorView - The editor view instance.
* @param {string} nodeType - The type of node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {Object|string} content - The content needed to create the node, which varies based on node type.
* @returns {Object|null} - The created ProseMirror node or null if the type is not supported.
*/
const createNode = (editorView, nodeType, content) => {
const { state } = editorView;
switch (nodeType) {
case 'mention':
return state.schema.nodes.mention.create({
userId: content.id,
userFullName: content.name,
});
case 'cannedResponse':
return new MessageMarkdownTransformer(messageSchema).parse(content);
case 'variable':
return state.schema.text(`{{${content}}}`);
case 'emoji':
return state.schema.text(content);
default:
return null;
}
};
/**
* Object mapping types to their respective node creation functions.
*/
const nodeCreators = {
mention: (editorView, content, from, to) => ({
node: createNode(editorView, 'mention', content),
from,
to,
}),
cannedResponse: (editorView, content, from, to, variables) => {
const updatedMessage = replaceVariablesInMessage({
message: content,
variables,
});
const node = createNode(editorView, 'cannedResponse', updatedMessage);
return {
node,
from: node.textContent === updatedMessage ? from : from - 1,
to,
};
},
variable: (editorView, content, from, to) => ({
node: createNode(editorView, 'variable', content),
from,
to,
}),
emoji: (editorView, content, from, to) => ({
node: createNode(editorView, 'emoji', content),
from,
to,
}),
};
/**
* Retrieves a content node based on the specified type and content, using a functional approach to select the appropriate node creation function.
* @param {Object} editorView - The editor view instance.
* @param {string} type - The type of content node to create ('mention', 'cannedResponse', 'variable', 'emoji').
* @param {string|Object} content - The content to be transformed into a node.
* @param {Object} range - An object containing 'from' and 'to' properties indicating the range in the document where the node should be placed.
* @param {Object} variables - Optional. Variables to replace in the content, used for 'cannedResponse' type.
* @returns {Object} - An object containing the created node and the updated 'from' and 'to' positions.
*/
export const getContentNode = (
editorView,
type,
content,
{ from, to },
variables
) => {
const creator = nodeCreators[type];
return creator
? creator(editorView, content, from, to, variables)
: { node: null, from, to };
};
@@ -17,7 +17,6 @@ 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 -1
View File
@@ -1,4 +1,4 @@
import { INBOX_TYPES } from 'shared/mixins/inboxMixin';
import { INBOX_TYPES } from 'shared/composables/useInbox';
export const getInboxSource = (type, phoneNumber, inbox) => {
switch (type) {
@@ -1,127 +0,0 @@
// Moved from editorHelper.spec.js to editorContentHelper.spec.js
// the mock of chatwoot/prosemirror-schema is getting conflicted with other specs
import { getContentNode } from '../editorHelper';
import {
MessageMarkdownTransformer,
messageSchema,
} from '@chatwoot/prosemirror-schema';
import { replaceVariablesInMessage } from '@chatwoot/utils';
vi.mock('@chatwoot/prosemirror-schema', () => ({
MessageMarkdownTransformer: vi.fn(),
messageSchema: {},
}));
vi.mock('@chatwoot/utils', () => ({
replaceVariablesInMessage: vi.fn(),
}));
describe('getContentNode', () => {
let editorView;
beforeEach(() => {
editorView = {
state: {
schema: {
nodes: {
mention: {
create: vi.fn(),
},
},
text: vi.fn(),
},
},
};
});
describe('getMentionNode', () => {
it('should create a mention node', () => {
const content = { id: 1, name: 'John Doe' };
const from = 0;
const to = 10;
getContentNode(editorView, 'mention', content, {
from,
to,
});
expect(editorView.state.schema.nodes.mention.create).toHaveBeenCalledWith(
{
userId: content.id,
userFullName: content.name,
}
);
});
});
describe('getCannedResponseNode', () => {
it('should create a canned response node', () => {
const content = 'Hello {{name}}';
const variables = { name: 'John' };
const from = 0;
const to = 10;
const updatedMessage = 'Hello John';
replaceVariablesInMessage.mockReturnValue(updatedMessage);
MessageMarkdownTransformer.mockImplementation(() => ({
parse: vi.fn().mockReturnValue({ textContent: updatedMessage }),
}));
const { node } = getContentNode(
editorView,
'cannedResponse',
content,
{ from, to },
variables
);
expect(replaceVariablesInMessage).toHaveBeenCalledWith({
message: content,
variables,
});
expect(MessageMarkdownTransformer).toHaveBeenCalledWith(messageSchema);
expect(node.textContent).toBe(updatedMessage);
});
});
describe('getVariableNode', () => {
it('should create a variable node', () => {
const content = 'name';
const from = 0;
const to = 10;
getContentNode(editorView, 'variable', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('{{name}}');
});
});
describe('getEmojiNode', () => {
it('should create an emoji node', () => {
const content = '😊';
const from = 0;
const to = 2;
getContentNode(editorView, 'emoji', content, {
from,
to,
});
expect(editorView.state.schema.text).toHaveBeenCalledWith('😊');
});
});
describe('getContentNode', () => {
it('should return null for invalid type', () => {
const content = 'invalid';
const from = 0;
const to = 10;
const { node } = getContentNode(editorView, 'invalid', content, {
from,
to,
});
expect(node).toBeNull();
});
});
});
@@ -1,93 +1,52 @@
import { uploadFile } from '../uploadHelper';
import axios from 'axios';
import { uploadExternalImage, uploadFile } from '../uploadHelper';
global.axios = axios;
vi.mock('axios');
describe('Upload Helpers', () => {
describe('#Upload Helpers', () => {
afterEach(() => {
// Cleaning up the mock after each test
axios.post.mockReset();
});
describe('uploadFile', () => {
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadFile(mockFile, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
it('should send a POST request with correct data', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
axios.post.mockResolvedValueOnce(mockResponse);
axios.post.mockRejectedValueOnce(mockError);
const result = await uploadFile(mockFile, '1602');
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
expect.any(FormData),
{ headers: { 'Content-Type': 'multipart/form-data' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
});
describe('uploadExternalImage', () => {
it('should send a POST request with correct data', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockResponse = {
data: {
file_url: 'https://example.com/fileUrl',
blob_key: 'blobKey123',
blob_id: 'blobId456',
},
};
axios.post.mockResolvedValueOnce(mockResponse);
const result = await uploadExternalImage(mockUrl, '1602');
expect(axios.post).toHaveBeenCalledWith(
'/api/v1/accounts/1602/upload',
{ external_url: mockUrl },
{ headers: { 'Content-Type': 'application/json' } }
);
expect(result).toEqual({
fileUrl: 'https://example.com/fileUrl',
blobKey: 'blobKey123',
blobId: 'blobId456',
});
it('should handle errors', async () => {
const mockFile = new File(['dummy content'], 'example.png', {
type: 'image/png',
});
const mockError = new Error('Failed to upload');
it('should handle errors', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Failed to upload');
axios.post.mockRejectedValueOnce(mockError);
axios.post.mockRejectedValueOnce(mockError);
await expect(uploadExternalImage(mockUrl)).rejects.toThrow(
'Failed to upload'
);
});
await expect(uploadFile(mockFile)).rejects.toThrow('Failed to upload');
});
});
@@ -19,47 +19,26 @@ const HEADERS = {
* The function uses FormData to wrap the file and axios to send the request.
*
* @param {File} file - The file to be uploaded. It should be a File object (typically coming from a file input element).
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response when the upload is successful, or rejects if there's an error.
*/
export async function uploadFile(file, accountId) {
// Create a new FormData instance.
let formData = new FormData();
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
// Append the file to the FormData instance under the key 'attachment'.
let formData = new FormData();
formData.append('attachment', file);
// Use axios to send a POST request to the upload endpoint.
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
formData,
{ headers: HEADERS }
);
return {
fileUrl: data.file_url,
blobKey: data.blob_key,
blobId: data.blob_id,
};
}
/**
* Uploads an image from an external URL.
*
* @param {string} url - The external URL of the image.
* @param {string} accountId - The account ID.
* @returns {Promise} A promise that resolves with the server's response.
*/
export async function uploadExternalImage(url, accountId) {
if (!accountId) {
accountId = window.location.pathname.split('/')[3];
}
const { data } = await axios.post(
`/api/${API_VERSION}/accounts/${accountId}/upload`,
{ external_url: url },
{ headers: { 'Content-Type': 'application/json' } }
{
headers: HEADERS,
}
);
return {
@@ -55,9 +55,6 @@
"CREATED_AT": "Created at",
"LAST_ACTIVITY": "Last activity"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"GROUPS": {
"STANDARD_FILTERS": "Standard filters",
"ADDITIONAL_FILTERS": "Additional filters",
@@ -3,8 +3,7 @@
"HEADER": "Custom Attributes",
"HEADER_BTN_TXT": "Add Custom Attribute",
"LOADING": "Fetching custom attributes",
"DESCRIPTION": "A custom attribute tracks additional details about your contacts or conversations—such as the subscription plan or the date of their first purchase. You can add different types of custom attributes, such as text, lists, or numbers, to capture the specific information you need.",
"LEARN_MORE": "Learn more about custom attributes",
"SIDEBAR_TXT": "<p><b>Custom Attributes</b> <p>A custom attribute tracks facts about your contacts/conversation — like the subscription plan, or when they ordered the first item etc. <br /><br />For creating a Custom Attribute, just click on the <b>Add Custom Attribute.</b> You can also edit or delete an existing Custom Attribute by clicking on the Edit or Delete button.</p>",
"ADD": {
"TITLE": "Add Custom Attribute",
"SUBMIT": "Create",
@@ -92,12 +91,7 @@
"CONTACT": "Contact"
},
"LIST": {
"TABLE_HEADER": [
"Name",
"Description",
"Type",
"Key"
],
"TABLE_HEADER": ["Name", "Description", "Type", "Key"],
"BUTTONS": {
"EDIT": "Edit",
"DELETE": "Delete"
@@ -30,9 +30,6 @@
"is_lesser_than": "Is lesser than",
"days_before": "Is x days before"
},
"ERRORS": {
"VALUE_REQUIRED": "Value is required"
},
"ATTRIBUTES": {
"NAME": "Name",
"EMAIL": "Email",
@@ -41,7 +41,6 @@
"UPLOADING": "Uploading...",
"SUCCESS": "Image uploaded successfully",
"ERROR": "Error while uploading image",
"UN_AUTHORIZED_ERROR": "You are not authorized to upload images",
"ERROR_FILE_SIZE": "Image size should be less than {size}MB",
"ERROR_FILE_FORMAT": "Image format should be jpg, jpeg or png",
"ERROR_FILE_DIMENSIONS": "Image dimensions should be less than 2000 x 2000"
@@ -12,7 +12,6 @@
},
"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.",
@@ -308,11 +308,7 @@ 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}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
:error-message="validationErrors[`filter_${i}`]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -9,12 +9,11 @@ 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 inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox, INBOX_TYPES } from 'shared/composables/useInbox';
import FileUpload from 'vue-upload-component';
import AttachmentPreview from 'dashboard/components/widgets/AttachmentsPreview';
import { ALLOWED_FILE_TYPES } from 'shared/constants/messages';
@@ -36,7 +35,7 @@ export default {
AttachmentPreview,
MessageSignatureMissingAlert,
},
mixins: [inboxMixin, fileUploadMixin],
mixins: [fileUploadMixin],
props: {
contact: {
type: Object,
@@ -47,12 +46,18 @@ export default {
default: () => {},
},
},
setup() {
setup(props) {
const { channelType } = useInbox({ inboxObj: props.targetInbox });
const { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox } =
useUISettings();
const v$ = useVuelidate();
return { fetchSignatureFlagFromUISettings, setSignatureFlagForInbox, v$ };
return {
fetchSignatureFlagFromUISettings,
setSignatureFlagForInbox,
v$,
channelType,
};
},
data() {
return {
@@ -1,14 +1,14 @@
<script>
import { dynamicTime } from 'shared/helpers/timeHelper';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../mixins/portalMixin';
import { frontendURL } from 'dashboard/helper/URLHelper';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useAccount } from 'dashboard/composables/useAccount';
export default {
components: {
Thumbnail,
},
mixins: [portalMixin],
props: {
showDragIcon: {
type: Boolean,
@@ -44,11 +44,7 @@ export default {
default: 0,
},
},
setup() {
const { accountId } = useAccount();
const { articleUrl, portalSlug, locale, localeName } = usePortal();
return { accountId, portalSlug, locale, localeName, articleUrl };
},
computed: {
lastUpdatedAt() {
return dynamicTime(this.updatedAt);
@@ -7,7 +7,7 @@ import SearchResults from './SearchResults.vue';
import ArticleView from './ArticleView.vue';
import ArticlesAPI from 'dashboard/api/helpCenter/articles';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../../mixins/portalMixin';
export default {
name: 'ArticleSearchPopover',
@@ -16,16 +16,13 @@ export default {
SearchResults,
ArticleView,
},
mixins: [portalMixin],
props: {
selectedPortalSlug: {
type: String,
required: true,
},
},
setup() {
const { localeName } = usePortal();
return { localeName };
},
data() {
return {
searchQuery: '',
@@ -10,8 +10,7 @@ import WootKeyShortcutModal from 'dashboard/components/widgets/modal/WootKeyShor
import AccountSelector from 'dashboard/components/layout/sidebarComponents/AccountSelector.vue';
import NotificationPanel from 'dashboard/routes/dashboard/notifications/components/NotificationPanel.vue';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../mixins/portalMixin';
import AddCategory from '../pages/categories/AddCategory.vue';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
const CommandBar = () =>
@@ -29,16 +28,13 @@ export default {
UpgradePage,
WootKeyShortcutModal,
},
mixins: [portalMixin],
setup() {
const { uiSettings, updateUISettings } = useUISettings();
const { accountId } = useAccount();
const { locale, localeName } = usePortal();
return {
uiSettings,
updateUISettings,
accountId,
locale,
localeName,
};
},
data() {
@@ -55,6 +51,7 @@ export default {
computed: {
...mapGetters({
accountId: 'getCurrentAccountId',
portals: 'portals/allPortals',
categories: 'categories/allCategories',
meta: 'portals/getMeta',
@@ -1,6 +1,7 @@
<script>
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../mixins/portalMixin';
export default {
mixins: [portalMixin],
props: {
locales: {
type: Array,
@@ -11,10 +12,7 @@ export default {
default: '',
},
},
setup() {
const { localeName } = usePortal();
return { localeName };
},
methods: {
changeDefaultLocale(localeCode) {
this.$emit('changeDefaultLocale', { localeCode });
@@ -1,11 +1,12 @@
<script>
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../mixins/portalMixin';
export default {
components: {
Thumbnail,
},
mixins: [portalMixin],
props: {
portal: {
type: Object,
@@ -24,10 +25,6 @@ export default {
default: '',
},
},
setup() {
const { localeName } = usePortal();
return { localeName };
},
data() {
return {
selectedLocale: null,
@@ -0,0 +1,24 @@
import { mapGetters } from 'vuex';
import { frontendURL } from 'dashboard/helper/URLHelper';
import allLocales from 'shared/constants/locales.js';
export default {
computed: {
...mapGetters({ accountId: 'getCurrentAccountId' }),
portalSlug() {
return this.$route.params.portalSlug;
},
locale() {
return this.$route.params.locale;
},
},
methods: {
articleUrl(id) {
return frontendURL(
`accounts/${this.accountId}/portals/${this.portalSlug}/${this.locale}/articles/${id}`
);
},
localeName(code) {
return allLocales[code];
},
},
};
@@ -0,0 +1,76 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import portalMixin from '../portalMixin';
import Vuex from 'vuex';
import VueRouter from 'vue-router';
const localVue = createLocalVue();
localVue.use(Vuex);
localVue.use(VueRouter);
import ListAllArticles from '../../pages/portals/ListAllPortals.vue';
const router = new VueRouter({
routes: [
{
path: ':portalSlug/:locale/articles',
name: 'list_all_locale_articles',
component: ListAllArticles,
},
],
});
describe('portalMixin', () => {
let getters;
let store;
let wrapper;
beforeEach(() => {
getters = {
getCurrentAccountId: () => 1,
};
const Component = {
render() {},
title: 'TestComponent',
mixins: [portalMixin],
router,
};
store = new Vuex.Store({ getters });
wrapper = shallowMount(Component, { store, localVue });
});
it('return account id', () => {
expect(wrapper.vm.accountId).toBe(1);
});
it('returns article url', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'en' },
});
expect(wrapper.vm.articleUrl(1)).toBe(
'/app/accounts/1/portals/fur-rent/en/articles/1'
);
});
it('returns portal locale', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('fur-rent');
});
it('returns portal slug', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'campaign', locale: 'es' },
});
expect(wrapper.vm.portalSlug).toBe('campaign');
});
it('returns locale name', () => {
router.push({
name: 'list_all_locale_articles',
params: { portalSlug: 'fur-rent', locale: 'es' },
});
expect(wrapper.vm.localeName('es')).toBe('Spanish');
});
});
@@ -5,7 +5,7 @@ import EditArticleHeader from '../../components/Header/EditArticleHeader.vue';
import ArticleEditor from '../../components/ArticleEditor.vue';
import ArticleSettings from './ArticleSettings.vue';
import Spinner from 'shared/components/Spinner.vue';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../../mixins/portalMixin';
import wootConstants from 'dashboard/constants/globals';
import { buildPortalArticleURL } from 'dashboard/helper/portalHelper';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
@@ -18,10 +18,7 @@ export default {
Spinner,
ArticleSettings,
},
setup() {
const { locale } = usePortal();
return { locale };
},
mixins: [portalMixin],
data() {
return {
isUpdating: false,
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import EditArticleHeader from 'dashboard/routes/dashboard/helpcenter/components/Header/EditArticleHeader.vue';
import ArticleEditor from '../../components/ArticleEditor.vue';
import { usePortal } from 'dashboard/composables/usePortal';
import portalMixin from '../../mixins/portalMixin';
import ArticleSettings from './ArticleSettings.vue';
import { PORTALS_EVENTS } from '../../../../../helper/AnalyticsHelper/events';
export default {
@@ -12,10 +12,7 @@ export default {
ArticleEditor,
ArticleSettings,
},
setup() {
const { locale } = usePortal();
return { locale };
},
mixins: [portalMixin],
data() {
return {
articleTitle: '',
@@ -22,21 +22,17 @@ defineProps({
<template>
<div class="flex flex-col w-full h-full gap-10 font-inter">
<slot name="header" />
<!-- 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>
<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>
</template>
@@ -12,12 +12,6 @@ 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() };
@@ -26,10 +20,7 @@ export default {
return {
displayName: '',
description: '',
// 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,
attributeModel: 0,
attributeType: 0,
attributeKey: '',
regexPattern: null,
@@ -289,16 +280,13 @@ 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 {
@@ -307,16 +295,13 @@ export default {
}
}
}
::v-deep {
.multiselect {
margin-bottom: 0;
}
.multiselect__content-wrapper {
display: none;
}
.multiselect--active .multiselect__tags {
border-radius: var(--border-radius-normal);
}
@@ -1,141 +1,195 @@
<script setup>
<script>
import { mapGetters } from 'vuex';
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();
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;
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 = {};
},
},
};
</script>
<template>
<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"
<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"
>
{{ 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>
{{ $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>
<woot-modal :show.sync="showEditPopup" :on-close="hideEditPopup">
<EditAttribute
:selected-attribute="selectedAttribute"
@@ -162,4 +216,17 @@ const openDelete = value => {
.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,112 +1,40 @@
<script setup>
import { computed, onMounted, ref } from 'vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
<script>
import AddAttribute from './AddAttribute.vue';
import CustomAttribute from './CustomAttribute.vue';
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'),
export default {
components: {
AddAttribute,
CustomAttribute,
},
data() {
return {
showAddPopup: false,
};
},
methods: {
openAddPopup() {
this.showAddPopup = true;
},
{
key: 1,
name: t('ATTRIBUTES_MGMT.TABS.CONTACT'),
hideAddPopup() {
this.showAddPopup = false;
},
];
});
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>
<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"
<div class="flex-1 overflow-auto">
<woot-button
color-scheme="success"
class-names="button--fixed-top"
icon="add-circle"
@click="openAddPopup()"
>
<AddAttribute
:on-close="hideAddPopup"
:selected-attribute-model-tab="selectedTabIndex"
/>
{{ $t('ATTRIBUTES_MGMT.HEADER_BTN_TXT') }}
</woot-button>
<CustomAttribute />
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<AddAttribute :on-close="hideAddPopup" />
</woot-modal>
</SettingsLayout>
</div>
</template>
@@ -1,12 +1,17 @@
import { frontendURL } from '../../../../helper/URLHelper';
const SettingsWrapper = () => import('../SettingsWrapper.vue');
const SettingsContent = () => import('../Wrapper.vue');
const AttributesHome = () => import('./Index.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/custom-attributes'),
component: SettingsWrapper,
component: SettingsContent,
props: {
headerTitle: 'ATTRIBUTES_MGMT.HEADER',
icon: 'code',
showNewButton: false,
},
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 inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import FacebookReauthorize from './facebook/Reauthorize.vue';
import PreChatFormSettings from './PreChatForm/Settings.vue';
import WeeklyAvailability from './components/WeeklyAvailability.vue';
@@ -33,9 +33,42 @@ export default {
SenderNameExamplePreview,
MicrosoftReauthorize,
},
mixins: [inboxMixin],
setup() {
return { v$: useVuelidate() };
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,
};
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import SettingsSection from 'dashboard/components/SettingsSection.vue';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import BusinessDay from './BusinessDay.vue';
@@ -23,13 +23,22 @@ 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,
@@ -1,6 +1,6 @@
<script>
import { useAlert } from 'dashboard/composables';
import inboxMixin from 'shared/mixins/inboxMixin';
import { useInbox } from 'shared/composables/useInbox';
import SettingsSection from '../../../../../components/SettingsSection.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
@@ -13,15 +13,30 @@ export default {
ImapSettings,
SmtpSettings,
},
mixins: [inboxMixin],
props: {
inbox: {
type: Object,
default: () => ({}),
},
},
setup() {
return { v$: useVuelidate() };
setup(props) {
const {
isAPIInbox,
isAWebWidgetInbox,
isATwilioChannel,
isALineChannel,
isAnEmailChannel,
} = useInbox({
inboxObj: props.inbox,
});
return {
v$: useVuelidate(),
isAPIInbox,
isAWebWidgetInbox,
isATwilioChannel,
isALineChannel,
isAnEmailChannel,
};
},
data() {
return {
@@ -3,18 +3,16 @@ 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: {},
@@ -28,10 +26,8 @@ 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');
@@ -79,59 +75,69 @@ export default {
</script>
<template>
<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"
<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"
>
<WebhookRow
v-for="(webHookItem, index) in records"
:key="webHookItem.id"
:index="index"
:webhook="webHookItem"
@edit="openEditPopup"
@delete="openDeletePopup"
/>
</tbody>
</table>
</template>
{{ $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>
<woot-modal :show.sync="showAddPopup" :on-close="hideAddPopup">
<NewWebhook v-if="showAddPopup" :on-close="hideAddPopup" />
</woot-modal>
@@ -157,5 +163,5 @@ export default {
:confirm-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.YES')"
:reject-text="$t('INTEGRATION_SETTINGS.WEBHOOK.DELETE.CONFIRM.NO')"
/>
</SettingsLayout>
</div>
</template>
@@ -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,6 +18,7 @@ const SUPPORTED_WEBHOOK_EVENTS = [
];
export default {
mixins: [webhookMixin],
props: {
value: {
type: Object,
@@ -69,7 +70,6 @@ export default {
subscriptions: this.subscriptions,
});
},
getEventNamei18n,
},
};
</script>
@@ -105,10 +105,10 @@ export default {
type="checkbox"
:value="event"
name="subscriptions"
class="mr-2"
class="checkbox"
/>
<label :for="event" class="text-sm">
{{ `${$t(getEventNamei18n(event))} (${event})` }}
{{ `${getEventLabel(event)} (${event})` }}
</label>
</div>
</div>
@@ -129,3 +129,9 @@ export default {
</div>
</form>
</template>
<style lang="scss" scoped>
.checkbox {
@apply mr-2;
}
</style>
@@ -1,58 +1,59 @@
<script setup>
import { computed } from 'vue';
import { getEventNamei18n } from './webhookHelper';
<script>
import webhookMixin from './webhookMixin';
import ShowMore from 'dashboard/components/widgets/ShowMore.vue';
import { useI18n } from 'dashboard/composables/useI18n';
const props = defineProps({
webhook: {
type: Object,
required: true,
export default {
components: { ShowMore },
mixins: [webhookMixin],
props: {
webhook: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
},
index: {
type: Number,
required: true,
computed: {
subscribedEvents() {
const { subscriptions } = this.webhook;
return subscriptions.map(event => this.getEventLabel(event)).join(', ');
},
},
});
const { t } = useI18n();
const subscribedEvents = computed(() => {
const { subscriptions } = props.webhook;
return subscriptions.map(event => t(getEventNamei18n(event))).join(', ');
});
};
</script>
<template>
<tr>
<td class="py-4 ltr:pr-4 rtl:pl-4">
<tr class="space-x-2">
<td class="max-w-2xl">
<div class="font-medium break-words text-slate-700 dark:text-slate-100">
{{ webhook.url }}
</div>
<div class="text-sm text-slate-500 dark:text-slate-400 block mt-1">
<span class="text-xs text-slate-500 dark:text-slate-400">
<span class="font-medium">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SUBSCRIBED_EVENTS') }}:
</span>
<ShowMore :text="subscribedEvents" :limit="60" />
</div>
</span>
</td>
<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 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>
</tr>
</template>
@@ -1,9 +0,0 @@
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`
);
});
});
@@ -0,0 +1,26 @@
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`
);
});
});
});
@@ -1,4 +0,0 @@
export const getEventNamei18n = event => {
const eventName = event.toUpperCase();
return `INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`;
};
@@ -0,0 +1,10 @@
export default {
methods: {
getEventLabel(event) {
const eventName = event.toUpperCase();
return this.$t(
`INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.EVENTS.${eventName}`
);
},
},
};
@@ -30,14 +30,6 @@ export default {
permissions: ['administrator'],
},
},
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
],
},
{
@@ -57,6 +49,14 @@ export default {
};
},
children: [
{
path: 'webhook',
component: Webhook,
name: 'settings_integrations_webhook',
meta: {
permissions: ['administrator'],
},
},
{
path: 'slack',
name: 'settings_integrations_slack',
@@ -1,5 +1,5 @@
import articlesAPI from 'dashboard/api/helpCenter/articles';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import { uploadFile } from 'dashboard/helper/uploadHelper';
import { throwErrorMessage } from 'dashboard/store/utils/api';
import types from '../../mutation-types';
@@ -132,11 +132,6 @@ export const actions = {
return fileUrl;
},
uploadExternalImage: async (_, { url }) => {
const { fileUrl } = await uploadExternalImage(url);
return fileUrl;
},
reorder: async (_, { portalSlug, categorySlug, reorderedGroup }) => {
try {
await articlesAPI.reorderArticles({
@@ -1,7 +1,7 @@
import axios from 'axios';
import { uploadExternalImage, uploadFile } from 'dashboard/helper/uploadHelper';
import * as types from '../../../mutation-types';
import { actions } from '../actions';
import * as types from '../../../mutation-types';
import { uploadFile } from 'dashboard/helper/uploadHelper';
vi.mock('dashboard/helper/uploadHelper');
@@ -187,53 +187,33 @@ 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'
);
});
});
describe('uploadExternalImage', () => {
it('should upload the image from external URL and return the fileUrl', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockFileUrl = 'https://uploaded.example.com/image.jpg';
uploadExternalImage.mockResolvedValueOnce({ fileUrl: mockFileUrl });
// When
const result = await actions.uploadExternalImage({}, { url: mockUrl });
// Then
expect(uploadExternalImage).toHaveBeenCalledWith(mockUrl);
expect(result).toBe(mockFileUrl);
});
it('should throw an error if the upload fails', async () => {
const mockUrl = 'https://example.com/image.jpg';
const mockError = new Error('Upload failed');
uploadExternalImage.mockRejectedValueOnce(mockError);
await expect(
actions.uploadExternalImage({}, { url: mockUrl })
).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/mixins/inboxMixin';
import { INBOX_TYPES } from 'shared/composables/useInbox';
import InboxesAPI from '../../api/inboxes';
import WebChannel from '../../api/channel/webChannel';
import FBChannel from '../../api/channel/fbChannel';
+6 -14
View File
@@ -1,11 +1,12 @@
<script>
import CardButton from 'shared/components/CardButton.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
CardButton,
},
mixins: [darkModeMixin],
props: {
title: {
type: String,
@@ -24,30 +25,21 @@ export default {
default: () => [],
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {},
};
</script>
<template>
<div
class="card-message chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<img class="media" :src="mediaUrl" />
<div class="card-body">
<h4
class="title"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
>
<h4 class="title" :class="$dm('text-black-900', 'dark:text-slate-50')">
{{ title }}
</h4>
<p
class="body"
:class="getThemeClass('text-black-700', 'dark:text-slate-100')"
>
<p class="body" :class="$dm('text-black-700', 'dark:text-slate-100')">
{{ description }}
</p>
<CardButton v-for="action in actions" :key="action.id" :action="action" />
@@ -1,9 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { getContrastingTextColor } from '@chatwoot/utils';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
buttonLabel: {
type: String,
@@ -18,10 +19,6 @@ export default {
default: () => [],
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
formValues: {},
@@ -36,8 +33,8 @@ export default {
return getContrastingTextColor(this.widgetColor);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
isFormValid() {
return this.items.reduce((acc, { name }) => {
@@ -83,7 +80,7 @@ export default {
<template>
<div
class="form chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<form @submit.prevent="onSubmit">
<div
@@ -94,7 +91,7 @@ export default {
'has-submitted': hasSubmitted,
}"
>
<label :class="getThemeClass('text-black-900', 'dark:text-slate-50')">{{
<label :class="$dm('text-black-900', 'dark:text-slate-50')">{{
item.label
}}</label>
<input
@@ -3,7 +3,7 @@ import { mapGetters } from 'vuex';
import Spinner from 'shared/components/Spinner.vue';
import { CSAT_RATINGS } from 'shared/constants/messages';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
@@ -11,6 +11,7 @@ export default {
Spinner,
FluentIcon,
},
mixins: [darkModeMixin],
props: {
messageContentAttributes: {
type: Object,
@@ -21,10 +22,6 @@ export default {
required: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
email: '',
@@ -47,8 +44,8 @@ export default {
return !(this.selectedRating && this.feedback);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
textColor() {
return getContrastingTextColor(this.widgetColor);
@@ -108,13 +105,10 @@ export default {
<template>
<div
class="customer-satisfaction"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
:style="{ borderColor: widgetColor }"
>
<h6
class="title"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
>
<h6 class="title" :class="$dm('text-slate-900', 'dark:text-slate-50')">
{{ title }}
</h6>
<div class="ratings">
@@ -1,18 +1,15 @@
<script>
import { formatDate } from 'shared/helpers/DateHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
mixins: [darkModeMixin],
props: {
date: {
type: String,
required: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
formattedDate() {
return formatDate({
@@ -28,7 +25,7 @@ export default {
<template>
<div
class="date--separator"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ formattedDate }}
</div>
@@ -2,6 +2,7 @@ import DateSeparator from '../DateSeparator.vue';
import { createLocalVue, shallowMount } from '@vue/test-utils';
import Vuex from 'vuex';
import VueI18n from 'vue-i18n';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
const localVue = createLocalVue();
import i18n from 'dashboard/i18n';
localVue.use(Vuex);
@@ -39,6 +40,7 @@ describe('dateSeparator', () => {
propsData: { date: 'Nov 18, 2019' },
mocks: { $t: msg => msg },
i18n: i18nConfig,
mixins: [darkModeMixin],
});
});
@@ -0,0 +1,148 @@
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,8 +1,7 @@
import wootConstants from 'dashboard/constants/globals';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { filterAttributeGroups as conversationFilterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
import { filterAttributeGroups as contactFilterAttributeGroups } from 'dashboard/routes/dashboard/contacts/contactFilterItems';
import { filterAttributeGroups } from 'dashboard/components/widgets/conversation/advancedFilterItems';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const customAttributeInputType = key => {
@@ -43,11 +42,6 @@ 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);
@@ -0,0 +1,180 @@
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,5 +7,4 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 1000,
TWILIO_SMS: 320,
TWILIO_WHATSAPP: 1600,
EMAIL: 25000,
};
-141
View File
@@ -1,141 +0,0 @@
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;
},
},
};
@@ -1,286 +0,0 @@
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);
});
});
});
+2 -6
View File
@@ -18,7 +18,7 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
} from './constants/widgetBusEvents';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { SDK_SET_BUBBLE_VISIBILITY } from '../shared/constants/sharedFrameEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -27,11 +27,7 @@ export default {
components: {
Spinner,
},
mixins: [availabilityMixin, configMixin, routerMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode };
},
mixins: [availabilityMixin, configMixin, routerMixin, darkModeMixin],
data() {
return {
isMobile: false,
@@ -11,7 +11,7 @@ import { MESSAGE_TYPE } from 'widget/helpers/constants';
import configMixin from '../mixins/configMixin';
import messageMixin from '../mixins/messageMixin';
import { isASubmittedFormMessage } from 'shared/helpers/MessageTypeHelper';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import ReplyToChip from 'widget/components/ReplyToChip.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import { emitter } from 'shared/helpers/mitt';
@@ -28,7 +28,7 @@ export default {
MessageReplyButton,
ReplyToChip,
},
mixins: [configMixin, messageMixin],
mixins: [configMixin, messageMixin, darkModeMixin],
props: {
message: {
type: Object,
@@ -39,12 +39,6 @@ export default {
default: () => {},
},
},
setup() {
const { getThemeClass } = useDarkMode();
return {
getThemeClass,
};
},
data() {
return {
hasImageError: false,
@@ -198,9 +192,7 @@ export default {
<div
v-if="hasAttachments"
class="space-y-2 chat-bubble has-attachment agent"
:class="
(wrapClass, getThemeClass('bg-white', 'dark:bg-slate-700'))
"
:class="(wrapClass, $dm('bg-white', 'dark:bg-slate-700'))"
>
<div
v-for="attachment in message.attachments"
@@ -239,7 +231,7 @@ export default {
v-if="message.showAvatar || hasRecordedResponse"
v-dompurify-html="agentName"
class="agent-name"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
/>
</div>
</div>
@@ -6,7 +6,7 @@ import ChatOptions from 'shared/components/ChatOptions.vue';
import ChatArticle from './template/Article.vue';
import EmailInput from './template/EmailInput.vue';
import CustomerSatisfaction from 'shared/components/CustomerSatisfaction.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import IntegrationCard from './template/IntegrationCard.vue';
export default {
@@ -20,6 +20,7 @@ export default {
CustomerSatisfaction,
IntegrationCard,
},
mixins: [darkModeMixin],
props: {
message: { type: String, default: null },
contentType: { type: String, default: null },
@@ -33,13 +34,11 @@ export default {
setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter();
const { getThemeClass } = useDarkMode();
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
getThemeClass,
};
},
computed: {
@@ -99,7 +98,7 @@ export default {
!isCards && !isOptions && !isForm && !isArticle && !isCards && !isCSAT
"
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700 has-dark-mode')"
:class="$dm('bg-white', 'dark:bg-slate-700 has-dark-mode')"
>
<div
v-dompurify-html="formatMessage(message, false)"
@@ -1,11 +1,8 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixing from 'widget/mixins/darkModeMixin.js';
export default {
name: 'AgentTypingBubble',
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
mixins: [darkModeMixing],
};
</script>
@@ -16,7 +13,7 @@ export default {
<div class="message-wrap mt-2">
<div
class="typing-bubble chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<img
src="~widget/assets/images/typing.gif"
@@ -4,7 +4,7 @@ import nextAvailabilityTime from 'widget/mixins/nextAvailabilityTime';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import HeaderActions from './HeaderActions.vue';
import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeader',
@@ -12,7 +12,7 @@ export default {
FluentIcon,
HeaderActions,
},
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin],
mixins: [nextAvailabilityTime, availabilityMixin, routerMixin, darkMixin],
props: {
avatarUrl: {
type: String,
@@ -35,10 +35,6 @@ export default {
default: () => {},
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
isOnline() {
const { workingHoursEnabled } = this.channelConfig;
@@ -61,7 +57,7 @@ export default {
<template>
<header
class="flex justify-between w-full p-5"
:class="getThemeClass('bg-white', 'dark:bg-slate-900')"
:class="$dm('bg-white', 'dark:bg-slate-900')"
>
<div class="flex items-center">
<button
@@ -72,7 +68,7 @@ export default {
<FluentIcon
icon="chevron-left"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<img
@@ -84,7 +80,7 @@ export default {
<div>
<div
class="flex items-center text-base font-medium leading-4"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
>
<span v-dompurify-html="title" class="mr-1" />
<div
@@ -94,7 +90,7 @@ export default {
</div>
<div
class="mt-1 text-xs leading-3"
:class="getThemeClass('text-black-700', 'dark:text-slate-400')"
:class="$dm('text-black-700', 'dark:text-slate-400')"
>
{{ replyWaitMessage }}
</div>
@@ -1,12 +1,13 @@
<script>
import HeaderActions from './HeaderActions.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
name: 'ChatHeaderExpanded',
components: {
HeaderActions,
},
mixins: [darkModeMixin],
props: {
avatarUrl: {
type: String,
@@ -25,10 +26,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
};
</script>
@@ -54,12 +51,12 @@ export default {
<h2
v-dompurify-html="introHeading"
class="mt-4 text-2xl mb-1.5 font-medium"
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
:class="$dm('text-slate-900', 'dark:text-slate-50')"
/>
<p
v-dompurify-html="introBody"
class="text-base leading-normal"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
/>
</header>
</template>
@@ -6,7 +6,7 @@ import ChatSendButton from 'widget/components/ChatSendButton.vue';
import configMixin from '../mixins/configMixin';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import ResizableTextArea from 'shared/components/ResizableTextArea.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
const EmojiInput = () => import('shared/components/emoji/EmojiInput.vue');
@@ -19,7 +19,7 @@ export default {
FluentIcon,
ResizableTextArea,
},
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
onSendMessage: {
type: Function,
@@ -30,10 +30,7 @@ export default {
default: () => {},
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
userInput: '',
@@ -54,16 +51,13 @@ export default {
return this.userInput.length > 0;
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}`;
},
emojiIconColor() {
return this.showEmojiPicker
? `text-woot-500 ${this.getThemeClass(
'text-black-900',
'dark:text-slate-100'
)}`
: `${this.getThemeClass('text-black-900', 'dark:text-slate-100')}`;
? `text-woot-500 ${this.$dm('text-black-900', 'dark:text-slate-100')}`
: `${this.$dm('text-black-900', 'dark:text-slate-100')}`;
},
},
watch: {
@@ -134,7 +128,7 @@ export default {
<template>
<div
class="chat-message--input is-focused"
:class="getThemeClass('bg-white ', 'dark:bg-slate-600')"
:class="$dm('bg-white ', 'dark:bg-slate-600')"
@keydown.esc="hideEmojiPicker"
>
<ResizableTextArea
@@ -154,7 +148,7 @@ export default {
<div class="button-wrap">
<ChatAttachmentButton
v-if="showAttachment"
:class="getThemeClass('text-black-900', 'dark:text-slate-100')"
:class="$dm('text-black-900', 'dark:text-slate-100')"
:on-attach="onSendAttachment"
/>
<button
@@ -3,7 +3,7 @@ import ChatMessage from 'widget/components/ChatMessage.vue';
import AgentTypingBubble from 'widget/components/AgentTypingBubble.vue';
import DateSeparator from 'shared/components/DateSeparator.vue';
import Spinner from 'shared/components/Spinner.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import { MESSAGE_TYPE } from 'shared/constants/messages';
import { mapActions, mapGetters } from 'vuex';
@@ -15,16 +15,13 @@ export default {
DateSeparator,
Spinner,
},
mixins: [darkModeMixin],
props: {
groupedMessages: {
type: Array,
default: () => [],
},
},
setup() {
const { darkMode } = useDarkMode();
return { darkMode };
},
data() {
return {
previousScrollHeight: 0,
@@ -1,12 +1,13 @@
<script>
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
import { getContrastingTextColor } from '@chatwoot/utils';
export default {
components: {
FluentIcon,
},
mixins: [darkModeMixin],
props: {
url: {
type: String,
@@ -25,10 +26,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
title() {
return this.isInProgress
@@ -48,7 +45,7 @@ export default {
},
titleColor() {
return !this.isUserBubble
? this.getThemeClass('text-black-900', 'dark:text-slate-50')
? this.$dm('text-black-900', 'dark:text-slate-50')
: '';
},
},
@@ -1,6 +1,7 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -27,33 +28,20 @@ export default {
default: '',
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`
: `text-black-800 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`;
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isInputDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.error
@@ -2,13 +2,13 @@
import countries from 'shared/constants/countries.js';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import FormulateInputMixin from '@braid/vue-formulate/src/FormulateInputMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
components: {
FluentIcon,
},
mixins: [FormulateInputMixin],
mixins: [FormulateInputMixin, darkModeMixin],
props: {
placeholder: {
type: String,
@@ -19,10 +19,6 @@ export default {
default: false,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
data() {
return {
selectedIndex: -1,
@@ -49,31 +45,28 @@ export default {
return this.activeCountryCode ? 'Clear selection' : 'Select Country';
},
dropdownClass() {
return `${this.getThemeClass(
'bg-slate-100',
'dark:bg-slate-700'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
},
dropdownBackgroundClass() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-700'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
},
dropdownItemClass() {
return `${this.getThemeClass(
return `${this.$dm('bg-slate-100', 'dark:bg-slate-700')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)} ${this.getThemeClass('hover:bg-slate-50', 'dark:hover:bg-slate-600')}`;
},
activeDropdownItemClass() {
return `active ${this.getThemeClass(
'bg-slate-100',
'dark:bg-slate-800'
)}`;
},
dropdownBackgroundClass() {
return `${this.$dm('bg-white', 'dark:bg-slate-700')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
dropdownItemClass() {
return `${this.$dm('text-slate-700', 'dark:text-slate-50')} ${this.$dm(
'hover:bg-slate-50',
'dark:hover:bg-slate-600'
)}`;
},
activeDropdownItemClass() {
return `active ${this.$dm('bg-slate-100', 'dark:bg-slate-800')}`;
},
focusedDropdownItemClass() {
return `focus ${this.getThemeClass('bg-slate-50', 'dark:bg-slate-600')}`;
return `focus ${this.$dm('bg-slate-50', 'dark:bg-slate-600')}`;
},
inputHasError() {
return this.hasErrorInPhoneInput
@@ -81,16 +74,13 @@ export default {
: `hover:border-black-300 focus:border-black-300 ${this.inputLightAndDarkModeColor} ${this.inputBorderColor}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputLightAndDarkModeColor() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
items() {
return this.countries.filter(country => {
@@ -251,7 +241,7 @@ export default {
<span
v-if="activeDialCode"
class="py-2 pl-2 pr-0 text-base"
:class="getThemeClass('text-slate-700', 'dark:text-slate-50')"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
>
{{ activeDialCode }}
</span>
@@ -283,10 +273,7 @@ export default {
type="text"
:placeholder="$t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_SEARCH')"
class="w-full h-8 px-3 py-2 mt-1 mb-1 text-sm border border-solid rounded outline-none dropdown-search"
:class="[
getThemeClass('bg-slate-50', 'dark:bg-slate-600'),
inputBorderColor,
]"
:class="[$dm('bg-slate-50', 'dark:bg-slate-600'), inputBorderColor]"
/>
</div>
<div
@@ -311,7 +298,7 @@ export default {
<div v-if="items.length === 0">
<span
class="flex justify-center mt-4 text-sm text-center"
:class="getThemeClass('text-slate-700', 'dark:text-slate-50')"
:class="$dm('text-slate-700', 'dark:text-slate-50')"
>
{{ $t('PRE_CHAT_FORM.FIELDS.PHONE_NUMBER.DROPDOWN_EMPTY') }}
</span>
@@ -1,6 +1,7 @@
<script>
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
mixins: [darkModeMixin],
props: {
label: {
type: String,
@@ -19,33 +20,20 @@ export default {
default: '',
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
labelClass() {
return this.error
? `text-red-400 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`
: `text-black-800 ${this.getThemeClass(
'text-black-800',
'dark:text-slate-50'
)}`;
? `text-red-400 ${this.$dm('text-black-800', 'dark:text-slate-50')}`
: `text-black-800 ${this.$dm('text-black-800', 'dark:text-slate-50')}`;
},
isTextAreaDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
textAreaBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
isTextAreaHasError() {
return this.error
@@ -3,14 +3,14 @@ import { mapGetters } from 'vuex';
import { IFrameHelper, RNHelper } from 'widget/helpers/utils';
import { popoutChatWindow } from '../helpers/popoutHelper';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
import { CONVERSATION_STATUS } from 'shared/constants/messages';
export default {
name: 'HeaderActions',
components: { FluentIcon },
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
showPopoutButton: {
type: Boolean,
@@ -21,10 +21,6 @@ export default {
default: true,
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { getThemeClass };
},
computed: {
...mapGetters({
conversationAttributes: 'conversationAttributes/getConversationParams',
@@ -96,7 +92,7 @@ export default {
<FluentIcon
icon="sign-out"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
@@ -107,7 +103,7 @@ export default {
<FluentIcon
icon="open"
size="22"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
<button
@@ -120,7 +116,7 @@ export default {
<FluentIcon
icon="dismiss"
size="24"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
</button>
</div>
@@ -7,7 +7,7 @@ import { isEmptyObject } from 'widget/helpers/utils';
import { getRegexp } from 'shared/helpers/Validators';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import routerMixin from 'widget/mixins/routerMixin';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import configMixin from 'widget/mixins/configMixin';
export default {
@@ -15,7 +15,7 @@ export default {
CustomButton,
Spinner,
},
mixins: [routerMixin, configMixin],
mixins: [routerMixin, darkModeMixin, configMixin],
props: {
options: {
type: Object,
@@ -24,8 +24,9 @@ export default {
},
setup() {
const { formatMessage } = useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { formatMessage, getThemeClass };
return {
formatMessage,
};
},
data() {
return {
@@ -130,28 +131,25 @@ export default {
return `mt-1 border rounded w-full py-2 px-3 text-slate-700 outline-none`;
},
isInputDarkOrLightMode() {
return `${this.getThemeClass(
'bg-white',
'dark:bg-slate-600'
)} ${this.getThemeClass('text-slate-700', 'dark:text-slate-50')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')} ${this.$dm(
'text-slate-700',
'dark:text-slate-50'
)}`;
},
inputBorderColor() {
return `${this.getThemeClass(
'border-black-200',
'dark:border-black-500'
)}`;
return `${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
},
methods: {
labelClass(context) {
const { hasErrors } = context;
if (!hasErrors) {
return `text-xs font-medium ${this.getThemeClass(
return `text-xs font-medium ${this.$dm(
'text-black-800',
'dark:text-slate-50'
)}`;
}
return `text-xs font-medium ${this.getThemeClass(
return `text-xs font-medium ${this.$dm(
'text-red-400',
'dark:text-red-400'
)}`;
@@ -266,7 +264,7 @@ export default {
v-if="shouldShowHeaderMessage"
v-dompurify-html="formatMessage(headerMessage, false)"
class="mb-4 text-sm leading-5 pre-chat-header-message"
:class="getThemeClass('text-black-800', 'dark:text-slate-50')"
:class="$dm('text-black-800', 'dark:text-slate-50')"
/>
<FormulateInput
v-for="item in enabledPreChatFields"
@@ -7,11 +7,11 @@ import {
ON_CAMPAIGN_MESSAGE_CLICK,
ON_UNREAD_MESSAGE_CLICK,
} from '../constants/widgetBusEvents';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
export default {
name: 'UnreadMessage',
components: { Thumbnail },
mixins: [configMixin],
mixins: [configMixin, darkModeMixin],
props: {
message: {
type: String,
@@ -31,15 +31,9 @@ export default {
},
},
setup() {
const { formatMessage, getPlainText, truncateMessage, highlightContent } =
useMessageFormatter();
const { getThemeClass } = useDarkMode();
const { formatMessage } = useMessageFormatter();
return {
formatMessage,
getPlainText,
truncateMessage,
highlightContent,
getThemeClass,
};
},
computed: {
@@ -97,7 +91,7 @@ export default {
<div class="chat-bubble-wrap">
<button
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-50')"
:class="$dm('bg-white', 'dark:bg-slate-50')"
@click="onClickMessage"
>
<div v-if="showSender" class="row--agent-block">
@@ -1,12 +1,13 @@
<script>
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
},
mixins: [darkModeMixin],
props: {
items: {
type: Array,
@@ -15,8 +16,9 @@ export default {
},
setup() {
const { truncateMessage } = useMessageFormatter();
const { getThemeClass } = useDarkMode();
return { getThemeClass, truncateMessage };
return {
truncateMessage,
};
},
};
</script>
@@ -25,7 +27,7 @@ export default {
<div
v-if="!!items.length"
class="chat-bubble agent"
:class="getThemeClass('bg-white', 'dark:bg-slate-700')"
:class="$dm('bg-white', 'dark:bg-slate-700')"
>
<div v-for="item in items" :key="item.link" class="article-item">
<a :href="item.link" target="_blank" rel="noopener noreferrer nofollow">
@@ -33,16 +35,15 @@ export default {
<FluentIcon
icon="link"
class="mr-1"
:class="getThemeClass('text-black-900', 'dark:text-slate-50')"
:class="$dm('text-black-900', 'dark:text-slate-50')"
/>
<span
:class="getThemeClass('text-slate-900', 'dark:text-slate-50')"
>{{ item.title }}</span
>
<span :class="$dm('text-slate-900', 'dark:text-slate-50')">{{
item.title
}}</span>
</span>
<span
class="description"
:class="getThemeClass('text-slate-700', 'dark:text-slate-200')"
:class="$dm('text-slate-700', 'dark:text-slate-200')"
>
{{ truncateMessage(item.description) }}
</span>
@@ -6,13 +6,14 @@ import { getContrastingTextColor } from '@chatwoot/utils';
import FluentIcon from 'shared/components/FluentIcon/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin.js';
export default {
components: {
FluentIcon,
Spinner,
},
mixins: [darkModeMixin],
props: {
messageId: {
type: Number,
@@ -24,8 +25,7 @@ export default {
},
},
setup() {
const { getThemeClass } = useDarkMode();
return { v$: useVuelidate(), getThemeClass };
return { v$: useVuelidate() };
},
data() {
return {
@@ -47,9 +47,9 @@ export default {
);
},
inputColor() {
return `${this.getThemeClass('bg-white', 'dark:bg-slate-600')}
${this.getThemeClass('text-black-900', 'dark:text-slate-50')}
${this.getThemeClass('border-black-200', 'dark:border-black-500')}`;
return `${this.$dm('bg-white', 'dark:bg-slate-600')}
${this.$dm('text-black-900', 'dark:text-slate-50')}
${this.$dm('border-black-200', 'dark:border-black-500')}`;
},
inputHasError() {
return this.v$.email.$error
@@ -1,71 +0,0 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { useDarkMode } from '../useDarkMode';
import { useMapGetter } from 'dashboard/composables/store';
vi.mock('dashboard/composables/store', () => ({
useMapGetter: vi.fn(),
}));
describe('useDarkMode', () => {
let mockDarkMode;
beforeEach(() => {
mockDarkMode = { value: 'light' };
vi.mocked(useMapGetter).mockReturnValue(mockDarkMode);
});
it('returns darkMode, prefersDarkMode, and getThemeClass', () => {
const result = useDarkMode();
expect(result).toHaveProperty('darkMode');
expect(result).toHaveProperty('prefersDarkMode');
expect(result).toHaveProperty('getThemeClass');
});
describe('prefersDarkMode', () => {
it('returns false when darkMode is light', () => {
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
it('returns true when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns true when darkMode is auto and OS prefers dark mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: true });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(true);
});
it('returns false when darkMode is auto and OS prefers light mode', () => {
mockDarkMode.value = 'auto';
vi.spyOn(window, 'matchMedia').mockReturnValue({ matches: false });
const { prefersDarkMode } = useDarkMode();
expect(prefersDarkMode.value).toBe(false);
});
});
describe('getThemeClass', () => {
it('returns light class when darkMode is light', () => {
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('light-class');
});
it('returns dark class when darkMode is dark', () => {
mockDarkMode.value = 'dark';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe('dark-class');
});
it('returns both classes when darkMode is auto', () => {
mockDarkMode.value = 'auto';
const { getThemeClass } = useDarkMode();
expect(getThemeClass('light-class', 'dark-class')).toBe(
'light-class dark-class'
);
});
});
});
@@ -1,39 +0,0 @@
import { computed } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
const isDarkMode = mode => mode === 'dark';
const getSystemPreference = () =>
window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
const calculatePrefersDarkMode = (mode, systemPreference) =>
isDarkModeAuto(mode) ? systemPreference : isDarkMode(mode);
const calculateThemeClass = (mode, light, dark) => {
if (isDarkModeAuto(mode)) return `${light} ${dark}`;
return isDarkMode(mode) ? dark : light;
};
/**
* Composable for handling dark mode.
* @returns {Object} An object containing computed properties and methods for dark mode.
*/
export function useDarkMode() {
const darkMode = useMapGetter('appConfig/darkMode');
const systemPreference = computed(getSystemPreference);
const prefersDarkMode = computed(() =>
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
const getThemeClass = (light, dark) =>
calculateThemeClass(darkMode.value, light, dark);
return {
darkMode,
prefersDarkMode,
getThemeClass,
};
}
@@ -0,0 +1,24 @@
import { mapGetters } from 'vuex';
export default {
computed: {
...mapGetters({ darkMode: 'appConfig/darkMode' }),
prefersDarkMode() {
const isOSOnDarkMode =
this.darkMode === 'auto' &&
window.matchMedia('(prefers-color-scheme: dark)').matches;
return isOSOnDarkMode || this.darkMode === 'dark';
},
},
methods: {
$dm(light, dark) {
if (this.darkMode === 'light') {
return light;
}
if (this.darkMode === 'dark') {
return dark;
}
return light + ' ' + dark;
},
},
};
@@ -0,0 +1,41 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import darkModeMixin from '../darkModeMixin';
import Vuex from 'vuex';
const localVue = createLocalVue();
localVue.use(Vuex);
const darkModeValues = ['light', 'auto'];
describe('darkModeMixin', () => {
let getters;
let store;
beforeEach(() => {
getters = {
'appConfig/darkMode': () => darkModeValues[0],
};
store = new Vuex.Store({ getters });
});
it('if light theme', () => {
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100');
});
it('if auto theme', () => {
getters = {
'appConfig/darkMode': () => darkModeValues[2],
};
store = new Vuex.Store({ getters });
const Component = {
render() {},
mixins: [darkModeMixin],
};
const wrapper = shallowMount(Component, { store, localVue });
expect(wrapper.vm.$dm('bg-100', 'bg-600')).toBe('bg-100 bg-600');
});
});
+2 -6
View File
@@ -4,7 +4,7 @@ import ArticleHero from 'widget/components/ArticleHero.vue';
import ArticleCardSkeletonLoader from 'widget/components/ArticleCardSkeletonLoader.vue';
import { mapGetters } from 'vuex';
import { useDarkMode } from 'widget/composables/useDarkMode';
import darkModeMixin from 'widget/mixins/darkModeMixin';
import routerMixin from 'widget/mixins/routerMixin';
import configMixin from 'widget/mixins/configMixin';
@@ -15,11 +15,7 @@ export default {
TeamAvailability,
ArticleCardSkeletonLoader,
},
mixins: [configMixin, routerMixin],
setup() {
const { prefersDarkMode } = useDarkMode();
return { prefersDarkMode };
},
mixins: [configMixin, routerMixin, darkModeMixin],
computed: {
...mapGetters({
availableAgents: 'agent/availableAgents',
+1 -1
View File
@@ -31,7 +31,7 @@
"dependencies": {
"@braid/vue-formulate": "^2.5.2",
"@chatwoot/ninja-keys": "1.2.3",
"@chatwoot/prosemirror-schema": "1.0.17",
"@chatwoot/prosemirror-schema": "1.0.11",
"@chatwoot/utils": "^0.0.25",
"@hcaptcha/vue-hcaptcha": "^0.3.2",
"@june-so/analytics-next": "^2.0.0",
+32 -102
View File
@@ -1,112 +1,42 @@
require 'rails_helper'
RSpec.describe 'Api::V1::Accounts::UploadController', type: :request do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
let(:upload_url) { "/api/v1/accounts/#{account.id}/upload/" }
describe 'POST /api/v1/account/1/upload/' do
let(:account) { create(:account) }
let(:user) { create(:user, account: account) }
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') }
it 'uploads the image when authorized' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
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(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
it 'does not upload when unauthorized' do
post upload_url,
headers: {},
params: { attachment: file }
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
context 'when uploading from a URL' do
let(:valid_external_url) { 'http://example.com/image.jpg' }
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
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(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
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,
post "/api/v1/accounts/#{account.id}/upload/",
headers: user.create_new_auth_token,
params: {}
params: { attachment: file }
expect(response).to have_http_status(:unprocessable_entity)
expect(response.parsed_body['error']).to eq('No file or URL provided')
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
it 'does not upload when un-authorized' do
file = fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png')
post "/api/v1/accounts/#{account.id}/upload/",
headers: {},
params: { attachment: file }
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
end

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