Merge branch 'feat/saml-controllers' into feat/saml-ui
This commit is contained in:
+14
-22
@@ -1,6 +1,7 @@
|
||||
version: 2.1
|
||||
orbs:
|
||||
node: circleci/node@6.1.0
|
||||
qlty-orb: qltysh/qlty-orb@0.0
|
||||
|
||||
defaults: &defaults
|
||||
working_directory: ~/build
|
||||
@@ -89,14 +90,6 @@ jobs:
|
||||
command: |
|
||||
source ~/.rvm/scripts/rvm
|
||||
bundle install
|
||||
# pnpm install
|
||||
|
||||
- run:
|
||||
name: Download cc-test-reporter
|
||||
command: |
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://codeclimate.com/downloads/test-reporter/test-reporter-latest-linux-amd64 > ~/tmp/cc-test-reporter
|
||||
chmod +x ~/tmp/cc-test-reporter
|
||||
|
||||
# Swagger verification
|
||||
- run:
|
||||
@@ -108,10 +101,11 @@ jobs:
|
||||
echo "ERROR: The swagger.json file is not in sync with the yaml specification. Run 'rake swagger:build' and commit 'swagger/swagger.json'."
|
||||
exit 1
|
||||
fi
|
||||
mkdir -p ~/tmp
|
||||
curl -L https://repo1.maven.org/maven2/org/openapitools/openapi-generator-cli/6.3.0/openapi-generator-cli-6.3.0.jar > ~/tmp/openapi-generator-cli-6.3.0.jar
|
||||
java -jar ~/tmp/openapi-generator-cli-6.3.0.jar validate -i swagger/swagger.json
|
||||
|
||||
# we remove the FRONTED_URL from the .env before running the tests
|
||||
# Configure environment and database
|
||||
- run:
|
||||
name: Database Setup and Configure Environment Variables
|
||||
command: |
|
||||
@@ -149,17 +143,11 @@ jobs:
|
||||
command: pnpm run eslint
|
||||
|
||||
- run:
|
||||
name: Run frontend tests
|
||||
name: Run frontend tests (with coverage)
|
||||
command: |
|
||||
mkdir -p ~/build/coverage/frontend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
pnpm run test:coverage
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Frontend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t lcov -o "~/build/coverage/frontend/codeclimate.frontend_$CIRCLE_NODE_INDEX.json"
|
||||
|
||||
# Run backend tests
|
||||
- run:
|
||||
name: Run backend tests
|
||||
@@ -167,18 +155,18 @@ jobs:
|
||||
mkdir -p ~/tmp/test-results/rspec
|
||||
mkdir -p ~/tmp/test-artifacts
|
||||
mkdir -p ~/build/coverage/backend
|
||||
~/tmp/cc-test-reporter before-build
|
||||
TESTFILES=$(circleci tests glob "spec/**/*_spec.rb" | circleci tests split --split-by=timings)
|
||||
bundle exec rspec --format progress \
|
||||
bundle exec rspec -I ./spec --require coverage_helper --require spec_helper --format progress \
|
||||
--format RspecJunitFormatter \
|
||||
--out ~/tmp/test-results/rspec.xml \
|
||||
-- ${TESTFILES}
|
||||
no_output_timeout: 30m
|
||||
|
||||
- run:
|
||||
name: Code Climate Test Coverage (Backend)
|
||||
command: |
|
||||
~/tmp/cc-test-reporter format-coverage -t simplecov -o "~/build/coverage/backend/codeclimate.$CIRCLE_NODE_INDEX.json"
|
||||
# Qlty coverage publish
|
||||
- qlty-orb/coverage_publish:
|
||||
files: |
|
||||
coverage/coverage.json
|
||||
coverage/lcov.info
|
||||
|
||||
- run:
|
||||
name: List coverage directory contents
|
||||
@@ -189,3 +177,7 @@ jobs:
|
||||
root: ~/build
|
||||
paths:
|
||||
- coverage
|
||||
|
||||
- store_artifacts:
|
||||
path: coverage
|
||||
destination: coverage
|
||||
|
||||
@@ -229,6 +229,7 @@ group :test do
|
||||
gem 'webmock'
|
||||
# test profiling
|
||||
gem 'test-prof'
|
||||
gem 'simplecov_json_formatter', require: false
|
||||
end
|
||||
|
||||
group :development, :test do
|
||||
@@ -253,7 +254,7 @@ group :development, :test do
|
||||
gem 'rubocop-factory_bot', require: false
|
||||
gem 'seed_dump'
|
||||
gem 'shoulda-matchers'
|
||||
gem 'simplecov', '0.17.1', require: false
|
||||
gem 'simplecov', '>= 0.21', require: false
|
||||
gem 'spring'
|
||||
gem 'spring-watcher-listen'
|
||||
end
|
||||
|
||||
+9
-7
@@ -219,7 +219,7 @@ GEM
|
||||
diff-lcs (1.5.1)
|
||||
digest-crc (0.6.5)
|
||||
rake (>= 12.0.0, < 14.0.0)
|
||||
docile (1.4.0)
|
||||
docile (1.4.1)
|
||||
domain_name (0.5.20190701)
|
||||
unf (>= 0.0.5, < 1.0.0)
|
||||
dotenv (3.1.2)
|
||||
@@ -447,7 +447,7 @@ GEM
|
||||
rails-dom-testing (>= 1, < 3)
|
||||
railties (>= 4.2.0)
|
||||
thor (>= 0.14, < 2.0)
|
||||
json (2.12.0)
|
||||
json (2.13.2)
|
||||
json_refs (0.1.8)
|
||||
hana
|
||||
json_schemer (0.2.24)
|
||||
@@ -864,11 +864,12 @@ GEM
|
||||
faraday (>= 0.17.5, < 3.a)
|
||||
jwt (>= 1.5, < 3.0)
|
||||
multi_json (~> 1.10)
|
||||
simplecov (0.17.1)
|
||||
simplecov (0.22.0)
|
||||
docile (~> 1.1)
|
||||
json (>= 1.8, < 3)
|
||||
simplecov-html (~> 0.10.0)
|
||||
simplecov-html (0.10.2)
|
||||
simplecov-html (~> 0.11)
|
||||
simplecov_json_formatter (~> 0.1)
|
||||
simplecov-html (0.13.2)
|
||||
simplecov_json_formatter (0.1.4)
|
||||
slack-ruby-client (2.5.2)
|
||||
faraday (>= 2.0)
|
||||
faraday-mashify
|
||||
@@ -1093,7 +1094,8 @@ DEPENDENCIES
|
||||
sidekiq (>= 7.3.1)
|
||||
sidekiq-cron (>= 1.12.0)
|
||||
sidekiq_alive
|
||||
simplecov (= 0.17.1)
|
||||
simplecov (>= 0.21)
|
||||
simplecov_json_formatter
|
||||
slack-ruby-client (~> 2.5.2)
|
||||
spring
|
||||
spring-watcher-listen
|
||||
|
||||
@@ -74,6 +74,17 @@ module PortalHelper
|
||||
end
|
||||
end
|
||||
|
||||
def generate_portal_brand_url(brand_url, referer)
|
||||
url = URI.parse(brand_url.to_s)
|
||||
query_params = Rack::Utils.parse_query(url.query)
|
||||
query_params['utm_medium'] = 'helpcenter'
|
||||
query_params['utm_campaign'] = 'branding'
|
||||
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
|
||||
|
||||
url.query = query_params.to_query
|
||||
url.to_s
|
||||
end
|
||||
|
||||
def render_category_content(content)
|
||||
ChatwootMarkdownRenderer.new(content).render_markdown_to_plain_text
|
||||
end
|
||||
|
||||
+28
-6
@@ -11,12 +11,14 @@ import { extractTextFromMarkdown } from 'dashboard/helper/editorHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import WhatsAppOptions from './WhatsAppOptions.vue';
|
||||
import ContentTemplateSelector from './ContentTemplateSelector.vue';
|
||||
|
||||
const props = defineProps({
|
||||
attachedFiles: { type: Array, default: () => [] },
|
||||
isWhatsappInbox: { type: Boolean, default: false },
|
||||
isEmailOrWebWidgetInbox: { type: Boolean, default: false },
|
||||
isTwilioSmsInbox: { type: Boolean, default: false },
|
||||
isTwilioWhatsAppInbox: { type: Boolean, default: false },
|
||||
messageTemplates: { type: Array, default: () => [] },
|
||||
channelType: { type: String, default: '' },
|
||||
isLoading: { type: Boolean, default: false },
|
||||
@@ -32,6 +34,7 @@ const emit = defineEmits([
|
||||
'discard',
|
||||
'sendMessage',
|
||||
'sendWhatsappMessage',
|
||||
'sendTwilioMessage',
|
||||
'insertEmoji',
|
||||
'addSignature',
|
||||
'removeSignature',
|
||||
@@ -63,6 +66,20 @@ const sendWithSignature = computed(() => {
|
||||
return fetchSignatureFlagFromUISettings(props.channelType);
|
||||
});
|
||||
|
||||
const showTwilioContentTemplates = computed(() => {
|
||||
return props.isTwilioWhatsAppInbox && props.inboxId;
|
||||
});
|
||||
|
||||
const shouldShowEmojiButton = computed(() => {
|
||||
return (
|
||||
!props.isWhatsappInbox && !props.isTwilioWhatsAppInbox && !props.hasNoInbox
|
||||
);
|
||||
});
|
||||
|
||||
const isRegularMessageMode = computed(() => {
|
||||
return !props.isWhatsappInbox && !props.isTwilioWhatsAppInbox;
|
||||
});
|
||||
|
||||
const setSignature = () => {
|
||||
if (signatureToApply.value) {
|
||||
if (sendWithSignature.value) {
|
||||
@@ -125,7 +142,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -136,7 +153,7 @@ const keyboardEvents = {
|
||||
action: () => {
|
||||
if (
|
||||
isEditorHotKeyEnabled('cmd_enter') &&
|
||||
!props.isWhatsappInbox &&
|
||||
isRegularMessageMode.value &&
|
||||
!props.isDropdownActive
|
||||
) {
|
||||
emit('sendMessage');
|
||||
@@ -158,8 +175,13 @@ useKeyboardEvents(keyboardEvents);
|
||||
:message-templates="messageTemplates"
|
||||
@send-message="emit('sendWhatsappMessage', $event)"
|
||||
/>
|
||||
<ContentTemplateSelector
|
||||
v-if="showTwilioContentTemplates"
|
||||
:inbox-id="inboxId"
|
||||
@send-message="emit('sendTwilioMessage', $event)"
|
||||
/>
|
||||
<div
|
||||
v-if="!isWhatsappInbox && !hasNoInbox"
|
||||
v-if="shouldShowEmojiButton"
|
||||
v-on-click-outside="() => (isEmojiPickerOpen = false)"
|
||||
class="relative"
|
||||
>
|
||||
@@ -172,7 +194,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
<EmojiInput
|
||||
v-if="isEmojiPickerOpen"
|
||||
class="ltr:left-0 rtl:right-0 top-full mt-1.5"
|
||||
class="top-full mt-1.5 ltr:left-0 rtl:right-0"
|
||||
:on-click="onClickInsertEmoji"
|
||||
/>
|
||||
</div>
|
||||
@@ -199,7 +221,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
/>
|
||||
</FileUpload>
|
||||
<Button
|
||||
v-if="hasSelectedInbox && !isWhatsappInbox"
|
||||
v-if="hasSelectedInbox && isRegularMessageMode"
|
||||
icon="i-lucide-signature"
|
||||
color="slate"
|
||||
size="sm"
|
||||
@@ -218,7 +240,7 @@ useKeyboardEvents(keyboardEvents);
|
||||
@click="emit('discard')"
|
||||
/>
|
||||
<Button
|
||||
v-if="!isWhatsappInbox"
|
||||
v-if="isRegularMessageMode"
|
||||
:label="sendButtonLabel"
|
||||
size="sm"
|
||||
class="!text-xs font-medium"
|
||||
|
||||
+28
-1
@@ -74,6 +74,9 @@ const inboxTypes = computed(() => ({
|
||||
isTwilioSMS:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'sms',
|
||||
isTwilioWhatsapp:
|
||||
props.targetInbox?.channelType === INBOX_TYPES.TWILIO &&
|
||||
props.targetInbox?.medium === 'whatsapp',
|
||||
}));
|
||||
|
||||
const whatsappMessageTemplates = computed(() =>
|
||||
@@ -261,6 +264,28 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const handleSendTwilioMessage = async ({ message, templateParams }) => {
|
||||
const twilioMessagePayload = prepareWhatsAppMessagePayload({
|
||||
targetInbox: props.targetInbox,
|
||||
selectedContact: props.selectedContact,
|
||||
message,
|
||||
templateParams,
|
||||
currentUser: props.currentUser,
|
||||
});
|
||||
await emit('createConversation', {
|
||||
payload: twilioMessagePayload,
|
||||
isFromWhatsApp: true,
|
||||
});
|
||||
};
|
||||
|
||||
const shouldShowMessageEditor = computed(() => {
|
||||
return (
|
||||
!inboxTypes.value.isWhatsapp &&
|
||||
!showNoInboxAlert.value &&
|
||||
!inboxTypes.value.isTwilioWhatsapp
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -311,7 +336,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
/>
|
||||
|
||||
<MessageEditor
|
||||
v-if="!inboxTypes.isWhatsapp && !showNoInboxAlert"
|
||||
v-if="shouldShowMessageEditor"
|
||||
v-model="state.message"
|
||||
:message-signature="messageSignature"
|
||||
:send-with-signature="sendWithSignature"
|
||||
@@ -331,6 +356,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
:is-whatsapp-inbox="inboxTypes.isWhatsapp"
|
||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||
:is-twilio-sms-inbox="inboxTypes.isTwilioSMS"
|
||||
:is-twilio-whats-app-inbox="inboxTypes.isTwilioWhatsapp"
|
||||
:message-templates="whatsappMessageTemplates"
|
||||
:channel-type="inboxChannelType"
|
||||
:is-loading="isCreating"
|
||||
@@ -347,6 +373,7 @@ const handleSendWhatsappMessage = async ({ message, templateParams }) => {
|
||||
@discard="$emit('discard')"
|
||||
@send-message="handleSendMessage"
|
||||
@send-whatsapp-message="handleSendWhatsappMessage"
|
||||
@send-twilio-message="handleSendTwilioMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
+56
@@ -0,0 +1,56 @@
|
||||
<script setup>
|
||||
import ContentTemplateParser from 'dashboard/components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'back']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const handleSendMessage = payload => {
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute top-full mt-1.5 max-h-[30rem] overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-4 px-4 pt-6 pb-5 items-start w-[28.75rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<ContentTemplateParser
|
||||
:template="template"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
>
|
||||
<template #actions="{ sendMessage, goBack, disabled }">
|
||||
<div class="flex gap-3 justify-between items-end w-full h-14">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.BACK_BUTTON')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="w-full font-medium"
|
||||
@click="goBack"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.FORM.SEND_MESSAGE_BUTTON')"
|
||||
class="w-full font-medium"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</ContentTemplateParser>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import ContentTemplateForm from './ContentTemplateForm.vue';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const inbox = useMapGetter('inboxes/getInbox');
|
||||
|
||||
const searchQuery = ref('');
|
||||
const selectedTemplate = ref(null);
|
||||
const showTemplatesMenu = ref(false);
|
||||
|
||||
const contentTemplates = computed(() => {
|
||||
const inboxData = inbox.value(props.inboxId);
|
||||
return inboxData?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplates = computed(() => {
|
||||
return contentTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(searchQuery.value.toLowerCase()) &&
|
||||
template.status === 'approved'
|
||||
);
|
||||
});
|
||||
|
||||
const handleTriggerClick = () => {
|
||||
searchQuery.value = '';
|
||||
showTemplatesMenu.value = !showTemplatesMenu.value;
|
||||
};
|
||||
|
||||
const handleTemplateClick = template => {
|
||||
selectedTemplate.value = template;
|
||||
showTemplatesMenu.value = false;
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
selectedTemplate.value = null;
|
||||
showTemplatesMenu.value = true;
|
||||
};
|
||||
|
||||
const handleSendMessage = template => {
|
||||
emit('sendMessage', template);
|
||||
selectedTemplate.value = null;
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="relative">
|
||||
<Button
|
||||
icon="i-ph-whatsapp-logo"
|
||||
:label="t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.LABEL')"
|
||||
color="slate"
|
||||
size="sm"
|
||||
:disabled="selectedTemplate"
|
||||
class="!text-xs font-medium"
|
||||
@click="handleTriggerClick"
|
||||
/>
|
||||
<div
|
||||
v-if="showTemplatesMenu"
|
||||
class="absolute top-full mt-1.5 max-h-96 overflow-y-auto ltr:left-0 rtl:right-0 flex flex-col gap-2 p-4 items-center w-[21.875rem] h-auto bg-n-solid-2 border border-n-strong shadow-sm rounded-lg"
|
||||
>
|
||||
<div class="w-full">
|
||||
<Input
|
||||
v-model="searchQuery"
|
||||
type="search"
|
||||
:placeholder="
|
||||
t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.SEARCH_PLACEHOLDER')
|
||||
"
|
||||
custom-input-class="ltr:pl-10 rtl:pr-10"
|
||||
>
|
||||
<template #prefix>
|
||||
<Icon
|
||||
icon="i-lucide-search"
|
||||
class="absolute top-2 size-3.5 ltr:left-3 rtl:right-3"
|
||||
/>
|
||||
</template>
|
||||
</Input>
|
||||
</div>
|
||||
<div
|
||||
v-for="template in filteredTemplates"
|
||||
:key="template.content_sid"
|
||||
tabindex="0"
|
||||
class="flex flex-col gap-2 p-2 w-full rounded-lg cursor-pointer dark:hover:bg-n-alpha-3 hover:bg-n-alpha-1"
|
||||
@click="handleTemplateClick(template)"
|
||||
>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-sm text-n-slate-12">{{
|
||||
template.friendly_name
|
||||
}}</span>
|
||||
</div>
|
||||
<p class="mb-0 text-xs leading-5 text-n-slate-11 line-clamp-2">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
<template v-if="filteredTemplates.length === 0">
|
||||
<p class="pt-2 w-full text-sm text-n-slate-11">
|
||||
{{ t('COMPOSE_NEW_CONVERSATION.FORM.TWILIO_OPTIONS.EMPTY_STATE') }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
<ContentTemplateForm
|
||||
v-if="selectedTemplate"
|
||||
:template="selectedTemplate"
|
||||
@send-message="handleSendMessage"
|
||||
@back="handleBack"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
+2
-1
@@ -25,7 +25,7 @@ export const generateLabelForContactableInboxesList = ({
|
||||
channelType === INBOX_TYPES.TWILIO ||
|
||||
channelType === INBOX_TYPES.WHATSAPP
|
||||
) {
|
||||
return `${name} (${phoneNumber})`;
|
||||
return phoneNumber ? `${name} (${phoneNumber})` : name;
|
||||
}
|
||||
return name;
|
||||
};
|
||||
@@ -53,6 +53,7 @@ const transformInbox = ({
|
||||
email,
|
||||
phoneNumber,
|
||||
channelType,
|
||||
medium,
|
||||
...rest,
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, watch } from 'vue';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { requiredIf } from '@vuelidate/validators';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { extractFilenameFromUrl } from 'dashboard/helper/URLHelper';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
|
||||
const props = defineProps({
|
||||
template: {
|
||||
type: Object,
|
||||
default: () => ({}),
|
||||
validator: value => {
|
||||
if (!value || typeof value !== 'object') return false;
|
||||
if (!value.friendly_name) return false;
|
||||
return true;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['sendMessage', 'resetTemplate', 'back']);
|
||||
|
||||
const VARIABLE_PATTERN = /{{([^}]+)}}/g;
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const processedParams = ref({});
|
||||
|
||||
const languageLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.LANGUAGE')}: ${props.template.language || 'en'}`;
|
||||
});
|
||||
|
||||
const categoryLabel = computed(() => {
|
||||
return `${t('CONTENT_TEMPLATES.PARSER.CATEGORY')}: ${props.template.category || 'utility'}`;
|
||||
});
|
||||
|
||||
const templateBody = computed(() => {
|
||||
return props.template.body || '';
|
||||
});
|
||||
|
||||
const hasMediaTemplate = computed(() => {
|
||||
return props.template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA;
|
||||
});
|
||||
|
||||
const hasVariables = computed(() => {
|
||||
return templateBody.value?.match(VARIABLE_PATTERN) !== null;
|
||||
});
|
||||
|
||||
const mediaVariableKey = computed(() => {
|
||||
if (!hasMediaTemplate.value) return null;
|
||||
const mediaUrl = props.template?.types?.['twilio/media']?.media?.[0];
|
||||
if (!mediaUrl) return null;
|
||||
return mediaUrl.match(/{{(\d+)}}/)?.[1] ?? null;
|
||||
});
|
||||
|
||||
const hasMediaVariable = computed(() => {
|
||||
return hasMediaTemplate.value && mediaVariableKey.value !== null;
|
||||
});
|
||||
|
||||
const templateMediaUrl = computed(() => {
|
||||
if (!hasMediaTemplate.value) return '';
|
||||
|
||||
return props.template?.types?.['twilio/media']?.media?.[0] || '';
|
||||
});
|
||||
|
||||
const variablePattern = computed(() => {
|
||||
if (!hasVariables.value) return [];
|
||||
const matches = templateBody.value.match(VARIABLE_PATTERN) || [];
|
||||
return matches.map(match => match.replace(/[{}]/g, ''));
|
||||
});
|
||||
|
||||
const renderedTemplate = computed(() => {
|
||||
let rendered = templateBody.value;
|
||||
if (processedParams.value && Object.keys(processedParams.value).length > 0) {
|
||||
// Replace variables in the format {{1}}, {{2}}, etc.
|
||||
rendered = rendered.replace(VARIABLE_PATTERN, (match, variable) => {
|
||||
const cleanVariable = variable.trim();
|
||||
return processedParams.value[cleanVariable] || match;
|
||||
});
|
||||
}
|
||||
return rendered;
|
||||
});
|
||||
|
||||
const isFormInvalid = computed(() => {
|
||||
if (!hasVariables.value && !hasMediaVariable.value) return false;
|
||||
|
||||
if (hasVariables.value) {
|
||||
const hasEmptyVariable = variablePattern.value.some(
|
||||
variable => !processedParams.value[variable]
|
||||
);
|
||||
if (hasEmptyVariable) return true;
|
||||
}
|
||||
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
!processedParams.value[mediaVariableKey.value]
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
|
||||
const v$ = useVuelidate(
|
||||
{
|
||||
processedParams: {
|
||||
requiredIfKeysPresent: requiredIf(
|
||||
() => hasVariables.value || hasMediaVariable.value
|
||||
),
|
||||
},
|
||||
},
|
||||
{ processedParams }
|
||||
);
|
||||
|
||||
const initializeTemplateParameters = () => {
|
||||
processedParams.value = {};
|
||||
|
||||
if (hasVariables.value) {
|
||||
variablePattern.value.forEach(variable => {
|
||||
processedParams.value[variable] = '';
|
||||
});
|
||||
}
|
||||
|
||||
if (hasMediaVariable.value && mediaVariableKey.value) {
|
||||
processedParams.value[mediaVariableKey.value] = '';
|
||||
}
|
||||
};
|
||||
|
||||
const sendMessage = () => {
|
||||
v$.value.$touch();
|
||||
if (v$.value.$invalid || isFormInvalid.value) return;
|
||||
|
||||
const { friendly_name, language } = props.template;
|
||||
|
||||
// Process parameters and extract filename from media URL if needed
|
||||
const processedParameters = { ...processedParams.value };
|
||||
|
||||
// For media templates, extract filename from full URL
|
||||
if (
|
||||
hasMediaVariable.value &&
|
||||
mediaVariableKey.value &&
|
||||
processedParameters[mediaVariableKey.value]
|
||||
) {
|
||||
processedParameters[mediaVariableKey.value] = extractFilenameFromUrl(
|
||||
processedParameters[mediaVariableKey.value]
|
||||
);
|
||||
}
|
||||
|
||||
const payload = {
|
||||
message: renderedTemplate.value,
|
||||
templateParams: {
|
||||
name: friendly_name,
|
||||
language,
|
||||
processed_params: processedParameters,
|
||||
},
|
||||
};
|
||||
emit('sendMessage', payload);
|
||||
};
|
||||
|
||||
const resetTemplate = () => {
|
||||
emit('resetTemplate');
|
||||
};
|
||||
|
||||
const goBack = () => {
|
||||
emit('back');
|
||||
};
|
||||
|
||||
onMounted(initializeTemplateParameters);
|
||||
|
||||
watch(
|
||||
() => props.template,
|
||||
() => {
|
||||
initializeTemplateParameters();
|
||||
v$.value.$reset();
|
||||
},
|
||||
{ deep: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
processedParams,
|
||||
hasVariables,
|
||||
hasMediaTemplate,
|
||||
renderedTemplate,
|
||||
v$,
|
||||
sendMessage,
|
||||
resetTemplate,
|
||||
goBack,
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<div class="flex flex-col gap-4 p-4 mb-4 rounded-lg bg-n-alpha-black2">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-sm font-medium text-n-slate-12">
|
||||
{{ template.friendly_name }}
|
||||
</h3>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ languageLabel }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<div class="rounded-md">
|
||||
<div class="text-sm whitespace-pre-wrap text-n-slate-12">
|
||||
{{ renderedTemplate }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ categoryLabel }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="hasVariables || hasMediaVariable">
|
||||
<!-- Media URL for media templates -->
|
||||
<div v-if="hasMediaVariable" class="mb-4">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_LABEL') }}
|
||||
</p>
|
||||
<div class="flex items-center mb-2.5">
|
||||
<Input
|
||||
v-model="processedParams[mediaVariableKey]"
|
||||
type="url"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
templateMediaUrl ||
|
||||
t('CONTENT_TEMPLATES.PARSER.MEDIA_URL_PLACEHOLDER')
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body Variables Section -->
|
||||
<div v-if="hasVariables">
|
||||
<p class="mb-2.5 text-sm font-semibold">
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.VARIABLES_LABEL') }}
|
||||
</p>
|
||||
<div
|
||||
v-for="variable in variablePattern"
|
||||
:key="`variable-${variable}`"
|
||||
class="flex items-center mb-2.5"
|
||||
>
|
||||
<Input
|
||||
v-model="processedParams[variable]"
|
||||
type="text"
|
||||
class="flex-1"
|
||||
:placeholder="
|
||||
t('CONTENT_TEMPLATES.PARSER.VARIABLE_PLACEHOLDER', {
|
||||
variable: variable,
|
||||
})
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
v-if="v$.$dirty && (v$.$invalid || isFormInvalid)"
|
||||
class="p-2.5 text-center rounded-md bg-n-ruby-9/20 text-n-ruby-9"
|
||||
>
|
||||
{{ $t('CONTENT_TEMPLATES.PARSER.FORM_ERROR_MESSAGE') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<slot
|
||||
name="actions"
|
||||
:send-message="sendMessage"
|
||||
:reset-template="resetTemplate"
|
||||
:go-back="goBack"
|
||||
:is-valid="!v$.$invalid && !isFormInvalid"
|
||||
:disabled="isFormInvalid"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -95,6 +95,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
enableContentTemplates: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
conversationId: {
|
||||
type: Number,
|
||||
required: true,
|
||||
@@ -121,6 +125,7 @@ export default {
|
||||
'toggleInsertArticle',
|
||||
'toggleEditor',
|
||||
'selectWhatsappTemplate',
|
||||
'selectContentTemplate',
|
||||
],
|
||||
setup() {
|
||||
const { setSignatureFlagForInbox, fetchSignatureFlagFromUISettings } =
|
||||
@@ -347,6 +352,15 @@ export default {
|
||||
sm
|
||||
@click="$emit('selectWhatsappTemplate')"
|
||||
/>
|
||||
<NextButton
|
||||
v-if="enableContentTemplates"
|
||||
v-tooltip.top-end="'Content Templates'"
|
||||
icon="i-ph-whatsapp-logo"
|
||||
slate
|
||||
faded
|
||||
sm
|
||||
@click="$emit('selectContentTemplate')"
|
||||
/>
|
||||
<VideoCallButton
|
||||
v-if="(isAWebWidgetInbox || isAPIInbox) && !isOnPrivateNote"
|
||||
:conversation-id="conversationId"
|
||||
@@ -361,7 +375,7 @@ export default {
|
||||
<transition name="modal-fade">
|
||||
<div
|
||||
v-show="uploadRef && uploadRef.dropActive"
|
||||
class="fixed top-0 bottom-0 left-0 right-0 z-20 flex flex-col items-center justify-center w-full h-full gap-2 text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
class="flex fixed top-0 right-0 bottom-0 left-0 z-20 flex-col gap-2 justify-center items-center w-full h-full text-n-slate-12 bg-modal-backdrop-light dark:bg-modal-backdrop-dark"
|
||||
>
|
||||
<fluent-icon icon="cloud-backup" size="40" />
|
||||
<h4 class="text-2xl break-words text-n-slate-12">
|
||||
|
||||
+97
@@ -0,0 +1,97 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import TemplatesPicker from './ContentTemplatesPicker.vue';
|
||||
import TemplateParser from '../../../../components-next/content-templates/ContentTemplateParser.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSend', 'cancel', 'update:show']);
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const selectedContentTemplate = ref(null);
|
||||
|
||||
const localShow = computed({
|
||||
get() {
|
||||
return props.show;
|
||||
},
|
||||
set(value) {
|
||||
emit('update:show', value);
|
||||
},
|
||||
});
|
||||
|
||||
const modalHeaderContent = computed(() => {
|
||||
return selectedContentTemplate.value
|
||||
? t('CONTENT_TEMPLATES.MODAL.TEMPLATE_SELECTED_SUBTITLE', {
|
||||
templateName: selectedContentTemplate.value.friendly_name,
|
||||
})
|
||||
: t('CONTENT_TEMPLATES.MODAL.SUBTITLE');
|
||||
});
|
||||
|
||||
const pickTemplate = template => {
|
||||
selectedContentTemplate.value = template;
|
||||
};
|
||||
|
||||
const onResetTemplate = () => {
|
||||
selectedContentTemplate.value = null;
|
||||
};
|
||||
|
||||
const onSendMessage = message => {
|
||||
emit('onSend', message);
|
||||
};
|
||||
|
||||
const onClose = () => {
|
||||
emit('cancel');
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<woot-modal v-model:show="localShow" :on-close="onClose" size="modal-big">
|
||||
<woot-modal-header
|
||||
:header-title="$t('CONTENT_TEMPLATES.MODAL.TITLE')"
|
||||
:header-content="modalHeaderContent"
|
||||
/>
|
||||
<div class="px-8 py-6 row">
|
||||
<TemplatesPicker
|
||||
v-if="!selectedContentTemplate"
|
||||
:inbox-id="inboxId"
|
||||
@on-select="pickTemplate"
|
||||
/>
|
||||
<TemplateParser
|
||||
v-else
|
||||
:template="selectedContentTemplate"
|
||||
@reset-template="onResetTemplate"
|
||||
@send-message="onSendMessage"
|
||||
>
|
||||
<template #actions="{ sendMessage, resetTemplate, disabled }">
|
||||
<div class="flex gap-2 mt-6">
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.GO_BACK_LABEL')"
|
||||
color="slate"
|
||||
variant="faded"
|
||||
class="flex-1"
|
||||
@click="resetTemplate"
|
||||
/>
|
||||
<Button
|
||||
:label="t('CONTENT_TEMPLATES.PARSER.SEND_MESSAGE_LABEL')"
|
||||
class="flex-1"
|
||||
:disabled="disabled"
|
||||
@click="sendMessage"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</TemplateParser>
|
||||
</div>
|
||||
</woot-modal>
|
||||
</template>
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useStore } from 'dashboard/composables/store';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { TWILIO_CONTENT_TEMPLATE_TYPES } from 'shared/constants/messages';
|
||||
|
||||
const props = defineProps({
|
||||
inboxId: {
|
||||
type: Number,
|
||||
default: undefined,
|
||||
},
|
||||
});
|
||||
|
||||
const emit = defineEmits(['onSelect']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useStore();
|
||||
const query = ref('');
|
||||
const isRefreshing = ref(false);
|
||||
|
||||
const twilioTemplates = computed(() => {
|
||||
const inbox = store.getters['inboxes/getInbox'](props.inboxId);
|
||||
return inbox?.content_templates?.templates || [];
|
||||
});
|
||||
|
||||
const filteredTemplateMessages = computed(() =>
|
||||
twilioTemplates.value.filter(
|
||||
template =>
|
||||
template.friendly_name
|
||||
.toLowerCase()
|
||||
.includes(query.value.toLowerCase()) && template.status === 'approved'
|
||||
)
|
||||
);
|
||||
|
||||
const getTemplateType = template => {
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.MEDIA) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.MEDIA');
|
||||
}
|
||||
if (template.template_type === TWILIO_CONTENT_TEMPLATE_TYPES.QUICK_REPLY) {
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.QUICK_REPLY');
|
||||
}
|
||||
return t('CONTENT_TEMPLATES.PICKER.TYPES.TEXT');
|
||||
};
|
||||
|
||||
const refreshTemplates = async () => {
|
||||
isRefreshing.value = true;
|
||||
try {
|
||||
await store.dispatch('inboxes/syncTemplates', props.inboxId);
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONTENT_TEMPLATES.PICKER.REFRESH_ERROR'));
|
||||
} finally {
|
||||
isRefreshing.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="w-full">
|
||||
<div class="flex gap-2 mb-2.5">
|
||||
<div
|
||||
class="flex flex-1 gap-1 items-center px-2.5 py-0 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 focus-within:outline-n-brand dark:focus-within:outline-n-brand"
|
||||
>
|
||||
<fluent-icon icon="search" class="text-n-slate-12" size="16" />
|
||||
<input
|
||||
v-model="query"
|
||||
type="search"
|
||||
:placeholder="t('CONTENT_TEMPLATES.PICKER.SEARCH_PLACEHOLDER')"
|
||||
class="reset-base w-full h-9 bg-transparent text-n-slate-12 !text-sm !outline-0"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isRefreshing"
|
||||
class="flex justify-center items-center w-9 h-9 rounded-lg bg-n-alpha-black2 outline outline-1 outline-n-weak hover:outline-n-slate-6 dark:hover:outline-n-slate-6 hover:bg-n-alpha-2 dark:hover:bg-n-solid-2 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="t('CONTENT_TEMPLATES.PICKER.REFRESH_BUTTON')"
|
||||
@click="refreshTemplates"
|
||||
>
|
||||
<Icon
|
||||
icon="i-lucide-refresh-ccw"
|
||||
class="text-n-slate-12 size-4"
|
||||
:class="{ 'animate-spin': isRefreshing }"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="bg-n-background outline-n-container outline outline-1 rounded-lg max-h-[18.75rem] overflow-y-auto p-2.5"
|
||||
>
|
||||
<div
|
||||
v-for="(template, i) in filteredTemplateMessages"
|
||||
:key="template.content_sid"
|
||||
>
|
||||
<button
|
||||
class="block p-2.5 w-full text-left rounded-lg cursor-pointer hover:bg-n-alpha-2 dark:hover:bg-n-solid-2"
|
||||
@click="emit('onSelect', template)"
|
||||
>
|
||||
<div>
|
||||
<div class="flex justify-between items-center mb-2.5">
|
||||
<p class="text-sm">
|
||||
{{ template.friendly_name }}
|
||||
</p>
|
||||
<div class="flex gap-2">
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{ getTemplateType(template) }}
|
||||
</span>
|
||||
<span
|
||||
class="inline-block px-2 py-1 text-xs leading-none rounded-lg cursor-default bg-n-slate-3 text-n-slate-12"
|
||||
>
|
||||
{{
|
||||
`${t('CONTENT_TEMPLATES.PICKER.LABELS.LANGUAGE')}: ${template.language}`
|
||||
}}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Body -->
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.BODY') }}
|
||||
</p>
|
||||
<p class="text-sm label-body">
|
||||
{{ template.body || t('CONTENT_TEMPLATES.PICKER.NO_CONTENT') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between items-center mt-3">
|
||||
<div>
|
||||
<p class="text-xs font-medium text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.LABELS.CATEGORY') }}
|
||||
</p>
|
||||
<p class="text-sm">{{ template.category || 'utility' }}</p>
|
||||
</div>
|
||||
<div class="text-xs text-n-slate-11">
|
||||
{{ new Date(template.created_at).toLocaleDateString() }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<hr
|
||||
v-if="i != filteredTemplateMessages.length - 1"
|
||||
:key="`hr-${i}`"
|
||||
class="border-b border-solid border-n-weak my-2.5 mx-auto max-w-[95%]"
|
||||
/>
|
||||
</div>
|
||||
<div v-if="!filteredTemplateMessages.length" class="py-8 text-center">
|
||||
<div v-if="query && twilioTemplates.length">
|
||||
<p>
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_FOUND') }}
|
||||
<strong>{{ query }}</strong>
|
||||
</p>
|
||||
</div>
|
||||
<div v-else-if="!twilioTemplates.length" class="space-y-4">
|
||||
<p class="text-n-slate-11">
|
||||
{{ t('CONTENT_TEMPLATES.PICKER.NO_TEMPLATES_AVAILABLE') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.label-body {
|
||||
font-family: monospace;
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@ import ReplyEmailHead from './ReplyEmailHead.vue';
|
||||
import ReplyBottomPanel from 'dashboard/components/widgets/WootWriter/ReplyBottomPanel.vue';
|
||||
import ArticleSearchPopover from 'dashboard/routes/dashboard/helpcenter/components/ArticleSearch/SearchPopover.vue';
|
||||
import MessageSignatureMissingAlert from './MessageSignatureMissingAlert.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import ReplyBoxBanner from './ReplyBoxBanner.vue';
|
||||
import { REPLY_EDITOR_MODES } from 'dashboard/components/widgets/WootWriter/constants';
|
||||
import WootMessageEditor from 'dashboard/components/widgets/WootWriter/Editor.vue';
|
||||
import AudioRecorder from 'dashboard/components/widgets/WootWriter/AudioRecorder.vue';
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
replaceVariablesInMessage,
|
||||
} from '@chatwoot/utils';
|
||||
import WhatsappTemplates from './WhatsappTemplates/Modal.vue';
|
||||
import ContentTemplates from './ContentTemplates/ContentTemplatesModal.vue';
|
||||
import { MESSAGE_MAX_LENGTH } from 'shared/helpers/MessageTypeHelper';
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
import { trimContent, debounce, getRecipients } from '@chatwoot/utils';
|
||||
@@ -52,8 +53,8 @@ export default {
|
||||
ArticleSearchPopover,
|
||||
AttachmentPreview,
|
||||
AudioRecorder,
|
||||
Banner,
|
||||
CannedResponse,
|
||||
ReplyBoxBanner,
|
||||
EmojiInput,
|
||||
MessageSignatureMissingAlert,
|
||||
ReplyBottomPanel,
|
||||
@@ -61,6 +62,7 @@ export default {
|
||||
ReplyToMessage,
|
||||
ReplyTopPanel,
|
||||
ResizableTextArea,
|
||||
ContentTemplates,
|
||||
WhatsappTemplates,
|
||||
WootMessageEditor,
|
||||
},
|
||||
@@ -109,6 +111,7 @@ export default {
|
||||
toEmails: '',
|
||||
doAutoSaveDraft: () => {},
|
||||
showWhatsAppTemplatesModal: false,
|
||||
showContentTemplatesModal: false,
|
||||
updateEditorSelectionWith: '',
|
||||
undefinedVariableMessage: '',
|
||||
showMentions: false,
|
||||
@@ -155,38 +158,12 @@ export default {
|
||||
|
||||
return false;
|
||||
},
|
||||
assignedAgent: {
|
||||
get() {
|
||||
return this.currentChat.meta.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
this.$store.dispatch('setCurrentChatAssignee', agent);
|
||||
this.$store
|
||||
.dispatch('assignAgent', {
|
||||
conversationId: this.currentChat.id,
|
||||
agentId,
|
||||
})
|
||||
.then(() => {
|
||||
useAlert(this.$t('CONVERSATION.CHANGE_AGENT'));
|
||||
});
|
||||
},
|
||||
},
|
||||
showSelfAssignBanner() {
|
||||
if (this.message !== '' && !this.isOnPrivateNote) {
|
||||
if (!this.assignedAgent) {
|
||||
return true;
|
||||
}
|
||||
if (this.assignedAgent.id !== this.currentUser.id) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
return this.isOnPrivateNote;
|
||||
@@ -659,28 +636,11 @@ export default {
|
||||
hideWhatsappTemplatesModal() {
|
||||
this.showWhatsAppTemplatesModal = false;
|
||||
},
|
||||
onClickSelfAssign() {
|
||||
const {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
avatar_url,
|
||||
} = this.currentUser;
|
||||
const selfAssign = {
|
||||
account_id,
|
||||
availability_status,
|
||||
available_name,
|
||||
email,
|
||||
id,
|
||||
name,
|
||||
role,
|
||||
thumbnail: avatar_url,
|
||||
};
|
||||
this.assignedAgent = selfAssign;
|
||||
openContentTemplateModal() {
|
||||
this.showContentTemplatesModal = true;
|
||||
},
|
||||
hideContentTemplatesModal() {
|
||||
this.showContentTemplatesModal = false;
|
||||
},
|
||||
confirmOnSendReply() {
|
||||
if (this.isReplyButtonDisabled) {
|
||||
@@ -774,6 +734,13 @@ export default {
|
||||
});
|
||||
this.hideWhatsappTemplatesModal();
|
||||
},
|
||||
async onSendContentTemplateReply(messagePayload) {
|
||||
this.sendMessage({
|
||||
conversationId: this.currentChat.id,
|
||||
...messagePayload,
|
||||
});
|
||||
this.hideContentTemplatesModal();
|
||||
},
|
||||
replaceText(message) {
|
||||
if (this.sendWithSignature && !this.private) {
|
||||
// if signature is enabled, append it to the message
|
||||
@@ -1099,16 +1066,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg banner--self-assign"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<ReplyBoxBanner :message="message" :is-on-private-note="isOnPrivateNote" />
|
||||
<div ref="replyEditor" class="reply-box" :class="replyBoxClass">
|
||||
<ReplyTopPanel
|
||||
:mode="replyType"
|
||||
@@ -1217,6 +1175,7 @@ export default {
|
||||
:conversation-id="conversationId"
|
||||
:enable-multiple-file-upload="enableMultipleFileUpload"
|
||||
:enable-whats-app-templates="showWhatsappTemplates"
|
||||
:enable-content-templates="showContentTemplates"
|
||||
:inbox="inbox"
|
||||
:is-on-private-note="isOnPrivateNote"
|
||||
:is-recording-audio="isRecordingAudio"
|
||||
@@ -1239,6 +1198,7 @@ export default {
|
||||
:portal-slug="connectedPortalSlug"
|
||||
:new-conversation-modal-active="newConversationModalActive"
|
||||
@select-whatsapp-template="openWhatsappTemplateModal"
|
||||
@select-content-template="openContentTemplateModal"
|
||||
@toggle-editor="toggleRichContentEditor"
|
||||
@replace-text="replaceText"
|
||||
@toggle-insert-article="toggleInsertArticle"
|
||||
@@ -1251,6 +1211,14 @@ export default {
|
||||
@cancel="hideWhatsappTemplatesModal"
|
||||
/>
|
||||
|
||||
<ContentTemplates
|
||||
:inbox-id="inbox.id"
|
||||
:show="showContentTemplatesModal"
|
||||
@close="hideContentTemplatesModal"
|
||||
@on-send="onSendContentTemplateReply"
|
||||
@cancel="hideContentTemplatesModal"
|
||||
/>
|
||||
|
||||
<woot-confirm-modal
|
||||
ref="confirmDialog"
|
||||
:title="$t('CONVERSATION.REPLYBOX.UNDEFINED_VARIABLES.TITLE')"
|
||||
@@ -1264,10 +1232,6 @@ export default {
|
||||
@apply mb-0;
|
||||
}
|
||||
|
||||
.banner--self-assign {
|
||||
@apply py-2;
|
||||
}
|
||||
|
||||
.attachment-preview-box {
|
||||
@apply bg-transparent py-0 px-4;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
|
||||
const props = defineProps({
|
||||
message: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
isOnPrivateNote: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useStore();
|
||||
const { t } = useI18n();
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUser = useMapGetter('getCurrentUser');
|
||||
|
||||
const assignedAgent = computed({
|
||||
get() {
|
||||
return currentChat.value?.meta?.assignee;
|
||||
},
|
||||
set(agent) {
|
||||
const agentId = agent ? agent.id : 0;
|
||||
store.dispatch('setCurrentChatAssignee', agent);
|
||||
store.dispatch('assignAgent', {
|
||||
conversationId: currentChat.value?.id,
|
||||
agentId,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const isUserTyping = computed(
|
||||
() => props.message !== '' && !props.isOnPrivateNote
|
||||
);
|
||||
const isUnassigned = computed(() => !assignedAgent.value);
|
||||
const isAssignedToOtherAgent = computed(
|
||||
() => assignedAgent.value?.id !== currentUser.value?.id
|
||||
);
|
||||
|
||||
const showSelfAssignBanner = computed(() => {
|
||||
return (
|
||||
isUserTyping.value && (isUnassigned.value || isAssignedToOtherAgent.value)
|
||||
);
|
||||
});
|
||||
|
||||
const showBotHandoffBanner = computed(
|
||||
() =>
|
||||
isUserTyping.value &&
|
||||
currentChat.value?.status === wootConstants.STATUS_TYPE.PENDING
|
||||
);
|
||||
|
||||
const botHandoffActionLabel = computed(() => {
|
||||
return assignedAgent.value?.id === currentUser.value?.id
|
||||
? t('CONVERSATION.BOT_HANDOFF_REOPEN_ACTION')
|
||||
: t('CONVERSATION.BOT_HANDOFF_ACTION');
|
||||
});
|
||||
|
||||
const selfAssignConversation = async () => {
|
||||
const { avatar_url, ...rest } = currentUser.value || {};
|
||||
assignedAgent.value = { ...rest, thumbnail: avatar_url };
|
||||
};
|
||||
|
||||
const needsAssignmentToCurrentUser = computed(() => {
|
||||
return isUnassigned.value || isAssignedToOtherAgent.value;
|
||||
});
|
||||
|
||||
const onClickSelfAssign = async () => {
|
||||
try {
|
||||
await selfAssignConversation();
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.CHANGE_AGENT_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const reopenConversation = async () => {
|
||||
await store.dispatch('toggleStatus', {
|
||||
conversationId: currentChat.value?.id,
|
||||
status: wootConstants.STATUS_TYPE.OPEN,
|
||||
});
|
||||
};
|
||||
|
||||
const onClickBotHandoff = async () => {
|
||||
try {
|
||||
await reopenConversation();
|
||||
|
||||
if (needsAssignmentToCurrentUser.value) {
|
||||
await selfAssignConversation();
|
||||
}
|
||||
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_SUCCESS'));
|
||||
} catch (error) {
|
||||
useAlert(t('CONVERSATION.BOT_HANDOFF_ERROR'));
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Banner
|
||||
v-if="showSelfAssignBanner && !showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.NOT_ASSIGNED_TO_YOU')"
|
||||
has-action-button
|
||||
:action-button-label="$t('CONVERSATION.ASSIGN_TO_ME')"
|
||||
@primary-action="onClickSelfAssign"
|
||||
/>
|
||||
<Banner
|
||||
v-if="showBotHandoffBanner"
|
||||
action-button-variant="ghost"
|
||||
color-scheme="secondary"
|
||||
class="mx-2 mb-2 rounded-lg !py-2"
|
||||
:banner-message="$t('CONVERSATION.BOT_HANDOFF_MESSAGE')"
|
||||
has-action-button
|
||||
:action-button-label="botHandoffActionLabel"
|
||||
@primary-action="onClickBotHandoff"
|
||||
/>
|
||||
</template>
|
||||
@@ -125,3 +125,23 @@ export const getHostNameFromURL = url => {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Extracts filename from a URL
|
||||
* @param {string} url - The URL to extract filename from
|
||||
* @returns {string} - The extracted filename or original URL if extraction fails
|
||||
*/
|
||||
export const extractFilenameFromUrl = url => {
|
||||
if (!url || typeof url !== 'string') return url;
|
||||
|
||||
try {
|
||||
const urlObj = new URL(url);
|
||||
const pathname = urlObj.pathname;
|
||||
const filename = pathname.split('/').pop();
|
||||
return filename || url;
|
||||
} catch (error) {
|
||||
// If URL parsing fails, try to extract filename using regex
|
||||
const match = url.match(/\/([^/?#]+)(?:[?#]|$)/);
|
||||
return match ? match[1] : url;
|
||||
}
|
||||
};
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
hasValidAvatarUrl,
|
||||
timeStampAppendedURL,
|
||||
getHostNameFromURL,
|
||||
extractFilenameFromUrl,
|
||||
} from '../URLHelper';
|
||||
|
||||
describe('#URL Helpers', () => {
|
||||
@@ -263,4 +264,58 @@ describe('#URL Helpers', () => {
|
||||
expect(getHostNameFromURL('https://chatwoot.help')).toBe('chatwoot.help');
|
||||
});
|
||||
});
|
||||
|
||||
describe('extractFilenameFromUrl', () => {
|
||||
it('should extract filename from a valid URL', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/path/to/file.jpg')
|
||||
).toBe('file.jpg');
|
||||
expect(extractFilenameFromUrl('https://example.com/image.png')).toBe(
|
||||
'image.png'
|
||||
);
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/folder/document.pdf?query=1'
|
||||
)
|
||||
).toBe('document.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.txt#section')
|
||||
).toBe('file.txt');
|
||||
});
|
||||
|
||||
it('should handle URLs without filename', () => {
|
||||
expect(extractFilenameFromUrl('https://example.com/')).toBe(
|
||||
'https://example.com/'
|
||||
);
|
||||
expect(extractFilenameFromUrl('https://example.com')).toBe(
|
||||
'https://example.com'
|
||||
);
|
||||
});
|
||||
|
||||
it('should handle invalid URLs gracefully', () => {
|
||||
expect(extractFilenameFromUrl('not-a-url/file.txt')).toBe('file.txt');
|
||||
expect(extractFilenameFromUrl('invalid-url')).toBe('invalid-url');
|
||||
});
|
||||
|
||||
it('should handle edge cases', () => {
|
||||
expect(extractFilenameFromUrl('')).toBe('');
|
||||
expect(extractFilenameFromUrl(null)).toBe(null);
|
||||
expect(extractFilenameFromUrl(undefined)).toBe(undefined);
|
||||
expect(extractFilenameFromUrl(123)).toBe(123);
|
||||
});
|
||||
|
||||
it('should handle URLs with query parameters and fragments', () => {
|
||||
expect(
|
||||
extractFilenameFromUrl(
|
||||
'https://example.com/file.jpg?size=large&format=png'
|
||||
)
|
||||
).toBe('file.jpg');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.pdf#page=1')
|
||||
).toBe('file.pdf');
|
||||
expect(
|
||||
extractFilenameFromUrl('https://example.com/file.doc?v=1#section')
|
||||
).toBe('file.doc');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -612,6 +612,15 @@
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"TWILIO_OPTIONS": {
|
||||
"LABEL": "Select template",
|
||||
"SEARCH_PLACEHOLDER": "Search templates",
|
||||
"EMPTY_STATE": "No templates found",
|
||||
"TEMPLATE_PARSER": {
|
||||
"BACK": "Go back",
|
||||
"SEND_MESSAGE": "Send message"
|
||||
}
|
||||
},
|
||||
"ACTION_BUTTONS": {
|
||||
"DISCARD": "Discard",
|
||||
"SEND": "Send ({keyCode})"
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
{
|
||||
"CONTENT_TEMPLATES": {
|
||||
"MODAL": {
|
||||
"TITLE": "Twilio Templates",
|
||||
"SUBTITLE": "Select the Twilio template you want to send",
|
||||
"TEMPLATE_SELECTED_SUBTITLE": "Configure template: {templateName}"
|
||||
},
|
||||
"PICKER": {
|
||||
"SEARCH_PLACEHOLDER": "Search Templates",
|
||||
"NO_TEMPLATES_FOUND": "No templates found for",
|
||||
"NO_CONTENT": "No content",
|
||||
"HEADER": "Header",
|
||||
"BODY": "Body",
|
||||
"FOOTER": "Footer",
|
||||
"BUTTONS": "Buttons",
|
||||
"CATEGORY": "Category",
|
||||
"MEDIA_CONTENT": "Media Content",
|
||||
"MEDIA_CONTENT_FALLBACK": "media content",
|
||||
"NO_TEMPLATES_AVAILABLE": "No Twilio templates available. Click refresh to sync templates from Twilio.",
|
||||
"REFRESH_BUTTON": "Refresh templates",
|
||||
"REFRESH_SUCCESS": "Templates refresh initiated. It may take a couple of minutes to update.",
|
||||
"REFRESH_ERROR": "Failed to refresh templates. Please try again.",
|
||||
"LABELS": {
|
||||
"LANGUAGE": "Language",
|
||||
"TEMPLATE_BODY": "Template Body",
|
||||
"CATEGORY": "Category"
|
||||
},
|
||||
"TYPES": {
|
||||
"MEDIA": "Media",
|
||||
"QUICK_REPLY": "Quick Reply",
|
||||
"TEXT": "Text"
|
||||
}
|
||||
},
|
||||
"PARSER": {
|
||||
"VARIABLES_LABEL": "Variables",
|
||||
"LANGUAGE": "Language",
|
||||
"CATEGORY": "Category",
|
||||
"VARIABLE_PLACEHOLDER": "Enter {variable} value",
|
||||
"GO_BACK_LABEL": "Go Back",
|
||||
"SEND_MESSAGE_LABEL": "Send Message",
|
||||
"FORM_ERROR_MESSAGE": "Please fill all variables before sending",
|
||||
"MEDIA_HEADER_LABEL": "{type} Header",
|
||||
"MEDIA_URL_LABEL": "Enter full media URL",
|
||||
"MEDIA_URL_PLACEHOLDER": "https://example.com/image.jpg"
|
||||
},
|
||||
"FORM": {
|
||||
"BACK_BUTTON": "Back",
|
||||
"SEND_MESSAGE_BUTTON": "Send Message"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,6 +35,11 @@
|
||||
"API_HOURS_WINDOW": "You can only reply to this conversation within {hours} hours",
|
||||
"NOT_ASSIGNED_TO_YOU": "This conversation is not assigned to you. Would you like to assign this conversation to yourself?",
|
||||
"ASSIGN_TO_ME": "Assign to me",
|
||||
"BOT_HANDOFF_MESSAGE": "You are responding to a conversation which is currently handled by an assistant or a bot.",
|
||||
"BOT_HANDOFF_ACTION": "Mark open and assign to you",
|
||||
"BOT_HANDOFF_REOPEN_ACTION": "Mark conversation open",
|
||||
"BOT_HANDOFF_SUCCESS": "Conversation has been handed over to you",
|
||||
"BOT_HANDOFF_ERROR": "Failed to take over the conversation. Please try again.",
|
||||
"TWILIO_WHATSAPP_CAN_REPLY": "You can only reply to this conversation using a template message due to",
|
||||
"TWILIO_WHATSAPP_24_HOURS_WINDOW": "24 hour message window restriction",
|
||||
"OLD_INSTAGRAM_INBOX_REPLY_BANNER": "This Instagram account was migrated to the new Instagram channel inbox. All new messages will show up there. You won’t be able to send messages from this conversation anymore.",
|
||||
|
||||
@@ -35,6 +35,7 @@ import signup from './signup.json';
|
||||
import sla from './sla.json';
|
||||
import teamsSettings from './teamsSettings.json';
|
||||
import whatsappTemplates from './whatsappTemplates.json';
|
||||
import contentTemplates from './contentTemplates.json';
|
||||
|
||||
export default {
|
||||
...advancedFilters,
|
||||
@@ -74,4 +75,5 @@ export default {
|
||||
...sla,
|
||||
...teamsSettings,
|
||||
...whatsappTemplates,
|
||||
...contentTemplates,
|
||||
};
|
||||
|
||||
+13
@@ -125,6 +125,19 @@ export default {
|
||||
>
|
||||
<woot-code :script="inbox.callback_webhook_url" lang="html" />
|
||||
</SettingsSection>
|
||||
<SettingsSection
|
||||
v-if="isATwilioWhatsAppChannel"
|
||||
:title="$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_TITLE')"
|
||||
:sub-title="
|
||||
$t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_SUBHEADER')
|
||||
"
|
||||
>
|
||||
<div class="flex justify-start items-center mt-2">
|
||||
<NextButton :disabled="isSyncingTemplates" @click="syncTemplates">
|
||||
{{ $t('INBOX_MGMT.SETTINGS_POPUP.WHATSAPP_TEMPLATES_SYNC_BUTTON') }}
|
||||
</NextButton>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</div>
|
||||
<div v-else-if="isAVoiceChannel" class="mx-8">
|
||||
<SettingsSection
|
||||
|
||||
@@ -33,13 +33,15 @@ export default {
|
||||
brandRedirectURL() {
|
||||
try {
|
||||
const referrerHost = this.$store.getters['appConfig/getReferrerHost'];
|
||||
const baseURL = `${this.globalConfig.widgetBrandURL}?utm_source=${
|
||||
referrerHost ? 'widget_branding' : 'survey_branding'
|
||||
}`;
|
||||
const url = new URL(this.globalConfig.widgetBrandURL);
|
||||
if (referrerHost) {
|
||||
return `${baseURL}&utm_referrer=${referrerHost}`;
|
||||
url.searchParams.set('utm_source', referrerHost);
|
||||
url.searchParams.set('utm_medium', 'widget');
|
||||
} else {
|
||||
url.searchParams.set('utm_medium', 'survey');
|
||||
}
|
||||
return baseURL;
|
||||
url.searchParams.set('utm_campaign', 'branding');
|
||||
return url.toString();
|
||||
} catch (e) {
|
||||
// Suppressing the error as getter is not defined in some cases
|
||||
}
|
||||
|
||||
@@ -162,3 +162,9 @@ export const ATTACHMENT_ICONS = {
|
||||
location: 'location',
|
||||
fallback: 'link',
|
||||
};
|
||||
|
||||
export const TWILIO_CONTENT_TEMPLATE_TYPES = {
|
||||
TEXT: 'text',
|
||||
MEDIA: 'media',
|
||||
QUICK_REPLY: 'quick_reply',
|
||||
};
|
||||
|
||||
@@ -2,11 +2,16 @@
|
||||
* Writes a text string to the system clipboard.
|
||||
*
|
||||
* @async
|
||||
* @param {string} text text to be written to the clipboard
|
||||
* @param {string} data text to be written to the clipboard
|
||||
* @throws {Error} unable to copy text to clipboard
|
||||
*/
|
||||
export const copyTextToClipboard = async text => {
|
||||
export const copyTextToClipboard = async data => {
|
||||
try {
|
||||
const text =
|
||||
typeof data === 'object' && data !== null
|
||||
? JSON.stringify(data, null, 2)
|
||||
: String(data ?? '');
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
} catch (error) {
|
||||
throw new Error(`Unable to copy text to clipboard: ${error.message}`);
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
import { copyTextToClipboard } from '../clipboard';
|
||||
|
||||
const mockWriteText = vi.fn();
|
||||
Object.assign(navigator, {
|
||||
clipboard: {
|
||||
writeText: mockWriteText,
|
||||
},
|
||||
});
|
||||
|
||||
describe('copyTextToClipboard', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('with string input', () => {
|
||||
it('copies plain text string to clipboard', async () => {
|
||||
const text = 'Hello World';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('Hello World');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('copies empty string to clipboard', async () => {
|
||||
const text = '';
|
||||
await copyTextToClipboard(text);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with number input', () => {
|
||||
it('converts number to string', async () => {
|
||||
await copyTextToClipboard(42);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('42');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts zero to string', async () => {
|
||||
await copyTextToClipboard(0);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('0');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with boolean input', () => {
|
||||
it('converts true to string', async () => {
|
||||
await copyTextToClipboard(true);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('true');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts false to string', async () => {
|
||||
await copyTextToClipboard(false);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('false');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with null/undefined input', () => {
|
||||
it('converts null to empty string', async () => {
|
||||
await copyTextToClipboard(null);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('converts undefined to empty string', async () => {
|
||||
await copyTextToClipboard(undefined);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with object input', () => {
|
||||
it('stringifies simple object with proper formatting', async () => {
|
||||
const obj = { name: 'John', age: 30 };
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
const expectedJson = JSON.stringify(obj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies nested object with proper formatting', async () => {
|
||||
const nestedObj = {
|
||||
severity: {
|
||||
user_id: 1181505,
|
||||
user_name: 'test',
|
||||
server_name: '[1253]test1253',
|
||||
},
|
||||
};
|
||||
await copyTextToClipboard(nestedObj);
|
||||
|
||||
const expectedJson = JSON.stringify(nestedObj, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies array with proper formatting', async () => {
|
||||
const arr = [1, 2, { name: 'test' }];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
const expectedJson = JSON.stringify(arr, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty object', async () => {
|
||||
const obj = {};
|
||||
await copyTextToClipboard(obj);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('{}');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('stringifies empty array', async () => {
|
||||
const arr = [];
|
||||
await copyTextToClipboard(arr);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith('[]');
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws error when clipboard API fails', async () => {
|
||||
const error = new Error('Clipboard access denied');
|
||||
mockWriteText.mockRejectedValueOnce(error);
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard: Clipboard access denied'
|
||||
);
|
||||
});
|
||||
|
||||
it('handles clipboard API not available', async () => {
|
||||
// Temporarily remove clipboard API
|
||||
const originalClipboard = navigator.clipboard;
|
||||
delete navigator.clipboard;
|
||||
|
||||
await expect(copyTextToClipboard('test')).rejects.toThrow(
|
||||
'Unable to copy text to clipboard:'
|
||||
);
|
||||
|
||||
// Restore clipboard API
|
||||
navigator.clipboard = originalClipboard;
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge cases', () => {
|
||||
it('handles Date objects', async () => {
|
||||
const date = new Date('2023-01-01T00:00:00.000Z');
|
||||
await copyTextToClipboard(date);
|
||||
|
||||
const expectedJson = JSON.stringify(date, null, 2);
|
||||
expect(mockWriteText).toHaveBeenCalledWith(expectedJson);
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('handles functions by converting to string', async () => {
|
||||
const func = () => 'test';
|
||||
await copyTextToClipboard(func);
|
||||
|
||||
expect(mockWriteText).toHaveBeenCalledWith(func.toString());
|
||||
expect(mockWriteText).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup>
|
||||
import { computed, onMounted } from 'vue';
|
||||
import { computed, onMounted, watch } from 'vue';
|
||||
import ArticleBlock from 'widget/components/pageComponents/Home/Article/ArticleBlock.vue';
|
||||
import ArticleCardSkeletonLoader from 'widget/components/pageComponents/Home/Article/SkeletonLoader.vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
@@ -26,7 +26,7 @@ const locale = computed(() => {
|
||||
});
|
||||
|
||||
const fetchArticles = () => {
|
||||
if (portal.value && !popularArticles.value.length) {
|
||||
if (portal.value && locale.value) {
|
||||
store.dispatch('article/fetch', {
|
||||
slug: portal.value.slug,
|
||||
locale: locale.value,
|
||||
@@ -60,6 +60,14 @@ const hasArticles = computed(
|
||||
!!popularArticles.value.length &&
|
||||
!!locale.value
|
||||
);
|
||||
|
||||
// Watch for locale changes and refetch articles
|
||||
watch(locale, (newLocale, oldLocale) => {
|
||||
if (newLocale && newLocale !== oldLocale) {
|
||||
fetchArticles();
|
||||
}
|
||||
});
|
||||
|
||||
onMounted(() => fetchArticles());
|
||||
</script>
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
class Inboxes::BulkAutoAssignmentJob < ApplicationJob
|
||||
queue_as :scheduled_jobs
|
||||
include BillingHelper
|
||||
|
||||
def perform
|
||||
Account.feature_assignment_v2.find_each do |account|
|
||||
if should_skip_auto_assignment?(account)
|
||||
Rails.logger.info("Skipping auto assignment for account #{account.id}")
|
||||
next
|
||||
end
|
||||
|
||||
account.inboxes.where(enable_auto_assignment: true).find_each do |inbox|
|
||||
process_assignment(inbox)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def process_assignment(inbox)
|
||||
allowed_agent_ids = inbox.member_ids_with_assignment_capacity
|
||||
|
||||
if allowed_agent_ids.blank?
|
||||
Rails.logger.info("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
return
|
||||
end
|
||||
|
||||
assign_conversations(inbox, allowed_agent_ids)
|
||||
end
|
||||
|
||||
def assign_conversations(inbox, allowed_agent_ids)
|
||||
unassigned_conversations = inbox.conversations.unassigned.open.limit(Limits::AUTO_ASSIGNMENT_BULK_LIMIT)
|
||||
unassigned_conversations.find_each do |conversation|
|
||||
::AutoAssignment::AgentAssignmentService.new(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: allowed_agent_ids
|
||||
).perform
|
||||
Rails.logger.info("Assigned conversation #{conversation.id} to agent #{allowed_agent_ids.first}")
|
||||
end
|
||||
end
|
||||
|
||||
def should_skip_auto_assignment?(account)
|
||||
return false unless ChatwootApp.chatwoot_cloud?
|
||||
|
||||
default_plan?(account)
|
||||
end
|
||||
end
|
||||
@@ -1,10 +1,22 @@
|
||||
class AdministratorNotifications::AccountNotificationMailer < AdministratorNotifications::BaseMailer
|
||||
def account_deletion(account, reason = 'manual_deletion')
|
||||
subject = 'Your account has been marked for deletion'
|
||||
def account_deletion_user_initiated(account, reason)
|
||||
subject = 'Your Chatwoot account deletion has been scheduled'
|
||||
action_url = settings_url('general')
|
||||
meta = {
|
||||
'account_name' => account.name,
|
||||
'deletion_date' => account.custom_attributes['marked_for_deletion_at'],
|
||||
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
|
||||
'reason' => reason
|
||||
}
|
||||
|
||||
send_notification(subject, action_url: action_url, meta: meta)
|
||||
end
|
||||
|
||||
def account_deletion_for_inactivity(account, reason)
|
||||
subject = 'Your Chatwoot account is scheduled for deletion due to inactivity'
|
||||
action_url = settings_url('general')
|
||||
meta = {
|
||||
'account_name' => account.name,
|
||||
'deletion_date' => format_deletion_date(account.custom_attributes['marked_for_deletion_at']),
|
||||
'reason' => reason
|
||||
}
|
||||
|
||||
@@ -45,4 +57,14 @@ class AdministratorNotifications::AccountNotificationMailer < AdministratorNotif
|
||||
|
||||
send_notification(subject, action_url: action_url, meta: meta)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def format_deletion_date(deletion_date_str)
|
||||
return 'Unknown' if deletion_date_str.blank?
|
||||
|
||||
Time.zone.parse(deletion_date_str).strftime('%B %d, %Y')
|
||||
rescue StandardError
|
||||
'Unknown'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -17,21 +17,7 @@ class Twilio::TemplateSyncService
|
||||
end
|
||||
|
||||
def update_channel_templates
|
||||
formatted_templates = @templates.map do |template|
|
||||
{
|
||||
content_sid: template.sid,
|
||||
friendly_name: template.friendly_name,
|
||||
language: template.language,
|
||||
status: derive_status(template),
|
||||
template_type: derive_template_type(template),
|
||||
media_type: derive_media_type(template),
|
||||
variables: template.variables || {},
|
||||
category: derive_category(template),
|
||||
body: extract_body_content(template),
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
end
|
||||
formatted_templates = @templates.map { |template| format_template(template) }
|
||||
|
||||
channel.update!(
|
||||
content_templates: { templates: formatted_templates },
|
||||
@@ -39,6 +25,23 @@ class Twilio::TemplateSyncService
|
||||
)
|
||||
end
|
||||
|
||||
def format_template(template)
|
||||
{
|
||||
content_sid: template.sid,
|
||||
friendly_name: template.friendly_name,
|
||||
language: template.language,
|
||||
status: derive_status(template),
|
||||
template_type: derive_template_type(template),
|
||||
media_type: derive_media_type(template),
|
||||
variables: template.variables || {},
|
||||
category: derive_category(template),
|
||||
body: extract_body_content(template),
|
||||
types: template.types,
|
||||
created_at: template.date_created,
|
||||
updated_at: template.date_updated
|
||||
}
|
||||
end
|
||||
|
||||
def mark_templates_updated
|
||||
channel.update!(content_templates_last_updated: Time.current)
|
||||
end
|
||||
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Your account <strong>{{ meta.account_name }}</strong> has been marked for deletion. The account will be permanently deleted on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
{% if meta.reason == 'manual_deletion' %}
|
||||
<p>This action was requested by one of the administrators of your account.</p>
|
||||
{% else %}
|
||||
<p>Reason for deletion: {{ meta.reason }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p>If this was done in error, you can cancel the deletion process by visiting your account settings.</p>
|
||||
|
||||
<p><a href="{{ action_url }}">Cancel Account Deletion</a></p>
|
||||
|
||||
<p>Thank you,<br>
|
||||
Team Chatwoot</p>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
<p>Hello there,</p>
|
||||
|
||||
<p>We've noticed that your Chatwoot account <strong>{{ meta.account_name }}</strong> has been inactive for some time. Because of this, it's scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
<p><strong>How do I keep my account?</strong></p>
|
||||
|
||||
<p>Log in to your Chatwoot account before <strong>{{ meta.deletion_date }}</strong>. From your account settings, you can <a href="{{ action_url }}">cancel the deletion</a> and continue using your account.</p>
|
||||
|
||||
<p><strong>What happens if I don't cancel?</strong></p>
|
||||
|
||||
<p>Unless you cancel the account deletion before <strong>{{ meta.deletion_date }}</strong>, your account and all associated data — including conversations, contacts, reports, and settings — will be permanently deleted.</p>
|
||||
|
||||
<p><strong>Why are we doing this?</strong></p>
|
||||
|
||||
<p>To keep things secure and efficient, we regularly remove inactive accounts so our systems remain optimized for active teams.</p>
|
||||
|
||||
<p>If you have any questions, feel free to reach us at <a href="mailto:hello@chatwoot.com">hello@chatwoot.com</a>.</p>
|
||||
|
||||
<p>— The Chatwoot Team</p>
|
||||
|
||||
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
<p>Hello there,</p>
|
||||
|
||||
<p>An account administrator has requested deletion of the Chatwoot account <strong>{{ meta.account_name }}</strong>. The account is scheduled for deletion on <strong>{{ meta.deletion_date }}</strong>.</p>
|
||||
|
||||
<p><strong>What happens next?</strong></p>
|
||||
|
||||
<ul>
|
||||
<li>The account will remain accessible until the scheduled deletion date.</li>
|
||||
<li>After that, all data including conversations, contacts, integrations, and settings will be permanently removed.</li>
|
||||
</ul>
|
||||
|
||||
<p>If you change your mind before the deletion date, you can <a href="{{ action_url }}">cancel this request</a> by visiting your account settings.</p>
|
||||
|
||||
<p>— The Chatwoot Team</p>
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@
|
||||
alt="<%= @global_config['BRAND_NAME'] %>"
|
||||
src="<%= @global_config['LOGO_THUMBNAIL'] %>"
|
||||
/>
|
||||
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %>
|
||||
|
||||
<a class="hover:underline" href="<%= @global_config['BRAND_URL'] %>" target="_blank" rel="noopener noreferrer nofoll/ow"><%= @global_config['BRAND_NAME'] %></a>
|
||||
|
||||
</p>
|
||||
<p class="text-slate-700 dark:text-slate-300 text-sm font-medium text-center">
|
||||
<%= I18n.t('public_portal.footer.made_with') %>
|
||||
|
||||
<a class="hover:underline" href="<%= generate_portal_brand_url(@global_config['BRAND_URL'], request.referer) %>" target="_blank" rel="noopener noreferrer nofollow"><%= @global_config['BRAND_NAME'] %></a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
@@ -198,6 +198,7 @@
|
||||
- name: twilio_content_templates
|
||||
display_name: Twilio Content Templates
|
||||
enabled: false
|
||||
deprecated: true
|
||||
- name: advanced_search
|
||||
display_name: Advanced Search
|
||||
enabled: false
|
||||
|
||||
@@ -46,3 +46,10 @@ delete_accounts_job:
|
||||
cron: '0 1 * * *'
|
||||
class: 'Internal::DeleteAccountsJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
# executed every 15 minutes
|
||||
# to assign unassigned conversations for all inboxes
|
||||
bulk_auto_assignment_job:
|
||||
cron: '*/15 * * * *'
|
||||
class: 'Inboxes::BulkAutoAssignmentJob'
|
||||
queue: scheduled_jobs
|
||||
|
||||
+2
@@ -59,6 +59,8 @@ module Enterprise::DeviseOverrides::OmniauthCallbacksController
|
||||
return false if account_id.blank?
|
||||
|
||||
account = Account.find_by(id: account_id)
|
||||
|
||||
return false if account.nil?
|
||||
return false unless account.feature_enabled?('saml')
|
||||
|
||||
AccountSamlSettings.find_by(account_id: account_id).present?
|
||||
|
||||
@@ -4,10 +4,22 @@ module Enterprise::Account
|
||||
def manually_managed_features; end
|
||||
|
||||
def mark_for_deletion(reason = 'manual_deletion')
|
||||
result = custom_attributes.merge!('marked_for_deletion_at' => 7.days.from_now.iso8601, 'marked_for_deletion_reason' => reason) && save
|
||||
reason = reason.to_s == 'manual_deletion' ? 'manual_deletion' : 'inactivity'
|
||||
|
||||
result = custom_attributes.merge!(
|
||||
'marked_for_deletion_at' => 7.days.from_now.iso8601,
|
||||
'marked_for_deletion_reason' => reason
|
||||
) && save
|
||||
|
||||
# Send notification to admin users if the account was successfully marked for deletion
|
||||
AdministratorNotifications::AccountNotificationMailer.with(account: self).account_deletion(self, reason).deliver_later if result
|
||||
if result
|
||||
mailer = AdministratorNotifications::AccountNotificationMailer.with(account: self)
|
||||
if reason == 'manual_deletion'
|
||||
mailer.account_deletion_user_initiated(self, reason).deliver_later
|
||||
else
|
||||
mailer.account_deletion_for_inactivity(self, reason).deliver_later
|
||||
end
|
||||
end
|
||||
|
||||
result
|
||||
end
|
||||
|
||||
@@ -5,6 +5,7 @@ module Limits
|
||||
OUT_OF_OFFICE_MESSAGE_MAX_LENGTH = 10_000
|
||||
GREETING_MESSAGE_MAX_LENGTH = 10_000
|
||||
CATEGORIES_PER_PAGE = 1000
|
||||
AUTO_ASSIGNMENT_BULK_LIMIT = 100
|
||||
|
||||
def self.conversation_message_per_minute_limit
|
||||
ENV.fetch('CONVERSATION_MESSAGE_PER_MINUTE_LIMIT', '200').to_i
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
require 'simplecov'
|
||||
require 'simplecov_json_formatter'
|
||||
|
||||
# Configure SimpleCov to emit JSON for Qlty and HTML locally if needed
|
||||
SimpleCov.formatter = SimpleCov::Formatter::JSONFormatter
|
||||
SimpleCov.start 'rails' do
|
||||
SimpleCov.coverage_dir 'coverage'
|
||||
end
|
||||
@@ -229,18 +229,27 @@ RSpec.describe Account, type: :model do
|
||||
describe '#mark_for_deletion' do
|
||||
it 'sets the marked_for_deletion_at and marked_for_deletion_reason attributes' do
|
||||
expect do
|
||||
account.mark_for_deletion('test_reason')
|
||||
account.mark_for_deletion('inactivity')
|
||||
end.to change { account.reload.custom_attributes['marked_for_deletion_at'] }.from(nil).to(be_present)
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('test_reason')
|
||||
.and change { account.reload.custom_attributes['marked_for_deletion_reason'] }.from(nil).to('inactivity')
|
||||
end
|
||||
|
||||
it 'sends a notification email to admin users' do
|
||||
it 'sends a user-initiated deletion email when reason is manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion).with(account, 'test_reason').and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_user_initiated).with(account, 'manual_deletion').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('test_reason')
|
||||
account.mark_for_deletion('manual_deletion')
|
||||
end
|
||||
|
||||
it 'sends a system-initiated deletion email when reason is not manual_deletion' do
|
||||
mailer = double
|
||||
expect(AdministratorNotifications::AccountNotificationMailer).to receive(:with).with(account: account).and_return(mailer)
|
||||
expect(mailer).to receive(:account_deletion_for_inactivity).with(account, 'inactivity').and_return(mailer)
|
||||
expect(mailer).to receive(:deliver_later)
|
||||
|
||||
account.mark_for_deletion('inactivity')
|
||||
end
|
||||
|
||||
it 'returns true when successful' do
|
||||
|
||||
@@ -291,4 +291,40 @@ describe PortalHelper do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#generate_portal_brand_url' do
|
||||
it 'builds URL with UTM params and referer host as source (happy path)' do
|
||||
result = helper.generate_portal_brand_url('https://brand.example.com', 'https://app.chatwoot.com/some/page')
|
||||
uri = URI.parse(result)
|
||||
params = Rack::Utils.parse_query(uri.query)
|
||||
expect(uri.scheme).to eq('https')
|
||||
expect(uri.host).to eq('brand.example.com')
|
||||
expect(params['utm_medium']).to eq('helpcenter')
|
||||
expect(params['utm_campaign']).to eq('branding')
|
||||
expect(params['utm_source']).to eq('app.chatwoot.com')
|
||||
end
|
||||
|
||||
it 'returns utm string when brand_url is nil or empty' do
|
||||
expect(helper.generate_portal_brand_url(nil,
|
||||
'https://app.chatwoot.com')).to eq(
|
||||
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
|
||||
)
|
||||
expect(helper.generate_portal_brand_url('',
|
||||
'https://app.chatwoot.com')).to eq(
|
||||
'?utm_campaign=branding&utm_medium=helpcenter&utm_source=app.chatwoot.com'
|
||||
)
|
||||
end
|
||||
|
||||
it 'omits utm_source when referer is nil or invalid' do
|
||||
r1 = helper.generate_portal_brand_url('https://brand.example.com', nil)
|
||||
p1 = Rack::Utils.parse_query(URI.parse(r1).query)
|
||||
expect(p1.key?('utm_source')).to be(false)
|
||||
|
||||
r2 = helper.generate_portal_brand_url('https://brand.example.com', '::not-a-valid-url')
|
||||
p2 = Rack::Utils.parse_query(URI.parse(r2).query)
|
||||
expect(p2.key?('utm_source')).to be(false)
|
||||
expect(p2['utm_medium']).to eq('helpcenter')
|
||||
expect(p2['utm_campaign']).to eq('branding')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
require 'rails_helper'
|
||||
|
||||
RSpec.describe Inboxes::BulkAutoAssignmentJob do
|
||||
let(:account) { create(:account, custom_attributes: { 'plan_name' => 'Startups' }) }
|
||||
let(:agent) { create(:user, account: account, role: :agent, auto_offline: false) }
|
||||
let(:inbox) { create(:inbox, account: account) }
|
||||
let!(:conversation) { create(:conversation, account: account, inbox: inbox, assignee: nil, status: :open) }
|
||||
let(:assignment_service) { double }
|
||||
|
||||
describe '#perform' do
|
||||
before do
|
||||
allow(assignment_service).to receive(:perform)
|
||||
end
|
||||
|
||||
context 'when inbox has inbox members' do
|
||||
before do
|
||||
create(:inbox_member, user: agent, inbox: inbox)
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'assigns unassigned conversations in enabled inboxes' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
).and_return(assignment_service)
|
||||
|
||||
described_class.perform_now
|
||||
expect(AutoAssignment::AgentAssignmentService).to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
it 'skips inboxes with auto assignment disabled' do
|
||||
inbox.update!(enable_auto_assignment: false)
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to have_received(:new).with(
|
||||
conversation: conversation,
|
||||
allowed_agent_ids: [agent.id]
|
||||
)
|
||||
end
|
||||
|
||||
context 'when account is on default plan in chatwoot cloud' do
|
||||
before do
|
||||
account.update!(custom_attributes: {})
|
||||
InstallationConfig.create(name: 'CHATWOOT_CLOUD_PLANS', value: [{ 'name' => 'default' }])
|
||||
allow(ChatwootApp).to receive(:chatwoot_cloud?).and_return(true)
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("Skipping auto assignment for account #{account.id}")
|
||||
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when inbox has no members' do
|
||||
before do
|
||||
account.enable_features!('assignment_v2')
|
||||
inbox.update!(enable_auto_assignment: true)
|
||||
end
|
||||
|
||||
it 'does not assign conversations' do
|
||||
allow(Rails.logger).to receive(:info)
|
||||
expect(Rails.logger).to receive(:info).with("No agents available to assign conversation to inbox #{inbox.id}")
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
|
||||
context 'when assignment_v2 feature is disabled' do
|
||||
before do
|
||||
account.disable_features!('assignment_v2')
|
||||
end
|
||||
|
||||
it 'skips auto assignment' do
|
||||
allow(AutoAssignment::AgentAssignmentService).to receive(:new)
|
||||
expect(AutoAssignment::AgentAssignmentService).not_to receive(:new)
|
||||
|
||||
described_class.perform_now
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,116 +1,46 @@
|
||||
require 'rails_helper'
|
||||
require Rails.root.join 'spec/mailers/administrator_notifications/shared/smtp_config_shared.rb'
|
||||
|
||||
RSpec.describe AdministratorNotifications::AccountNotificationMailer do
|
||||
include_context 'with smtp config'
|
||||
let(:account) { create(:account, name: 'Test Account') }
|
||||
let(:mailer) { described_class.with(account: account) }
|
||||
let(:class_instance) { described_class.new }
|
||||
|
||||
let!(:account) { create(:account) }
|
||||
let!(:admin) { create(:user, account: account, role: :administrator) }
|
||||
before do
|
||||
allow(described_class).to receive(:new).and_return(class_instance)
|
||||
allow(class_instance).to receive(:smtp_config_set_or_development?).and_return(true)
|
||||
account.custom_attributes['marked_for_deletion_at'] = 7.days.from_now.iso8601
|
||||
account.save!
|
||||
end
|
||||
|
||||
describe 'account_deletion' do
|
||||
let(:reason) { 'manual_deletion' }
|
||||
let(:mail) { described_class.with(account: account).account_deletion(account, reason) }
|
||||
let(:deletion_date) { 7.days.from_now.iso8601 }
|
||||
|
||||
before do
|
||||
account.update!(custom_attributes: {
|
||||
'marked_for_deletion_at' => deletion_date,
|
||||
'marked_for_deletion_reason' => reason
|
||||
})
|
||||
end
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Your account has been marked for deletion')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
|
||||
it 'includes the account name in the email body' do
|
||||
expect(mail.body.encoded).to include(account.name)
|
||||
end
|
||||
|
||||
it 'includes the deletion date in the email body' do
|
||||
expect(mail.body.encoded).to include(deletion_date)
|
||||
end
|
||||
|
||||
it 'includes a link to cancel the deletion' do
|
||||
expect(mail.body.encoded).to include('Cancel Account Deletion')
|
||||
end
|
||||
|
||||
context 'when reason is manual_deletion' do
|
||||
it 'includes the administrator message' do
|
||||
expect(mail.body.encoded).to include('This action was requested by one of the administrators of your account')
|
||||
end
|
||||
end
|
||||
|
||||
context 'when reason is not manual_deletion' do
|
||||
let(:reason) { 'inactivity' }
|
||||
|
||||
it 'includes the reason directly' do
|
||||
expect(mail.body.encoded).to include('Reason for deletion: inactivity')
|
||||
end
|
||||
describe '#account_deletion_user_initiated' do
|
||||
it 'sets the correct subject for user-initiated deletion' do
|
||||
mail = mailer.account_deletion_user_initiated(account, 'manual_deletion')
|
||||
expect(mail.subject).to eq('Your Chatwoot account deletion has been scheduled')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_import_complete' do
|
||||
let!(:data_import) { build(:data_import, total_records: 10, processed_records: 8) }
|
||||
let(:mail) { described_class.with(account: account).contact_import_complete(data_import).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Contact Import Completed')
|
||||
end
|
||||
|
||||
it 'renders the processed records' do
|
||||
expect(mail.body.encoded).to include('Number of records imported: 8')
|
||||
expect(mail.body.encoded).to include('Number of records failed: 2')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
describe '#account_deletion_for_inactivity' do
|
||||
it 'sets the correct subject for system-initiated deletion' do
|
||||
mail = mailer.account_deletion_for_inactivity(account, 'Account Inactive')
|
||||
expect(mail.subject).to eq('Your Chatwoot account is scheduled for deletion due to inactivity')
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_import_failed' do
|
||||
let(:mail) { described_class.with(account: account).contact_import_failed.deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Contact Import Failed')
|
||||
describe '#format_deletion_date' do
|
||||
it 'formats a valid date string' do
|
||||
date_str = '2024-12-31T23:59:59Z'
|
||||
formatted = described_class.new.send(:format_deletion_date, date_str)
|
||||
expect(formatted).to eq('December 31, 2024')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'contact_export_complete' do
|
||||
let!(:file_url) { 'http://test.com/test' }
|
||||
let(:mail) { described_class.with(account: account).contact_export_complete(file_url, admin.email).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq("Your contact's export file is available to download.")
|
||||
it 'handles blank dates' do
|
||||
formatted = described_class.new.send(:format_deletion_date, nil)
|
||||
expect(formatted).to eq('Unknown')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
end
|
||||
|
||||
describe 'automation_rule_disabled' do
|
||||
let(:rule) { instance_double(AutomationRule, name: 'Test Rule') }
|
||||
let(:mail) { described_class.with(account: account).automation_rule_disabled(rule).deliver_now }
|
||||
|
||||
it 'renders the subject' do
|
||||
expect(mail.subject).to eq('Automation rule disabled due to validation errors.')
|
||||
end
|
||||
|
||||
it 'renders the receiver email' do
|
||||
expect(mail.to).to eq([admin.email])
|
||||
end
|
||||
|
||||
it 'includes the rule name in the email body' do
|
||||
expect(mail.body.encoded).to include('Test Rule')
|
||||
it 'handles invalid dates' do
|
||||
formatted = described_class.new.send(:format_deletion_date, 'invalid-date')
|
||||
expect(formatted).to eq('Unknown')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
require 'simplecov'
|
||||
require 'webmock/rspec'
|
||||
|
||||
SimpleCov.start 'rails'
|
||||
WebMock.disable_net_connect!(allow_localhost: true)
|
||||
|
||||
RSpec.configure do |config|
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
ENV['RAILS_ENV'] ||= 'test'
|
||||
require 'simplecov'
|
||||
SimpleCov.start 'rails'
|
||||
|
||||
require File.expand_path('../config/environment', __dir__)
|
||||
require 'rails/test_help'
|
||||
|
||||
Reference in New Issue
Block a user