Compare commits

..
89 changed files with 1177 additions and 2043 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
@@ -4,7 +4,6 @@ import { mapGetters } from 'vuex';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { useAlert } from 'dashboard/composables';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import { useFilter } from 'shared/composables/useFilter';
import VirtualList from 'vue-virtual-scroll-list';
import ChatListHeader from './ChatListHeader.vue';
@@ -17,6 +16,7 @@ import filterQueryGenerator from '../helper/filterQueryGenerator.js';
import AddCustomViews from 'dashboard/routes/dashboard/customviews/AddCustomViews.vue';
import DeleteCustomViews from 'dashboard/routes/dashboard/customviews/DeleteCustomViews.vue';
import ConversationBulkActions from './widgets/conversation/conversationBulkActions/Index.vue';
import filterMixin from 'shared/mixins/filterMixin';
import languages from 'dashboard/components/widgets/conversation/advancedFilterItems/languages';
import countries from 'shared/constants/countries';
import { generateValuesForEditCustomViews } from 'dashboard/helper/customViewsHelper';
@@ -41,6 +41,7 @@ export default {
IntersectionObserver,
VirtualList,
},
mixins: [filterMixin],
provide() {
return {
// Actions to be performed on virtual list item and context menu.
@@ -90,15 +91,6 @@ export default {
const conversationListRef = ref(null);
const {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
const getKeyboardListenerParams = () => {
const allConversations = conversationListRef.value.querySelectorAll(
'div.conversations-list div.conversation'
@@ -117,41 +109,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,
},
};
@@ -160,9 +146,6 @@ export default {
return {
uiSettings,
conversationListRef,
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
};
},
data() {
@@ -877,25 +860,6 @@ export default {
onContextMenuToggle(state) {
this.isContextMenuOpen = state;
},
initializeExistingFilterToModal() {
const statusFilter = this.initializeStatusAndAssigneeFilterToModal(
this.activeStatus,
this.currentUserDetails,
this.activeAssigneeTab
);
if (statusFilter) {
this.appliedFilter.push(statusFilter);
}
const otherFilters = this.initializeInboxTeamAndLabelFilterToModal(
this.conversationInbox,
this.inbox,
this.teamId,
this.activeTeam,
this.label
);
this.appliedFilter.push(...otherFilters);
},
},
};
</script>
@@ -331,12 +331,10 @@ export default {
::v-deep {
.selector-wrap {
@apply m-0 top-1;
.selector-name {
@apply ml-0;
}
}
.name {
@apply ml-0;
}
@@ -1,7 +1,9 @@
<script>
/* eslint no-console: 0 */
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
items: {
type: Array,
@@ -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();
}
},
},
});
},
@@ -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>
@@ -5,7 +5,7 @@ import languages from './advancedFilterItems/languages';
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from './advancedFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONVERSATION_EVENTS } from '../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
@@ -14,6 +14,7 @@ export default {
components: {
FilterInputBox,
},
mixins: [filterMixin],
props: {
onClose: {
type: Function,
@@ -36,15 +37,6 @@ export default {
default: false,
},
},
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'FILTER',
attributeModel: 'conversation_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
@@ -75,11 +67,7 @@ export default {
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.setFilterAttributes();
this.$store.dispatch('campaigns/get');
if (this.getAppliedConversationFilters.length) {
this.appliedFilters = [];
@@ -338,11 +326,7 @@ export default {
:show-query-operator="i !== appliedFilters.length - 1"
:show-user-input="showUserInput(appliedFilters[i].filter_operator)"
grouped-filters
:error-message="
validationErrors[`filter_${i}`]
? $t(`CONTACTS_FILTER.ERRORS.VALUE_REQUIRED`)
: ''
"
:error-message="validationErrors[`filter_${i}`]"
@resetFilter="resetFilter(i, appliedFilters[i])"
@removeFilter="removeFilter(i)"
/>
@@ -237,9 +237,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() {
@@ -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>
@@ -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>
@@ -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 };
};
@@ -9,7 +9,6 @@ const FEATURE_HELP_URLS = {
custom_attributes: 'https://chwt.app/hc/custom-attributes',
dashboard_apps: 'https://chwt.app/hc/dashboard-apps',
help_center: 'https://chwt.app/hc/help-center',
inboxes: 'https://chwt.app/hc/inboxes',
integrations: 'https://chwt.app/hc/integrations',
labels: 'https://chwt.app/hc/labels',
macros: 'https://chwt.app/hc/macros',
@@ -17,7 +16,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,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"
@@ -1,8 +1,7 @@
{
"INBOX_MGMT": {
"HEADER": "Inboxes",
"DESCRIPTION": "A channel is the mode of communication your customer chooses to interact with you. An inbox is where you manage interactions for a specific channel. It can include communications from various sources such as email, live chat, and social media.",
"LEARN_MORE": "Learn more about inboxes",
"SIDEBAR_TXT": "<p><b>Inbox</b></p> <p> When you connect a website or a facebook Page to Chatwoot, it is called an <b>Inbox</b>. You can have unlimited inboxes in your Chatwoot account. </p><p> Click on <b>Add Inbox</b> to connect a website or a Facebook Page. </p><p> In the Dashboard, you can see all the conversations from all your inboxes in a single place and respond to them under the `Conversations` tab. </p><p> You can also see conversations specific to an inbox by clicking on the inbox name on the left pane of the dashboard. </p>",
"RECONNECTION_REQUIRED": "Your inbox is disconnected. You won't receive new messages until you reauthorize it.",
"CLICK_TO_RECONNECT": "Click here to reconnect.",
"LIST": {
@@ -746,18 +745,6 @@
"MICROSOFT": "Microsoft",
"GOOGLE": "Google",
"OTHER_PROVIDERS": "Other Providers"
},
"CHANNELS": {
"MESSENGER": "Messenger",
"WEB_WIDGET": "Website",
"TWITTER_PROFILE": "Twitter",
"TWILIO_SMS": "Twilio SMS",
"WHATSAPP": "WhatsApp",
"SMS": "SMS",
"EMAIL": "Email",
"TELEGRAM": "Telegram",
"LINE": "Line",
"API": "API Channel"
}
}
}
@@ -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.",
@@ -3,7 +3,7 @@ import WidgetHead from './WidgetHead.vue';
import WidgetBody from './WidgetBody.vue';
import WidgetFooter from './WidgetFooter.vue';
import InputRadioGroup from 'dashboard/routes/dashboard/settings/inbox/components/InputRadioGroup.vue';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
export default {
@@ -14,6 +14,7 @@ export default {
WidgetFooter,
InputRadioGroup,
},
mixins: [globalConfigMixin],
props: {
welcomeHeading: {
type: String,
@@ -143,7 +144,6 @@ export default {
this.isWidgetVisible = !this.isWidgetVisible;
this.isDefaultScreen = true;
},
useInstallationName,
},
};
</script>
@@ -4,7 +4,7 @@ import FilterInputBox from '../../../../components/widgets/FilterInput/Index.vue
import countries from 'shared/constants/countries.js';
import { mapGetters } from 'vuex';
import { filterAttributeGroups } from '../contactFilterItems';
import { useFilter } from 'shared/composables/useFilter';
import filterMixin from 'shared/mixins/filterMixin';
import * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
import { CONTACTS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
import { validateConversationOrContactFilters } from 'dashboard/helper/validations.js';
@@ -13,6 +13,7 @@ export default {
components: {
FilterInputBox,
},
mixins: [filterMixin],
props: {
onClose: {
type: Function,
@@ -35,15 +36,6 @@ export default {
default: '',
},
},
setup() {
const { setFilterAttributes } = useFilter({
filteri18nKey: 'CONTACTS_FILTER',
attributeModel: 'contact_attribute',
});
return {
setFilterAttributes,
};
},
data() {
return {
show: true,
@@ -77,10 +69,7 @@ export default {
},
},
mounted() {
const { filterGroups, filterTypes } = this.setFilterAttributes();
this.filterTypes = [...this.filterTypes, ...filterTypes];
this.filterGroups = filterGroups;
this.setFilterAttributes();
if (this.getAppliedContactFilters.length) {
this.appliedFilters = [...this.getAppliedContactFilters];
} else if (!this.isSegmentsView) {
@@ -308,11 +297,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)"
/>
@@ -1,4 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SettingsHeader from 'dashboard/routes/dashboard/settings/SettingsHeader.vue';
import SettingIntroBanner from 'dashboard/components/widgets/SettingIntroBanner.vue';
@@ -7,6 +8,7 @@ export default {
SettingsHeader,
SettingIntroBanner,
},
mixins: [globalConfigMixin],
computed: {
currentPortal() {
const slug = this.$route.params.portalSlug;
@@ -1,11 +1,12 @@
<script>
import { mapGetters } from 'vuex';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SettingsHeader from 'dashboard/routes/dashboard/settings/SettingsHeader.vue';
export default {
components: {
SettingsHeader,
},
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -37,9 +38,6 @@ export default {
return '';
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -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: '',
@@ -4,7 +4,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import WootDateTimePicker from 'dashboard/components/ui/DateTimePicker.vue';
import { URLPattern } from 'urlpattern-polyfill';
import { CAMPAIGNS_EVENTS } from '../../../../helper/AnalyticsHelper/events';
@@ -14,9 +14,9 @@ export default {
WootDateTimePicker,
WootMessageEditor,
},
mixins: [campaignMixin],
setup() {
const { campaignType, isOngoingType, isOneOffType } = useCampaign();
return { v$: useVuelidate(), campaignType, isOngoingType, isOneOffType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,7 +1,7 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignsTable from './CampaignsTable.vue';
import EditCampaign from './EditCampaign.vue';
export default {
@@ -9,16 +9,13 @@ export default {
CampaignsTable,
EditCampaign,
},
mixins: [campaignMixin],
props: {
type: {
type: String,
default: '',
},
},
setup() {
const { campaignType } = useCampaign();
return { campaignType };
},
data() {
return {
showEditPopup: false,
@@ -1,7 +1,7 @@
<script>
import Spinner from 'shared/components/Spinner.vue';
import EmptyState from 'dashboard/components/widgets/EmptyState.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import CampaignCard from './CampaignCard.vue';
export default {
@@ -10,6 +10,9 @@ export default {
Spinner,
CampaignCard,
},
mixins: [campaignMixin],
props: {
campaigns: {
type: Array,
@@ -24,10 +27,7 @@ export default {
default: false,
},
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
computed: {
currentInboxId() {
return this.$route.params.inboxId;
@@ -4,13 +4,14 @@ import { useVuelidate } from '@vuelidate/core';
import { required } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import { URLPattern } from 'urlpattern-polyfill';
export default {
components: {
WootMessageEditor,
},
mixins: [campaignMixin],
props: {
selectedCampaign: {
type: Object,
@@ -18,8 +19,7 @@ export default {
},
},
setup() {
const { isOngoingType } = useCampaign();
return { v$: useVuelidate(), isOngoingType };
return { v$: useVuelidate() };
},
data() {
return {
@@ -1,5 +1,5 @@
<script>
import { useCampaign } from 'shared/composables/useCampaign';
import campaignMixin from 'shared/mixins/campaignMixin';
import Campaign from './Campaign.vue';
import AddCampaign from './AddCampaign.vue';
@@ -8,10 +8,7 @@ export default {
Campaign,
AddCampaign,
},
setup() {
const { isOngoingType } = useCampaign();
return { isOngoingType };
},
mixins: [campaignMixin],
data() {
return { showAddPopup: false };
},
@@ -3,13 +3,14 @@ import ChannelItem from 'dashboard/components/widgets/ChannelItem.vue';
import router from '../../../index';
import PageHeader from '../SettingsSubPageHeader.vue';
import { mapGetters } from 'vuex';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
components: {
ChannelItem,
PageHeader,
},
mixins: [globalConfigMixin],
data() {
return {
enabledFeatures: {},
@@ -55,7 +56,6 @@ export default {
};
router.push({ name: 'settings_inboxes_page_channel', params });
},
useInstallationName,
},
};
</script>
@@ -1,8 +1,9 @@
<script>
import { mapGetters } from 'vuex';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
computed: {
...mapGetters({
globalConfig: 'globalConfig/get',
@@ -17,9 +18,6 @@ export default {
}));
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -1,177 +1,219 @@
<script setup>
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import Thumbnail from 'dashboard/components/widgets/Thumbnail.vue';
import { useAdmin } from 'dashboard/composables/useAdmin';
import SettingsLayout from '../SettingsLayout.vue';
import BaseSettingsHeader from '../components/BaseSettingsHeader.vue';
import { computed, ref } from 'vue';
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters, useStore } from 'dashboard/composables/store';
import ChannelName from './components/ChannelName.vue';
import { useAccount } from 'dashboard/composables/useAccount';
import Settings from './Settings.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
const getters = useStoreGetters();
const store = useStore();
const { t } = useI18n();
const { isAdmin } = useAdmin();
export default {
components: {
Settings,
},
mixins: [globalConfigMixin],
setup() {
const { isAdmin } = useAdmin();
const { accountScopedUrl } = useAccount();
return {
isAdmin,
accountScopedUrl,
};
},
data() {
return {
loading: {},
showSettings: false,
showDeletePopup: false,
selectedInbox: {},
};
},
computed: {
...mapGetters({
inboxesList: 'inboxes/getInboxes',
globalConfig: 'globalConfig/get',
}),
// Delete Modal
deleteConfirmText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.YES')} ${
this.selectedInbox.name
}`;
},
deleteRejectText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.NO')} ${
this.selectedInbox.name
}`;
},
confirmDeleteMessage() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.MESSAGE')} ${
this.selectedInbox.name
}?`;
},
confirmPlaceHolderText() {
return `${this.$t('INBOX_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
inboxName: this.selectedInbox.name,
})}`;
},
},
methods: {
twilioChannelName(item) {
const { medium = '' } = item;
if (medium === 'whatsapp') return 'WhatsApp';
return 'Twilio SMS';
},
openSettings(inbox) {
this.showSettings = true;
this.selectedInbox = inbox;
},
closeSettings() {
this.showSettings = false;
this.selectedInbox = {};
},
async deleteInbox({ id }) {
try {
await this.$store.dispatch('inboxes/delete', id);
useAlert(this.$t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
}
},
const showDeletePopup = ref(false);
const selectedInbox = ref({});
const inboxesList = computed(() => getters['inboxes/getInboxes'].value);
const uiFlags = computed(() => getters['inboxes/getUIFlags'].value);
const deleteConfirmText = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.YES')} ${selectedInbox.value.name}`
);
const deleteRejectText = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.NO')} ${selectedInbox.value.name}`
);
const confirmDeleteMessage = computed(
() => `${t('INBOX_MGMT.DELETE.CONFIRM.MESSAGE')} ${selectedInbox.value.name}?`
);
const confirmPlaceHolderText = computed(
() =>
`${t('INBOX_MGMT.DELETE.CONFIRM.PLACE_HOLDER', {
inboxName: selectedInbox.value.name,
})}`
);
const deleteInbox = async ({ id }) => {
try {
await store.dispatch('inboxes/delete', id);
useAlert(t('INBOX_MGMT.DELETE.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(t('INBOX_MGMT.DELETE.API.ERROR_MESSAGE'));
}
};
const closeDelete = () => {
showDeletePopup.value = false;
selectedInbox.value = {};
};
const confirmDeletion = () => {
deleteInbox(selectedInbox.value);
closeDelete();
};
const openDelete = inbox => {
showDeletePopup.value = true;
selectedInbox.value = inbox;
confirmDeletion() {
this.deleteInbox(this.selectedInbox);
this.closeDelete();
},
openDelete(inbox) {
this.showDeletePopup = true;
this.selectedInbox = inbox;
},
closeDelete() {
this.showDeletePopup = false;
this.selectedInbox = {};
},
},
};
</script>
<template>
<SettingsLayout
:no-records-found="!inboxesList.length"
:no-records-message="$t('INBOX_MGMT.LIST.404')"
:is-loading="uiFlags.isFetching"
>
<template #header>
<BaseSettingsHeader
:title="$t('INBOX_MGMT.HEADER')"
:description="$t('INBOX_MGMT.DESCRIPTION')"
:link-text="$t('INBOX_MGMT.LEARN_MORE')"
feature-name="inboxes"
>
<template #actions>
<div class="flex-1 overflow-auto">
<div class="flex flex-row gap-4 p-8">
<div class="w-full lg:w-3/5">
<p
v-if="!inboxesList.length"
class="flex flex-col items-center justify-center h-full"
>
{{ $t('INBOX_MGMT.LIST.404') }}
<router-link
v-if="isAdmin"
class="button nice rounded-md"
:to="{ name: 'settings_inbox_new' }"
:to="accountScopedUrl('settings/inboxes/new')"
>
<fluent-icon icon="add-circle" />
{{ $t('SETTINGS.INBOXES.NEW_INBOX') }}
</router-link>
</template>
</BaseSettingsHeader>
</template>
<template #body>
<table
class="min-w-full overflow-x-auto divide-y divide-slate-75 dark:divide-slate-700"
>
<tbody
class="divide-y divide-slate-25 dark:divide-slate-800 flex-1 text-slate-700 dark:text-slate-100"
>
<tr v-for="inbox in inboxesList" :key="inbox.id">
<td class="py-4 ltr:pr-4 rtl:pl-4">
<div class="flex items-center flex-row gap-4">
<Thumbnail
v-if="inbox.avatar_url"
class="bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm"
:src="inbox.avatar_url"
:username="inbox.name"
size="48px"
</p>
<table v-if="inboxesList.length" class="woot-table">
<tbody>
<tr v-for="item in inboxesList" :key="item.id">
<td>
<img
v-if="item.avatar_url"
class="woot-thumbnail"
:src="item.avatar_url"
alt="No Page Image"
/>
<div
<img
v-else
class="w-12 h-12 bg-black-50 dark:bg-black-800 rounded-full p-2 ring ring-opacity-20 dark:ring-opacity-80 ring-black-100 dark:ring-black-900 border border-slate-100 dark:border-slate-700/50 shadow-sm block"
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="32"
height="32"
viewBox="0 0 24 24"
class="opacity-80 p-1"
class="woot-thumbnail"
src="~dashboard/assets/images/flag.svg"
alt="No Page Image"
/>
</td>
<!-- Short Code -->
<td>
<span class="agent-name">{{ item.name }}</span>
<span v-if="item.channel_type === 'Channel::FacebookPage'">
{{ 'Facebook' }}
</span>
<span v-if="item.channel_type === 'Channel::WebWidget'">
{{ 'Website' }}
</span>
<span v-if="item.channel_type === 'Channel::TwitterProfile'">
{{ 'Twitter' }}
</span>
<span v-if="item.channel_type === 'Channel::TwilioSms'">
{{ twilioChannelName(item) }}
</span>
<span v-if="item.channel_type === 'Channel::Whatsapp'">
{{ 'Whatsapp' }}
</span>
<span v-if="item.channel_type === 'Channel::Sms'">
{{ 'Sms' }}
</span>
<span v-if="item.channel_type === 'Channel::Email'">
{{ 'Email' }}
</span>
<span v-if="item.channel_type === 'Channel::Telegram'">
{{ 'Telegram' }}
</span>
<span v-if="item.channel_type === 'Channel::Line'">
{{ 'Line' }}
</span>
<span v-if="item.channel_type === 'Channel::Api'">
{{ globalConfig.apiChannelName || 'API' }}
</span>
</td>
<!-- Action Buttons -->
<td>
<div class="button-wrapper">
<router-link
:to="accountScopedUrl(`settings/inboxes/${item.id}`)"
>
<path
fill="currentColor"
d="M1 12c0-5.185 0-7.778 1.61-9.39C4.223 1 6.816 1 12 1s7.778 0 9.39 1.61C23 4.223 23 6.816 23 12s0 7.778-1.61 9.39C19.777 23 17.184 23 12 23s-7.778 0-9.39-1.61C1 19.777 1 17.184 1 12"
opacity=".35"
/>
<path
fill="currentColor"
d="M2.61 21.389c1.612 1.61 4.205 1.61 9.39 1.61s7.778 0 9.39-1.61c1.492-1.493 1.601-3.829 1.61-8.29h-3.476c-.996 0-1.494 0-1.931.202c-.438.201-.762.58-1.41 1.335l-.666.777c-.648.756-.972 1.134-1.41 1.335s-.935.202-1.93.202h-.353c-.996 0-1.494 0-1.931-.202c-.438-.2-.762-.579-1.41-1.335l-.666-.777c-.648-.756-.972-1.134-1.41-1.335s-.935-.201-1.93-.201H1c.008 4.46.118 6.796 1.61 8.289"
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
variant="smooth"
size="tiny"
icon="settings"
color-scheme="secondary"
class-names="grey-btn"
/>
</svg>
</div>
<div>
<span class="block font-medium capitalize">
{{ inbox.name }}
</span>
<ChannelName
:channel-type="inbox.channel_type"
:medium="inbox.medium"
/>
</div>
</div>
</td>
</router-link>
<td class="py-4">
<div class="flex gap-1 justify-end">
<router-link
:to="{
name: 'settings_inbox_show',
params: { inboxId: inbox.id },
}"
>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.SETTINGS')"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
icon="settings"
color-scheme="secondary"
class-names="grey-btn"
:is-loading="loading[item.id]"
icon="dismiss-circle"
@click="openDelete(item)"
/>
</router-link>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<woot-button
v-if="isAdmin"
v-tooltip.top="$t('INBOX_MGMT.DELETE.BUTTON_TEXT')"
variant="smooth"
color-scheme="alert"
size="tiny"
class-names="grey-btn"
icon="dismiss-circle"
@click="openDelete(inbox)"
/>
</div>
</td>
</tr>
</tbody>
</table>
</template>
<div class="hidden w-1/3 lg:block">
<span
v-dompurify-html="
useInstallationName(
$t('INBOX_MGMT.SIDEBAR_TXT'),
globalConfig.installationName
)
"
/>
</div>
</div>
<Settings
v-if="showSettings"
:show.sync="showSettings"
:on-close="closeSettings"
:inbox="selectedInbox"
/>
<woot-confirm-delete-modal
v-if="showDeletePopup"
@@ -185,5 +227,5 @@ const openDelete = inbox => {
@onConfirm="confirmDeletion"
@onClose="closeDelete"
/>
</SettingsLayout>
</template>
</div>
</template>
@@ -10,7 +10,7 @@ import { mapGetters } from 'vuex';
import ChannelApi from '../../../../../api/channels';
import PageHeader from '../../SettingsSubPageHeader.vue';
import router from '../../../../index';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { loadScript } from 'dashboard/helper/DOMHelpers';
import * as Sentry from '@sentry/browser';
@@ -20,6 +20,7 @@ export default {
LoadingState,
PageHeader,
},
mixins: [globalConfigMixin],
setup() {
const { accountId } = useAccount();
return {
@@ -73,7 +74,6 @@ export default {
},
methods: {
useInstallationName,
async startLogin() {
this.hasLoginStarted = true;
try {
@@ -1,55 +0,0 @@
<script setup>
import { useI18n } from 'dashboard/composables/useI18n';
import { useStoreGetters } from 'dashboard/composables/store';
import { computed } from 'vue';
const props = defineProps({
channelType: {
type: String,
required: true,
},
medium: {
type: String,
default: '',
},
});
const getters = useStoreGetters();
const { t } = useI18n();
const globalConfig = computed(() => getters['globalConfig/get'].value);
const i18nMap = {
'Channel::FacebookPage': 'MESSENGER',
'Channel::WebWidget': 'WEB_WIDGET',
'Channel::TwitterProfile': 'TWITTER_PROFILE',
'Channel::TwilioSms': 'TWILIO_SMS',
'Channel::Whatsapp': 'WHATSAPP',
'Channel::Sms': 'SMS',
'Channel::Email': 'EMAIL',
'Channel::Telegram': 'TELEGRAM',
'Channel::Line': 'LINE',
'Channel::Api': 'API',
};
const twilioChannelName = () => {
if (props.medium === 'whatsapp') {
return t(`INBOX_MGMT.CHANNELS.WHATSAPP`);
}
return t(`INBOX_MGMT.CHANNELS.TWILIO_SMS`);
};
const readableChannelName = computed(() => {
if (props.channelType === 'Channel::Api') {
return globalConfig.value.apiChannelName || t('INBOX_MGMT.CHANNELS.API');
}
if (props.channelType === 'Channel::TwilioSms') {
return twilioChannelName();
}
return t(`INBOX_MGMT.CHANNELS.${i18nMap[props.channelType]}`);
});
</script>
<template>
<span>
{{ readableChannelName }}
</span>
</template>
@@ -1,8 +1,8 @@
/* eslint arrow-body-style: 0 */
import { frontendURL } from '../../../../helper/URLHelper';
import channelFactory from './channel-factory';
const SettingsContent = () => import('../Wrapper.vue');
const SettingWrapper = () => import('../SettingsWrapper.vue');
const InboxHome = () => import('./Index.vue');
const Settings = () => import('./Settings.vue');
const InboxChannel = () => import('./InboxChannels.vue');
@@ -12,24 +12,6 @@ const FinishSetup = () => import('./FinishSetup.vue');
export default {
routes: [
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingWrapper,
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
meta: {
permissions: ['administrator'],
},
},
],
},
{
path: frontendURL('accounts/:accountId/settings/inboxes'),
component: SettingsContent,
@@ -44,6 +26,18 @@ export default {
};
},
children: [
{
path: '',
redirect: 'list',
},
{
path: 'list',
name: 'settings_inbox_list',
component: InboxHome,
meta: {
permissions: ['administrator'],
},
},
{
path: 'new',
component: InboxChannel,
@@ -3,6 +3,7 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import DashboardAppModal from './DashboardAppModal.vue';
import DashboardAppsRow from './DashboardAppsRow.vue';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import BaseSettingsHeader from '../../components/BaseSettingsHeader.vue';
export default {
@@ -11,6 +12,7 @@ export default {
DashboardAppModal,
DashboardAppsRow,
},
mixins: [globalConfigMixin],
data() {
return {
loading: {},
@@ -2,9 +2,10 @@
import { mapGetters } from 'vuex';
import { frontendURL } from '../../../../helper/URLHelper';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
export default {
mixins: [globalConfigMixin],
props: {
integrationId: {
type: [String, Number],
@@ -29,7 +30,6 @@ export default {
}),
},
methods: {
useInstallationName,
frontendURL,
openDeletePopup() {
this.showDeleteConfirmationPopup = true;
@@ -3,7 +3,7 @@ import { computed } from 'vue';
import { useStoreGetters } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
import { frontendURL } from 'dashboard/helper/URLHelper';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import { useInstallationName } from 'shared/mixins/globalConfigMixin';
const props = defineProps({
id: {
@@ -1,4 +1,5 @@
<script>
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import IntegrationHelpText from './IntegrationHelpText.vue';
@@ -7,6 +8,7 @@ export default {
Integration,
IntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
integrationId: {
@@ -1,5 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import Integration from './Integration.vue';
import SelectChannelWarning from './Slack/SelectChannelWarning.vue';
import SlackIntegrationHelpText from './Slack/SlackIntegrationHelpText.vue';
@@ -11,6 +12,7 @@ export default {
SelectChannelWarning,
SlackIntegrationHelpText,
},
mixins: [globalConfigMixin],
props: {
code: { type: String, default: '' },
},
@@ -1,10 +1,11 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { useMessageFormatter } from 'shared/composables/useMessageFormatter';
export default {
mixins: [globalConfigMixin],
props: {
hasConnectedAChannel: {
type: Boolean,
@@ -32,7 +33,6 @@ export default {
},
},
methods: {
useInstallationName,
async fetchChannels() {
try {
this.availableChannels = await this.$store.dispatch(
@@ -3,19 +3,16 @@ import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import NewWebhook from './NewWebHook.vue';
import EditWebhook from './EditWebHook.vue';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
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: {},
@@ -29,16 +26,13 @@ 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');
},
methods: {
useInstallationName,
openAddPopup() {
this.showAddPopup = true;
},
@@ -81,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>
@@ -159,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,11 +1,12 @@
<script>
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { mapGetters } from 'vuex';
import WebhookForm from './WebhookForm.vue';
export default {
components: { WebhookForm },
mixins: [globalConfigMixin],
props: {
onClose: {
type: Function,
@@ -19,7 +20,6 @@ export default {
}),
},
methods: {
useInstallationName,
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
@@ -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',
@@ -4,7 +4,7 @@ import { useAlert } from 'dashboard/composables';
import { useUISettings } from 'dashboard/composables/useUISettings';
import { clearCookiesOnLogout } from 'dashboard/store/utils/api.js';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import UserProfilePicture from './UserProfilePicture.vue';
import UserBasicDetails from './UserBasicDetails.vue';
import MessageSignature from './MessageSignature.vue';
@@ -27,6 +27,7 @@ export default {
AudioNotifications,
AccessToken,
},
mixins: [globalConfigMixin],
setup() {
const { uiSettings, updateUISettings, isEditorHotKeyEnabled } =
useUISettings();
@@ -85,7 +86,6 @@ export default {
}
},
methods: {
useInstallationName,
initializeUser() {
this.name = this.currentUser.name;
this.email = this.currentUser.email;
@@ -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,5 +1,5 @@
<script>
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
const {
LOGO_THUMBNAIL: logoThumbnail,
@@ -8,6 +8,7 @@ const {
} = window.globalConfig || {};
export default {
mixins: [globalConfigMixin],
props: {
disableBranding: {
type: Boolean,
@@ -40,9 +41,6 @@ export default {
return '';
},
},
methods: {
useInstallationName,
},
};
</script>
@@ -1,47 +0,0 @@
import { describe, it, expect, vi } from 'vitest';
import { useCampaign } from '../useCampaign';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../../constants/campaign';
// Mock the useRoute composable
vi.mock('dashboard/composables/route', () => ({
useRoute: vi.fn(),
}));
describe('useCampaign', () => {
it('returns the correct campaign type for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONGOING);
});
it('returns the correct campaign type for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { campaignType } = useCampaign();
expect(campaignType.value).toBe(CAMPAIGN_TYPES.ONE_OFF);
});
it('isOngoingType returns true for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(true);
});
it('isOngoingType returns false for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOngoingType } = useCampaign();
expect(isOngoingType.value).toBe(false);
});
it('isOneOffType returns true for one-off campaigns', () => {
useRoute.mockReturnValue({ name: 'one_off' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(true);
});
it('isOneOffType returns false for ongoing campaigns', () => {
useRoute.mockReturnValue({ name: 'ongoing_campaigns' });
const { isOneOffType } = useCampaign();
expect(isOneOffType.value).toBe(false);
});
});
@@ -1,145 +0,0 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { useFilter } from '../useFilter';
import { useStore } from 'dashboard/composables/store';
import { useI18n } from 'dashboard/composables/useI18n';
// Mock the dependencies
vi.mock('dashboard/composables/store');
vi.mock('dashboard/composables/useI18n');
describe('useFilter', () => {
// Setup mocks
beforeEach(() => {
vi.mocked(useStore).mockReturnValue({
getters: {
'attributes/getAttributesByModel': vi.fn(),
},
});
vi.mocked(useI18n).mockReturnValue({
t: vi.fn(key => key),
});
});
it('should return the correct functions', () => {
const {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
} = useFilter({ filteri18nKey: 'TEST', attributeModel: 'conversation' });
expect(setFilterAttributes).toBeDefined();
expect(initializeStatusAndAssigneeFilterToModal).toBeDefined();
expect(initializeInboxTeamAndLabelFilterToModal).toBeDefined();
});
describe('setFilterAttributes', () => {
it('should return filterGroups and filterTypes', () => {
const mockAttributes = [
{
attribute_key: 'test_key',
attribute_display_name: 'Test Name',
attribute_display_type: 'text',
},
];
vi.mocked(useStore)().getters[
'attributes/getAttributesByModel'
].mockReturnValue(mockAttributes);
const { setFilterAttributes } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = setFilterAttributes();
expect(result).toHaveProperty('filterGroups');
expect(result).toHaveProperty('filterTypes');
expect(result.filterGroups.length).toBeGreaterThan(0);
expect(result.filterTypes.length).toBeGreaterThan(0);
});
});
describe('initializeStatusAndAssigneeFilterToModal', () => {
it('should return status filter when activeStatus is provided', () => {
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeStatusAndAssigneeFilterToModal('open', {}, '');
expect(result).toEqual({
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{ id: 'open', name: 'CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.open.TEXT' },
],
query_operator: 'and',
custom_attribute_type: '',
});
});
it('should return null when no active filters', () => {
const { initializeStatusAndAssigneeFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeStatusAndAssigneeFilterToModal('', {}, '');
expect(result).toBeNull();
});
});
describe('initializeInboxTeamAndLabelFilterToModal', () => {
it('should return filters for inbox, team, and label when provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
1,
{ name: 'Inbox 1' },
2,
[{ id: 2, name: 'Team 2' }],
'Label 1'
);
expect(result).toHaveLength(3);
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
expect(result[1]).toHaveProperty('attribute_key', 'team_id');
expect(result[2]).toHaveProperty('attribute_key', 'labels');
});
it('should return empty array when no filters are provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
null,
null,
null,
null,
null
);
expect(result).toEqual([]);
});
it('should return only inbox filter when only inbox is provided', () => {
const { initializeInboxTeamAndLabelFilterToModal } = useFilter({
filteri18nKey: 'TEST',
attributeModel: 'conversation',
});
const result = initializeInboxTeamAndLabelFilterToModal(
1,
{ name: 'Inbox 1' },
null,
null,
null
);
expect(result).toHaveLength(1);
expect(result[0]).toHaveProperty('attribute_key', 'inbox_id');
});
});
});
@@ -1,43 +0,0 @@
import { computed } from 'vue';
import { useRoute } from 'dashboard/composables/route';
import { CAMPAIGN_TYPES } from '../constants/campaign';
/**
* Composable to manage campaign types.
*
* @returns {Object} - Computed properties for campaign type and its checks.
*/
export const useCampaign = () => {
const route = useRoute();
/**
* Computed property to determine the current campaign type based on the route name.
*/
const campaignType = computed(() => {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[route.name];
});
/**
* Computed property to check if the current campaign type is 'ongoing'.
*/
const isOngoingType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONGOING;
});
/**
* Computed property to check if the current campaign type is 'one-off'.
*/
const isOneOffType = computed(() => {
return campaignType.value === CAMPAIGN_TYPES.ONE_OFF;
});
return {
campaignType,
isOngoingType,
isOneOffType,
};
};
@@ -1,181 +0,0 @@
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 * as OPERATORS from 'dashboard/components/widgets/FilterInput/FilterOperatorTypes.js';
const customAttributeInputType = key => {
switch (key) {
case 'date':
return 'date';
case 'text':
return 'plain_text';
case 'list':
return 'search_select';
case 'checkbox':
return 'search_select';
default:
return 'plain_text';
}
};
const getOperatorTypes = key => {
switch (key) {
case 'list':
return OPERATORS.OPERATOR_TYPES_1;
case 'text':
return OPERATORS.OPERATOR_TYPES_3;
case 'number':
return OPERATORS.OPERATOR_TYPES_1;
case 'link':
return OPERATORS.OPERATOR_TYPES_1;
case 'date':
return OPERATORS.OPERATOR_TYPES_4;
case 'checkbox':
return OPERATORS.OPERATOR_TYPES_1;
default:
return OPERATORS.OPERATOR_TYPES_1;
}
};
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);
const customAttributesFormatted = {
name: $t(`${filteri18nKey}.GROUPS.CUSTOM_ATTRIBUTES`),
attributes: allCustomAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
};
}),
};
const allFilterGroups = filterAttributeGroups.map(group => {
return {
name: $t(`${filteri18nKey}.GROUPS.${group.i18nGroup}`),
attributes: group.attributes.map(attribute => {
return {
key: attribute.key,
name: $t(`${filteri18nKey}.ATTRIBUTES.${attribute.i18nKey}`),
};
}),
};
});
const customAttributeTypes = allCustomAttributes.map(attr => {
return {
attributeKey: attr.attribute_key,
attributeI18nKey: `CUSTOM_ATTRIBUTE_${attr.attribute_display_type.toUpperCase()}`,
inputType: customAttributeInputType(attr.attribute_display_type),
filterOperators: getOperatorTypes(attr.attribute_display_type),
attributeModel: 'custom_attributes',
};
});
return {
filterGroups: [...allFilterGroups, customAttributesFormatted],
filterTypes: [...customAttributeTypes],
};
};
const initializeStatusAndAssigneeFilterToModal = (
activeStatus,
currentUserDetails,
activeAssigneeTab
) => {
if (activeStatus !== '') {
return {
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: activeStatus,
name: $t(`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${activeStatus}.TEXT`),
},
],
query_operator: 'and',
custom_attribute_type: '',
};
}
if (activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ME) {
return {
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: currentUserDetails,
query_operator: 'and',
custom_attribute_type: '',
};
}
return null;
};
const initializeInboxTeamAndLabelFilterToModal = (
conversationInbox,
inbox,
teamId,
activeTeam,
label
) => {
const filters = [];
if (conversationInbox) {
filters.push({
attribute_key: 'inbox_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: conversationInbox,
name: inbox.name,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (teamId) {
filters.push({
attribute_key: 'team_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: activeTeam,
query_operator: 'and',
custom_attribute_type: '',
});
}
if (label) {
filters.push({
attribute_key: 'labels',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: label,
name: label,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
return filters;
};
return {
setFilterAttributes,
initializeStatusAndAssigneeFilterToModal,
initializeInboxTeamAndLabelFilterToModal,
};
};
@@ -7,5 +7,4 @@ export const MESSAGE_MAX_LENGTH = {
FACEBOOK: 1000,
TWILIO_SMS: 320,
TWILIO_WHATSAPP: 1600,
EMAIL: 25000,
};
+40 -2
View File
@@ -1,5 +1,8 @@
import { emitter } from 'shared/helpers/mitt';
/**
* Writes a text string to the system clipboard.
* @deprecated This will be removed in the future. Use `https://vueuse.org/core/useClipboard/` instead.
*
* @async
* @param {string} text text to be written to the clipboard
@@ -7,8 +10,43 @@
*/
export const copyTextToClipboard = async text => {
try {
await navigator.clipboard.writeText(text);
const permission = await navigator.permissions.query({
name: 'clipboard-write',
});
if (permission.state === 'granted' || permission.state === 'prompt') {
await navigator.clipboard.writeText(text);
} else {
throw new Error('Clipboard write permission denied');
}
} catch (error) {
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.opacity = '0';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
const successful = document.execCommand('copy');
if (successful) {
emitter.emit(
'newToastMessage',
'Text successfully copied to clipboard.'
);
} else {
throw new Error('Fallback method failed');
}
} catch (execCommandError) {
emitter.emit(
'newToastMessage',
'Network Error: Unable to copy text to clipboard.'
);
throw new Error(
`Unable to copy text to clipboard: ${execCommandError.message}`
);
} finally {
document.body.removeChild(textArea);
}
}
};
@@ -1,14 +0,0 @@
/**
* Replaces occurrences of "Chatwoot" with the provided installation name in a given string.
*
* @param {string} str - The original string that may contain "Chatwoot".
* @param {string} installationName - The name to replace "Chatwoot" with.
* @returns {string} The modified string with "Chatwoot" replaced by the installation name,
* or the original string if either input is falsy.
*/
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
@@ -1,35 +0,0 @@
import { useInstallationName } from '../installationNameHelper';
describe('installationNameHelper', () => {
it('should return the string with the installation name', () => {
const str = 'Chatwoot is awesome';
const installationName = 'Acme Inc';
expect(useInstallationName(str, installationName)).toBe(
'Acme Inc is awesome'
);
});
it('should return the string without the installation name', () => {
const str = 'Chatwoot is awesome';
const installationName = '';
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is awesome'
);
});
it('should handle null installation name properly', () => {
const str = 'Chatwoot is super cool';
const installationName = null;
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is super cool'
);
});
it('should handle undefined installation name properly', () => {
const str = 'Chatwoot is super cool';
const installationName = undefined;
expect(useInstallationName(str, installationName)).toBe(
'Chatwoot is super cool'
);
});
});
@@ -0,0 +1,19 @@
import { CAMPAIGN_TYPES } from '../constants/campaign';
export default {
computed: {
campaignType() {
const campaignTypeMap = {
ongoing_campaigns: CAMPAIGN_TYPES.ONGOING,
one_off: CAMPAIGN_TYPES.ONE_OFF,
};
return campaignTypeMap[this.$route.name];
},
isOngoingType() {
return this.campaignType === CAMPAIGN_TYPES.ONGOING;
},
isOneOffType() {
return this.campaignType === CAMPAIGN_TYPES.ONE_OFF;
},
},
};
+119
View File
@@ -0,0 +1,119 @@
import wootConstants from 'dashboard/constants/globals';
export default {
methods: {
setFilterAttributes() {
const allCustomAttributes = this.$store.getters[
'attributes/getAttributesByModel'
](this.attributeModel);
const customAttributesFormatted = {
name: this.$t(`${this.filtersFori18n}.GROUPS.CUSTOM_ATTRIBUTES`),
attributes: allCustomAttributes.map(attr => {
return {
key: attr.attribute_key,
name: attr.attribute_display_name,
};
}),
};
const allFilterGroups = this.filterAttributeGroups.map(group => {
return {
name: this.$t(`${this.filtersFori18n}.GROUPS.${group.i18nGroup}`),
attributes: group.attributes.map(attribute => {
return {
key: attribute.key,
name: this.$t(
`${this.filtersFori18n}.ATTRIBUTES.${attribute.i18nKey}`
),
};
}),
};
});
const customAttributeTypes = allCustomAttributes.map(attr => {
return {
attributeKey: attr.attribute_key,
attributeI18nKey: `CUSTOM_ATTRIBUTE_${attr.attribute_display_type.toUpperCase()}`,
inputType: this.customAttributeInputType(attr.attribute_display_type),
filterOperators: this.getOperatorTypes(attr.attribute_display_type),
attributeModel: 'custom_attributes',
};
});
this.filterTypes = [...this.filterTypes, ...customAttributeTypes];
this.filterGroups = [...allFilterGroups, customAttributesFormatted];
},
initializeExistingFilterToModal() {
this.initializeStatusAndAssigneeFilterToModal();
this.initializeInboxTeamAndLabelFilterToModal();
},
initializeStatusAndAssigneeFilterToModal() {
if (this.activeStatus !== '') {
this.appliedFilter.push({
attribute_key: 'status',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.activeStatus,
name: this.$t(
`CHAT_LIST.CHAT_STATUS_FILTER_ITEMS.${this.activeStatus}.TEXT`
),
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.activeAssigneeTab === wootConstants.ASSIGNEE_TYPE.ME) {
this.appliedFilter.push({
attribute_key: 'assignee_id',
filter_operator: 'equal_to',
values: this.currentUserDetails,
query_operator: 'and',
custom_attribute_type: '',
});
}
},
initializeInboxTeamAndLabelFilterToModal() {
if (this.conversationInbox) {
this.appliedFilter.push({
attribute_key: 'inbox_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.conversationInbox,
name: this.inbox.name,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.teamId) {
this.appliedFilter.push({
attribute_key: 'team_id',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: this.activeTeam,
query_operator: 'and',
custom_attribute_type: '',
});
}
if (this.label) {
this.appliedFilter.push({
attribute_key: 'labels',
attribute_model: 'standard',
filter_operator: 'equal_to',
values: [
{
id: this.label,
name: this.label,
},
],
query_operator: 'and',
custom_attribute_type: '',
});
}
},
},
};
@@ -0,0 +1,12 @@
export const useInstallationName = (str, installationName) => {
if (str && installationName) {
return str.replace(/Chatwoot/g, installationName);
}
return str;
};
export default {
methods: {
useInstallationName,
},
};
@@ -0,0 +1,34 @@
import { shallowMount } from '@vue/test-utils';
import campaignMixin from '../campaignMixin';
const buildComponent = routeName => ({
template: '<div></div>',
computed: {
$route() {
return { name: routeName };
},
},
mixins: [campaignMixin],
});
describe('campaignMixin', () => {
beforeEach(() => {
global.window = Object.create(window);
});
it('returns the correct campaign type', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.campaignType).toBe('one_off');
});
it('isOneOffType returns true if path is one_off', () => {
const Component = buildComponent('one_off');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOneOffType).toBe(true);
});
it('isOngoingType returns true if path is ongoing_campaigns', () => {
const Component = buildComponent('ongoing_campaigns');
const wrapper = shallowMount(Component);
expect(wrapper.vm.isOngoingType).toBe(true);
});
});
@@ -0,0 +1,17 @@
import filterMixin from '../filterMixin';
import { shallowMount } from '@vue/test-utils';
import MockComponent from './MockComponent.vue';
describe('Test mixin function', () => {
const wrapper = shallowMount(MockComponent, {
mixins: [filterMixin],
});
it('should return proper value from bool', () => {
expect(wrapper.vm.setFilterAttributes).toBeTruthy();
});
it('should return proper value from bool', () => {
expect(wrapper.vm.initializeExistingFilterToModal).toBeTruthy();
});
});
@@ -3,13 +3,14 @@ import { useVuelidate } from '@vuelidate/core';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { required, minLength, email } from '@vuelidate/validators';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import FormInput from '../../../../components/Form/Input.vue';
import { resetPassword } from '../../../../api/auth';
import SubmitButton from '../../../../components/Button/SubmitButton.vue';
export default {
components: { FormInput, SubmitButton },
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
@@ -36,7 +37,6 @@ export default {
},
},
methods: {
useInstallationName,
showAlertMessage(message) {
// Reset loading, current selected agent
this.resetPassword.showLoading = false;
@@ -1,6 +1,6 @@
<script>
import { mapGetters } from 'vuex';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SignupForm from './components/Signup/Form.vue';
import Testimonials from './components/Testimonials/Index.vue';
import Spinner from 'shared/components/Spinner.vue';
@@ -11,6 +11,7 @@ export default {
Spinner,
Testimonials,
},
mixins: [globalConfigMixin],
data() {
return { isLoading: false };
},
@@ -24,7 +25,6 @@ export default {
this.isLoading = this.isAChatwootInstance;
},
methods: {
useInstallationName,
resizeContainers() {
this.isLoading = false;
},
@@ -3,6 +3,7 @@ import { useVuelidate } from '@vuelidate/core';
import { required, minLength, email } from '@vuelidate/validators';
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import { DEFAULT_REDIRECT_URL } from 'dashboard/constants/globals';
import VueHcaptcha from '@hcaptcha/vue-hcaptcha';
import FormInput from '../../../../../components/Form/Input.vue';
@@ -19,6 +20,7 @@ export default {
SubmitButton,
VueHcaptcha,
},
mixins: [globalConfigMixin],
setup() {
return { v$: useVuelidate() };
},
+2 -3
View File
@@ -2,8 +2,7 @@
import { useVuelidate } from '@vuelidate/core';
import { required, email } from '@vuelidate/validators';
import { useAlert } from 'dashboard/composables';
import { useInstallationName } from 'shared/helpers/installationNameHelper';
import globalConfigMixin from 'shared/mixins/globalConfigMixin';
import SubmitButton from '../../components/Button/SubmitButton.vue';
import { mapGetters } from 'vuex';
import { parseBoolean } from '@chatwoot/utils';
@@ -23,6 +22,7 @@ export default {
Spinner,
SubmitButton,
},
mixins: [globalConfigMixin],
props: {
ssoAuthToken: { type: String, default: '' },
ssoAccountId: { type: String, default: '' },
@@ -88,7 +88,6 @@ export default {
// TODO: Remove this when Safari gets wider support
// Ref: https://caniuse.com/requestidlecallback
//
useInstallationName,
requestIdleCallbackPolyfill(callback) {
if (window.requestIdleCallback) {
window.requestIdleCallback(callback);
+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
@@ -69,8 +69,7 @@ RSpec.describe 'Custom Roles API', type: :request do
describe 'POST #create' do
let(:valid_params) do
{ custom_role: { name: 'Support', description: 'Support role',
permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) } }
{ custom_role: { name: 'Support', description: 'Support role', permissions: CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) } }
end
context 'when it is an authenticated administrator' do
+1 -1
View File
@@ -3,6 +3,6 @@ FactoryBot.define do
account
name { Faker::Name.name }
description { Faker::Lorem.sentence }
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(1..4)) }
permissions { CustomRole::PERMISSIONS.sample(SecureRandom.random_number(4)) }
end
end
+4 -4
View File
@@ -2914,10 +2914,10 @@
hotkeys-js "3.8.7"
lit "2.2.6"
"@chatwoot/prosemirror-schema@1.0.17":
version "1.0.17"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.17.tgz#5c04a94c8200b303d943369b2534730d5ba15a64"
integrity sha512-3hwBSMdb5a+Ilct0fNSlAvQu/9+pnyI/FoGshVRC9Hn1tCdZdS7LUlxOuDweta3HneVclhE+SxJN5lM33v5WGw==
"@chatwoot/prosemirror-schema@1.0.11":
version "1.0.11"
resolved "https://registry.yarnpkg.com/@chatwoot/prosemirror-schema/-/prosemirror-schema-1.0.11.tgz#b66201be8b09cd4c6370a60607cc5d4f9d924bdb"
integrity sha512-OAUa1CuHtetEPh/ZhRkMLVQHY2/eesCRb2IRdVzjh0z+4spp3fG826y8FbOEsa7m4hisNirnGF/mDH+NMmYetw==
dependencies:
markdown-it-sup "^1.0.0"
prosemirror-commands "^1.1.4"