Merge branch 'develop' into feat/voice-as-twilio-capability
This commit is contained in:
@@ -31,6 +31,12 @@ class CaptainCustomTools extends ApiClient {
|
||||
delete(id) {
|
||||
return axios.delete(`${this.url}/${id}`);
|
||||
}
|
||||
|
||||
test(data = {}) {
|
||||
return axios.post(`${this.url}/test`, {
|
||||
custom_tool: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
export default new CaptainCustomTools();
|
||||
|
||||
+3
-6
@@ -101,12 +101,9 @@ const authTypeLabel = computed(() => {
|
||||
</Policy>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center justify-between w-full gap-4">
|
||||
<div class="flex items-center gap-3 flex-1">
|
||||
<span
|
||||
v-if="description"
|
||||
class="text-sm truncate text-n-slate-11 flex-1"
|
||||
>
|
||||
<div class="flex items-center justify-between w-full gap-4 min-w-0">
|
||||
<div class="flex items-center gap-3 flex-1 min-w-0">
|
||||
<span v-if="description" class="text-sm truncate text-n-slate-11">
|
||||
{{ description }}
|
||||
</span>
|
||||
<span
|
||||
|
||||
+80
-6
@@ -1,9 +1,10 @@
|
||||
<script setup>
|
||||
import { reactive, computed, useTemplateRef, watch } from 'vue';
|
||||
import { reactive, computed, ref, useTemplateRef, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useVuelidate } from '@vuelidate/core';
|
||||
import { required } from '@vuelidate/validators';
|
||||
import { required, maxLength } from '@vuelidate/validators';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import CustomToolsAPI from 'dashboard/api/captain/customTools';
|
||||
|
||||
import Input from 'dashboard/components-next/input/Input.vue';
|
||||
import TextArea from 'dashboard/components-next/textarea/TextArea.vue';
|
||||
@@ -72,8 +73,12 @@ const DEFAULT_PARAM = {
|
||||
required: false,
|
||||
};
|
||||
|
||||
// OpenAI enforces a 64-char limit on function names. The backend slug is
|
||||
// "custom_" (7 chars) + parameterized title, so cap the title conservatively.
|
||||
const MAX_TOOL_NAME_LENGTH = 55;
|
||||
|
||||
const validationRules = {
|
||||
title: { required },
|
||||
title: { required, maxLength: maxLength(MAX_TOOL_NAME_LENGTH) },
|
||||
endpoint_url: { required },
|
||||
http_method: { required },
|
||||
auth_type: { required },
|
||||
@@ -103,9 +108,15 @@ const isLoading = computed(() =>
|
||||
);
|
||||
|
||||
const getErrorMessage = (field, errorKey) => {
|
||||
return v$.value[field].$error
|
||||
? t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`)
|
||||
: '';
|
||||
if (!v$.value[field].$error) return '';
|
||||
|
||||
const failedRule = v$.value[field].$errors[0]?.$validator;
|
||||
if (failedRule === 'maxLength') {
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.MAX_LENGTH_ERROR`, {
|
||||
max: MAX_TOOL_NAME_LENGTH,
|
||||
});
|
||||
}
|
||||
return t(`CAPTAIN.CUSTOM_TOOLS.FORM.${errorKey}.ERROR`);
|
||||
};
|
||||
|
||||
const formErrors = computed(() => ({
|
||||
@@ -140,6 +151,30 @@ const handleSubmit = async () => {
|
||||
|
||||
emit('submit', state);
|
||||
};
|
||||
|
||||
const isTesting = ref(false);
|
||||
const testResult = ref(null);
|
||||
const isTestDisabled = computed(
|
||||
() => state.endpoint_url.includes('{{') || !!state.request_template
|
||||
);
|
||||
|
||||
const handleTest = async () => {
|
||||
if (!state.endpoint_url) return;
|
||||
|
||||
isTesting.value = true;
|
||||
testResult.value = null;
|
||||
try {
|
||||
const { data } = await CustomToolsAPI.test(state);
|
||||
const isOk = data.status >= 200 && data.status < 300;
|
||||
testResult.value = { success: isOk, status: data.status };
|
||||
} catch (e) {
|
||||
const message =
|
||||
e.response?.data?.error || t('CAPTAIN.CUSTOM_TOOLS.TEST.ERROR');
|
||||
testResult.value = { success: false, message };
|
||||
} finally {
|
||||
isTesting.value = false;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -248,6 +283,45 @@ const handleSubmit = async () => {
|
||||
class="[&_textarea]:font-mono"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="faded"
|
||||
color="slate"
|
||||
icon="i-lucide-play"
|
||||
:label="t('CAPTAIN.CUSTOM_TOOLS.TEST.BUTTON')"
|
||||
:is-loading="isTesting"
|
||||
:disabled="isTesting || !state.endpoint_url || isTestDisabled"
|
||||
@click="handleTest"
|
||||
/>
|
||||
<p v-if="isTestDisabled" class="text-xs text-n-slate-11">
|
||||
{{ t('CAPTAIN.CUSTOM_TOOLS.TEST.DISABLED_HINT') }}
|
||||
</p>
|
||||
<div
|
||||
v-if="testResult"
|
||||
class="flex items-center gap-2 px-3 py-2 text-xs rounded-lg"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-n-teal-2 text-n-teal-11'
|
||||
: 'bg-n-ruby-2 text-n-ruby-11'
|
||||
"
|
||||
>
|
||||
<span
|
||||
:class="
|
||||
testResult.success ? 'i-lucide-check-circle' : 'i-lucide-x-circle'
|
||||
"
|
||||
class="size-3.5 shrink-0"
|
||||
/>
|
||||
{{
|
||||
testResult.status
|
||||
? t('CAPTAIN.CUSTOM_TOOLS.TEST.SUCCESS', {
|
||||
status: testResult.status,
|
||||
})
|
||||
: testResult.message
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 justify-between items-center w-full">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
+12
@@ -1,8 +1,11 @@
|
||||
<script setup>
|
||||
import { useAccount } from 'dashboard/composables/useAccount';
|
||||
import EmptyStateLayout from 'dashboard/components-next/EmptyStateLayout.vue';
|
||||
import FeatureSpotlight from 'dashboard/components-next/feature-spotlight/FeatureSpotlight.vue';
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
const emit = defineEmits(['click']);
|
||||
const { isOnChatwootCloud } = useAccount();
|
||||
|
||||
const onClick = () => {
|
||||
emit('click');
|
||||
@@ -10,6 +13,15 @@ const onClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<FeatureSpotlight
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.TITLE')"
|
||||
:note="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.FEATURE_SPOTLIGHT.NOTE')"
|
||||
fallback-thumbnail="/assets/images/dashboard/captain/assistant-light.svg"
|
||||
fallback-thumbnail-dark="/assets/images/dashboard/captain/assistant-dark.svg"
|
||||
learn-more-url="https://chwt.app/hc/captain-tools"
|
||||
class="mb-8"
|
||||
:hide-actions="!isOnChatwootCloud"
|
||||
/>
|
||||
<EmptyStateLayout
|
||||
:title="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.TITLE')"
|
||||
:subtitle="$t('CAPTAIN.CUSTOM_TOOLS.EMPTY_STATE.SUBTITLE')"
|
||||
|
||||
@@ -63,6 +63,16 @@ const hasAdvancedAssignment = computed(() => {
|
||||
);
|
||||
});
|
||||
|
||||
const hasCustomTools = computed(() => {
|
||||
return (
|
||||
isFeatureEnabledonAccount.value(
|
||||
accountId.value,
|
||||
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
|
||||
) ||
|
||||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
|
||||
);
|
||||
});
|
||||
|
||||
const toggleShortcutModalFn = show => {
|
||||
if (show) {
|
||||
emit('openKeyShortcutModal');
|
||||
@@ -364,14 +374,18 @@ const menuItems = computed(() => {
|
||||
navigationPath: 'captain_assistants_inboxes_index',
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
...(hasCustomTools.value
|
||||
? [
|
||||
{
|
||||
name: 'Tools',
|
||||
label: t('SIDEBAR.CAPTAIN_TOOLS'),
|
||||
activeOn: ['captain_tools_index'],
|
||||
to: accountScopedRoute('captain_assistants_index', {
|
||||
navigationPath: 'captain_tools_index',
|
||||
}),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: 'Settings',
|
||||
label: t('SIDEBAR.CAPTAIN_SETTINGS'),
|
||||
|
||||
@@ -38,6 +38,7 @@ export const FEATURE_FLAGS = {
|
||||
CHANNEL_TIKTOK: 'channel_tiktok',
|
||||
CHANNEL_VOICE: 'channel_voice',
|
||||
CONTACT_CHATWOOT_SUPPORT_TEAM: 'contact_chatwoot_support_team',
|
||||
CAPTAIN_CUSTOM_TOOLS: 'custom_tools',
|
||||
CAPTAIN_V2: 'captain_integration_v2',
|
||||
CAPTAIN_TASKS: 'captain_tasks',
|
||||
SAML: 'saml',
|
||||
|
||||
@@ -807,6 +807,7 @@
|
||||
"CUSTOM_TOOLS": {
|
||||
"HEADER": "Tools",
|
||||
"ADD_NEW": "Create a new tool",
|
||||
"SOFT_LIMIT_WARNING": "Having more than 10 tools may reduce the assistant's reliability in selecting the right tool. Consider removing unused tools for better results.",
|
||||
"EMPTY_STATE": {
|
||||
"TITLE": "No custom tools available",
|
||||
"SUBTITLE": "Create custom tools to connect your assistant with external APIs and services, enabling it to fetch data and perform actions on your behalf.",
|
||||
@@ -837,11 +838,18 @@
|
||||
"SUCCESS_MESSAGE": "Custom tool deleted successfully",
|
||||
"ERROR_MESSAGE": "Failed to delete custom tool"
|
||||
},
|
||||
"TEST": {
|
||||
"BUTTON": "Test connection",
|
||||
"SUCCESS": "Endpoint returned HTTP {status}",
|
||||
"ERROR": "Connection failed",
|
||||
"DISABLED_HINT": "Testing is only available for endpoints without templates or request bodies."
|
||||
},
|
||||
"FORM": {
|
||||
"TITLE": {
|
||||
"LABEL": "Tool Name",
|
||||
"PLACEHOLDER": "Order Lookup",
|
||||
"ERROR": "Tool name is required"
|
||||
"ERROR": "Tool name is required",
|
||||
"MAX_LENGTH_ERROR": "Tool name must be {max} characters or fewer"
|
||||
},
|
||||
"DESCRIPTION": {
|
||||
"LABEL": "Description",
|
||||
|
||||
@@ -46,7 +46,7 @@ const assistantRoutes = [
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
|
||||
component: CustomToolsIndex,
|
||||
name: 'captain_tools_index',
|
||||
meta: metaV2,
|
||||
meta,
|
||||
},
|
||||
{
|
||||
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),
|
||||
|
||||
@@ -2,21 +2,29 @@
|
||||
import { computed, onMounted, ref, nextTick } from 'vue';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
import { usePolicy } from 'dashboard/composables/usePolicy';
|
||||
|
||||
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
|
||||
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
|
||||
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
|
||||
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
|
||||
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
|
||||
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
|
||||
|
||||
const store = useStore();
|
||||
const { isFeatureFlagEnabled } = usePolicy();
|
||||
|
||||
const SOFT_LIMIT = 10;
|
||||
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
|
||||
|
||||
const uiFlags = useMapGetter('captainCustomTools/getUIFlags');
|
||||
const customTools = useMapGetter('captainCustomTools/getRecords');
|
||||
const isFetching = computed(() => uiFlags.value.fetchingList);
|
||||
const customToolsMeta = useMapGetter('captainCustomTools/getMeta');
|
||||
|
||||
const showSoftLimitWarning = computed(
|
||||
() => !isV2.value && customToolsMeta.value.totalCount > SOFT_LIMIT
|
||||
);
|
||||
|
||||
const createDialogRef = ref(null);
|
||||
const deleteDialogRef = ref(null);
|
||||
const selectedTool = ref(null);
|
||||
@@ -86,21 +94,23 @@ onMounted(() => {
|
||||
:show-pagination-footer="!isFetching && !!customTools.length"
|
||||
:is-fetching="isFetching"
|
||||
:is-empty="!customTools.length"
|
||||
:feature-flag="FEATURE_FLAGS.CAPTAIN_V2"
|
||||
:show-know-more="false"
|
||||
@update:current-page="onPageChange"
|
||||
@click="openCreateDialog"
|
||||
>
|
||||
<template #paywall>
|
||||
<CaptainPaywall />
|
||||
</template>
|
||||
|
||||
<template #emptyState>
|
||||
<CustomToolsPageEmptyState @click="openCreateDialog" />
|
||||
</template>
|
||||
|
||||
<template #body>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div
|
||||
v-if="showSoftLimitWarning"
|
||||
class="flex items-center gap-2 px-4 py-3 text-sm rounded-lg bg-n-amber-2 text-n-amber-11"
|
||||
>
|
||||
<span class="i-lucide-triangle-alert size-4 shrink-0" />
|
||||
{{ $t('CAPTAIN.CUSTOM_TOOLS.SOFT_LIMIT_WARNING') }}
|
||||
</div>
|
||||
<CustomToolCard
|
||||
v-for="tool in customTools"
|
||||
:id="tool.id"
|
||||
|
||||
@@ -6,13 +6,26 @@ const createConversationAPI = async content => {
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendMessageAPI = async (content, replyTo = null) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo);
|
||||
const sendMessageAPI = async (
|
||||
content,
|
||||
replyTo = null,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendMessage(content, replyTo, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
const sendAttachmentAPI = async (attachment, replyTo = null) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, replyTo);
|
||||
const sendAttachmentAPI = async (
|
||||
attachment,
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const urlData = endPoints.sendAttachment(attachment, {
|
||||
customAttributes,
|
||||
labels,
|
||||
});
|
||||
return API.post(urlData.url, urlData.params);
|
||||
};
|
||||
|
||||
|
||||
@@ -22,23 +22,30 @@ const createConversation = params => {
|
||||
};
|
||||
};
|
||||
|
||||
const sendMessage = (content, replyTo) => {
|
||||
const sendMessage = (content, replyTo, { customAttributes, labels } = {}) => {
|
||||
const referrerURL = window.referrerURL || '';
|
||||
const search = buildSearchParamsWithLocale(window.location.search);
|
||||
return {
|
||||
url: `/api/v1/widget/messages${search}`,
|
||||
params: {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
const params = {
|
||||
message: {
|
||||
content,
|
||||
reply_to: replyTo,
|
||||
timestamp: new Date().toString(),
|
||||
referer_url: referrerURL,
|
||||
},
|
||||
};
|
||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
||||
params.custom_attributes = customAttributes;
|
||||
}
|
||||
if (labels && labels.length > 0) {
|
||||
params.labels = labels;
|
||||
}
|
||||
return { url: `/api/v1/widget/messages${search}`, params };
|
||||
};
|
||||
|
||||
const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
const sendAttachment = (
|
||||
{ attachment, replyTo = null },
|
||||
{ customAttributes, labels } = {}
|
||||
) => {
|
||||
const { referrerURL = '' } = window;
|
||||
const timestamp = new Date().toString();
|
||||
const { file } = attachment;
|
||||
@@ -55,6 +62,16 @@ const sendAttachment = ({ attachment, replyTo = null }) => {
|
||||
if (replyTo !== null) {
|
||||
formData.append('message[reply_to]', replyTo);
|
||||
}
|
||||
if (customAttributes && Object.keys(customAttributes).length > 0) {
|
||||
Object.entries(customAttributes).forEach(([key, value]) => {
|
||||
formData.append(`custom_attributes[${key}]`, value);
|
||||
});
|
||||
}
|
||||
if (labels && labels.length > 0) {
|
||||
labels.forEach(label => {
|
||||
formData.append('labels[]', label);
|
||||
});
|
||||
}
|
||||
return {
|
||||
url: `/api/v1/widget/messages${window.location.search}`,
|
||||
params: formData,
|
||||
|
||||
@@ -32,6 +32,50 @@ describe('#sendMessage', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#sendMessage with pending metadata', () => {
|
||||
it('includes custom_attributes and labels in payload', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.sendMessage('hello', null, {
|
||||
customAttributes: { plan: 'enterprise' },
|
||||
labels: ['vip'],
|
||||
});
|
||||
|
||||
expect(result.params.custom_attributes).toEqual({ plan: 'enterprise' });
|
||||
expect(result.params.labels).toEqual(['vip']);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
it('does not include metadata keys when not provided', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => ({
|
||||
toString: () => 'mock date',
|
||||
}));
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
...window.location,
|
||||
search: '?param=1',
|
||||
});
|
||||
|
||||
window.WOOT_WIDGET = {
|
||||
$root: { $i18n: { locale: 'ar' } },
|
||||
};
|
||||
|
||||
const result = endPoints.sendMessage('hello');
|
||||
expect(result.params.custom_attributes).toBeUndefined();
|
||||
expect(result.params.labels).toBeUndefined();
|
||||
spy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('#getConversation', () => {
|
||||
it('returns correct payload', () => {
|
||||
vi.spyOn(window, 'location', 'get').mockReturnValue({
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
html,
|
||||
body {
|
||||
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
|
||||
@apply antialiased h-full;
|
||||
}
|
||||
|
||||
.is-mobile {
|
||||
|
||||
@@ -85,10 +85,9 @@ export default {
|
||||
},
|
||||
methods: {
|
||||
async retrySendMessage() {
|
||||
await this.$store.dispatch(
|
||||
'conversation/sendMessageWithData',
|
||||
this.message
|
||||
);
|
||||
await this.$store.dispatch('conversation/sendMessageWithData', {
|
||||
message: this.message,
|
||||
});
|
||||
},
|
||||
onImageLoadError() {
|
||||
this.hasImageError = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { computed, watchEffect } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
|
||||
const isDarkModeAuto = mode => mode === 'auto';
|
||||
@@ -23,10 +23,6 @@ export function useDarkMode() {
|
||||
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
|
||||
);
|
||||
|
||||
watchEffect(() => {
|
||||
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
|
||||
});
|
||||
|
||||
return {
|
||||
darkMode,
|
||||
prefersDarkMode,
|
||||
|
||||
@@ -30,18 +30,37 @@ export const actions = {
|
||||
commit('setConversationUIFlag', { isCreating: false });
|
||||
}
|
||||
},
|
||||
sendMessage: async ({ dispatch }, params) => {
|
||||
sendMessage: async ({ dispatch, state: conversationState }, params) => {
|
||||
const { content, replyTo } = params;
|
||||
const message = createTemporaryMessage({ content, replyTo });
|
||||
dispatch('sendMessageWithData', message);
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
dispatch('sendMessageWithData', {
|
||||
message,
|
||||
pendingCustomAttributes,
|
||||
pendingLabels,
|
||||
});
|
||||
},
|
||||
sendMessageWithData: async ({ commit }, message) => {
|
||||
sendMessageWithData: async (
|
||||
{ commit },
|
||||
{ message, pendingCustomAttributes = {}, pendingLabels = [] }
|
||||
) => {
|
||||
const { id, content, replyTo, meta = {} } = message;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', message);
|
||||
commit('updateMessageMeta', { id, meta: { ...meta, error: '' } });
|
||||
try {
|
||||
const { data } = await sendMessageAPI(content, replyTo);
|
||||
const { data } = await sendMessageAPI(content, replyTo, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
|
||||
// [VITE] Don't delete this manually, since `pushMessageToConversation` does the replacement for us anyway
|
||||
// commit('deleteMessage', message.id);
|
||||
@@ -59,7 +78,7 @@ export const actions = {
|
||||
commit('setLastMessageId');
|
||||
},
|
||||
|
||||
sendAttachment: async ({ commit }, params) => {
|
||||
sendAttachment: async ({ commit, state: conversationState }, params) => {
|
||||
const {
|
||||
attachment: { thumbUrl, fileType },
|
||||
meta = {},
|
||||
@@ -74,9 +93,22 @@ export const actions = {
|
||||
attachments: [attachment],
|
||||
replyTo: params.replyTo,
|
||||
});
|
||||
const { pendingCustomAttributes, pendingLabels } = conversationState;
|
||||
const hasPendingMetadata =
|
||||
Object.keys(pendingCustomAttributes).length > 0 ||
|
||||
pendingLabels.length > 0;
|
||||
|
||||
commit('pushMessageToConversation', tempMessage);
|
||||
try {
|
||||
const { data } = await sendAttachmentAPI(params);
|
||||
const { data } = await sendAttachmentAPI(params, {
|
||||
customAttributes: hasPendingMetadata
|
||||
? pendingCustomAttributes
|
||||
: undefined,
|
||||
labels: hasPendingMetadata ? pendingLabels : undefined,
|
||||
});
|
||||
if (hasPendingMetadata) {
|
||||
commit('clearPendingConversationMetadata');
|
||||
}
|
||||
commit('updateAttachmentMessageStatus', {
|
||||
message: data,
|
||||
tempId: tempMessage.id,
|
||||
@@ -180,7 +212,14 @@ export const actions = {
|
||||
await toggleStatus();
|
||||
},
|
||||
|
||||
setCustomAttributes: async (_, customAttributes = {}) => {
|
||||
setCustomAttributes: async (
|
||||
{ commit, rootGetters },
|
||||
customAttributes = {}
|
||||
) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('setPendingCustomAttributes', customAttributes);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await setCustomAttributes(customAttributes);
|
||||
} catch (error) {
|
||||
@@ -188,7 +227,11 @@ export const actions = {
|
||||
}
|
||||
},
|
||||
|
||||
deleteCustomAttribute: async (_, customAttribute) => {
|
||||
deleteCustomAttribute: async ({ commit, rootGetters }, customAttribute) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('removePendingCustomAttribute', customAttribute);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await deleteCustomAttribute(customAttribute);
|
||||
} catch (error) {
|
||||
|
||||
@@ -33,6 +33,8 @@ export const getters = {
|
||||
messages: groupConversationBySender(conversationGroupedByDate[date]),
|
||||
}));
|
||||
},
|
||||
getPendingCustomAttributes: _state => _state.pendingCustomAttributes,
|
||||
getPendingLabels: _state => _state.pendingLabels,
|
||||
getIsFetchingList: _state => _state.uiFlags.isFetchingList,
|
||||
getMessageCount: _state => {
|
||||
return Object.values(_state.conversations).length;
|
||||
|
||||
@@ -14,6 +14,8 @@ const state = {
|
||||
isCreating: false,
|
||||
},
|
||||
lastMessageId: null,
|
||||
pendingCustomAttributes: {},
|
||||
pendingLabels: [],
|
||||
};
|
||||
|
||||
export default {
|
||||
|
||||
@@ -4,6 +4,8 @@ import { findUndeliveredMessage } from './helpers';
|
||||
export const mutations = {
|
||||
clearConversations($state) {
|
||||
$state.conversations = {};
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
pushMessageToConversation($state, message) {
|
||||
const { id, status, message_type: type } = message;
|
||||
@@ -113,4 +115,31 @@ export const mutations = {
|
||||
const { id } = lastMessage;
|
||||
$state.lastMessageId = id;
|
||||
},
|
||||
|
||||
setPendingCustomAttributes($state, data) {
|
||||
$state.pendingCustomAttributes = {
|
||||
...$state.pendingCustomAttributes,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
setPendingLabels($state, label) {
|
||||
if (!$state.pendingLabels.includes(label)) {
|
||||
$state.pendingLabels.push(label);
|
||||
}
|
||||
},
|
||||
|
||||
removePendingCustomAttribute($state, key) {
|
||||
const { [key]: _, ...rest } = $state.pendingCustomAttributes;
|
||||
$state.pendingCustomAttributes = rest;
|
||||
},
|
||||
|
||||
removePendingLabel($state, label) {
|
||||
$state.pendingLabels = $state.pendingLabels.filter(l => l !== label);
|
||||
},
|
||||
|
||||
clearPendingConversationMetadata($state) {
|
||||
$state.pendingCustomAttributes = {};
|
||||
$state.pendingLabels = [];
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,14 +5,22 @@ const state = {};
|
||||
export const getters = {};
|
||||
|
||||
export const actions = {
|
||||
create: async (_, label) => {
|
||||
create: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/setPendingLabels', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.create(label);
|
||||
} catch (error) {
|
||||
// Ignore error
|
||||
}
|
||||
},
|
||||
destroy: async (_, label) => {
|
||||
destroy: async ({ commit, rootGetters }, label) => {
|
||||
if (!rootGetters['conversationAttributes/getConversationParams']?.id) {
|
||||
commit('conversation/removePendingLabel', label, { root: true });
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await conversationLabels.destroy(label);
|
||||
} catch (error) {
|
||||
|
||||
@@ -111,20 +111,45 @@ describe('#actions', () => {
|
||||
search: '?param=1',
|
||||
},
|
||||
}));
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
await actions.sendMessage(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ content: 'hello', replyTo: 124 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
windowSpy.mockRestore();
|
||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
status: 'in_progress',
|
||||
message: {
|
||||
attachments: undefined,
|
||||
content: 'hello',
|
||||
created_at: 1466424490,
|
||||
id: '1111',
|
||||
message_type: 0,
|
||||
replyTo: 124,
|
||||
status: 'in_progress',
|
||||
},
|
||||
pendingCustomAttributes: {},
|
||||
pendingLabels: [],
|
||||
});
|
||||
});
|
||||
|
||||
it('includes pending metadata when available', async () => {
|
||||
const mockDate = new Date(1466424490000);
|
||||
getUuid.mockImplementationOnce(() => '2222');
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
await actions.sendMessage(
|
||||
{ commit, dispatch, state },
|
||||
{ content: 'hello' }
|
||||
);
|
||||
spy.mockRestore();
|
||||
expect(dispatch).toBeCalledWith('sendMessageWithData', {
|
||||
message: expect.objectContaining({ content: 'hello' }),
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -136,9 +161,10 @@ describe('#actions', () => {
|
||||
const spy = vi.spyOn(global, 'Date').mockImplementation(() => mockDate);
|
||||
const thumbUrl = '';
|
||||
const attachment = { thumbUrl, fileType: 'file' };
|
||||
const state = { pendingCustomAttributes: {}, pendingLabels: [] };
|
||||
|
||||
actions.sendAttachment(
|
||||
{ commit, dispatch },
|
||||
{ commit, dispatch, state },
|
||||
{ attachment, replyTo: 135 }
|
||||
);
|
||||
spy.mockRestore();
|
||||
@@ -180,6 +206,58 @@ describe('#actions', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setCustomAttributes', () => {
|
||||
it('queues to pending state when no conversation exists', async () => {
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
await actions.setCustomAttributes(
|
||||
{ commit, rootGetters },
|
||||
{ plan: 'enterprise' }
|
||||
);
|
||||
expect(commit).toBeCalledWith('setPendingCustomAttributes', {
|
||||
plan: 'enterprise',
|
||||
});
|
||||
});
|
||||
|
||||
it('calls API when conversation exists', async () => {
|
||||
API.post.mockResolvedValue({ data: {} });
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: 123 },
|
||||
};
|
||||
await actions.setCustomAttributes(
|
||||
{ commit, rootGetters },
|
||||
{ plan: 'enterprise' }
|
||||
);
|
||||
expect(commit).not.toBeCalledWith(
|
||||
'setPendingCustomAttributes',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#deleteCustomAttribute', () => {
|
||||
it('removes from pending state when no conversation exists', async () => {
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: '' },
|
||||
};
|
||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
||||
expect(commit).toBeCalledWith('removePendingCustomAttribute', 'plan');
|
||||
});
|
||||
|
||||
it('calls API when conversation exists', async () => {
|
||||
API.post.mockResolvedValue({ data: {} });
|
||||
const rootGetters = {
|
||||
'conversationAttributes/getConversationParams': { id: 123 },
|
||||
};
|
||||
await actions.deleteCustomAttribute({ commit, rootGetters }, 'plan');
|
||||
expect(commit).not.toBeCalledWith(
|
||||
'removePendingCustomAttribute',
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('sends correct mutations', () => {
|
||||
actions.clearConversations({ commit });
|
||||
|
||||
@@ -169,10 +169,77 @@ describe('#mutations', () => {
|
||||
});
|
||||
|
||||
describe('#clearConversations', () => {
|
||||
it('clears the state', () => {
|
||||
const state = { conversations: { 1: { id: 1 } } };
|
||||
it('clears conversations and pending metadata', () => {
|
||||
const state = {
|
||||
conversations: { 1: { id: 1 } },
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearConversations(state);
|
||||
expect(state.conversations).toEqual({});
|
||||
expect(state.pendingCustomAttributes).toEqual({});
|
||||
expect(state.pendingLabels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPendingCustomAttributes', () => {
|
||||
it('merges custom attributes into pending state', () => {
|
||||
const state = { pendingCustomAttributes: { existing: 'value' } };
|
||||
mutations.setPendingCustomAttributes(state, { plan: 'enterprise' });
|
||||
expect(state.pendingCustomAttributes).toEqual({
|
||||
existing: 'value',
|
||||
plan: 'enterprise',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('#setPendingLabels', () => {
|
||||
it('adds label to pending state', () => {
|
||||
const state = { pendingLabels: [] };
|
||||
mutations.setPendingLabels(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
|
||||
it('does not add duplicate labels', () => {
|
||||
const state = { pendingLabels: ['vip'] };
|
||||
mutations.setPendingLabels(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removePendingCustomAttribute', () => {
|
||||
it('removes a single key from pending custom attributes', () => {
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise', region: 'us' },
|
||||
};
|
||||
mutations.removePendingCustomAttribute(state, 'plan');
|
||||
expect(state.pendingCustomAttributes).toEqual({ region: 'us' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('#removePendingLabel', () => {
|
||||
it('removes a label from pending labels', () => {
|
||||
const state = { pendingLabels: ['vip', 'premium'] };
|
||||
mutations.removePendingLabel(state, 'vip');
|
||||
expect(state.pendingLabels).toEqual(['premium']);
|
||||
});
|
||||
|
||||
it('does nothing if label not present', () => {
|
||||
const state = { pendingLabels: ['vip'] };
|
||||
mutations.removePendingLabel(state, 'premium');
|
||||
expect(state.pendingLabels).toEqual(['vip']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#clearPendingConversationMetadata', () => {
|
||||
it('clears pending custom attributes and labels', () => {
|
||||
const state = {
|
||||
pendingCustomAttributes: { plan: 'enterprise' },
|
||||
pendingLabels: ['vip'],
|
||||
};
|
||||
mutations.clearPendingConversationMetadata(state);
|
||||
expect(state.pendingCustomAttributes).toEqual({});
|
||||
expect(state.pendingLabels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ export default {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="bg-n-solid-1 h-full">
|
||||
<div class="bg-white h-full">
|
||||
<IframeLoader :url="$route.query.link" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user