Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
40a8cc8498 | ||
|
|
1df5fd513a | ||
|
|
37d3c0803b | ||
|
|
6525d93370 | ||
|
|
4627aad56d | ||
|
|
f23d95e004 | ||
|
|
1ef945de7b | ||
|
|
92fc286ecb | ||
|
|
dedb0426fb | ||
|
|
eb0410af53 | ||
|
|
3ded13c8d5 | ||
|
|
f1079574e3 | ||
|
|
73ad27e94c | ||
|
|
6a83cad69d | ||
|
|
7e0507e3b5 | ||
|
|
94a9e4e067 | ||
|
|
7b20ecd27b | ||
|
|
e635122ff6 | ||
|
|
fa07b9158a | ||
|
|
46eb6f39f3 | ||
|
|
f0538b25ed | ||
|
|
389b864020 | ||
|
|
26778156dc | ||
|
|
48627da0f9 | ||
|
|
6a712b7592 | ||
|
|
e9c60aec04 | ||
|
|
a8e9acfae9 | ||
|
|
f5908b0f8a |
@@ -220,6 +220,7 @@ ANDROID_SHA256_CERT_FINGERPRINT=AC:73:8E:DE:EB:56:EA:CC:10:87:02:A7:65:37:7B:38:
|
||||
## https://github.com/DataDog/dd-trace-rb/blob/master/docs/GettingStarted.md#environment-variables
|
||||
# DD_TRACE_AGENT_URL=
|
||||
|
||||
|
||||
# MaxMindDB API key to download GeoLite2 City database
|
||||
# IP_LOOKUP_API_KEY=
|
||||
|
||||
|
||||
@@ -99,3 +99,5 @@ CLAUDE.local.md
|
||||
# Histoire deployment
|
||||
.netlify
|
||||
.histoire
|
||||
.pnpm-store/*
|
||||
local/
|
||||
|
||||
+1
-1
@@ -39,7 +39,7 @@ exclude_patterns = [
|
||||
"**/target/**",
|
||||
"**/templates/**",
|
||||
"**/testdata/**",
|
||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/captain/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||
"**/vendor/**", "spec/", "**/specs/**/**", "**/spec/**/**", "db/*", "bin/**/*", "db/**/*", "config/**/*", "public/**/*", "vendor/**/*", "node_modules/**/*", "lib/tasks/auto_annotate_models.rake", "app/test-matchers.js", "docs/*", "**/*.md", "**/*.yml", "app/javascript/dashboard/i18n/locale", "**/*.stories.js", "stories/", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/index.js", "app/javascript/shared/constants/countries.js", "app/javascript/dashboard/components/widgets/conversation/advancedFilterItems/languages.js", "app/javascript/dashboard/routes/dashboard/contacts/contactFilterItems/index.js", "app/javascript/dashboard/routes/dashboard/settings/automation/constants.js", "app/javascript/dashboard/components/widgets/FilterInput/FilterOperatorTypes.js", "app/javascript/dashboard/routes/dashboard/settings/reports/constants.js", "app/javascript/dashboard/store/storeFactory.js", "app/javascript/dashboard/i18n/index.js", "app/javascript/widget/i18n/index.js", "app/javascript/survey/i18n/index.js", "app/javascript/shared/constants/locales.js", "app/javascript/dashboard/helper/specs/macrosFixtures.js", "app/javascript/dashboard/routes/dashboard/settings/macros/constants.js", "**/fixtures/**", "**/*/fixtures.js",
|
||||
]
|
||||
|
||||
test_patterns = [
|
||||
|
||||
+5
-1
@@ -7,6 +7,7 @@ plugins:
|
||||
require:
|
||||
- ./rubocop/use_from_email.rb
|
||||
- ./rubocop/custom_cop_location.rb
|
||||
- ./rubocop/one_class_per_file.rb
|
||||
|
||||
Layout/LineLength:
|
||||
Max: 150
|
||||
@@ -87,7 +88,7 @@ Metrics/ModuleLength:
|
||||
Rails/HelperInstanceVariable:
|
||||
Exclude:
|
||||
- enterprise/app/helpers/captain/chat_helper.rb
|
||||
|
||||
- 'enterprise/app/helpers/captain/tool_execution_helper.rb'
|
||||
Rails/ApplicationController:
|
||||
Exclude:
|
||||
- 'app/controllers/api/v1/widget/messages_controller.rb'
|
||||
@@ -205,6 +206,9 @@ UseFromEmail:
|
||||
CustomCopLocation:
|
||||
Enabled: true
|
||||
|
||||
Style/OneClassPerFile:
|
||||
Enabled: true
|
||||
|
||||
AllCops:
|
||||
NewCops: enable
|
||||
Exclude:
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
- **Test Ruby**: `bundle exec rspec spec/path/to/file_spec.rb`
|
||||
- **Single Test**: `bundle exec rspec spec/path/to/file_spec.rb:LINE_NUMBER`
|
||||
- **Run Project**: `overmind start -f Procfile.dev`
|
||||
- **Ruby Version**: Manage Ruby via `rbenv` and install the version listed in `.ruby-version` (e.g., `rbenv install $(cat .ruby-version)`)
|
||||
- **rbenv setup**: Before running any `bundle` or `rspec` commands, init rbenv in your shell (`eval "$(rbenv init -)"`) so the correct Ruby/Bundler versions are used
|
||||
- Always prefer `bundle exec` for Ruby CLI tasks (rspec, rake, rubocop, etc.)
|
||||
|
||||
## Code Style
|
||||
|
||||
@@ -37,6 +40,8 @@
|
||||
|
||||
- MVP focus: Least code change, happy-path only
|
||||
- No unnecessary defensive programming
|
||||
- Ship the happy path first: limit guards/fallbacks to what production has proven necessary, then iterate
|
||||
- Prefer minimal, readable code over elaborate abstractions; clarity beats cleverness
|
||||
- Break down complex tasks into small, testable units
|
||||
- Iterate after confirmation
|
||||
- Avoid writing specs unless explicitly asked
|
||||
|
||||
@@ -196,6 +196,10 @@ gem 'ai-agents', '>= 0.4.3'
|
||||
# TODO: Move this gem as a dependency of ai-agents
|
||||
gem 'ruby_llm-schema'
|
||||
|
||||
# OpenTelemetry for LLM observability
|
||||
gem 'opentelemetry-sdk'
|
||||
gem 'opentelemetry-exporter-otlp'
|
||||
|
||||
gem 'shopify_api'
|
||||
|
||||
### Gems required only in specific deployment environments ###
|
||||
|
||||
@@ -625,6 +625,25 @@ GEM
|
||||
faraday (>= 1.0, < 3)
|
||||
multi_json (>= 1.0)
|
||||
openssl (3.2.0)
|
||||
opentelemetry-api (1.7.0)
|
||||
opentelemetry-common (0.23.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
opentelemetry-exporter-otlp (0.31.1)
|
||||
google-protobuf (>= 3.18)
|
||||
googleapis-common-protos-types (~> 1.3)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-sdk (~> 1.10)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-registry (0.4.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-sdk (1.10.0)
|
||||
opentelemetry-api (~> 1.1)
|
||||
opentelemetry-common (~> 0.20)
|
||||
opentelemetry-registry (~> 0.2)
|
||||
opentelemetry-semantic_conventions
|
||||
opentelemetry-semantic_conventions (1.36.0)
|
||||
opentelemetry-api (~> 1.0)
|
||||
orm_adapter (0.5.0)
|
||||
os (1.1.4)
|
||||
ostruct (0.6.1)
|
||||
@@ -1073,6 +1092,8 @@ DEPENDENCIES
|
||||
omniauth-rails_csrf_protection (~> 1.0, >= 1.0.2)
|
||||
omniauth-saml
|
||||
opensearch-ruby
|
||||
opentelemetry-exporter-otlp
|
||||
opentelemetry-sdk
|
||||
pg
|
||||
pg_search
|
||||
pgvector
|
||||
|
||||
@@ -103,3 +103,5 @@ class ContactInboxBuilder
|
||||
@inbox.email? || @inbox.sms? || @inbox.twilio? || @inbox.whatsapp?
|
||||
end
|
||||
end
|
||||
|
||||
ContactInboxBuilder.prepend_mod_with('ContactInboxBuilder')
|
||||
|
||||
@@ -138,6 +138,7 @@ class Messages::MessageBuilder
|
||||
private: @private,
|
||||
sender: sender,
|
||||
content_type: @params[:content_type],
|
||||
content_attributes: content_attributes.presence,
|
||||
items: @items,
|
||||
in_reply_to: @in_reply_to,
|
||||
echo_id: @params[:echo_id],
|
||||
@@ -222,3 +223,5 @@ class Messages::MessageBuilder
|
||||
})
|
||||
end
|
||||
end
|
||||
|
||||
Messages::MessageBuilder.prepend_mod_with('Messages::MessageBuilder')
|
||||
|
||||
@@ -47,6 +47,12 @@ class ContactAPI extends ApiClient {
|
||||
return axios.get(`${this.url}/${contactId}/labels`);
|
||||
}
|
||||
|
||||
initiateCall(contactId, inboxId) {
|
||||
return axios.post(`${this.url}/${contactId}/call`, {
|
||||
inbox_id: inboxId,
|
||||
});
|
||||
}
|
||||
|
||||
updateContactLabels(contactId, labels) {
|
||||
return axios.post(`${this.url}/${contactId}/labels`, { labels });
|
||||
}
|
||||
|
||||
@@ -57,6 +57,12 @@ class OpenAIAPI extends ApiClient {
|
||||
content,
|
||||
};
|
||||
|
||||
// Always include conversation_display_id when available for session tracking
|
||||
if (conversationId) {
|
||||
data.conversation_display_id = conversationId;
|
||||
}
|
||||
|
||||
// For conversation-level events, only send conversation_display_id
|
||||
if (this.conversation_events.includes(type)) {
|
||||
data = {
|
||||
conversation_display_id: conversationId,
|
||||
|
||||
@@ -102,6 +102,7 @@ const closeMobileSidebar = () => {
|
||||
/>
|
||||
<VoiceCallButton
|
||||
:phone="selectedContact?.phoneNumber"
|
||||
:contact-id="contactId"
|
||||
:label="$t('CONTACT_PANEL.CALL')"
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
<script setup>
|
||||
import { computed, ref, useAttrs } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useMapGetter, useStore } from 'dashboard/composables/store';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
import { useAlert } from 'dashboard/composables';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||
|
||||
const props = defineProps({
|
||||
phone: { type: String, default: '' },
|
||||
contactId: { type: [String, Number], required: true },
|
||||
label: { type: String, default: '' },
|
||||
icon: { type: [String, Object, Function], default: '' },
|
||||
size: { type: String, default: 'sm' },
|
||||
@@ -18,10 +21,17 @@ const props = defineProps({
|
||||
|
||||
defineOptions({ inheritAttrs: false });
|
||||
const attrs = useAttrs();
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const store = useStore();
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const dialogRef = ref(null);
|
||||
|
||||
const inboxesList = useMapGetter('inboxes/getInboxes');
|
||||
const contactsUiFlags = useMapGetter('contacts/getUIFlags');
|
||||
|
||||
const voiceInboxes = computed(() =>
|
||||
(inboxesList.value || []).filter(
|
||||
inbox => inbox.channel_type === INBOX_TYPES.VOICE
|
||||
@@ -32,20 +42,51 @@ const hasVoiceInboxes = computed(() => voiceInboxes.value.length > 0);
|
||||
// Unified behavior: hide when no phone
|
||||
const shouldRender = computed(() => hasVoiceInboxes.value && !!props.phone);
|
||||
|
||||
const dialogRef = ref(null);
|
||||
const isInitiatingCall = computed(() => {
|
||||
return contactsUiFlags.value?.isInitiatingCall || false;
|
||||
});
|
||||
|
||||
const onClick = () => {
|
||||
const navigateToConversation = conversationId => {
|
||||
const accountId = route.params.accountId;
|
||||
if (conversationId && accountId) {
|
||||
const path = frontendURL(
|
||||
conversationUrl({
|
||||
accountId,
|
||||
id: conversationId,
|
||||
})
|
||||
);
|
||||
router.push({ path });
|
||||
}
|
||||
};
|
||||
|
||||
const startCall = async inboxId => {
|
||||
if (isInitiatingCall.value) return;
|
||||
|
||||
try {
|
||||
const response = await store.dispatch('contacts/initiateCall', {
|
||||
contactId: props.contactId,
|
||||
inboxId,
|
||||
});
|
||||
useAlert(t('CONTACT_PANEL.CALL_INITIATED'));
|
||||
navigateToConversation(response?.conversation_id);
|
||||
} catch (error) {
|
||||
const apiError = error?.message;
|
||||
useAlert(apiError || t('CONTACT_PANEL.CALL_FAILED'));
|
||||
}
|
||||
};
|
||||
|
||||
const onClick = async () => {
|
||||
if (voiceInboxes.value.length > 1) {
|
||||
dialogRef.value?.open();
|
||||
return;
|
||||
}
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
const [inbox] = voiceInboxes.value;
|
||||
await startCall(inbox.id);
|
||||
};
|
||||
|
||||
const onPickInbox = () => {
|
||||
// Placeholder until actual call wiring happens
|
||||
useAlert(t('CONTACT_PANEL.CALL_UNDER_DEVELOPMENT'));
|
||||
const onPickInbox = async inbox => {
|
||||
dialogRef.value?.close();
|
||||
await startCall(inbox.id);
|
||||
};
|
||||
</script>
|
||||
|
||||
@@ -55,6 +96,8 @@ const onPickInbox = () => {
|
||||
v-if="shouldRender"
|
||||
v-tooltip.top-end="tooltipLabel || null"
|
||||
v-bind="attrs"
|
||||
:disabled="isInitiatingCall"
|
||||
:is-loading="isInitiatingCall"
|
||||
:label="label"
|
||||
:icon="icon"
|
||||
:size="size"
|
||||
|
||||
+1
@@ -118,6 +118,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.DESCRIPTION.PLACEHOLDER')"
|
||||
:message="formErrors.description"
|
||||
:message-type="formErrors.description ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
+3
@@ -108,6 +108,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.HANDOFF_MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.handoffMessage"
|
||||
:message-type="formErrors.handoffMessage ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
@@ -116,6 +117,7 @@ watch(
|
||||
:placeholder="t('CAPTAIN.ASSISTANTS.FORM.RESOLUTION_MESSAGE.PLACEHOLDER')"
|
||||
:message="formErrors.resolutionMessage"
|
||||
:message-type="formErrors.resolutionMessage ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<Editor
|
||||
@@ -126,6 +128,7 @@ watch(
|
||||
:message="formErrors.instructions"
|
||||
:max-length="20000"
|
||||
:message-type="formErrors.instructions ? 'error' : 'info'"
|
||||
class="z-0"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
|
||||
@@ -1,41 +1,102 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
import { useMessageContext } from '../provider.js';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { MESSAGE_TYPES, VOICE_CALL_STATUS } from '../constants';
|
||||
|
||||
const { contentAttributes } = useMessageContext();
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
import BaseBubble from 'next/message/bubbles/Base.vue';
|
||||
|
||||
const LABEL_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const SUBTEXT_MAP = {
|
||||
[VOICE_CALL_STATUS.RINGING]: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const BG_COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'bg-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'bg-n-teal-9 animate-pulse',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'bg-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'bg-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'bg-n-ruby-9',
|
||||
};
|
||||
|
||||
const { contentAttributes, messageType } = useMessageContext();
|
||||
|
||||
const data = computed(() => contentAttributes.value?.data);
|
||||
const status = computed(() => data.value?.status?.toString());
|
||||
|
||||
const status = computed(() => data.value?.status);
|
||||
const direction = computed(() => data.value?.call_direction);
|
||||
const isOutbound = computed(() => messageType.value === MESSAGE_TYPES.OUTGOING);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(status.value)
|
||||
);
|
||||
|
||||
const { labelKey, subtextKey, bubbleIconBg, bubbleIconName } =
|
||||
useVoiceCallStatus(status, direction);
|
||||
|
||||
const containerRingClass = computed(() => {
|
||||
return status.value === 'ringing' ? 'ring-1 ring-emerald-300' : '';
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_MAP[status.value]) return LABEL_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
if (SUBTEXT_MAP[status.value]) return SUBTEXT_MAP[status.value];
|
||||
if (status.value === VOICE_CALL_STATUS.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.NO_ANSWER'
|
||||
: 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[status.value]) return ICON_MAP[status.value];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const bgColor = computed(() => BG_COLOR_MAP[status.value] || 'bg-n-teal-9');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BaseBubble class="p-0 border-none" hide-meta>
|
||||
<div
|
||||
class="flex overflow-hidden flex-col w-full max-w-xs bg-white rounded-lg border border-slate-100 text-slate-900 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-100"
|
||||
:class="containerRingClass"
|
||||
>
|
||||
<div class="flex overflow-hidden flex-col w-full max-w-xs">
|
||||
<div class="flex gap-3 items-center p-3 w-full">
|
||||
<div
|
||||
class="flex justify-center items-center rounded-full size-10 shrink-0"
|
||||
:class="bubbleIconBg"
|
||||
:class="bgColor"
|
||||
>
|
||||
<span class="text-xl" :class="bubbleIconName" />
|
||||
<Icon
|
||||
class="size-5"
|
||||
:icon="iconName"
|
||||
:class="{
|
||||
'text-n-slate-1': status === VOICE_CALL_STATUS.COMPLETED,
|
||||
'text-white': status !== VOICE_CALL_STATUS.COMPLETED,
|
||||
}"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex overflow-hidden flex-col flex-grow">
|
||||
<span class="text-base font-medium truncate">{{ $t(labelKey) }}</span>
|
||||
<span class="text-xs text-slate-500">{{ $t(subtextKey) }}</span>
|
||||
<span class="text-sm font-medium truncate text-n-slate-12">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
<span class="text-xs text-n-slate-11">
|
||||
{{ $t(subtextKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -73,3 +73,16 @@ export const MEDIA_TYPES = [
|
||||
ATTACHMENT_TYPES.AUDIO,
|
||||
ATTACHMENT_TYPES.IG_REEL,
|
||||
];
|
||||
|
||||
export const VOICE_CALL_STATUS = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
COMPLETED: 'completed',
|
||||
NO_ANSWER: 'no-answer',
|
||||
FAILED: 'failed',
|
||||
};
|
||||
|
||||
export const VOICE_CALL_DIRECTION = {
|
||||
INBOUND: 'inbound',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useNumberFormatter } from 'shared/composables/useNumberFormatter';
|
||||
|
||||
import Button from 'dashboard/components-next/button/Button.vue';
|
||||
|
||||
@@ -24,6 +25,7 @@ const props = defineProps({
|
||||
});
|
||||
const emit = defineEmits(['update:currentPage']);
|
||||
const { t } = useI18n();
|
||||
const { formatCompactNumber, formatFullNumber } = useNumberFormatter();
|
||||
|
||||
const totalPages = computed(() =>
|
||||
Math.ceil(props.totalItems / props.itemsPerPage)
|
||||
@@ -43,21 +45,27 @@ const changePage = newPage => {
|
||||
};
|
||||
|
||||
const currentPageInformation = computed(() => {
|
||||
const translationKey = props.currentPageInfo || 'PAGINATION_FOOTER.SHOWING';
|
||||
return t(
|
||||
props.currentPageInfo ? props.currentPageInfo : 'PAGINATION_FOOTER.SHOWING',
|
||||
translationKey,
|
||||
{
|
||||
startItem: startItem.value,
|
||||
endItem: endItem.value,
|
||||
totalItems: props.totalItems,
|
||||
}
|
||||
startItem: formatFullNumber(startItem.value),
|
||||
endItem: formatFullNumber(endItem.value),
|
||||
totalItems: formatCompactNumber(props.totalItems),
|
||||
},
|
||||
Number(props.totalItems)
|
||||
);
|
||||
});
|
||||
|
||||
const pageInfo = computed(() => {
|
||||
return t('PAGINATION_FOOTER.CURRENT_PAGE_INFO', {
|
||||
currentPage: '',
|
||||
totalPages: totalPages.value,
|
||||
});
|
||||
return t(
|
||||
'PAGINATION_FOOTER.CURRENT_PAGE_INFO',
|
||||
{
|
||||
currentPage: '',
|
||||
totalPages: formatCompactNumber(totalPages.value),
|
||||
},
|
||||
Number(totalPages.value)
|
||||
);
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -91,9 +99,11 @@ const pageInfo = computed(() => {
|
||||
/>
|
||||
<div class="inline-flex items-center gap-2 text-sm text-n-slate-11">
|
||||
<span class="px-3 tabular-nums py-0.5 bg-n-alpha-black2 rounded-md">
|
||||
{{ currentPage }}
|
||||
{{ formatFullNumber(currentPage) }}
|
||||
</span>
|
||||
<span class="truncate">
|
||||
{{ pageInfo }}
|
||||
</span>
|
||||
<span class="truncate">{{ pageInfo }}</span>
|
||||
</div>
|
||||
<Button
|
||||
icon="i-lucide-chevron-right"
|
||||
|
||||
@@ -3,7 +3,6 @@ import { computed, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import { getLastMessage } from 'dashboard/helper/conversationHelper';
|
||||
import { useVoiceCallStatus } from 'dashboard/composables/useVoiceCallStatus';
|
||||
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
|
||||
import Avatar from 'next/avatar/Avatar.vue';
|
||||
import MessagePreview from './MessagePreview.vue';
|
||||
@@ -14,6 +13,7 @@ import CardLabels from './conversationCardComponents/CardLabels.vue';
|
||||
import PriorityMark from './PriorityMark.vue';
|
||||
import SLACardLabel from './components/SLACardLabel.vue';
|
||||
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
|
||||
import VoiceCallStatus from './VoiceCallStatus.vue';
|
||||
|
||||
const props = defineProps({
|
||||
activeLabel: { type: String, default: '' },
|
||||
@@ -83,15 +83,10 @@ const isInboxNameVisible = computed(() => !activeInbox.value);
|
||||
|
||||
const lastMessageInChat = computed(() => getLastMessage(props.chat));
|
||||
|
||||
const callStatus = computed(
|
||||
() => props.chat.additional_attributes?.call_status
|
||||
);
|
||||
const callDirection = computed(
|
||||
() => props.chat.additional_attributes?.call_direction
|
||||
);
|
||||
|
||||
const { labelKey: voiceLabelKey, listIconColor: voiceIconColor } =
|
||||
useVoiceCallStatus(callStatus, callDirection);
|
||||
const voiceCallData = computed(() => ({
|
||||
status: props.chat.additional_attributes?.call_status,
|
||||
direction: props.chat.additional_attributes?.call_direction,
|
||||
}));
|
||||
|
||||
const inboxId = computed(() => props.chat.inbox_id);
|
||||
|
||||
@@ -317,20 +312,13 @@ const deleteConversation = () => {
|
||||
>
|
||||
{{ currentContact.name }}
|
||||
</h4>
|
||||
<div
|
||||
v-if="callStatus"
|
||||
<VoiceCallStatus
|
||||
v-if="voiceCallData.status"
|
||||
key="voice-status-row"
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<span
|
||||
class="inline-block -mt-0.5 align-middle text-[16px] i-ph-phone-incoming"
|
||||
:class="[voiceIconColor]"
|
||||
/>
|
||||
<span class="mx-1">
|
||||
{{ $t(voiceLabelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
:status="voiceCallData.status"
|
||||
:direction="voiceCallData.direction"
|
||||
:message-preview-class="messagePreviewClass"
|
||||
/>
|
||||
<MessagePreview
|
||||
v-else-if="lastMessageInChat"
|
||||
key="message-preview"
|
||||
|
||||
@@ -1,450 +1,462 @@
|
||||
<script>
|
||||
import { ref, provide } from 'vue';
|
||||
// composable
|
||||
<script setup>
|
||||
import {
|
||||
ref,
|
||||
computed,
|
||||
watch,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
nextTick,
|
||||
provide,
|
||||
getCurrentInstance,
|
||||
} from 'vue';
|
||||
|
||||
import { useConfig } from 'dashboard/composables/useConfig';
|
||||
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
|
||||
import { useAI } from 'dashboard/composables/useAI';
|
||||
import { useSnakeCase } from 'dashboard/composables/useTransformKeys';
|
||||
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||
import {
|
||||
useInbox,
|
||||
INBOX_FEATURES,
|
||||
INBOX_FEATURE_MAP,
|
||||
} from 'dashboard/composables/useInbox';
|
||||
import { useRoute } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
// components
|
||||
import ReplyBox from './ReplyBox.vue';
|
||||
import MessageList from 'next/message/MessageList.vue';
|
||||
import ConversationLabelSuggestion from './conversation/LabelSuggestion.vue';
|
||||
import Banner from 'dashboard/components/ui/Banner.vue';
|
||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||
|
||||
// stores and apis
|
||||
import { mapGetters } from 'vuex';
|
||||
|
||||
// mixins
|
||||
import inboxMixin, { INBOX_FEATURES } from 'shared/mixins/inboxMixin';
|
||||
|
||||
// utils
|
||||
import { emitter } from 'shared/helpers/mitt';
|
||||
import { getTypingUsersText } from '../../../helper/commons';
|
||||
import { calculateScrollTop } from './helpers/scrollTopCalculationHelper';
|
||||
import { LocalStorage } from 'shared/helpers/localStorage';
|
||||
import {
|
||||
filterDuplicateSourceMessages,
|
||||
getReadMessages,
|
||||
getUnreadMessages,
|
||||
} from 'dashboard/helper/conversationHelper';
|
||||
|
||||
// constants
|
||||
import { BUS_EVENTS } from 'shared/constants/busEvents';
|
||||
import { REPLY_POLICY } from 'shared/constants/links';
|
||||
import wootConstants from 'dashboard/constants/globals';
|
||||
import { LOCAL_STORAGE_KEYS } from 'dashboard/constants/localStorage';
|
||||
import { INBOX_TYPES } from 'dashboard/helper/inbox';
|
||||
|
||||
export default {
|
||||
components: {
|
||||
MessageList,
|
||||
ReplyBox,
|
||||
Banner,
|
||||
ConversationLabelSuggestion,
|
||||
Spinner,
|
||||
},
|
||||
mixins: [inboxMixin],
|
||||
setup() {
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const { isEnterprise } = useConfig();
|
||||
const store = useStore();
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const instance = getCurrentInstance();
|
||||
const { isEnterprise } = useConfig();
|
||||
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
const isPopOutReplyBox = ref(false);
|
||||
const conversationPanelRef = ref(null);
|
||||
const isLoadingPrevious = ref(true);
|
||||
const heightBeforeLoad = ref(null);
|
||||
const scrollTopBeforeLoad = ref(null);
|
||||
const conversationPanel = ref(null);
|
||||
const hasUserScrolled = ref(false);
|
||||
const isProgrammaticScroll = ref(false);
|
||||
const messageSentSinceOpened = ref(false);
|
||||
const labelSuggestions = ref([]);
|
||||
|
||||
const {
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
} = useAI();
|
||||
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
return {
|
||||
isEnterprise,
|
||||
isPopOutReplyBox,
|
||||
isAIIntegrationEnabled,
|
||||
isLabelSuggestionFeatureEnabled,
|
||||
fetchIntegrationsIfRequired,
|
||||
fetchLabelSuggestions,
|
||||
conversationPanelRef,
|
||||
};
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isLoadingPrevious: true,
|
||||
heightBeforeLoad: null,
|
||||
conversationPanel: null,
|
||||
hasUserScrolled: false,
|
||||
isProgrammaticScroll: false,
|
||||
messageSentSinceOpened: false,
|
||||
labelSuggestions: [],
|
||||
};
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters({
|
||||
currentChat: 'getSelectedChat',
|
||||
currentUserId: 'getCurrentUserID',
|
||||
listLoadingStatus: 'getAllMessagesLoaded',
|
||||
currentAccountId: 'getCurrentAccountId',
|
||||
}),
|
||||
isOpen() {
|
||||
return this.currentChat?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
},
|
||||
shouldShowLabelSuggestions() {
|
||||
return (
|
||||
this.isOpen &&
|
||||
this.isEnterprise &&
|
||||
this.isAIIntegrationEnabled &&
|
||||
!this.messageSentSinceOpened
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
},
|
||||
inbox() {
|
||||
return this.$store.getters['inboxes/getInbox'](this.inboxId);
|
||||
},
|
||||
typingUsersList() {
|
||||
const userList = this.$store.getters[
|
||||
'conversationTypingStatus/getUserList'
|
||||
](this.currentChat.id);
|
||||
return userList;
|
||||
},
|
||||
isAnyoneTyping() {
|
||||
const userList = this.typingUsersList;
|
||||
return userList.length !== 0;
|
||||
},
|
||||
typingUserNames() {
|
||||
const userList = this.typingUsersList;
|
||||
if (this.isAnyoneTyping) {
|
||||
const [i18nKey, params] = getTypingUsersText(userList);
|
||||
return this.$t(i18nKey, params);
|
||||
}
|
||||
|
||||
return '';
|
||||
},
|
||||
getMessages() {
|
||||
const messages = this.currentChat.messages || [];
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return filterDuplicateSourceMessages(messages);
|
||||
}
|
||||
return messages;
|
||||
},
|
||||
readMessages() {
|
||||
return getReadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
},
|
||||
unReadMessages() {
|
||||
return getUnreadMessages(
|
||||
this.getMessages,
|
||||
this.currentChat.agent_last_seen_at
|
||||
);
|
||||
},
|
||||
shouldShowSpinner() {
|
||||
return (
|
||||
(this.currentChat && this.currentChat.dataFetched === undefined) ||
|
||||
(!this.listLoadingStatus && this.isLoadingPrevious)
|
||||
);
|
||||
},
|
||||
// Check there is a instagram inbox exists with the same instagram_id
|
||||
hasDuplicateInstagramInbox() {
|
||||
const instagramId = this.inbox.instagram_id;
|
||||
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
||||
const instagramInbox =
|
||||
this.$store.getters['inboxes/getInstagramInboxByInstagramId'](
|
||||
instagramId
|
||||
);
|
||||
|
||||
return (
|
||||
this.inbox.channel_type === INBOX_TYPES.FB &&
|
||||
additionalAttributes.type === 'instagram_direct_message' &&
|
||||
instagramInbox
|
||||
);
|
||||
},
|
||||
|
||||
replyWindowBannerMessage() {
|
||||
if (this.isAWhatsAppChannel) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
}
|
||||
if (this.isAPIInbox) {
|
||||
const { additional_attributes: additionalAttributes = {} } = this.inbox;
|
||||
if (additionalAttributes) {
|
||||
const {
|
||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||
agent_reply_time_window: agentReplyTimeWindow,
|
||||
} = additionalAttributes;
|
||||
return (
|
||||
agentReplyTimeWindowMessage ||
|
||||
this.$t('CONVERSATION.API_HOURS_WINDOW', {
|
||||
hours: agentReplyTimeWindow,
|
||||
})
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return this.$t('CONVERSATION.CANNOT_REPLY');
|
||||
},
|
||||
replyWindowLink() {
|
||||
if (this.isAFacebookInbox || this.isAnInstagramChannel) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
}
|
||||
if (this.isAWhatsAppCloudChannel) {
|
||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
}
|
||||
return '';
|
||||
},
|
||||
replyWindowLinkText() {
|
||||
if (
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAFacebookInbox ||
|
||||
this.isAnInstagramChannel
|
||||
) {
|
||||
return this.$t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
if (!this.isAPIInbox) {
|
||||
return this.$t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||
}
|
||||
return '';
|
||||
},
|
||||
unreadMessageCount() {
|
||||
return this.currentChat.unread_count || 0;
|
||||
},
|
||||
unreadMessageLabel() {
|
||||
const count =
|
||||
this.unreadMessageCount > 9 ? '9+' : this.unreadMessageCount;
|
||||
const label =
|
||||
this.unreadMessageCount > 1
|
||||
? 'CONVERSATION.UNREAD_MESSAGES'
|
||||
: 'CONVERSATION.UNREAD_MESSAGE';
|
||||
return `${count} ${this.$t(label)}`;
|
||||
},
|
||||
inboxSupportsReplyTo() {
|
||||
const incoming = this.inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
const outgoing =
|
||||
this.inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
|
||||
!this.is360DialogWhatsAppChannel;
|
||||
|
||||
return { incoming, outgoing };
|
||||
},
|
||||
},
|
||||
|
||||
watch: {
|
||||
currentChat(newChat, oldChat) {
|
||||
if (newChat.id === oldChat.id) {
|
||||
return;
|
||||
}
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
this.messageSentSinceOpened = false;
|
||||
},
|
||||
},
|
||||
|
||||
created() {
|
||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
// when a new message comes in, we refetch the label suggestions
|
||||
emitter.on(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS, this.fetchSuggestions);
|
||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||
this.messageSentSinceOpened = true;
|
||||
});
|
||||
},
|
||||
|
||||
mounted() {
|
||||
this.addScrollListener();
|
||||
this.fetchAllAttachmentsFromCurrentChat();
|
||||
this.fetchSuggestions();
|
||||
},
|
||||
|
||||
unmounted() {
|
||||
this.removeBusListeners();
|
||||
this.removeScrollListener();
|
||||
},
|
||||
|
||||
methods: {
|
||||
async fetchSuggestions() {
|
||||
// start empty, this ensures that the label suggestions are not shown
|
||||
this.labelSuggestions = [];
|
||||
|
||||
if (this.isLabelSuggestionDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.isEnterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
// method available in mixin, need to ensure that integrations are present
|
||||
await this.fetchIntegrationsIfRequired();
|
||||
|
||||
if (!this.isLabelSuggestionFeatureEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.labelSuggestions = await this.fetchLabelSuggestions({
|
||||
conversationId: this.currentChat.id,
|
||||
});
|
||||
|
||||
// once the labels are fetched, we need to scroll to bottom
|
||||
// but we need to wait for the DOM to be updated
|
||||
// so we use the nextTick method
|
||||
this.$nextTick(() => {
|
||||
// this param is added to route, telling the UI to navigate to the message
|
||||
// it is triggered by the SCROLL_TO_MESSAGE method
|
||||
// see setActiveChat on ConversationView.vue for more info
|
||||
const { messageId } = this.$route.query;
|
||||
|
||||
// only trigger the scroll to bottom if the user has not scrolled
|
||||
// and there's no active messageId that is selected in view
|
||||
if (!messageId && !this.hasUserScrolled) {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
},
|
||||
isLabelSuggestionDismissed() {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
this.currentAccountId,
|
||||
this.currentChat.id
|
||||
);
|
||||
},
|
||||
fetchAllAttachmentsFromCurrentChat() {
|
||||
this.$store.dispatch('fetchAllAttachments', this.currentChat.id);
|
||||
},
|
||||
removeBusListeners() {
|
||||
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, this.onScrollToMessage);
|
||||
},
|
||||
onScrollToMessage({ messageId = '' } = {}) {
|
||||
this.$nextTick(() => {
|
||||
const messageElement = document.getElementById('message' + messageId);
|
||||
if (messageElement) {
|
||||
this.isProgrammaticScroll = true;
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
this.fetchPreviousMessages();
|
||||
} else {
|
||||
this.scrollToBottom();
|
||||
}
|
||||
});
|
||||
this.makeMessagesRead();
|
||||
},
|
||||
addScrollListener() {
|
||||
this.conversationPanel = this.$el.querySelector('.conversation-panel');
|
||||
this.setScrollParams();
|
||||
this.conversationPanel.addEventListener('scroll', this.handleScroll);
|
||||
this.$nextTick(() => this.scrollToBottom());
|
||||
this.isLoadingPrevious = false;
|
||||
},
|
||||
removeScrollListener() {
|
||||
this.conversationPanel.removeEventListener('scroll', this.handleScroll);
|
||||
},
|
||||
scrollToBottom() {
|
||||
this.isProgrammaticScroll = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
// label suggestions are not part of the messages list
|
||||
// so we need to handle them separately
|
||||
let labelSuggestions =
|
||||
this.conversationPanel.querySelector('.label-suggestion');
|
||||
|
||||
// if there are unread messages, scroll to the first unread message
|
||||
if (this.unreadMessageCount > 0) {
|
||||
// capturing only the unread messages
|
||||
relevantMessages =
|
||||
this.conversationPanel.querySelectorAll('.message--unread');
|
||||
} else if (labelSuggestions) {
|
||||
// when scrolling to the bottom, the label suggestions is below the last message
|
||||
// so we scroll there if there are no unread messages
|
||||
// Unread messages always take the highest priority
|
||||
relevantMessages = [labelSuggestions];
|
||||
} else {
|
||||
// if there are no unread messages or label suggestion, scroll to the last message
|
||||
// capturing last message from the messages list
|
||||
relevantMessages = Array.from(
|
||||
this.conversationPanel.querySelectorAll('.message--read')
|
||||
).slice(-1);
|
||||
}
|
||||
|
||||
this.conversationPanel.scrollTop = calculateScrollTop(
|
||||
this.conversationPanel.scrollHeight,
|
||||
this.$el.scrollHeight,
|
||||
relevantMessages
|
||||
);
|
||||
},
|
||||
setScrollParams() {
|
||||
this.heightBeforeLoad = this.conversationPanel.scrollHeight;
|
||||
this.scrollTopBeforeLoad = this.conversationPanel.scrollTop;
|
||||
},
|
||||
|
||||
async fetchPreviousMessages(scrollTop = 0) {
|
||||
this.setScrollParams();
|
||||
const shouldLoadMoreMessages =
|
||||
this.currentChat.dataFetched === true &&
|
||||
!this.listLoadingStatus &&
|
||||
!this.isLoadingPrevious;
|
||||
|
||||
if (
|
||||
scrollTop < 100 &&
|
||||
!this.isLoadingPrevious &&
|
||||
shouldLoadMoreMessages
|
||||
) {
|
||||
this.isLoadingPrevious = true;
|
||||
try {
|
||||
await this.$store.dispatch('fetchPreviousMessages', {
|
||||
conversationId: this.currentChat.id,
|
||||
before: this.currentChat.messages[0].id,
|
||||
});
|
||||
const heightDifference =
|
||||
this.conversationPanel.scrollHeight - this.heightBeforeLoad;
|
||||
this.conversationPanel.scrollTop =
|
||||
this.scrollTopBeforeLoad + heightDifference;
|
||||
this.setScrollParams();
|
||||
} catch (error) {
|
||||
// Ignore Error
|
||||
} finally {
|
||||
this.isLoadingPrevious = false;
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
handleScroll(e) {
|
||||
if (this.isProgrammaticScroll) {
|
||||
// Reset the flag
|
||||
this.isProgrammaticScroll = false;
|
||||
this.hasUserScrolled = false;
|
||||
} else {
|
||||
this.hasUserScrolled = true;
|
||||
}
|
||||
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
|
||||
this.fetchPreviousMessages(e.target.scrollTop);
|
||||
},
|
||||
|
||||
makeMessagesRead() {
|
||||
this.$store.dispatch('markMessagesRead', { id: this.currentChat.id });
|
||||
},
|
||||
async handleMessageRetry(message) {
|
||||
if (!message) return;
|
||||
const payload = useSnakeCase(message);
|
||||
await this.$store.dispatch('sendMessageWithData', payload);
|
||||
const keyboardEvents = {
|
||||
Escape: {
|
||||
action: () => {
|
||||
isPopOutReplyBox.value = false;
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
useKeyboardEvents(keyboardEvents);
|
||||
|
||||
provide('contextMenuElementTarget', conversationPanelRef);
|
||||
|
||||
const currentChat = useMapGetter('getSelectedChat');
|
||||
const currentUserId = useMapGetter('getCurrentUserID');
|
||||
const listLoadingStatus = useMapGetter('getAllMessagesLoaded');
|
||||
const currentAccountId = useMapGetter('getCurrentAccountId');
|
||||
const instagramInboxByIdGetter = useMapGetter(
|
||||
'inboxes/getInstagramInboxByInstagramId'
|
||||
);
|
||||
const inboxGetter = useMapGetter('inboxes/getInbox');
|
||||
const typingUsersListGetter = useMapGetter(
|
||||
'conversationTypingStatus/getUserList'
|
||||
);
|
||||
|
||||
const isOpen = computed(() => {
|
||||
return currentChat.value?.status === wootConstants.STATUS_TYPE.OPEN;
|
||||
});
|
||||
|
||||
const shouldShowLabelSuggestions = computed(() => {
|
||||
return (
|
||||
isOpen.value &&
|
||||
isEnterprise &&
|
||||
isAIIntegrationEnabled.value &&
|
||||
!messageSentSinceOpened.value
|
||||
);
|
||||
});
|
||||
|
||||
const inboxId = computed(() => {
|
||||
return currentChat.value?.inbox_id;
|
||||
});
|
||||
|
||||
const inbox = computed(() => {
|
||||
return inboxGetter.value(inboxId.value);
|
||||
});
|
||||
|
||||
const {
|
||||
isAPIInbox,
|
||||
isAFacebookInbox,
|
||||
isAWhatsAppChannel,
|
||||
isAWhatsAppCloudChannel,
|
||||
isAnInstagramChannel,
|
||||
is360DialogWhatsAppChannel,
|
||||
isAnEmailChannel,
|
||||
} = useInbox();
|
||||
|
||||
const inboxHasFeature = feature => {
|
||||
const channelType = inbox.value?.channel_type;
|
||||
return INBOX_FEATURE_MAP[feature]?.includes(channelType) ?? false;
|
||||
};
|
||||
|
||||
const typingUsersList = computed(() => {
|
||||
const userList = typingUsersListGetter.value(currentChat.value.id);
|
||||
return userList;
|
||||
});
|
||||
|
||||
const isAnyoneTyping = computed(() => {
|
||||
const userList = typingUsersList.value;
|
||||
return userList.length !== 0;
|
||||
});
|
||||
|
||||
const typingUserNames = computed(() => {
|
||||
const userList = typingUsersList.value;
|
||||
if (isAnyoneTyping.value) {
|
||||
const [i18nKey, params] = getTypingUsersText(userList);
|
||||
return t(i18nKey, params);
|
||||
}
|
||||
|
||||
return '';
|
||||
});
|
||||
|
||||
const getMessages = computed(() => {
|
||||
const messages = currentChat.value.messages || [];
|
||||
if (isAWhatsAppChannel.value) {
|
||||
return filterDuplicateSourceMessages(messages);
|
||||
}
|
||||
return messages;
|
||||
});
|
||||
|
||||
const unReadMessages = computed(() => {
|
||||
return getUnreadMessages(
|
||||
getMessages.value,
|
||||
currentChat.value.agent_last_seen_at
|
||||
);
|
||||
});
|
||||
|
||||
const shouldShowSpinner = computed(() => {
|
||||
return (
|
||||
(currentChat.value && currentChat.value.dataFetched === undefined) ||
|
||||
(!listLoadingStatus.value && isLoadingPrevious.value)
|
||||
);
|
||||
});
|
||||
|
||||
// Check there is a instagram inbox exists with the same instagram_id
|
||||
const hasDuplicateInstagramInbox = computed(() => {
|
||||
const instagramId = inbox.value.instagram_id;
|
||||
const { additional_attributes: additionalAttributes = {} } = inbox.value;
|
||||
const instagramInbox = instagramInboxByIdGetter.value(instagramId);
|
||||
|
||||
return (
|
||||
inbox.value.channel_type === INBOX_TYPES.FB &&
|
||||
additionalAttributes.type === 'instagram_direct_message' &&
|
||||
instagramInbox
|
||||
);
|
||||
});
|
||||
|
||||
const replyWindowBannerMessage = computed(() => {
|
||||
if (isAWhatsAppChannel.value) {
|
||||
return t('CONVERSATION.TWILIO_WHATSAPP_CAN_REPLY');
|
||||
}
|
||||
if (isAPIInbox.value) {
|
||||
const { additional_attributes: additionalAttributes = {} } = inbox.value;
|
||||
if (additionalAttributes) {
|
||||
const {
|
||||
agent_reply_time_window_message: agentReplyTimeWindowMessage,
|
||||
agent_reply_time_window: agentReplyTimeWindow,
|
||||
} = additionalAttributes;
|
||||
return (
|
||||
agentReplyTimeWindowMessage ||
|
||||
t('CONVERSATION.API_HOURS_WINDOW', {
|
||||
hours: agentReplyTimeWindow,
|
||||
})
|
||||
);
|
||||
}
|
||||
return '';
|
||||
}
|
||||
return t('CONVERSATION.CANNOT_REPLY');
|
||||
});
|
||||
|
||||
const replyWindowLink = computed(() => {
|
||||
if (isAFacebookInbox.value || isAnInstagramChannel.value) {
|
||||
return REPLY_POLICY.FACEBOOK;
|
||||
}
|
||||
if (isAWhatsAppCloudChannel.value) {
|
||||
return REPLY_POLICY.WHATSAPP_CLOUD;
|
||||
}
|
||||
if (!isAPIInbox.value) {
|
||||
return REPLY_POLICY.TWILIO_WHATSAPP;
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const replyWindowLinkText = computed(() => {
|
||||
if (
|
||||
isAWhatsAppChannel.value ||
|
||||
isAFacebookInbox.value ||
|
||||
isAnInstagramChannel.value
|
||||
) {
|
||||
return t('CONVERSATION.24_HOURS_WINDOW');
|
||||
}
|
||||
if (!isAPIInbox.value) {
|
||||
return t('CONVERSATION.TWILIO_WHATSAPP_24_HOURS_WINDOW');
|
||||
}
|
||||
return '';
|
||||
});
|
||||
|
||||
const unreadMessageCount = computed(() => {
|
||||
return currentChat.value.unread_count || 0;
|
||||
});
|
||||
|
||||
const unreadMessageLabel = computed(() => {
|
||||
const count = unreadMessageCount.value > 9 ? '9+' : unreadMessageCount.value;
|
||||
const label =
|
||||
unreadMessageCount.value > 1
|
||||
? 'CONVERSATION.UNREAD_MESSAGES'
|
||||
: 'CONVERSATION.UNREAD_MESSAGE';
|
||||
return `${count} ${t(label)}`;
|
||||
});
|
||||
|
||||
const inboxSupportsReplyTo = computed(() => {
|
||||
const incoming = inboxHasFeature(INBOX_FEATURES.REPLY_TO);
|
||||
const outgoing =
|
||||
inboxHasFeature(INBOX_FEATURES.REPLY_TO_OUTGOING) &&
|
||||
!is360DialogWhatsAppChannel.value;
|
||||
|
||||
return { incoming, outgoing };
|
||||
});
|
||||
|
||||
const isLabelSuggestionDismissed = () => {
|
||||
return LocalStorage.getFlag(
|
||||
LOCAL_STORAGE_KEYS.DISMISSED_LABEL_SUGGESTIONS,
|
||||
currentAccountId.value,
|
||||
currentChat.value.id
|
||||
);
|
||||
};
|
||||
|
||||
const fetchAllAttachmentsFromCurrentChat = () => {
|
||||
store.dispatch('fetchAllAttachments', currentChat.value.id);
|
||||
};
|
||||
|
||||
const makeMessagesRead = () => {
|
||||
store.dispatch('markMessagesRead', { id: currentChat.value.id });
|
||||
};
|
||||
|
||||
const setScrollParams = () => {
|
||||
heightBeforeLoad.value = conversationPanel.value.scrollHeight;
|
||||
scrollTopBeforeLoad.value = conversationPanel.value.scrollTop;
|
||||
};
|
||||
|
||||
const scrollToBottom = () => {
|
||||
isProgrammaticScroll.value = true;
|
||||
let relevantMessages = [];
|
||||
|
||||
// label suggestions are not part of the messages list
|
||||
// so we need to handle them separately
|
||||
let labelSuggestionsEl =
|
||||
conversationPanel.value.querySelector('.label-suggestion');
|
||||
|
||||
// if there are unread messages, scroll to the first unread message
|
||||
if (unreadMessageCount.value > 0) {
|
||||
// capturing only the unread messages
|
||||
relevantMessages =
|
||||
conversationPanel.value.querySelectorAll('.message--unread');
|
||||
} else if (labelSuggestionsEl) {
|
||||
// when scrolling to the bottom, the label suggestions is below the last message
|
||||
// so we scroll there if there are no unread messages
|
||||
// Unread messages always take the highest priority
|
||||
relevantMessages = [labelSuggestionsEl];
|
||||
} else {
|
||||
// if there are no unread messages or label suggestion, scroll to the last message
|
||||
// capturing last message from the messages list
|
||||
relevantMessages = Array.from(
|
||||
conversationPanel.value.querySelectorAll('.message--read')
|
||||
).slice(-1);
|
||||
}
|
||||
|
||||
conversationPanel.value.scrollTop = calculateScrollTop(
|
||||
conversationPanel.value.scrollHeight,
|
||||
instance.proxy.$el.scrollHeight,
|
||||
relevantMessages
|
||||
);
|
||||
};
|
||||
|
||||
const fetchPreviousMessages = async (scrollTop = 0) => {
|
||||
setScrollParams();
|
||||
const shouldLoadMoreMessages =
|
||||
currentChat.value.dataFetched === true &&
|
||||
!listLoadingStatus.value &&
|
||||
!isLoadingPrevious.value;
|
||||
|
||||
if (scrollTop < 100 && !isLoadingPrevious.value && shouldLoadMoreMessages) {
|
||||
isLoadingPrevious.value = true;
|
||||
try {
|
||||
await store.dispatch('fetchPreviousMessages', {
|
||||
conversationId: currentChat.value.id,
|
||||
before: currentChat.value.messages[0].id,
|
||||
});
|
||||
const heightDifference =
|
||||
conversationPanel.value.scrollHeight - heightBeforeLoad.value;
|
||||
conversationPanel.value.scrollTop =
|
||||
scrollTopBeforeLoad.value + heightDifference;
|
||||
setScrollParams();
|
||||
} catch (error) {
|
||||
// Ignore Error
|
||||
} finally {
|
||||
isLoadingPrevious.value = false;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const onScrollToMessage = ({ messageId = '' } = {}) => {
|
||||
nextTick(() => {
|
||||
const messageElement = document.getElementById('message' + messageId);
|
||||
if (messageElement) {
|
||||
isProgrammaticScroll.value = true;
|
||||
messageElement.scrollIntoView({ behavior: 'smooth' });
|
||||
fetchPreviousMessages();
|
||||
} else {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
makeMessagesRead();
|
||||
};
|
||||
|
||||
const handleScroll = e => {
|
||||
if (isProgrammaticScroll.value) {
|
||||
// Reset the flag
|
||||
isProgrammaticScroll.value = false;
|
||||
hasUserScrolled.value = false;
|
||||
} else {
|
||||
hasUserScrolled.value = true;
|
||||
}
|
||||
emitter.emit(BUS_EVENTS.ON_MESSAGE_LIST_SCROLL);
|
||||
fetchPreviousMessages(e.target.scrollTop);
|
||||
};
|
||||
|
||||
const addScrollListener = () => {
|
||||
conversationPanel.value = instance.proxy.$el.querySelector(
|
||||
'.conversation-panel'
|
||||
);
|
||||
setScrollParams();
|
||||
conversationPanel.value.addEventListener('scroll', handleScroll);
|
||||
nextTick(() => scrollToBottom());
|
||||
isLoadingPrevious.value = false;
|
||||
};
|
||||
|
||||
const removeScrollListener = () => {
|
||||
conversationPanel.value.removeEventListener('scroll', handleScroll);
|
||||
};
|
||||
|
||||
const removeBusListeners = () => {
|
||||
emitter.off(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
|
||||
};
|
||||
|
||||
const fetchSuggestions = async () => {
|
||||
// start empty, this ensures that the label suggestions are not shown
|
||||
labelSuggestions.value = [];
|
||||
|
||||
if (isLabelSuggestionDismissed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isEnterprise) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Early exit if conversation already has labels - no need to suggest more
|
||||
const existingLabels = currentChat.value?.labels || [];
|
||||
if (existingLabels.length > 0) return;
|
||||
|
||||
// method available in mixin, need to ensure that integrations are present
|
||||
await fetchIntegrationsIfRequired();
|
||||
|
||||
if (!isLabelSuggestionFeatureEnabled.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
labelSuggestions.value = await fetchLabelSuggestions({
|
||||
conversationId: currentChat.value.id,
|
||||
});
|
||||
|
||||
// once the labels are fetched, we need to scroll to bottom
|
||||
// but we need to wait for the DOM to be updated
|
||||
// so we use the nextTick method
|
||||
nextTick(() => {
|
||||
// this param is added to route, telling the UI to navigate to the message
|
||||
// it is triggered by the SCROLL_TO_MESSAGE method
|
||||
// see setActiveChat on ConversationView.vue for more info
|
||||
const { messageId } = route.query;
|
||||
|
||||
// only trigger the scroll to bottom if the user has not scrolled
|
||||
// and there's no active messageId that is selected in view
|
||||
if (!messageId && !hasUserScrolled.value) {
|
||||
scrollToBottom();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const handleMessageRetry = async message => {
|
||||
if (!message) return;
|
||||
const payload = useSnakeCase(message);
|
||||
await store.dispatch('sendMessageWithData', payload);
|
||||
};
|
||||
|
||||
watch(currentChat, (newChat, oldChat) => {
|
||||
if (newChat.id === oldChat.id) {
|
||||
return;
|
||||
}
|
||||
fetchAllAttachmentsFromCurrentChat();
|
||||
fetchSuggestions();
|
||||
messageSentSinceOpened.value = false;
|
||||
});
|
||||
|
||||
onMounted(() => {
|
||||
emitter.on(BUS_EVENTS.SCROLL_TO_MESSAGE, onScrollToMessage);
|
||||
// when a message is sent we set the flag to true this hides the label suggestions,
|
||||
// until the chat is changed and the flag is reset in the watch for currentChat
|
||||
emitter.on(BUS_EVENTS.MESSAGE_SENT, () => {
|
||||
messageSentSinceOpened.value = true;
|
||||
});
|
||||
|
||||
addScrollListener();
|
||||
fetchAllAttachmentsFromCurrentChat();
|
||||
fetchSuggestions();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
removeBusListeners();
|
||||
removeScrollListener();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -461,7 +473,7 @@ export default {
|
||||
v-else-if="hasDuplicateInstagramInbox"
|
||||
color-scheme="alert"
|
||||
class="mx-2 mt-2 overflow-hidden rounded-lg"
|
||||
:banner-message="$t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
:banner-message="t('CONVERSATION.OLD_INSTAGRAM_INBOX_REPLY_BANNER')"
|
||||
/>
|
||||
<MessageList
|
||||
ref="conversationPanelRef"
|
||||
|
||||
@@ -174,19 +174,31 @@ export default {
|
||||
return false;
|
||||
},
|
||||
showWhatsappTemplates() {
|
||||
return this.isAWhatsAppCloudChannel && !this.isPrivate;
|
||||
// We support templates for API channels if someone updates templates manually via API
|
||||
// That's why we don't explicitly check for channel type here
|
||||
const templates = this.$store.getters['inboxes/getWhatsAppTemplates'](
|
||||
this.inboxId
|
||||
);
|
||||
return !!(templates && templates.length) && !this.isPrivate;
|
||||
},
|
||||
showContentTemplates() {
|
||||
return this.isATwilioWhatsAppChannel && !this.isPrivate;
|
||||
},
|
||||
isPrivate() {
|
||||
if (this.currentChat.can_reply || this.isAWhatsAppChannel) {
|
||||
if (
|
||||
this.currentChat.can_reply ||
|
||||
this.isAWhatsAppChannel ||
|
||||
this.isAPIInbox
|
||||
) {
|
||||
return this.isOnPrivateNote;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
isReplyRestricted() {
|
||||
return !this.currentChat?.can_reply && !this.isAWhatsAppChannel;
|
||||
return (
|
||||
!this.currentChat?.can_reply &&
|
||||
!(this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
);
|
||||
},
|
||||
inboxId() {
|
||||
return this.currentChat.inbox_id;
|
||||
@@ -442,7 +454,7 @@ export default {
|
||||
return;
|
||||
}
|
||||
|
||||
if (canReply || this.isAWhatsAppChannel) {
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox) {
|
||||
this.replyType = REPLY_EDITOR_MODES.REPLY;
|
||||
} else {
|
||||
this.replyType = REPLY_EDITOR_MODES.NOTE;
|
||||
@@ -861,7 +873,8 @@ export default {
|
||||
this.$store.dispatch('draftMessages/setReplyEditorMode', {
|
||||
mode,
|
||||
});
|
||||
if (canReply || this.isAWhatsAppChannel) this.replyType = mode;
|
||||
if (canReply || this.isAWhatsAppChannel || this.isAPIInbox)
|
||||
this.replyType = mode;
|
||||
if (this.showRichContentEditor) {
|
||||
if (this.isRecordingAudio) {
|
||||
this.toggleAudioRecorder();
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import {
|
||||
VOICE_CALL_STATUS,
|
||||
VOICE_CALL_DIRECTION,
|
||||
} from 'dashboard/components-next/message/constants';
|
||||
import Icon from 'dashboard/components-next/icon/Icon.vue';
|
||||
|
||||
const props = defineProps({
|
||||
status: { type: String, default: '' },
|
||||
direction: { type: String, default: '' },
|
||||
messagePreviewClass: { type: [String, Array, Object], default: '' },
|
||||
});
|
||||
|
||||
const LABEL_KEYS = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'CONVERSATION.VOICE_CALL.CALL_ENDED',
|
||||
};
|
||||
|
||||
const ICON_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'i-ph-phone-call',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'i-ph-phone-x',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'i-ph-phone-x',
|
||||
};
|
||||
|
||||
const COLOR_MAP = {
|
||||
[VOICE_CALL_STATUS.IN_PROGRESS]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.RINGING]: 'text-n-teal-9',
|
||||
[VOICE_CALL_STATUS.COMPLETED]: 'text-n-slate-11',
|
||||
[VOICE_CALL_STATUS.NO_ANSWER]: 'text-n-ruby-9',
|
||||
[VOICE_CALL_STATUS.FAILED]: 'text-n-ruby-9',
|
||||
};
|
||||
|
||||
const isOutbound = computed(
|
||||
() => props.direction === VOICE_CALL_DIRECTION.OUTBOUND
|
||||
);
|
||||
const isFailed = computed(() =>
|
||||
[VOICE_CALL_STATUS.NO_ANSWER, VOICE_CALL_STATUS.FAILED].includes(props.status)
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
if (LABEL_KEYS[props.status]) return LABEL_KEYS[props.status];
|
||||
if (props.status === VOICE_CALL_STATUS.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
return isFailed.value
|
||||
? 'CONVERSATION.VOICE_CALL.MISSED_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const iconName = computed(() => {
|
||||
if (ICON_MAP[props.status]) return ICON_MAP[props.status];
|
||||
return isOutbound.value ? 'i-ph-phone-outgoing' : 'i-ph-phone-incoming';
|
||||
});
|
||||
|
||||
const statusColor = computed(
|
||||
() => COLOR_MAP[props.status] || 'text-n-slate-11'
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="my-0 mx-2 leading-6 h-6 flex-1 min-w-0 text-sm overflow-hidden text-ellipsis whitespace-nowrap"
|
||||
:class="messagePreviewClass"
|
||||
>
|
||||
<Icon
|
||||
class="inline-block -mt-0.5 align-middle size-4"
|
||||
:icon="iconName"
|
||||
:class="statusColor"
|
||||
/>
|
||||
<span class="mx-1" :class="statusColor">
|
||||
{{ $t(labelKey) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -1,161 +0,0 @@
|
||||
import { computed, unref } from 'vue';
|
||||
|
||||
const CALL_STATUSES = {
|
||||
IN_PROGRESS: 'in-progress',
|
||||
RINGING: 'ringing',
|
||||
NO_ANSWER: 'no-answer',
|
||||
BUSY: 'busy',
|
||||
FAILED: 'failed',
|
||||
COMPLETED: 'completed',
|
||||
CANCELED: 'canceled',
|
||||
};
|
||||
|
||||
const CALL_DIRECTIONS = {
|
||||
INBOUND: 'inbound',
|
||||
OUTBOUND: 'outbound',
|
||||
};
|
||||
|
||||
/**
|
||||
* Composable for handling voice call status display logic
|
||||
* @param {Ref|string} statusRef - Call status (ringing, in-progress, etc.)
|
||||
* @param {Ref|string} directionRef - Call direction (inbound, outbound)
|
||||
* @returns {Object} UI properties for displaying call status
|
||||
*/
|
||||
export function useVoiceCallStatus(statusRef, directionRef) {
|
||||
const status = computed(() => unref(statusRef)?.toString());
|
||||
const direction = computed(() => unref(directionRef)?.toString());
|
||||
|
||||
// Status group helpers
|
||||
const isFailedStatus = computed(() =>
|
||||
[
|
||||
CALL_STATUSES.NO_ANSWER,
|
||||
CALL_STATUSES.BUSY,
|
||||
CALL_STATUSES.FAILED,
|
||||
].includes(status.value)
|
||||
);
|
||||
const isEndedStatus = computed(() =>
|
||||
[CALL_STATUSES.COMPLETED, CALL_STATUSES.CANCELED].includes(status.value)
|
||||
);
|
||||
const isOutbound = computed(
|
||||
() => direction.value === CALL_DIRECTIONS.OUTBOUND
|
||||
);
|
||||
|
||||
const labelKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.CALL_IN_PROGRESS';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.OUTGOING_CALL'
|
||||
: 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.NO_ANSWER) {
|
||||
return 'CONVERSATION.VOICE_CALL.MISSED_CALL';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.INCOMING_CALL';
|
||||
});
|
||||
|
||||
const subtextKey = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.RINGING) {
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
}
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'CONVERSATION.VOICE_CALL.THEY_ANSWERED'
|
||||
: 'CONVERSATION.VOICE_CALL.YOU_ANSWERED';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.NO_ANSWER';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'CONVERSATION.VOICE_CALL.CALL_ENDED';
|
||||
}
|
||||
|
||||
return 'CONVERSATION.VOICE_CALL.NOT_ANSWERED_YET';
|
||||
});
|
||||
|
||||
const bubbleIconName = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'i-ph-phone-x-fill';
|
||||
}
|
||||
|
||||
// For ringing/completed/canceled show direction when possible
|
||||
return isOutbound.value
|
||||
? 'i-ph-phone-outgoing-fill'
|
||||
: 'i-ph-phone-incoming-fill';
|
||||
});
|
||||
|
||||
const bubbleIconBg = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS) {
|
||||
return 'bg-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'bg-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'bg-n-slate-11';
|
||||
}
|
||||
|
||||
// default (e.g., ringing)
|
||||
return 'bg-n-teal-9 animate-pulse';
|
||||
});
|
||||
|
||||
const listIconColor = computed(() => {
|
||||
const s = status.value;
|
||||
|
||||
if (s === CALL_STATUSES.IN_PROGRESS || s === CALL_STATUSES.RINGING) {
|
||||
return 'text-n-teal-9';
|
||||
}
|
||||
|
||||
if (isFailedStatus.value) {
|
||||
return 'text-n-ruby-9';
|
||||
}
|
||||
|
||||
if (isEndedStatus.value) {
|
||||
return 'text-n-slate-11';
|
||||
}
|
||||
|
||||
return 'text-n-teal-9';
|
||||
});
|
||||
|
||||
return {
|
||||
status,
|
||||
labelKey,
|
||||
subtextKey,
|
||||
bubbleIconName,
|
||||
bubbleIconBg,
|
||||
listIconColor,
|
||||
};
|
||||
}
|
||||
@@ -26,7 +26,7 @@
|
||||
},
|
||||
"COMPANIES_LAYOUT": {
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} companies"
|
||||
"SHOWING": "Showing {startItem} – {endItem} of {totalItems} company | Showing {startItem} – {endItem} of {totalItems} companies"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} items",
|
||||
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} pages"
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} item | Showing {startItem} - {endItem} of {totalItems} items",
|
||||
"CURRENT_PAGE_INFO": "{currentPage} of {totalPages} page | {currentPage} of {totalPages} pages"
|
||||
},
|
||||
"COMBOBOX": {
|
||||
"PLACEHOLDER": "Select an option...",
|
||||
|
||||
@@ -18,7 +18,8 @@
|
||||
"CREATED_AT_LABEL": "Created",
|
||||
"NEW_MESSAGE": "New message",
|
||||
"CALL": "Call",
|
||||
"CALL_UNDER_DEVELOPMENT": "Calling is under development",
|
||||
"CALL_INITIATED": "Calling the contact…",
|
||||
"CALL_FAILED": "Unable to start the call. Please try again.",
|
||||
"VOICE_INBOX_PICKER": {
|
||||
"TITLE": "Choose a voice inbox"
|
||||
},
|
||||
@@ -376,7 +377,7 @@
|
||||
}
|
||||
},
|
||||
"PAGINATION_FOOTER": {
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
"SHOWING": "Showing {startItem} - {endItem} of {totalItems} contact | Showing {startItem} - {endItem} of {totalItems} contacts"
|
||||
},
|
||||
"FILTER": {
|
||||
"NAME": "Name",
|
||||
|
||||
@@ -13,6 +13,8 @@ import InboxPageEmptyState from 'dashboard/components-next/captain/pageComponent
|
||||
const store = useStore();
|
||||
const dialogType = ref('');
|
||||
const route = useRoute();
|
||||
|
||||
const assistantId = computed(() => route.params.assistantId);
|
||||
const assistantUiFlags = useMapGetter('captainAssistants/getUIFlags');
|
||||
const uiFlags = useMapGetter('captainInboxes/getUIFlags');
|
||||
const isFetchingAssistant = computed(() => assistantUiFlags.value.fetchingItem);
|
||||
@@ -47,10 +49,9 @@ const handleCreateClose = () => {
|
||||
selectedInbox.value = null;
|
||||
};
|
||||
|
||||
const assistantId = Number(route.params.assistantId);
|
||||
onMounted(() =>
|
||||
store.dispatch('captainInboxes/get', {
|
||||
assistantId: assistantId,
|
||||
assistantId: assistantId.value,
|
||||
})
|
||||
);
|
||||
</script>
|
||||
|
||||
@@ -34,7 +34,7 @@ const selectedResponse = ref(null);
|
||||
const deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedAssistantId = Number(route.params.assistantId);
|
||||
const selectedAssistantId = computed(() => route.params.assistantId);
|
||||
const dialogType = ref('');
|
||||
const searchQuery = ref('');
|
||||
const { t } = useI18n();
|
||||
@@ -45,7 +45,7 @@ const backUrl = computed(() => ({
|
||||
name: 'captain_assistants_responses_index',
|
||||
params: {
|
||||
accountId: route.params.accountId,
|
||||
assistantId: selectedAssistantId,
|
||||
assistantId: selectedAssistantId.value,
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -125,8 +125,8 @@ const updateURLWithFilters = (page, search) => {
|
||||
const fetchResponses = (page = 1) => {
|
||||
const filterParams = { page, status: 'pending' };
|
||||
|
||||
if (selectedAssistantId) {
|
||||
filterParams.assistantId = selectedAssistantId;
|
||||
if (selectedAssistantId.value) {
|
||||
filterParams.assistantId = selectedAssistantId.value;
|
||||
}
|
||||
if (searchQuery.value) {
|
||||
filterParams.search = searchQuery.value;
|
||||
|
||||
@@ -1,11 +1,10 @@
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, reactive } from 'vue';
|
||||
import { useStore } from 'vuex';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMapGetter } from 'dashboard/composables/store';
|
||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||
import { debounce } from '@chatwoot/utils';
|
||||
import { useCompaniesStore } from 'dashboard/stores/companies';
|
||||
|
||||
import CompaniesListLayout from 'dashboard/components-next/Companies/CompaniesListLayout.vue';
|
||||
import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/CompaniesCard.vue';
|
||||
@@ -13,13 +12,18 @@ import CompaniesCard from 'dashboard/components-next/Companies/CompaniesCard/Com
|
||||
const DEFAULT_SORT_FIELD = 'created_at';
|
||||
const DEBOUNCE_DELAY = 300;
|
||||
|
||||
const store = useStore();
|
||||
const companiesStore = useCompaniesStore();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { t } = useI18n();
|
||||
|
||||
const { updateUISettings, uiSettings } = useUISettings();
|
||||
|
||||
const companies = computed(() => companiesStore.getCompaniesList);
|
||||
const meta = computed(() => companiesStore.getMeta);
|
||||
const uiFlags = computed(() => companiesStore.getUIFlags);
|
||||
|
||||
const searchQuery = computed(() => route.query?.search || '');
|
||||
const searchValue = ref(searchQuery.value);
|
||||
const pageNumber = computed(() => Number(route.query?.page) || 1);
|
||||
@@ -46,10 +50,6 @@ const sortState = reactive({
|
||||
const activeSort = computed(() => sortState.activeSort);
|
||||
const activeOrdering = computed(() => sortState.activeOrdering);
|
||||
|
||||
const companies = useMapGetter('companies/getCompaniesList');
|
||||
const meta = useMapGetter('companies/getMeta');
|
||||
const uiFlags = useMapGetter('companies/getUIFlags');
|
||||
|
||||
const isFetchingList = computed(() => uiFlags.value.fetchingList);
|
||||
|
||||
const buildSortAttr = () =>
|
||||
@@ -89,13 +89,13 @@ const fetchCompanies = async (page, search, sort) => {
|
||||
}
|
||||
|
||||
if (currentSearch) {
|
||||
await store.dispatch('companies/search', {
|
||||
await companiesStore.search({
|
||||
search: currentSearch,
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
} else {
|
||||
await store.dispatch('companies/get', {
|
||||
await companiesStore.get({
|
||||
page: currentPage,
|
||||
sort: currentSort,
|
||||
});
|
||||
|
||||
@@ -282,6 +282,7 @@ export default {
|
||||
</ComposeConversation>
|
||||
<VoiceCallButton
|
||||
:phone="contact.phone_number"
|
||||
:contact-id="contact.id"
|
||||
icon="i-ri-phone-fill"
|
||||
size="sm"
|
||||
:tooltip-label="$t('CONTACT_PANEL.CALL')"
|
||||
|
||||
@@ -217,7 +217,7 @@ onMounted(handleBillingPageLogic);
|
||||
solid
|
||||
slate
|
||||
icon="i-lucide-life-buoy"
|
||||
@open="onToggleChatWindow"
|
||||
@click="onToggleChatWindow"
|
||||
>
|
||||
{{ $t('BILLING_SETTINGS.CHAT_WITH_US.BUTTON_TXT') }}
|
||||
</ButtonV4>
|
||||
|
||||
+5
-4
@@ -52,11 +52,12 @@ const dayMenuItemConfigs = computed(() => [
|
||||
},
|
||||
]);
|
||||
|
||||
const resolvedLocale = computed(
|
||||
() =>
|
||||
const resolvedLocale = computed(() => {
|
||||
const currentLocale =
|
||||
locale.value ||
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
|
||||
);
|
||||
(typeof navigator !== 'undefined' ? navigator.language : 'en');
|
||||
return currentLocale.replace('_', '-');
|
||||
});
|
||||
|
||||
const monthFormatter = computed(
|
||||
() =>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainAssistantAPI from 'dashboard/api/captain/assistant';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainAssistant',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopilotMessagesAPI from 'dashboard/api/captain/copilotMessages';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotMessages',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CopilotThreadsAPI from 'dashboard/api/captain/copilotThreads';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CopilotThreads',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainCustomTools from 'dashboard/api/captain/customTools';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainDocumentAPI from 'dashboard/api/captain/document';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
export default createStore({
|
||||
name: 'CaptainDocument',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainInboxes from 'dashboard/api/captain/inboxes';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainResponseAPI from 'dashboard/api/captain/response';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
|
||||
const SET_PENDING_COUNT = 'SET_PENDING_COUNT';
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import CaptainScenarios from 'dashboard/api/captain/scenarios';
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export default createStore({
|
||||
|
||||
@@ -1,94 +0,0 @@
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
getRecords,
|
||||
showRecord,
|
||||
updateRecord,
|
||||
} from './storeFactoryHelper';
|
||||
|
||||
export const generateMutationTypes = name => {
|
||||
const capitalizedName = name.toUpperCase();
|
||||
return {
|
||||
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
|
||||
SET: `SET_${capitalizedName}`,
|
||||
ADD: `ADD_${capitalizedName}`,
|
||||
EDIT: `EDIT_${capitalizedName}`,
|
||||
DELETE: `DELETE_${capitalizedName}`,
|
||||
SET_META: `SET_${capitalizedName}_META`,
|
||||
UPSERT: `UPSERT_${capitalizedName}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const createInitialState = () => ({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const createGetters = () => ({
|
||||
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
|
||||
getRecord: state => id =>
|
||||
state.records.find(record => record.id === Number(id)) || {},
|
||||
getUIFlags: state => state.uiFlags,
|
||||
getMeta: state => state.meta,
|
||||
});
|
||||
|
||||
export const createMutations = mutationTypes => ({
|
||||
[mutationTypes.SET_UI_FLAG](state, data) {
|
||||
state.uiFlags = {
|
||||
...state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET]: MutationHelpers.set,
|
||||
[mutationTypes.ADD]: MutationHelpers.create,
|
||||
[mutationTypes.EDIT]: MutationHelpers.update,
|
||||
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
||||
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
|
||||
});
|
||||
|
||||
export const createCrudActions = (API, mutationTypes) => ({
|
||||
get: getRecords(mutationTypes, API),
|
||||
show: showRecord(mutationTypes, API),
|
||||
create: createRecord(mutationTypes, API),
|
||||
update: updateRecord(mutationTypes, API),
|
||||
delete: deleteRecord(mutationTypes, API),
|
||||
});
|
||||
|
||||
export const createStore = options => {
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
|
||||
return {
|
||||
namespaced: true,
|
||||
state: createInitialState(),
|
||||
getters: {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -1,380 +0,0 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
generateMutationTypes,
|
||||
createInitialState,
|
||||
createGetters,
|
||||
createMutations,
|
||||
createCrudActions,
|
||||
createStore,
|
||||
} from './storeFactory';
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
|
||||
set: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setSingleRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storeFactory', () => {
|
||||
describe('generateMutationTypes', () => {
|
||||
it('generates correct mutation types with capitalized name', () => {
|
||||
const result = generateMutationTypes('test');
|
||||
expect(result).toEqual({
|
||||
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
|
||||
SET: 'SET_TEST',
|
||||
ADD: 'ADD_TEST',
|
||||
EDIT: 'EDIT_TEST',
|
||||
DELETE: 'DELETE_TEST',
|
||||
SET_META: 'SET_TEST_META',
|
||||
UPSERT: 'UPSERT_TEST',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInitialState', () => {
|
||||
it('returns the correct initial state structure', () => {
|
||||
const result = createInitialState();
|
||||
expect(result).toEqual({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGetters', () => {
|
||||
it('returns getters with correct implementations', () => {
|
||||
const getters = createGetters();
|
||||
|
||||
const state = {
|
||||
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||
uiFlags: { fetchingList: true },
|
||||
meta: { totalCount: 10, page: 1 },
|
||||
};
|
||||
expect(getters.getRecords(state)).toEqual([
|
||||
{ id: 3 },
|
||||
{ id: 2 },
|
||||
{ id: 1 },
|
||||
]);
|
||||
|
||||
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
|
||||
expect(getters.getRecord(state)(4)).toEqual({});
|
||||
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
fetchingList: true,
|
||||
});
|
||||
|
||||
expect(getters.getMeta(state)).toEqual({
|
||||
totalCount: 10,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMutations', () => {
|
||||
it('creates mutations with correct implementations', () => {
|
||||
const mutationTypes = generateMutationTypes('test');
|
||||
const mutations = createMutations(mutationTypes);
|
||||
|
||||
const state = { uiFlags: { fetchingList: false } };
|
||||
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
|
||||
expect(state.uiFlags).toEqual({ fetchingList: true });
|
||||
|
||||
const metaState = { meta: {} };
|
||||
mutations[mutationTypes.SET_META](metaState, {
|
||||
total_count: '10',
|
||||
page: '2',
|
||||
});
|
||||
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
|
||||
|
||||
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
|
||||
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
|
||||
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
|
||||
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
|
||||
expect(mutations[mutationTypes.UPSERT]).toBe(
|
||||
MutationHelpers.setSingleRecord
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCrudActions', () => {
|
||||
let API;
|
||||
let commit;
|
||||
let mutationTypes;
|
||||
let actions;
|
||||
|
||||
beforeEach(() => {
|
||||
API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
commit = vi.fn();
|
||||
mutationTypes = generateMutationTypes('test');
|
||||
actions = createCrudActions(API, mutationTypes);
|
||||
});
|
||||
|
||||
describe('get action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const payload = [{ id: 1 }];
|
||||
const meta = { total_count: 10, page: 1 };
|
||||
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.get.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.show.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.create.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.create.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.update.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
API.delete.mockResolvedValue({});
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(API.delete).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.delete.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore', () => {
|
||||
it('creates a complete store with default options', () => {
|
||||
const API = {};
|
||||
const store = createStore({ name: 'test', API });
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toEqual(createInitialState());
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
]);
|
||||
expect(Object.keys(store.mutations)).toEqual([
|
||||
'SET_TEST_UI_FLAG',
|
||||
'SET_TEST_META',
|
||||
'SET_TEST',
|
||||
'ADD_TEST',
|
||||
'EDIT_TEST',
|
||||
'DELETE_TEST',
|
||||
'UPSERT_TEST',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a store with custom actions and getters', () => {
|
||||
const API = {};
|
||||
const customGetters = { customGetter: () => 'custom' };
|
||||
const customActions = () => ({
|
||||
customAction: () => 'custom',
|
||||
});
|
||||
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
getters: customGetters,
|
||||
actions: customActions,
|
||||
});
|
||||
|
||||
expect(store.getters).toHaveProperty('customGetter');
|
||||
expect(store.actions).toHaveProperty('customAction');
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
'customGetter',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'customAction',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,77 +0,0 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
export const getRecords =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, params = {}) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
commit(mutationTypes.SET, response.data.payload);
|
||||
commit(mutationTypes.SET_META, response.data.meta);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const showRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, dataObj) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
commit(mutationTypes.EDIT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
commit(mutationTypes.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createStore } from './storeFactory';
|
||||
import { createStore } from '../storeFactory';
|
||||
import CaptainToolsAPI from '../../api/captain/tools';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
|
||||
@@ -14,7 +14,6 @@ import bulkActions from './modules/bulkActions';
|
||||
import campaigns from './modules/campaigns';
|
||||
import cannedResponse from './modules/cannedResponse';
|
||||
import categories from './modules/helpCenterCategories';
|
||||
import companies from './modules/companies';
|
||||
import contactConversations from './modules/contactConversations';
|
||||
import contactLabels from './modules/contactLabels';
|
||||
import contactNotes from './modules/contactNotes';
|
||||
@@ -78,7 +77,6 @@ export default createStore({
|
||||
campaigns,
|
||||
cannedResponse,
|
||||
categories,
|
||||
companies,
|
||||
contactConversations,
|
||||
contactLabels,
|
||||
contactNotes,
|
||||
|
||||
@@ -1,29 +0,0 @@
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/captain/storeFactory';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export default createStore({
|
||||
name: 'Company',
|
||||
API: CompanyAPI,
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
},
|
||||
actions: mutationTypes => ({
|
||||
search: async ({ commit }, { search, page, sort }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
commit(mutationTypes.SET, payload);
|
||||
commit(mutationTypes.SET_META, meta);
|
||||
} catch (error) {
|
||||
// Error
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -302,4 +302,22 @@ export const actions = {
|
||||
clearContactFilters({ commit }) {
|
||||
commit(types.CLEAR_CONTACT_FILTERS);
|
||||
},
|
||||
|
||||
initiateCall: async ({ commit }, { contactId, inboxId }) => {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true });
|
||||
try {
|
||||
const response = await ContactAPI.initiateCall(contactId, inboxId);
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
commit(types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false });
|
||||
if (error.response?.data?.message) {
|
||||
throw new ExceptionWithMessage(error.response.data.message);
|
||||
} else if (error.response?.data?.error) {
|
||||
throw new ExceptionWithMessage(error.response.data.error);
|
||||
} else {
|
||||
throw new Error(error);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -17,6 +17,7 @@ const state = {
|
||||
isDeleting: false,
|
||||
isExporting: false,
|
||||
isImporting: false,
|
||||
isInitiatingCall: false,
|
||||
},
|
||||
sortOrder: [],
|
||||
appliedFilters: [],
|
||||
|
||||
@@ -194,7 +194,6 @@ export const mutations = {
|
||||
const { conversation: { unread_count: unreadCount = 0 } = {} } = message;
|
||||
chat.unread_count = unreadCount;
|
||||
if (selectedChatId === conversationId) {
|
||||
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
}
|
||||
}
|
||||
@@ -225,7 +224,6 @@ export const mutations = {
|
||||
const { messages, ...updates } = conversation;
|
||||
allConversations[index] = { ...selectedConversation, ...updates };
|
||||
if (_state.selectedChatId === conversation.id) {
|
||||
emitter.emit(BUS_EVENTS.FETCH_LABEL_SUGGESTIONS);
|
||||
emitter.emit(BUS_EVENTS.SCROLL_TO_MESSAGE);
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -359,4 +359,83 @@ describe('#actions', () => {
|
||||
).rejects.toThrow(Error);
|
||||
});
|
||||
});
|
||||
|
||||
describe('#initiateCall', () => {
|
||||
const contactId = 123;
|
||||
const inboxId = 456;
|
||||
|
||||
it('sends correct mutations if API is success', async () => {
|
||||
const mockResponse = {
|
||||
data: {
|
||||
conversation_id: 789,
|
||||
status: 'initiated',
|
||||
},
|
||||
};
|
||||
axios.post.mockResolvedValue(mockResponse);
|
||||
|
||||
const result = await actions.initiateCall(
|
||||
{ commit },
|
||||
{ contactId, inboxId }
|
||||
);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||
]);
|
||||
expect(result).toEqual(mockResponse.data);
|
||||
});
|
||||
|
||||
it('sends correct actions if API returns error with message', async () => {
|
||||
const errorMessage = 'Failed to initiate call';
|
||||
axios.post.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
message: errorMessage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||
).rejects.toThrow(ExceptionWithMessage);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends correct actions if API returns error with error field', async () => {
|
||||
const errorMessage = 'Call initiation error';
|
||||
axios.post.mockRejectedValue({
|
||||
response: {
|
||||
data: {
|
||||
error: errorMessage,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await expect(
|
||||
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||
).rejects.toThrow(ExceptionWithMessage);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||
]);
|
||||
});
|
||||
|
||||
it('sends correct actions if API returns generic error', async () => {
|
||||
axios.post.mockRejectedValue({ message: 'Network error' });
|
||||
|
||||
await expect(
|
||||
actions.initiateCall({ commit }, { contactId, inboxId })
|
||||
).rejects.toThrow(Error);
|
||||
|
||||
expect(commit.mock.calls).toEqual([
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: true }],
|
||||
[types.SET_CONTACT_UI_FLAG, { isInitiatingCall: false }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -753,7 +753,6 @@ describe('#mutations', () => {
|
||||
};
|
||||
|
||||
mutations[types.UPDATE_CONVERSATION](state, conversation);
|
||||
expect(emitter.emit).toHaveBeenCalledWith('FETCH_LABEL_SUGGESTIONS');
|
||||
expect(emitter.emit).toHaveBeenCalledWith('SCROLL_TO_MESSAGE');
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Universal Store Factory
|
||||
*
|
||||
* This factory creates stores for both Vuex and Pinia, allowing gradual
|
||||
* migration from Vuex to Pinia without breaking existing functionality.
|
||||
*
|
||||
* @module storeFactory
|
||||
* @see https://pinia.vuejs.org/ - Pinia documentation
|
||||
* @see https://vuex.vuejs.org/ - Vuex documentation
|
||||
*/
|
||||
|
||||
import { defineStore } from 'pinia';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
// Vuex helpers
|
||||
createRecord,
|
||||
deleteRecord,
|
||||
getRecords,
|
||||
showRecord,
|
||||
updateRecord,
|
||||
// Pinia helpers
|
||||
piniaGetRecords,
|
||||
piniaShowRecord,
|
||||
piniaCreateRecord,
|
||||
piniaUpdateRecord,
|
||||
piniaDeleteRecord,
|
||||
} from './storeFactoryHelper';
|
||||
|
||||
export const generateMutationTypes = name => {
|
||||
const capitalizedName = name.toUpperCase();
|
||||
return {
|
||||
SET_UI_FLAG: `SET_${capitalizedName}_UI_FLAG`,
|
||||
SET: `SET_${capitalizedName}`,
|
||||
ADD: `ADD_${capitalizedName}`,
|
||||
EDIT: `EDIT_${capitalizedName}`,
|
||||
DELETE: `DELETE_${capitalizedName}`,
|
||||
SET_META: `SET_${capitalizedName}_META`,
|
||||
UPSERT: `UPSERT_${capitalizedName}`,
|
||||
};
|
||||
};
|
||||
|
||||
export const createInitialState = () => ({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
|
||||
export const createGetters = () => ({
|
||||
getRecords: state => state.records.sort((r1, r2) => r2.id - r1.id),
|
||||
getRecord: state => id =>
|
||||
state.records.find(record => record.id === Number(id)) || {},
|
||||
getUIFlags: state => state.uiFlags,
|
||||
getMeta: state => state.meta,
|
||||
});
|
||||
|
||||
export const createMutations = mutationTypes => ({
|
||||
[mutationTypes.SET_UI_FLAG](state, data) {
|
||||
state.uiFlags = {
|
||||
...state.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET_META](state, meta) {
|
||||
state.meta = {
|
||||
...state.meta,
|
||||
totalCount: Number(meta.total_count),
|
||||
page: Number(meta.page),
|
||||
};
|
||||
},
|
||||
[mutationTypes.SET]: MutationHelpers.set,
|
||||
[mutationTypes.ADD]: MutationHelpers.create,
|
||||
[mutationTypes.EDIT]: MutationHelpers.update,
|
||||
[mutationTypes.DELETE]: MutationHelpers.destroy,
|
||||
[mutationTypes.UPSERT]: MutationHelpers.setSingleRecord,
|
||||
});
|
||||
|
||||
export const createCrudActions = (API, mutationTypes) => ({
|
||||
get: getRecords(mutationTypes, API),
|
||||
show: showRecord(mutationTypes, API),
|
||||
create: createRecord(mutationTypes, API),
|
||||
update: updateRecord(mutationTypes, API),
|
||||
delete: deleteRecord(mutationTypes, API),
|
||||
});
|
||||
|
||||
/**
|
||||
* Create Vuex store with standard CRUD operations
|
||||
*
|
||||
* @param {Object} options - Store configuration
|
||||
* @param {string} options.name - Store name
|
||||
* @param {Object} options.API - API client
|
||||
* @param {Object} [options.getters] - Custom getters
|
||||
* @param {Function} [options.actions] - Custom actions function
|
||||
* @param {Object} [options.mutations] - Custom mutations
|
||||
* @returns {Object} Vuex module configuration
|
||||
*/
|
||||
export const createVuexStore = options => {
|
||||
const { name, API, actions, getters, mutations } = options;
|
||||
|
||||
const mutationTypes = generateMutationTypes(name);
|
||||
const customActions = actions ? actions(mutationTypes) : {};
|
||||
|
||||
return {
|
||||
namespaced: true,
|
||||
state: createInitialState(),
|
||||
getters: {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
mutations: {
|
||||
...createMutations(mutationTypes),
|
||||
...(mutations || {}),
|
||||
},
|
||||
actions: {
|
||||
...createCrudActions(API, mutationTypes),
|
||||
...customActions,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Create Pinia store with standard CRUD operations
|
||||
*
|
||||
* @param {Object} options - Store configuration
|
||||
* @param {string} options.name - Store name
|
||||
* @param {Object} options.API - API client
|
||||
* @param {Object} [options.getters] - Custom getters
|
||||
* @param {Function} [options.actions] - Custom actions function
|
||||
* @returns {Function} Pinia store composable
|
||||
*/
|
||||
export const createPiniaStore = options => {
|
||||
const { name, API, actions, getters } = options;
|
||||
|
||||
return defineStore(name.toLowerCase(), {
|
||||
state: createInitialState,
|
||||
|
||||
getters: {
|
||||
...createGetters(),
|
||||
...(getters || {}),
|
||||
},
|
||||
|
||||
actions: {
|
||||
setUIFlag(data) {
|
||||
this.uiFlags = {
|
||||
...this.uiFlags,
|
||||
...data,
|
||||
};
|
||||
},
|
||||
|
||||
setMeta(meta) {
|
||||
this.meta = {
|
||||
...this.meta,
|
||||
totalCount: Number(meta.total_count || meta.totalCount || 0),
|
||||
page: Number(meta.page || 1),
|
||||
};
|
||||
},
|
||||
|
||||
async get(params) {
|
||||
return piniaGetRecords(this, API, params);
|
||||
},
|
||||
|
||||
async show(id) {
|
||||
return piniaShowRecord(this, API, id);
|
||||
},
|
||||
|
||||
async create(obj) {
|
||||
return piniaCreateRecord(this, API, obj);
|
||||
},
|
||||
|
||||
async update(payload) {
|
||||
return piniaUpdateRecord(this, API, payload);
|
||||
},
|
||||
|
||||
async delete(id) {
|
||||
return piniaDeleteRecord(this, API, id);
|
||||
},
|
||||
|
||||
...(actions ? actions() : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Universal Store Factory - Main Entry Point
|
||||
*
|
||||
* Creates either a Vuex or Pinia store based on the 'type' parameter.
|
||||
* Defaults to Vuex for backward compatibility.
|
||||
*
|
||||
* @param {Object} options - Store configuration
|
||||
* @param {string} options.name - Store name
|
||||
* @param {Object} options.API - API client for CRUD operations
|
||||
* @param {string} [options.type='vuex'] - Store type: 'vuex' or 'pinia'
|
||||
* @param {Object} [options.getters] - Custom getters
|
||||
* @param {Function} [options.actions] - Custom actions function
|
||||
* @param {Object} [options.mutations] - Custom mutations (Vuex only)
|
||||
*
|
||||
* @returns {Object|Function} Vuex module or Pinia store composable
|
||||
*
|
||||
* @example
|
||||
* Create Vuex store (default)
|
||||
* export default createStore({
|
||||
* name: 'Company',
|
||||
* API: CompanyAPI,
|
||||
* });
|
||||
*
|
||||
* @example
|
||||
* Create Pinia store
|
||||
* export const useCompaniesStore = createStore({
|
||||
* name: 'Company',
|
||||
* type: 'pinia',
|
||||
* API: CompanyAPI,
|
||||
* });
|
||||
*/
|
||||
export const createStore = options => {
|
||||
const { type = 'vuex' } = options;
|
||||
|
||||
if (type === 'pinia') {
|
||||
return createPiniaStore(options);
|
||||
}
|
||||
return createVuexStore(options);
|
||||
};
|
||||
@@ -0,0 +1,761 @@
|
||||
import { setActivePinia, createPinia } from 'pinia';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import * as MutationHelpers from 'shared/helpers/vuex/mutationHelpers';
|
||||
import {
|
||||
generateMutationTypes,
|
||||
createInitialState,
|
||||
createGetters,
|
||||
createMutations,
|
||||
createCrudActions,
|
||||
createStore,
|
||||
} from './storeFactory';
|
||||
|
||||
vi.mock('dashboard/store/utils/api', () => ({
|
||||
throwErrorMessage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('shared/helpers/vuex/mutationHelpers', () => ({
|
||||
set: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
destroy: vi.fn(),
|
||||
setSingleRecord: vi.fn(),
|
||||
}));
|
||||
|
||||
describe('storeFactory', () => {
|
||||
describe('createStore with type parameter', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
});
|
||||
|
||||
it('creates Vuex store by default', () => {
|
||||
const API = {};
|
||||
const store = createStore({ name: 'test', API });
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toBeDefined();
|
||||
expect(store.mutations).toBeDefined();
|
||||
});
|
||||
|
||||
it('creates Pinia store when type is "pinia"', () => {
|
||||
const API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
|
||||
expect(store.records).toBeDefined();
|
||||
expect(store.get).toBeTypeOf('function');
|
||||
expect(store.setUIFlag).toBeTypeOf('function');
|
||||
});
|
||||
|
||||
it('creates Vuex store when type is "vuex"', () => {
|
||||
const API = {};
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'vuex',
|
||||
});
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateMutationTypes', () => {
|
||||
it('generates correct mutation types with capitalized name', () => {
|
||||
const result = generateMutationTypes('test');
|
||||
expect(result).toEqual({
|
||||
SET_UI_FLAG: 'SET_TEST_UI_FLAG',
|
||||
SET: 'SET_TEST',
|
||||
ADD: 'ADD_TEST',
|
||||
EDIT: 'EDIT_TEST',
|
||||
DELETE: 'DELETE_TEST',
|
||||
SET_META: 'SET_TEST_META',
|
||||
UPSERT: 'UPSERT_TEST',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createInitialState', () => {
|
||||
it('returns the correct initial state structure', () => {
|
||||
const result = createInitialState();
|
||||
expect(result).toEqual({
|
||||
records: [],
|
||||
meta: {},
|
||||
uiFlags: {
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createGetters', () => {
|
||||
it('returns getters with correct implementations', () => {
|
||||
const getters = createGetters();
|
||||
|
||||
const state = {
|
||||
records: [{ id: 2 }, { id: 1 }, { id: 3 }],
|
||||
uiFlags: { fetchingList: true },
|
||||
meta: { totalCount: 10, page: 1 },
|
||||
};
|
||||
expect(getters.getRecords(state)).toEqual([
|
||||
{ id: 3 },
|
||||
{ id: 2 },
|
||||
{ id: 1 },
|
||||
]);
|
||||
|
||||
expect(getters.getRecord(state)(2)).toEqual({ id: 2 });
|
||||
expect(getters.getRecord(state)(4)).toEqual({});
|
||||
|
||||
expect(getters.getUIFlags(state)).toEqual({
|
||||
fetchingList: true,
|
||||
});
|
||||
|
||||
expect(getters.getMeta(state)).toEqual({
|
||||
totalCount: 10,
|
||||
page: 1,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createMutations', () => {
|
||||
it('creates mutations with correct implementations', () => {
|
||||
const mutationTypes = generateMutationTypes('test');
|
||||
const mutations = createMutations(mutationTypes);
|
||||
|
||||
const state = { uiFlags: { fetchingList: false } };
|
||||
mutations[mutationTypes.SET_UI_FLAG](state, { fetchingList: true });
|
||||
expect(state.uiFlags).toEqual({ fetchingList: true });
|
||||
|
||||
const metaState = { meta: {} };
|
||||
mutations[mutationTypes.SET_META](metaState, {
|
||||
total_count: '10',
|
||||
page: '2',
|
||||
});
|
||||
expect(metaState.meta).toEqual({ totalCount: 10, page: 2 });
|
||||
|
||||
expect(mutations[mutationTypes.SET]).toBe(MutationHelpers.set);
|
||||
expect(mutations[mutationTypes.ADD]).toBe(MutationHelpers.create);
|
||||
expect(mutations[mutationTypes.EDIT]).toBe(MutationHelpers.update);
|
||||
expect(mutations[mutationTypes.DELETE]).toBe(MutationHelpers.destroy);
|
||||
expect(mutations[mutationTypes.UPSERT]).toBe(
|
||||
MutationHelpers.setSingleRecord
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('createCrudActions', () => {
|
||||
let API;
|
||||
let commit;
|
||||
let mutationTypes;
|
||||
let actions;
|
||||
|
||||
beforeEach(() => {
|
||||
API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
commit = vi.fn();
|
||||
mutationTypes = generateMutationTypes('test');
|
||||
actions = createCrudActions(API, mutationTypes);
|
||||
});
|
||||
|
||||
describe('get action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const payload = [{ id: 1 }];
|
||||
const meta = { total_count: 10, page: 1 };
|
||||
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET, payload);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_META, meta);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual(payload);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.get.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.get({ commit });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingList: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.show.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.show({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
fetchingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('create action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.create.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.UPSERT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.create.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.create({ commit }, { name: 'Test' });
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
creatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data });
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.EDIT, data);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.update.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.update(
|
||||
{ commit },
|
||||
{ id: 1, name: 'Updated' }
|
||||
);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
updatingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('handles successful API response', async () => {
|
||||
API.delete.mockResolvedValue({});
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(API.delete).toHaveBeenCalledWith(1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.DELETE, 1);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual(1);
|
||||
});
|
||||
|
||||
it('handles API error', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.delete.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const result = await actions.delete({ commit }, 1);
|
||||
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: true,
|
||||
});
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(commit).toHaveBeenCalledWith(mutationTypes.SET_UI_FLAG, {
|
||||
deletingItem: false,
|
||||
});
|
||||
expect(result).toEqual('Error thrown');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore - Vuex type', () => {
|
||||
it('creates a complete Vuex store with default options', () => {
|
||||
const API = {};
|
||||
const store = createStore({ name: 'test', API, type: 'vuex' });
|
||||
|
||||
expect(store.namespaced).toBe(true);
|
||||
expect(store.state).toEqual(createInitialState());
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
]);
|
||||
expect(Object.keys(store.mutations)).toEqual([
|
||||
'SET_TEST_UI_FLAG',
|
||||
'SET_TEST_META',
|
||||
'SET_TEST',
|
||||
'ADD_TEST',
|
||||
'EDIT_TEST',
|
||||
'DELETE_TEST',
|
||||
'UPSERT_TEST',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a Vuex store with custom actions and getters', () => {
|
||||
const API = {};
|
||||
const customGetters = { customGetter: () => 'custom' };
|
||||
const customActions = () => ({
|
||||
customAction: () => 'custom',
|
||||
});
|
||||
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'vuex',
|
||||
getters: customGetters,
|
||||
actions: customActions,
|
||||
});
|
||||
|
||||
expect(store.getters).toHaveProperty('customGetter');
|
||||
expect(store.actions).toHaveProperty('customAction');
|
||||
expect(Object.keys(store.getters)).toEqual([
|
||||
'getRecords',
|
||||
'getRecord',
|
||||
'getUIFlags',
|
||||
'getMeta',
|
||||
'customGetter',
|
||||
]);
|
||||
expect(Object.keys(store.actions)).toEqual([
|
||||
'get',
|
||||
'show',
|
||||
'create',
|
||||
'update',
|
||||
'delete',
|
||||
'customAction',
|
||||
]);
|
||||
});
|
||||
|
||||
it('creates a Vuex store with custom mutations', () => {
|
||||
const API = {};
|
||||
const customMutations = {
|
||||
CUSTOM_MUTATION: state => {
|
||||
state.custom = true;
|
||||
},
|
||||
};
|
||||
|
||||
const store = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'vuex',
|
||||
mutations: customMutations,
|
||||
});
|
||||
|
||||
expect(store.mutations).toHaveProperty('CUSTOM_MUTATION');
|
||||
});
|
||||
});
|
||||
|
||||
describe('createStore - Pinia type', () => {
|
||||
let API;
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia());
|
||||
API = {
|
||||
get: vi.fn(),
|
||||
show: vi.fn(),
|
||||
create: vi.fn(),
|
||||
update: vi.fn(),
|
||||
delete: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
it('creates a Pinia store with correct structure', () => {
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
|
||||
expect(store.records).toEqual([]);
|
||||
expect(store.meta).toEqual({});
|
||||
expect(store.uiFlags).toEqual({
|
||||
fetchingList: false,
|
||||
fetchingItem: false,
|
||||
creatingItem: false,
|
||||
updatingItem: false,
|
||||
deletingItem: false,
|
||||
});
|
||||
});
|
||||
|
||||
it('has standard getters', () => {
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 2 }, { id: 1 }, { id: 3 }];
|
||||
store.meta = { totalCount: 10, page: 1 };
|
||||
store.uiFlags = { fetchingList: true };
|
||||
|
||||
expect(store.getRecords).toEqual([{ id: 3 }, { id: 2 }, { id: 1 }]);
|
||||
expect(store.getRecord(2)).toEqual({ id: 2 });
|
||||
expect(store.getRecord(4)).toEqual({});
|
||||
expect(store.getUIFlags).toEqual({ fetchingList: true });
|
||||
expect(store.getMeta).toEqual({ totalCount: 10, page: 1 });
|
||||
});
|
||||
|
||||
it('has custom getters', () => {
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
getters: {
|
||||
customGetter: state => state.records.length,
|
||||
},
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
expect(store.customGetter).toBe(2);
|
||||
});
|
||||
|
||||
describe('setUIFlag action', () => {
|
||||
it('updates UI flags correctly', () => {
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
store.setUIFlag({ fetchingList: true });
|
||||
expect(store.uiFlags.fetchingList).toBe(true);
|
||||
|
||||
store.setUIFlag({ creatingItem: true });
|
||||
expect(store.uiFlags.fetchingList).toBe(true);
|
||||
expect(store.uiFlags.creatingItem).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setMeta action', () => {
|
||||
it('updates meta with snake_case input', () => {
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
store.setMeta({ total_count: '10', page: '2' });
|
||||
expect(store.meta.totalCount).toBe(10);
|
||||
expect(store.meta.page).toBe(2);
|
||||
});
|
||||
|
||||
it('updates meta with camelCase input', () => {
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
store.setMeta({ totalCount: 15, page: 3 });
|
||||
expect(store.meta.totalCount).toBe(15);
|
||||
expect(store.meta.page).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get action', () => {
|
||||
it('fetches records successfully', async () => {
|
||||
const payload = [{ id: 1, name: 'Test' }];
|
||||
const meta = { total_count: 1, page: 1 };
|
||||
API.get.mockResolvedValue({ data: { payload, meta } });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
await store.get({ page: 1 });
|
||||
|
||||
expect(API.get).toHaveBeenCalledWith({ page: 1 });
|
||||
expect(store.records).toEqual(payload);
|
||||
expect(store.meta.totalCount).toBe(1);
|
||||
expect(store.meta.page).toBe(1);
|
||||
expect(store.uiFlags.fetchingList).toBe(false);
|
||||
});
|
||||
|
||||
it('handles API response without meta', async () => {
|
||||
const data = [{ id: 1, name: 'Test' }];
|
||||
API.get.mockResolvedValue({ data });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
await store.get();
|
||||
|
||||
expect(store.records).toEqual(data);
|
||||
});
|
||||
|
||||
it('handles errors and resets UI flags', async () => {
|
||||
const error = new Error('API Error');
|
||||
API.get.mockRejectedValue(error);
|
||||
throwErrorMessage.mockReturnValue('Error thrown');
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
const result = await store.get();
|
||||
|
||||
expect(throwErrorMessage).toHaveBeenCalledWith(error);
|
||||
expect(store.uiFlags.fetchingList).toBe(false);
|
||||
expect(result).toBe('Error thrown');
|
||||
});
|
||||
});
|
||||
|
||||
describe('show action', () => {
|
||||
it('fetches and upserts a record', async () => {
|
||||
const data = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
const result = await store.show(1);
|
||||
|
||||
expect(API.show).toHaveBeenCalledWith(1);
|
||||
expect(store.records).toContainEqual(data);
|
||||
expect(result).toEqual(data);
|
||||
expect(store.uiFlags.fetchingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('updates existing record', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.show.mockResolvedValue({ data });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1, name: 'Original' }];
|
||||
|
||||
await store.show(1);
|
||||
|
||||
expect(store.records).toHaveLength(1);
|
||||
expect(store.records[0].name).toBe('Updated');
|
||||
});
|
||||
|
||||
it('handles payload wrapper', async () => {
|
||||
const record = { id: 1, name: 'Test' };
|
||||
API.show.mockResolvedValue({ data: { payload: record } });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
const result = await store.show(1);
|
||||
|
||||
expect(result).toEqual(record);
|
||||
expect(store.records).toContainEqual(record);
|
||||
});
|
||||
});
|
||||
|
||||
describe('create action', () => {
|
||||
it('creates a new record', async () => {
|
||||
const data = { id: 1, name: 'New' };
|
||||
API.create.mockResolvedValue({ data });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
const result = await store.create({ name: 'New' });
|
||||
|
||||
expect(API.create).toHaveBeenCalledWith({ name: 'New' });
|
||||
expect(store.records).toContainEqual(data);
|
||||
expect(result).toEqual(data);
|
||||
expect(store.uiFlags.creatingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('handles payload wrapper', async () => {
|
||||
const record = { id: 1, name: 'New' };
|
||||
API.create.mockResolvedValue({ data: { payload: record } });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
|
||||
const result = await store.create({ name: 'New' });
|
||||
|
||||
expect(result).toEqual(record);
|
||||
expect(store.records).toContainEqual(record);
|
||||
});
|
||||
});
|
||||
|
||||
describe('update action', () => {
|
||||
it('updates an existing record', async () => {
|
||||
const data = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1, name: 'Original' }];
|
||||
|
||||
const result = await store.update({ id: 1, name: 'Updated' });
|
||||
|
||||
expect(API.update).toHaveBeenCalledWith(1, { name: 'Updated' });
|
||||
expect(store.records[0].name).toBe('Updated');
|
||||
expect(result).toEqual(data);
|
||||
expect(store.uiFlags.updatingItem).toBe(false);
|
||||
});
|
||||
|
||||
it('handles payload wrapper', async () => {
|
||||
const record = { id: 1, name: 'Updated' };
|
||||
API.update.mockResolvedValue({ data: { payload: record } });
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1, name: 'Original' }];
|
||||
|
||||
const result = await store.update({ id: 1, name: 'Updated' });
|
||||
|
||||
expect(result).toEqual(record);
|
||||
expect(store.records[0]).toEqual(record);
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete action', () => {
|
||||
it('deletes a record', async () => {
|
||||
API.delete.mockResolvedValue({});
|
||||
|
||||
const useTestStore = createStore({ name: 'test', API, type: 'pinia' });
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1 }, { id: 2 }, { id: 3 }];
|
||||
|
||||
const result = await store.delete(2);
|
||||
|
||||
expect(API.delete).toHaveBeenCalledWith(2);
|
||||
expect(store.records).toHaveLength(2);
|
||||
expect(store.records).not.toContainEqual({ id: 2 });
|
||||
expect(result).toBe(2);
|
||||
expect(store.uiFlags.deletingItem).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('custom actions', () => {
|
||||
it('includes custom actions', async () => {
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
actions: () => ({
|
||||
customAction() {
|
||||
return 'custom result';
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
const result = store.customAction();
|
||||
|
||||
expect(result).toBe('custom result');
|
||||
});
|
||||
|
||||
it('custom actions can access store state', () => {
|
||||
const useTestStore = createStore({
|
||||
name: 'test',
|
||||
API,
|
||||
type: 'pinia',
|
||||
actions: () => ({
|
||||
getRecordCount() {
|
||||
return this.records.length;
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
const store = useTestStore();
|
||||
store.records = [{ id: 1 }, { id: 2 }];
|
||||
|
||||
expect(store.getRecordCount()).toBe(2);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,203 @@
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
|
||||
// ============================================================================
|
||||
// VUEX HELPERS
|
||||
// ============================================================================
|
||||
|
||||
export const getRecords =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, params = {}) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
commit(mutationTypes.SET, response.data.payload);
|
||||
commit(mutationTypes.SET_META, response.data.meta);
|
||||
return response.data.payload;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingList: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const showRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { fetchingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const createRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, dataObj) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
commit(mutationTypes.UPSERT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { creatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const updateRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, { id, ...updateObj }) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
commit(mutationTypes.EDIT, response.data);
|
||||
return response.data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { updatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteRecord =
|
||||
(mutationTypes, API) =>
|
||||
async ({ commit }, id) => {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
commit(mutationTypes.DELETE, id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
commit(mutationTypes.SET_UI_FLAG, { deletingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
// ============================================================================
|
||||
// PINIA HELPERS
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Get records from API and update Pinia store
|
||||
* @param {Object} store - Pinia store instance (this context)
|
||||
* @param {Object} API - API client
|
||||
* @param {Object} params - Query parameters
|
||||
*/
|
||||
export const piniaGetRecords = async (store, API, params = {}) => {
|
||||
store.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const response = await API.get(params);
|
||||
const { data } = response;
|
||||
store.records = data.payload || data;
|
||||
if (data.meta) {
|
||||
store.setMeta(data.meta);
|
||||
}
|
||||
return data.payload || data;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
store.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Show single record from API and upsert to Pinia store
|
||||
* @param {Object} store - Pinia store instance (this context)
|
||||
* @param {Object} API - API client
|
||||
* @param {Number|String} id - Record ID
|
||||
*/
|
||||
export const piniaShowRecord = async (store, API, id) => {
|
||||
store.setUIFlag({ fetchingItem: true });
|
||||
try {
|
||||
const response = await API.show(id);
|
||||
const { data } = response;
|
||||
const record = data.payload || data;
|
||||
|
||||
// Upsert logic
|
||||
const index = store.records.findIndex(r => r.id === record.id);
|
||||
if (index !== -1) {
|
||||
store.records[index] = record;
|
||||
} else {
|
||||
store.records.push(record);
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
store.setUIFlag({ fetchingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Create new record via API and add to Pinia store
|
||||
* @param {Object} store - Pinia store instance (this context)
|
||||
* @param {Object} API - API client
|
||||
* @param {Object} dataObj - Data to create
|
||||
*/
|
||||
export const piniaCreateRecord = async (store, API, dataObj) => {
|
||||
store.setUIFlag({ creatingItem: true });
|
||||
try {
|
||||
const response = await API.create(dataObj);
|
||||
const { data } = response;
|
||||
const record = data.payload || data;
|
||||
store.records.push(record);
|
||||
return record;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
store.setUIFlag({ creatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Update existing record via API and update in Pinia store
|
||||
* @param {Object} store - Pinia store instance (this context)
|
||||
* @param {Object} API - API client
|
||||
* @param {Object} payload - Update payload with id
|
||||
*/
|
||||
export const piniaUpdateRecord = async (store, API, { id, ...updateObj }) => {
|
||||
store.setUIFlag({ updatingItem: true });
|
||||
try {
|
||||
const response = await API.update(id, updateObj);
|
||||
const { data } = response;
|
||||
const record = data.payload || data;
|
||||
|
||||
const index = store.records.findIndex(r => r.id === record.id);
|
||||
if (index !== -1) {
|
||||
store.records[index] = record;
|
||||
}
|
||||
|
||||
return record;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
store.setUIFlag({ updatingItem: false });
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Delete record via API and remove from Pinia store
|
||||
* @param {Object} store - Pinia store instance (this context)
|
||||
* @param {Object} API - API client
|
||||
* @param {Number|String} id - Record ID to delete
|
||||
*/
|
||||
export const piniaDeleteRecord = async (store, API, id) => {
|
||||
store.setUIFlag({ deletingItem: true });
|
||||
try {
|
||||
await API.delete(id);
|
||||
store.records = store.records.filter(record => record.id !== id);
|
||||
return id;
|
||||
} catch (error) {
|
||||
return throwErrorMessage(error);
|
||||
} finally {
|
||||
store.setUIFlag({ deletingItem: false });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
import CompanyAPI from 'dashboard/api/companies';
|
||||
import { createStore } from 'dashboard/store/storeFactory';
|
||||
import { throwErrorMessage } from 'dashboard/store/utils/api';
|
||||
import camelcaseKeys from 'camelcase-keys';
|
||||
|
||||
export const useCompaniesStore = createStore({
|
||||
name: 'companies',
|
||||
type: 'pinia',
|
||||
API: CompanyAPI,
|
||||
getters: {
|
||||
getCompaniesList: state => {
|
||||
return camelcaseKeys(state.records, { deep: true });
|
||||
},
|
||||
},
|
||||
actions: () => ({
|
||||
async search({ search, page, sort }) {
|
||||
this.setUIFlag({ fetchingList: true });
|
||||
try {
|
||||
const {
|
||||
data: { payload, meta },
|
||||
} = await CompanyAPI.search(search, page, sort);
|
||||
this.records = payload;
|
||||
this.setMeta(meta);
|
||||
} catch (error) {
|
||||
throwErrorMessage(error);
|
||||
} finally {
|
||||
this.setUIFlag({ fetchingList: false });
|
||||
}
|
||||
},
|
||||
}),
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import createAxios from 'dashboard/helper/APIHelper';
|
||||
|
||||
import commonHelpers, { isJSONValid } from 'dashboard/helper/commons';
|
||||
import { sync } from 'vuex-router-sync';
|
||||
import { createPinia } from 'pinia';
|
||||
import router, { initalizeRouter } from 'dashboard/routes';
|
||||
import store from 'dashboard/store';
|
||||
import constants from 'dashboard/constants/globals';
|
||||
@@ -41,9 +42,12 @@ const i18n = createI18n({
|
||||
|
||||
sync(store, router);
|
||||
|
||||
const pinia = createPinia();
|
||||
|
||||
const app = createApp(App);
|
||||
app.use(i18n);
|
||||
app.use(store);
|
||||
app.use(pinia);
|
||||
app.use(router);
|
||||
|
||||
// [VITE] Disabled this, need to renable later
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useLocale } from '../useLocale';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
vi.mock('vue-i18n');
|
||||
|
||||
describe('useLocale', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
locale: ref('en-US'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolvedLocale', () => {
|
||||
it('should return normalized locale for valid hyphen-based tags', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
expect(resolvedLocale.value).toBe('en-US');
|
||||
});
|
||||
|
||||
it('should normalize underscore-based locale tags to hyphens', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize pt_BR to pt-BR
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should normalize zh_CN to zh-CN or fall back to zh', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize zh_CN to zh-CN or fall back to zh
|
||||
expect(resolvedLocale.value).toMatch(/^zh(-CN)?$/);
|
||||
});
|
||||
|
||||
it('should normalize en_US to en-US or fall back to en', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize en_US to en-US or fall back to en
|
||||
expect(resolvedLocale.value).toMatch(/^en(-US)?$/);
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Use a specific locale that might not be fully supported
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should return either pt-BR or pt (base language)
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle null locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle undefined locale gracefully', () => {
|
||||
const mockLocale = ref(undefined);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should fall back to 'en'
|
||||
expect(resolvedLocale.value).toBe('en');
|
||||
});
|
||||
|
||||
it('should handle base language code without region', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should work with base language
|
||||
expect(resolvedLocale.value).toBe('pt');
|
||||
});
|
||||
|
||||
it('should handle multiple underscores in locale tag', () => {
|
||||
const mockLocale = ref('zh_Hans_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
// Should normalize all underscores to hyphens
|
||||
expect(resolvedLocale.value).toMatch(/^zh(-Hans-CN|-Hans|-CN)?$/);
|
||||
});
|
||||
|
||||
it('should be reactive to locale changes', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
expect(resolvedLocale.value).toBe('en-US');
|
||||
|
||||
// Change locale
|
||||
mockLocale.value = 'pt_BR';
|
||||
|
||||
// Should update reactively
|
||||
expect(resolvedLocale.value).toMatch(/^pt(-BR)?$/);
|
||||
});
|
||||
|
||||
it('should work with common locales', () => {
|
||||
const testCases = [
|
||||
{ input: 'de-DE', expected: /^de(-DE)?$/ },
|
||||
{ input: 'fr-FR', expected: /^fr(-FR)?$/ },
|
||||
{ input: 'es-ES', expected: /^es(-ES)?$/ },
|
||||
{ input: 'ja-JP', expected: /^ja(-JP)?$/ },
|
||||
{ input: 'ko-KR', expected: /^ko(-KR)?$/ },
|
||||
{ input: 'ar-SA', expected: /^ar(-SA)?$/ },
|
||||
{ input: 'hi-IN', expected: /^hi(-IN)?$/ },
|
||||
{ input: 'ru-RU', expected: /^ru(-RU)?$/ },
|
||||
{ input: 'tr-TR', expected: /^tr(-TR)?$/ },
|
||||
];
|
||||
|
||||
testCases.forEach(({ input, expected }) => {
|
||||
const mockLocale = ref(input);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
expect(resolvedLocale.value).toMatch(expected);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('locale (raw)', () => {
|
||||
it('should expose raw locale value', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { locale } = useLocale();
|
||||
// Raw locale should be unchanged
|
||||
expect(locale.value).toBe('pt_BR');
|
||||
});
|
||||
|
||||
it('should be reactive', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { locale } = useLocale();
|
||||
|
||||
expect(locale.value).toBe('en-US');
|
||||
|
||||
mockLocale.value = 'pt-BR';
|
||||
|
||||
expect(locale.value).toBe('pt-BR');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Intl API compatibility', () => {
|
||||
it('should work with Intl.NumberFormat', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.NumberFormat(resolvedLocale.value).format(1234567);
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with Intl.DateTimeFormat', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
|
||||
}).not.toThrow();
|
||||
});
|
||||
|
||||
it('should work with Intl.Collator', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
// Should not throw error
|
||||
expect(() => {
|
||||
new Intl.Collator(resolvedLocale.value).compare('a', 'b');
|
||||
}).not.toThrow();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,364 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { useNumberFormatter } from '../useNumberFormatter';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { ref } from 'vue';
|
||||
|
||||
vi.mock('vue-i18n');
|
||||
|
||||
describe('useNumberFormatter', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(useI18n).mockReturnValue({
|
||||
locale: ref('en-US'),
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatCompactNumber', () => {
|
||||
it('should return exact numbers for values under 1,000', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(0)).toBe('0');
|
||||
expect(formatCompactNumber(1)).toBe('1');
|
||||
expect(formatCompactNumber(42)).toBe('42');
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should return "Xk" for exact thousands and "Xk+" for values with remainder', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1000)).toBe('1k');
|
||||
expect(formatCompactNumber(1020)).toBe('1k+');
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1999)).toBe('1k+');
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
expect(formatCompactNumber(15000)).toBe('15k');
|
||||
expect(formatCompactNumber(15500)).toBe('15k+');
|
||||
expect(formatCompactNumber(999999)).toBe('999k+');
|
||||
});
|
||||
|
||||
it('should return millions/billion/trillion format for values 1,000,000 and above', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
expect(formatCompactNumber(1000001)).toBe('1.0M');
|
||||
expect(formatCompactNumber(1200000)).toBe('1.2M');
|
||||
expect(formatCompactNumber(1234000)).toBe('1.2M');
|
||||
expect(formatCompactNumber(2500000)).toBe('2.5M');
|
||||
expect(formatCompactNumber(10000000)).toBe('10M');
|
||||
expect(formatCompactNumber(1000000000)).toBe('1B');
|
||||
expect(formatCompactNumber(1100000000)).toBe('1.1B');
|
||||
expect(formatCompactNumber(10000000000)).toBe('10B');
|
||||
expect(formatCompactNumber(11000000000)).toBe('11B');
|
||||
expect(formatCompactNumber(1000000000000)).toBe('1T');
|
||||
expect(formatCompactNumber(1100000000000)).toBe('1.1T');
|
||||
expect(formatCompactNumber(10000000000000)).toBe('10T');
|
||||
expect(formatCompactNumber(11000000000000)).toBe('11T');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(null)).toBe('0');
|
||||
expect(formatCompactNumber(undefined)).toBe('0');
|
||||
expect(formatCompactNumber(NaN)).toBe('0');
|
||||
expect(formatCompactNumber('string')).toBe('0');
|
||||
});
|
||||
|
||||
it('should handle negative numbers', () => {
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(-500)).toBe('-500');
|
||||
expect(formatCompactNumber(-1000)).toBe('-1k');
|
||||
expect(formatCompactNumber(-1500)).toBe('-1k+');
|
||||
expect(formatCompactNumber(-2000)).toBe('-2k');
|
||||
expect(formatCompactNumber(-1200000)).toBe('-1.2M');
|
||||
});
|
||||
|
||||
it('should format with en-US locale', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
});
|
||||
|
||||
it('should format with de-DE locale', () => {
|
||||
const mockLocale = ref('de-DE');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
});
|
||||
|
||||
it('should format with fr-FR locale (compact notation)', () => {
|
||||
const mockLocale = ref('fr-FR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(1000000);
|
||||
expect(result).toMatch(/1\s*M/); // French uses space before M
|
||||
});
|
||||
|
||||
it('should format with ja-JP locale', () => {
|
||||
const mockLocale = ref('ja-JP');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
});
|
||||
|
||||
it('should format with ar-SA locale (Arabic numerals)', () => {
|
||||
const mockLocale = ref('ar-SA');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(5000);
|
||||
expect(result).toContain('k');
|
||||
expect(typeof result).toBe('string');
|
||||
});
|
||||
|
||||
it('should format with es-ES locale', () => {
|
||||
const mockLocale = ref('es-ES');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(7500)).toBe('7k+');
|
||||
});
|
||||
|
||||
it('should format with hi-IN locale', () => {
|
||||
const mockLocale = ref('hi-IN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(100000)).toBe('100k');
|
||||
});
|
||||
|
||||
it('should format with ru-RU locale', () => {
|
||||
const mockLocale = ref('ru-RU');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(3000)).toBe('3k');
|
||||
});
|
||||
|
||||
it('should format with ko-KR locale (uses 만 for 10,000)', () => {
|
||||
const mockLocale = ref('ko-KR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
const result = formatCompactNumber(2500000);
|
||||
expect(result).toContain('만'); // Korean uses 만 (10,000) as a unit, so 2,500,000 should contain 만
|
||||
});
|
||||
|
||||
it('should format with pt-BR locale', () => {
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
expect(formatCompactNumber(8888)).toBe('8k+');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (pt_BR)', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize pt_BR to pt-BR and work correctly
|
||||
expect(formatCompactNumber(8888)).toBe('8k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1\u00a0mi');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (zh_CN)', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize zh_CN to zh-CN and work correctly
|
||||
expect(formatCompactNumber(999)).toBe('999');
|
||||
expect(formatCompactNumber(5000)).toBe('5k');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (en_US)', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should normalize en_US to en-US and work correctly
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
});
|
||||
|
||||
it('should handle null/undefined locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' locale
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Simulate a case where pt-BR might not be fully supported but pt is
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should work with either pt-BR or pt fallback
|
||||
const result = formatCompactNumber(1500);
|
||||
expect(result).toMatch(/1k\+/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
// Use a completely made-up locale
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' and work
|
||||
expect(formatCompactNumber(1500)).toBe('1k+');
|
||||
expect(formatCompactNumber(1000000)).toBe('1M');
|
||||
});
|
||||
|
||||
it('should handle edge case with only base language code', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatCompactNumber } = useNumberFormatter();
|
||||
// Should work with base language
|
||||
expect(formatCompactNumber(2000)).toBe('2k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('formatFullNumber', () => {
|
||||
it('should format numbers with locale-specific formatting', () => {
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1000)).toBe('1,000');
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
expect(formatFullNumber(1234567890)).toBe('1,234,567,890');
|
||||
expect(formatFullNumber(1234567890123)).toBe('1,234,567,890,123');
|
||||
});
|
||||
|
||||
it('should handle edge cases gracefully', () => {
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(null)).toBe('0');
|
||||
expect(formatFullNumber(undefined)).toBe('0');
|
||||
expect(formatFullNumber(NaN)).toBe('0');
|
||||
expect(formatFullNumber('string')).toBe('0');
|
||||
});
|
||||
|
||||
it('should format with en-US locale (comma separator)', () => {
|
||||
const mockLocale = ref('en-US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should format with de-DE locale (period separator)', () => {
|
||||
const mockLocale = ref('de-DE');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(9876543)).toBe('9.876.543');
|
||||
});
|
||||
|
||||
it('should format with es-ES locale (period separator)', () => {
|
||||
const mockLocale = ref('es-ES');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(5555555)).toBe('5.555.555');
|
||||
});
|
||||
|
||||
it('should format with zh-CN locale', () => {
|
||||
const mockLocale = ref('zh-CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(1000000)).toBe('1,000,000');
|
||||
});
|
||||
|
||||
it('should format with ar-EG locale (Arabic numerals, RTL)', () => {
|
||||
const mockLocale = ref('ar-EG');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(7654321);
|
||||
// Arabic locale uses Eastern Arabic numerals (٠-٩)
|
||||
// Just verify it's formatted (length should be reasonable)
|
||||
expect(result.length).toBeGreaterThan(6);
|
||||
});
|
||||
|
||||
it('should format with fr-FR locale (narrow no-break space)', () => {
|
||||
const mockLocale = ref('fr-FR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(3333333);
|
||||
expect(result).toContain('3');
|
||||
expect(result).toContain('333');
|
||||
});
|
||||
|
||||
it('should format with hi-IN locale (Indian numbering system)', () => {
|
||||
const mockLocale = ref('hi-IN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(9999999)).toBe('99,99,999');
|
||||
});
|
||||
|
||||
it('should format with th-TH locale', () => {
|
||||
const mockLocale = ref('th-TH');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(4444444)).toBe('4,444,444');
|
||||
});
|
||||
|
||||
it('should format with tr-TR locale', () => {
|
||||
const mockLocale = ref('tr-TR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
expect(formatFullNumber(6666666)).toBe('6.666.666');
|
||||
});
|
||||
|
||||
it('should format with pt-PT locale (space separator)', () => {
|
||||
const mockLocale = ref('pt-PT');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
const result = formatFullNumber(2222222);
|
||||
// Portuguese (Portugal) uses narrow no-break space as separator
|
||||
expect(result).toMatch(/2[\s\u202f]222[\s\u202f]222/);
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (pt_BR)', () => {
|
||||
const mockLocale = ref('pt_BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize pt_BR to pt-BR and work correctly
|
||||
expect(formatFullNumber(1234567)).toBe('1.234.567');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (zh_CN)', () => {
|
||||
const mockLocale = ref('zh_CN');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize zh_CN to zh-CN and work correctly
|
||||
expect(formatFullNumber(1000000)).toBe('1,000,000');
|
||||
});
|
||||
|
||||
it('should handle underscore-based locale tags (en_US)', () => {
|
||||
const mockLocale = ref('en_US');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should normalize en_US to en-US and work correctly
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should handle null/undefined locale gracefully', () => {
|
||||
const mockLocale = ref(null);
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' locale
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should fall back to base language when specific locale not supported', () => {
|
||||
// Simulate a case where pt-BR might not be fully supported but pt is
|
||||
const mockLocale = ref('pt-BR');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should work with either pt-BR or pt fallback
|
||||
const result = formatFullNumber(1234567);
|
||||
// Portuguese uses period as thousands separator
|
||||
expect(result).toMatch(/1[.,\s]234[.,\s]567/);
|
||||
});
|
||||
|
||||
it('should fall back to English for completely unsupported locales', () => {
|
||||
// Use a completely made-up locale
|
||||
const mockLocale = ref('xx-YY');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should fall back to 'en' and work
|
||||
expect(formatFullNumber(1234567)).toBe('1,234,567');
|
||||
});
|
||||
|
||||
it('should handle edge case with only base language code', () => {
|
||||
const mockLocale = ref('pt');
|
||||
vi.mocked(useI18n).mockReturnValue({ locale: mockLocale });
|
||||
const { formatFullNumber } = useNumberFormatter();
|
||||
// Should work with base language
|
||||
const result = formatFullNumber(1000000);
|
||||
expect(result).toMatch(/1[.,\s]000[.,\s]000/);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
/**
|
||||
* Composable for locale resolution and validation
|
||||
* Provides a normalized, validated locale that works with Intl APIs
|
||||
*/
|
||||
export function useLocale() {
|
||||
const { locale } = useI18n();
|
||||
|
||||
/**
|
||||
* Resolves and validates the current locale for use with Intl APIs
|
||||
*
|
||||
* Handles multiple fallback scenarios:
|
||||
* 1. Normalizes underscore-based tags (pt_BR → pt-BR, zh_CN → zh-CN)
|
||||
* 2. Falls back to base language if specific locale unsupported (pt-BR → pt)
|
||||
* 3. Falls back to English if base language unsupported (xx-YY → en)
|
||||
*
|
||||
* @returns {string} Valid BCP 47 locale tag for Intl APIs
|
||||
*
|
||||
* @example
|
||||
* const { resolvedLocale } = useLocale();
|
||||
* new Intl.NumberFormat(resolvedLocale.value).format(1234);
|
||||
* new Intl.DateTimeFormat(resolvedLocale.value).format(new Date());
|
||||
*/
|
||||
const resolvedLocale = computed(() => {
|
||||
// Handle null/undefined locale
|
||||
if (!locale.value) return 'en';
|
||||
|
||||
// Normalize underscore to hyphen (pt_BR → pt-BR, zh_CN → zh-CN)
|
||||
const normalized = locale.value.replace(/_/g, '-');
|
||||
|
||||
// Check if the specific locale is supported (e.g., pt-BR, zh-CN)
|
||||
const supportedLocales = Intl.NumberFormat.supportedLocalesOf([normalized]);
|
||||
if (supportedLocales.length > 0) {
|
||||
return normalized;
|
||||
}
|
||||
|
||||
// If specific locale not supported, try base language (pt-BR → pt, zh-CN → zh)
|
||||
const baseLocale = normalized.split('-')[0];
|
||||
const baseSupportedLocales = Intl.NumberFormat.supportedLocalesOf([
|
||||
baseLocale,
|
||||
]);
|
||||
if (baseSupportedLocales.length > 0) {
|
||||
return baseLocale;
|
||||
}
|
||||
|
||||
// If base language also not supported, fall back to English
|
||||
return 'en';
|
||||
});
|
||||
|
||||
return {
|
||||
resolvedLocale,
|
||||
// Also expose the raw locale for cases where you need it
|
||||
locale,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import { useLocale } from './useLocale';
|
||||
|
||||
/**
|
||||
* Composable for number formatting with i18n locale support
|
||||
* Provides methods to format numbers in compact and full display formats
|
||||
*/
|
||||
export function useNumberFormatter() {
|
||||
const { resolvedLocale } = useLocale();
|
||||
|
||||
/**
|
||||
* Formats numbers for display with clean, minimal formatting
|
||||
* - Up to 1,000: show exact number (e.g., 999)
|
||||
* - 1,000 to 999,999: show as "Xk" for exact thousands or "Xk+" for remainder (e.g., 1000 → "1k", 1500 → "1k+")
|
||||
* - 1,000,000+: show in millions with 1 decimal place (e.g., 1,234,000 → "1.2M")
|
||||
*
|
||||
* Uses browser-native Intl.NumberFormat with proper i18n locale support
|
||||
*
|
||||
* @param {number} num - The number to format
|
||||
* @returns {string} Formatted number string
|
||||
*/
|
||||
const formatCompactNumber = num => {
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
// For numbers between -1000 and 1000 (exclusive), show exact number with locale formatting
|
||||
if (Math.abs(num) < 1000) {
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
}
|
||||
|
||||
// For numbers with absolute value above 1,000,000, show in millions with 1 decimal place
|
||||
if (Math.abs(num) >= 1000000) {
|
||||
const millions = num / 1000000;
|
||||
return new Intl.NumberFormat(resolvedLocale.value, {
|
||||
notation: 'compact',
|
||||
compactDisplay: 'short',
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: millions % 1 === 0 ? 0 : 1,
|
||||
}).format(num);
|
||||
}
|
||||
|
||||
// For numbers with absolute value between 1,000 and 1,000,000, show as "Xk" or "Xk+" using floor value
|
||||
// For negative numbers, we want to floor towards zero (truncate), not towards negative infinity
|
||||
const thousands = num >= 0 ? Math.floor(num / 1000) : Math.ceil(num / 1000);
|
||||
const remainder = Math.abs(num) % 1000;
|
||||
const suffix = remainder === 0 ? 'k' : 'k+';
|
||||
return `${new Intl.NumberFormat(resolvedLocale.value).format(thousands)}${suffix}`;
|
||||
};
|
||||
|
||||
/**
|
||||
* Format a number for full display with locale-specific formatting
|
||||
* @param {number} num - The number to format
|
||||
* @returns {string} Formatted number string with full precision and locale formatting (e.g., 1,234,567)
|
||||
*/
|
||||
const formatFullNumber = num => {
|
||||
if (typeof num !== 'number' || Number.isNaN(num)) {
|
||||
return '0';
|
||||
}
|
||||
|
||||
return new Intl.NumberFormat(resolvedLocale.value).format(num);
|
||||
};
|
||||
|
||||
return {
|
||||
formatCompactNumber,
|
||||
formatFullNumber,
|
||||
};
|
||||
}
|
||||
@@ -4,7 +4,6 @@ export const BUS_EVENTS = {
|
||||
FOCUS_CUSTOM_ATTRIBUTE: 'FOCUS_CUSTOM_ATTRIBUTE',
|
||||
SCROLL_TO_MESSAGE: 'SCROLL_TO_MESSAGE',
|
||||
MESSAGE_SENT: 'MESSAGE_SENT',
|
||||
FETCH_LABEL_SUGGESTIONS: 'FETCH_LABEL_SUGGESTIONS',
|
||||
ON_MESSAGE_LIST_SCROLL: 'ON_MESSAGE_LIST_SCROLL',
|
||||
WEBSOCKET_DISCONNECT: 'WEBSOCKET_DISCONNECT',
|
||||
WEBSOCKET_RECONNECT: 'WEBSOCKET_RECONNECT',
|
||||
|
||||
@@ -48,6 +48,7 @@ class Account < ApplicationRecord
|
||||
check_for_column: false
|
||||
}.freeze
|
||||
|
||||
validates :name, presence: true
|
||||
validates :domain, length: { maximum: 100 }
|
||||
validates_with JsonSchemaValidator,
|
||||
schema: SETTINGS_PARAMS_SCHEMA,
|
||||
|
||||
@@ -40,6 +40,8 @@ class Channel::Line < ApplicationRecord
|
||||
config.channel_id = line_channel_id
|
||||
config.channel_secret = line_channel_secret
|
||||
config.channel_token = line_channel_token
|
||||
# Skip SSL verification in development to avoid certificate issues
|
||||
config.http_options = { verify_mode: OpenSSL::SSL::VERIFY_NONE } if Rails.env.development?
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -125,7 +125,7 @@ class MailPresenter < SimpleDelegator
|
||||
|
||||
def from
|
||||
# changing to downcase to avoid case mismatch while finding contact
|
||||
(@mail.reply_to.presence || @mail.from).map(&:downcase)
|
||||
Array.wrap(@mail.reply_to.presence || @mail.from).map(&:downcase)
|
||||
end
|
||||
|
||||
def sender_name
|
||||
|
||||
@@ -71,3 +71,5 @@ class Contacts::ContactableInboxesService
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
Contacts::ContactableInboxesService.prepend_mod_with('Contacts::ContactableInboxesService')
|
||||
|
||||
@@ -3,8 +3,9 @@ class Whatsapp::OneoffCampaignService
|
||||
|
||||
def perform
|
||||
validate_campaign!
|
||||
process_audience(extract_audience_labels)
|
||||
# marks campaign completed so that other jobs won't pick it up
|
||||
campaign.completed!
|
||||
process_audience(extract_audience_labels)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
@@ -199,7 +199,6 @@
|
||||
description: 'The limits for the Captain AI service for different plans'
|
||||
value:
|
||||
type: code
|
||||
|
||||
# End of Captain Config
|
||||
|
||||
# ------- Chatwoot Internal Config for Cloud ----#
|
||||
@@ -476,3 +475,28 @@
|
||||
locked: false
|
||||
description: 'Token expiry in days'
|
||||
## ------ End of Customizations for Customers ------ ##
|
||||
|
||||
## ----- LLM Observability ---- ##
|
||||
- name: OTEL_PROVIDER
|
||||
display_title: 'OpenTelemetry Provider'
|
||||
description: 'LLM observability provider (langfuse, langsmith, etc.)'
|
||||
value: ''
|
||||
locked: false
|
||||
- name: LANGFUSE_PUBLIC_KEY
|
||||
display_title: 'Langfuse Public Key'
|
||||
description: 'Public key for Langfuse authentication'
|
||||
value: ''
|
||||
locked: false
|
||||
type: secret
|
||||
- name: LANGFUSE_SECRET_KEY
|
||||
display_title: 'Langfuse Secret Key'
|
||||
description: 'Secret key for Langfuse authentication'
|
||||
value: ''
|
||||
locked: false
|
||||
type: secret
|
||||
- name: LANGFUSE_BASE_URL
|
||||
display_title: 'Langfuse Base URL'
|
||||
description: 'Langfuse endpoint (US: https://us.cloud.langfuse.com, EU: https://cloud.langfuse.com)'
|
||||
value: 'https://us.cloud.langfuse.com'
|
||||
locked: false
|
||||
## ---- End of LLM Observability ---- ##
|
||||
|
||||
@@ -52,10 +52,25 @@ mp4:
|
||||
Your browser does not support the video tag.
|
||||
</video>
|
||||
|
||||
arcade_tab:
|
||||
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/?]+)\?(?:[^&#]*&)*embed_mobile=tab(?:[&#?][^ ]*)?'
|
||||
template: |
|
||||
<div style="position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/%{video_id}?embed&embed_mobile=tab"
|
||||
frameborder="0"
|
||||
webkitallowfullscreen
|
||||
mozallowfullscreen
|
||||
allowfullscreen
|
||||
allow="fullscreen"
|
||||
style="position: absolute; top: 0; left: 0; width: 100%; height: 100%;">
|
||||
</iframe>
|
||||
</div>
|
||||
|
||||
arcade:
|
||||
regex: 'https?://(?:www\.)?app\.arcade\.software/share/(?<video_id>[^&/]+)'
|
||||
template: |
|
||||
<div style="position: relative; padding-bottom: 62.5%; height: 0;">
|
||||
<div style="position: relative; padding-bottom: calc(62.793% + 41px); height: 0px; width: 100%;">
|
||||
<iframe
|
||||
src="https://app.arcade.software/embed/%{video_id}"
|
||||
frameborder="0"
|
||||
|
||||
@@ -177,6 +177,7 @@ Rails.application.routes.draw do
|
||||
resources :contact_inboxes, only: [:create]
|
||||
resources :labels, only: [:create, :index]
|
||||
resources :notes
|
||||
post :call, on: :member, to: 'calls#create' if ChatwootApp.enterprise?
|
||||
end
|
||||
end
|
||||
resources :csat_survey_responses, only: [:index] do
|
||||
@@ -550,6 +551,7 @@ Rails.application.routes.draw do
|
||||
collection do
|
||||
post 'call/:phone', action: :call_twiml
|
||||
post 'status/:phone', action: :status
|
||||
post 'conference_status/:phone', action: :conference_status
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@@ -44,6 +44,7 @@ class ArticleKeyConverter
|
||||
end
|
||||
end
|
||||
|
||||
# rubocop:disable Style/OneClassPerFile
|
||||
class UpdateArticleImageKeys < ActiveRecord::Migration[7.0]
|
||||
def change
|
||||
# Iterate through all articles
|
||||
@@ -53,3 +54,4 @@ class UpdateArticleImageKeys < ActiveRecord::Migration[7.0]
|
||||
end
|
||||
end
|
||||
end
|
||||
# rubocop:enable Style/OneClassPerFile
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
module Enterprise::ContactInboxBuilder
|
||||
private
|
||||
|
||||
def generate_source_id
|
||||
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
phone_source_id
|
||||
end
|
||||
|
||||
def phone_source_id
|
||||
return super unless @inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
return SecureRandom.uuid if @contact.phone_number.blank?
|
||||
|
||||
@contact.phone_number
|
||||
end
|
||||
|
||||
def allowed_channels?
|
||||
super || @inbox.channel_type == 'Channel::Voice'
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,9 @@
|
||||
module Enterprise::Messages::MessageBuilder
|
||||
private
|
||||
|
||||
def message_type
|
||||
return @message_type if @message_type == 'incoming' && @conversation.inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
super
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,38 @@
|
||||
class Api::V1::Accounts::Contacts::CallsController < Api::V1::Accounts::BaseController
|
||||
before_action :contact
|
||||
before_action :voice_inbox
|
||||
|
||||
def create
|
||||
authorize contact, :show?
|
||||
authorize voice_inbox, :show?
|
||||
|
||||
result = Voice::OutboundCallBuilder.perform!(
|
||||
account: Current.account,
|
||||
inbox: voice_inbox,
|
||||
user: Current.user,
|
||||
contact: contact
|
||||
)
|
||||
|
||||
conversation = result[:conversation]
|
||||
|
||||
render json: {
|
||||
conversation_id: conversation.display_id,
|
||||
inbox_id: voice_inbox.id,
|
||||
call_sid: result[:call_sid],
|
||||
conference_sid: conversation.additional_attributes['conference_sid']
|
||||
}
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def contact
|
||||
@contact ||= Current.account.contacts.find(params[:id])
|
||||
end
|
||||
|
||||
def voice_inbox
|
||||
@voice_inbox ||= Current.user.assigned_inboxes.where(
|
||||
account_id: Current.account.id,
|
||||
channel_type: 'Channel::Voice'
|
||||
).find(params.require(:inbox_id))
|
||||
end
|
||||
end
|
||||
@@ -36,7 +36,8 @@ module Enterprise::SuperAdmin::AppConfigsController
|
||||
def internal_config_options
|
||||
%w[CHATWOOT_INBOX_TOKEN CHATWOOT_INBOX_HMAC_KEY ANALYTICS_TOKEN CLEARBIT_API_KEY DASHBOARD_SCRIPTS INACTIVE_WHATSAPP_NUMBERS BLOCKED_EMAIL_DOMAINS
|
||||
SKIP_INCOMING_BCC_PROCESSING CAPTAIN_CLOUD_PLAN_LIMITS ACCOUNT_SECURITY_NOTIFICATION_WEBHOOK_URL CHATWOOT_INSTANCE_ADMIN_EMAIL
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID]
|
||||
OG_IMAGE_CDN_URL OG_IMAGE_CLIENT_REF CLOUDFLARE_API_KEY CLOUDFLARE_ZONE_ID
|
||||
OTEL_PROVIDER LANGFUSE_PUBLIC_KEY LANGFUSE_SECRET_KEY LANGFUSE_BASE_URL]
|
||||
end
|
||||
|
||||
def captain_config_options
|
||||
|
||||
@@ -1,38 +1,189 @@
|
||||
class Twilio::VoiceController < ApplicationController
|
||||
CONFERENCE_EVENT_PATTERNS = {
|
||||
/conference-start/i => 'start',
|
||||
/participant-join/i => 'join',
|
||||
/participant-leave/i => 'leave',
|
||||
/conference-end/i => 'end'
|
||||
}.freeze
|
||||
|
||||
before_action :set_inbox!
|
||||
|
||||
def status
|
||||
Voice::StatusUpdateService.new(
|
||||
account: @inbox.account,
|
||||
call_sid: params[:CallSid],
|
||||
call_status: params[:CallStatus]
|
||||
account: current_account,
|
||||
call_sid: twilio_call_sid,
|
||||
call_status: params[:CallStatus],
|
||||
payload: params.to_unsafe_h
|
||||
).perform
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
def call_twiml
|
||||
account = @inbox.account
|
||||
call_sid = params[:CallSid]
|
||||
from_number = params[:From].to_s
|
||||
to_number = params[:To].to_s
|
||||
account = current_account
|
||||
Rails.logger.info(
|
||||
"TWILIO_VOICE_TWIML account=#{account.id} call_sid=#{twilio_call_sid} from=#{twilio_from} direction=#{twilio_direction}"
|
||||
)
|
||||
|
||||
builder = Voice::InboundCallBuilder.new(
|
||||
account: account,
|
||||
inbox: @inbox,
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
call_sid: call_sid
|
||||
).perform
|
||||
render xml: builder.twiml_response
|
||||
conversation = resolve_conversation
|
||||
conference_sid = ensure_conference_sid!(conversation)
|
||||
|
||||
render xml: conference_twiml(conference_sid, agent_leg?(twilio_from))
|
||||
end
|
||||
|
||||
def conference_status
|
||||
event = mapped_conference_event
|
||||
return head :no_content unless event
|
||||
|
||||
conversation = find_conversation_for_conference!(
|
||||
friendly_name: params[:FriendlyName],
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
|
||||
Voice::Conference::Manager.new(
|
||||
conversation: conversation,
|
||||
event: event,
|
||||
call_sid: twilio_call_sid,
|
||||
participant_label: participant_label
|
||||
).process
|
||||
|
||||
head :no_content
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_call_sid
|
||||
params[:CallSid]
|
||||
end
|
||||
|
||||
def twilio_from
|
||||
params[:From].to_s
|
||||
end
|
||||
|
||||
def twilio_to
|
||||
params[:To]
|
||||
end
|
||||
|
||||
def twilio_direction
|
||||
@twilio_direction ||= (params['Direction'] || params['CallDirection']).to_s
|
||||
end
|
||||
|
||||
def mapped_conference_event
|
||||
event = params[:StatusCallbackEvent].to_s
|
||||
CONFERENCE_EVENT_PATTERNS.each do |pattern, mapped|
|
||||
return mapped if event.match?(pattern)
|
||||
end
|
||||
nil
|
||||
end
|
||||
|
||||
def agent_leg?(from_number)
|
||||
from_number.start_with?('client:')
|
||||
end
|
||||
|
||||
def resolve_conversation
|
||||
return find_conversation_for_agent if agent_leg?(twilio_from)
|
||||
|
||||
case twilio_direction
|
||||
when 'inbound'
|
||||
Voice::InboundCallBuilder.perform!(
|
||||
account: current_account,
|
||||
inbox: inbox,
|
||||
from_number: twilio_from,
|
||||
call_sid: twilio_call_sid
|
||||
)
|
||||
when 'outbound-api', 'outbound-dial'
|
||||
sync_outbound_leg(
|
||||
call_sid: twilio_call_sid,
|
||||
from_number: twilio_from,
|
||||
direction: twilio_direction
|
||||
)
|
||||
else
|
||||
raise ArgumentError, "Unsupported Twilio direction: #{twilio_direction}"
|
||||
end
|
||||
end
|
||||
|
||||
def find_conversation_for_agent
|
||||
if params[:conversation_id].present?
|
||||
current_account.conversations.find_by!(display_id: params[:conversation_id])
|
||||
else
|
||||
current_account.conversations.find_by!(identifier: twilio_call_sid)
|
||||
end
|
||||
end
|
||||
|
||||
def sync_outbound_leg(call_sid:, from_number:, direction:)
|
||||
parent_sid = params['ParentCallSid'].presence
|
||||
lookup_sid = direction == 'outbound-dial' ? parent_sid || call_sid : call_sid
|
||||
conversation = current_account.conversations.find_by!(identifier: lookup_sid)
|
||||
|
||||
Voice::CallSessionSyncService.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid,
|
||||
message_call_sid: conversation.identifier,
|
||||
leg: {
|
||||
from_number: from_number,
|
||||
to_number: twilio_to,
|
||||
direction: 'outbound'
|
||||
}
|
||||
).perform
|
||||
end
|
||||
|
||||
def ensure_conference_sid!(conversation)
|
||||
attrs = conversation.additional_attributes || {}
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
attrs['conference_sid']
|
||||
end
|
||||
|
||||
def conference_twiml(conference_sid, agent_leg)
|
||||
Twilio::TwiML::VoiceResponse.new.tap do |response|
|
||||
response.dial do |dial|
|
||||
dial.conference(
|
||||
conference_sid,
|
||||
start_conference_on_enter: agent_leg,
|
||||
end_conference_on_exit: false,
|
||||
status_callback: conference_status_callback_url,
|
||||
status_callback_event: 'start end join leave',
|
||||
status_callback_method: 'POST',
|
||||
participant_label: agent_leg ? 'agent' : 'contact'
|
||||
)
|
||||
end
|
||||
end.to_s
|
||||
end
|
||||
|
||||
def conference_status_callback_url
|
||||
host = ENV.fetch('FRONTEND_URL', '')
|
||||
phone_digits = inbox_channel.phone_number.delete_prefix('+')
|
||||
"#{host}/twilio/voice/conference_status/#{phone_digits}"
|
||||
end
|
||||
|
||||
def find_conversation_for_conference!(friendly_name:, call_sid:)
|
||||
name = friendly_name.to_s
|
||||
scope = current_account.conversations
|
||||
|
||||
if name.present?
|
||||
conversation = scope.where("additional_attributes->>'conference_sid' = ?", name).first
|
||||
return conversation if conversation
|
||||
end
|
||||
|
||||
scope.find_by!(identifier: call_sid)
|
||||
end
|
||||
|
||||
def set_inbox!
|
||||
# Resolve from the digits in the route param and look up exact E.164 match
|
||||
digits = params[:phone].to_s.gsub(/\D/, '')
|
||||
e164 = "+#{digits}"
|
||||
channel = Channel::Voice.find_by!(phone_number: e164)
|
||||
@inbox = channel.inbox
|
||||
end
|
||||
|
||||
def current_account
|
||||
@current_account ||= inbox_account
|
||||
end
|
||||
|
||||
def participant_label
|
||||
params[:ParticipantLabel].to_s
|
||||
end
|
||||
|
||||
attr_reader :inbox
|
||||
|
||||
delegate :account, :channel, to: :inbox, prefix: true
|
||||
end
|
||||
|
||||
@@ -1,94 +1,78 @@
|
||||
module Captain::ChatHelper
|
||||
include Integrations::LlmInstrumentation
|
||||
include Captain::ToolExecutionHelper
|
||||
|
||||
def request_chat_completion
|
||||
log_chat_completion_request
|
||||
|
||||
response = @client.chat(
|
||||
parameters: {
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: @assistant&.config&.[]('temperature').to_f || 1
|
||||
}
|
||||
)
|
||||
|
||||
handle_response(response)
|
||||
with_agent_session do
|
||||
response = instrument_llm_call(instrumentation_params) do
|
||||
@client.chat(
|
||||
parameters: chat_parameters
|
||||
)
|
||||
end
|
||||
handle_response(response)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "#{self.class.name} Assistant: #{@assistant.id}, Error in chat completion: #{e}"
|
||||
raise e
|
||||
end
|
||||
|
||||
def instrumentation_params
|
||||
{
|
||||
span_name: "llm.captain.#{feature_name}",
|
||||
account_id: resolved_account_id,
|
||||
conversation_id: @conversation_id,
|
||||
feature_name: feature_name,
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
temperature: temperature,
|
||||
metadata: {
|
||||
assistant_id: @assistant&.id
|
||||
}
|
||||
}
|
||||
end
|
||||
|
||||
def chat_parameters
|
||||
{
|
||||
model: @model,
|
||||
messages: @messages,
|
||||
tools: @tool_registry&.registered_tools || [],
|
||||
response_format: { type: 'json_object' },
|
||||
temperature: temperature
|
||||
}
|
||||
end
|
||||
|
||||
def temperature
|
||||
@assistant&.config&.[]('temperature').to_f || 1
|
||||
end
|
||||
|
||||
def resolved_account_id
|
||||
@account&.id || @assistant&.account_id
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
message = JSON.parse(message['content'].strip)
|
||||
persist_message(message, 'assistant')
|
||||
message
|
||||
end
|
||||
# Ensures all LLM calls and tool executions within an agentic loop
|
||||
# are grouped under a single trace/session in Langfuse.
|
||||
#
|
||||
# Without this guard, each recursive call to request_chat_completion
|
||||
# (triggered by tool calls) would create a separate trace instead of
|
||||
# nesting within the existing session span.
|
||||
def with_agent_session(&)
|
||||
already_active = @agent_session_active
|
||||
return yield if already_active
|
||||
|
||||
@agent_session_active = true
|
||||
instrument_agent_session(instrumentation_params, &)
|
||||
ensure
|
||||
@agent_session_active = false unless already_active
|
||||
end
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
tool_calls.each do |tool_call|
|
||||
process_tool_call(tool_call)
|
||||
end
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(function_name, tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.using_tool', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
result = @tool_registry.send(function_name, arguments)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t('captain.copilot.completed_tool_call', function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@messages << {
|
||||
role: 'assistant',
|
||||
tool_calls: tool_calls
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message({ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name }, 'assistant_thinking')
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: content
|
||||
}
|
||||
# Must be implemented by including class to identify the feature for instrumentation.
|
||||
# Used for Langfuse tagging and span naming.
|
||||
def feature_name
|
||||
raise NotImplementedError, "#{self.class.name} must implement #feature_name"
|
||||
end
|
||||
|
||||
def log_chat_completion_request
|
||||
|
||||
@@ -0,0 +1,83 @@
|
||||
module Captain::ToolExecutionHelper
|
||||
private
|
||||
|
||||
def handle_response(response)
|
||||
Rails.logger.debug { "#{self.class.name} Assistant: #{@assistant.id}, Received response #{response}" }
|
||||
message = response.dig('choices', 0, 'message')
|
||||
|
||||
if message['tool_calls']
|
||||
process_tool_calls(message['tool_calls'])
|
||||
else
|
||||
message = JSON.parse(message['content'].strip)
|
||||
persist_message(message, 'assistant')
|
||||
message
|
||||
end
|
||||
end
|
||||
|
||||
def process_tool_calls(tool_calls)
|
||||
append_tool_calls(tool_calls)
|
||||
tool_calls.each { |tool_call| process_tool_call(tool_call) }
|
||||
request_chat_completion
|
||||
end
|
||||
|
||||
def process_tool_call(tool_call)
|
||||
arguments = JSON.parse(tool_call['function']['arguments'])
|
||||
function_name = tool_call['function']['name']
|
||||
tool_call_id = tool_call['id']
|
||||
|
||||
if @tool_registry.respond_to?(function_name)
|
||||
execute_tool(function_name, arguments, tool_call_id)
|
||||
else
|
||||
process_invalid_tool_call(function_name, tool_call_id)
|
||||
end
|
||||
end
|
||||
|
||||
def execute_tool(function_name, arguments, tool_call_id)
|
||||
persist_tool_status(function_name, 'captain.copilot.using_tool')
|
||||
result = perform_tool_call(function_name, arguments)
|
||||
persist_tool_status(function_name, 'captain.copilot.completed_tool_call')
|
||||
append_tool_response(result, tool_call_id)
|
||||
end
|
||||
|
||||
def perform_tool_call(function_name, arguments)
|
||||
instrument_tool_call(function_name, arguments) do
|
||||
@tool_registry.send(function_name, arguments)
|
||||
end
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Tool #{function_name} failed: #{e.message}"
|
||||
"Error executing #{function_name}: #{e.message}"
|
||||
end
|
||||
|
||||
def persist_tool_status(function_name, translation_key)
|
||||
persist_message(
|
||||
{
|
||||
content: I18n.t(translation_key, function_name: function_name),
|
||||
function_name: function_name
|
||||
},
|
||||
'assistant_thinking'
|
||||
)
|
||||
end
|
||||
|
||||
def append_tool_calls(tool_calls)
|
||||
@messages << {
|
||||
role: 'assistant',
|
||||
tool_calls: tool_calls
|
||||
}
|
||||
end
|
||||
|
||||
def process_invalid_tool_call(function_name, tool_call_id)
|
||||
persist_message(
|
||||
{ content: I18n.t('captain.copilot.invalid_tool_call'), function_name: function_name },
|
||||
'assistant_thinking'
|
||||
)
|
||||
append_tool_response(I18n.t('captain.copilot.tool_not_available'), tool_call_id)
|
||||
end
|
||||
|
||||
def append_tool_response(content, tool_call_id)
|
||||
@messages << {
|
||||
role: 'tool',
|
||||
tool_call_id: tool_call_id,
|
||||
content: content
|
||||
}
|
||||
end
|
||||
end
|
||||
@@ -30,7 +30,7 @@ class Captain::Conversation::ResponseBuilderJob < ApplicationJob
|
||||
delegate :account, :inbox, to: :@conversation
|
||||
|
||||
def generate_and_process_response
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant).generate_response(
|
||||
@response = Captain::Llm::AssistantChatService.new(assistant: @assistant, conversation_id: @conversation.id).generate_response(
|
||||
message_history: collect_previous_messages
|
||||
)
|
||||
process_response
|
||||
|
||||
@@ -15,11 +15,14 @@ class Captain::Copilot::ResponseJob < ApplicationJob
|
||||
private
|
||||
|
||||
def generate_chat_response(assistant:, conversation_id:, user_id:, copilot_thread_id:, message:)
|
||||
Captain::Copilot::ChatService.new(
|
||||
service = Captain::Copilot::ChatService.new(
|
||||
assistant,
|
||||
user_id: user_id,
|
||||
copilot_thread_id: copilot_thread_id,
|
||||
conversation_id: conversation_id
|
||||
).generate_response(message)
|
||||
)
|
||||
# When using copilot_thread, message is already in previous_history
|
||||
# Pass nil to avoid duplicate
|
||||
service.generate_response(copilot_thread_id.present? ? nil : message)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,6 +42,17 @@ class Channel::Voice < ApplicationRecord
|
||||
false
|
||||
end
|
||||
|
||||
def initiate_call(to:)
|
||||
case provider
|
||||
when 'twilio'
|
||||
Voice::Provider::TwilioAdapter.new(self).initiate_call(
|
||||
to: to
|
||||
)
|
||||
else
|
||||
raise "Unsupported voice provider: #{provider}"
|
||||
end
|
||||
end
|
||||
|
||||
# Public URLs used to configure Twilio webhooks
|
||||
def voice_call_webhook_url
|
||||
digits = phone_number.delete_prefix('+')
|
||||
@@ -76,6 +87,15 @@ class Channel::Voice < ApplicationRecord
|
||||
errors.add(:provider_config, "#{key} is required for Twilio provider") if config[key].blank?
|
||||
end
|
||||
end
|
||||
# twilio_client and initiate_twilio_call moved to Voice::Provider::TwilioAdapter
|
||||
|
||||
def provider_config_hash
|
||||
if provider_config.is_a?(Hash)
|
||||
provider_config
|
||||
else
|
||||
JSON.parse(provider_config.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def provision_twilio_on_create
|
||||
service = ::Twilio::VoiceWebhookSetupService.new(channel: self)
|
||||
@@ -96,4 +116,6 @@ class Channel::Voice < ApplicationRecord
|
||||
Rails.logger.error("TWILIO_VOICE_SETUP_ON_CREATE_ERROR: #{error_details}")
|
||||
errors.add(:base, "Twilio setup failed: #{e.message}")
|
||||
end
|
||||
|
||||
public :provider_config_hash
|
||||
end
|
||||
|
||||
@@ -13,6 +13,7 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
@user = nil
|
||||
@copilot_thread = nil
|
||||
@previous_history = []
|
||||
@conversation_id = config[:conversation_id]
|
||||
setup_user(config)
|
||||
setup_message_history(config)
|
||||
register_tools
|
||||
@@ -113,4 +114,8 @@ class Captain::Copilot::ChatService < Llm::BaseOpenAiService
|
||||
message_type: message_type
|
||||
)
|
||||
end
|
||||
|
||||
def feature_name
|
||||
'copilot'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -3,10 +3,11 @@ require 'openai'
|
||||
class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
include Captain::ChatHelper
|
||||
|
||||
def initialize(assistant: nil)
|
||||
def initialize(assistant: nil, conversation_id: nil)
|
||||
super()
|
||||
|
||||
@assistant = assistant
|
||||
@conversation_id = conversation_id
|
||||
@messages = [system_message]
|
||||
@response = ''
|
||||
register_tools
|
||||
@@ -42,4 +43,8 @@ class Captain::Llm::AssistantChatService < Llm::BaseOpenAiService
|
||||
def persist_message(message, message_type = 'assistant')
|
||||
# No need to implement
|
||||
end
|
||||
|
||||
def feature_name
|
||||
'assistant'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
module Enterprise::Contacts::ContactableInboxesService
|
||||
private
|
||||
|
||||
# Extend base selection to include Voice inboxes
|
||||
def get_contactable_inbox(inbox)
|
||||
return voice_contactable_inbox(inbox) if inbox.channel_type == 'Channel::Voice'
|
||||
|
||||
super
|
||||
end
|
||||
|
||||
def voice_contactable_inbox(inbox)
|
||||
return if @contact.phone_number.blank?
|
||||
|
||||
{ source_id: @contact.phone_number, inbox: inbox }
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,90 @@
|
||||
class Voice::CallMessageBuilder
|
||||
def self.perform!(conversation:, direction:, payload:, user: nil, timestamps: {})
|
||||
new(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: payload,
|
||||
user: user,
|
||||
timestamps: timestamps
|
||||
).perform!
|
||||
end
|
||||
|
||||
def initialize(conversation:, direction:, payload:, user:, timestamps:)
|
||||
@conversation = conversation
|
||||
@direction = direction
|
||||
@payload = payload
|
||||
@user = user
|
||||
@timestamps = timestamps
|
||||
end
|
||||
|
||||
def perform!
|
||||
validate_sender!
|
||||
message = latest_message
|
||||
message ? update_message!(message) : create_message!
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
attr_reader :conversation, :direction, :payload, :user, :timestamps
|
||||
|
||||
def latest_message
|
||||
conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
end
|
||||
|
||||
def update_message!(message)
|
||||
message.update!(
|
||||
message_type: message_type,
|
||||
content_attributes: { 'data' => base_payload },
|
||||
sender: sender
|
||||
)
|
||||
end
|
||||
|
||||
def create_message!
|
||||
params = {
|
||||
content: 'Voice Call',
|
||||
message_type: message_type,
|
||||
content_type: 'voice_call',
|
||||
content_attributes: { 'data' => base_payload }
|
||||
}
|
||||
Messages::MessageBuilder.new(sender, conversation, params).perform
|
||||
end
|
||||
|
||||
def base_payload
|
||||
@base_payload ||= begin
|
||||
data = payload.slice(
|
||||
:call_sid,
|
||||
:status,
|
||||
:call_direction,
|
||||
:conference_sid,
|
||||
:from_number,
|
||||
:to_number
|
||||
).stringify_keys
|
||||
data['call_direction'] = direction
|
||||
data['meta'] = {
|
||||
'created_at' => timestamps[:created_at] || current_timestamp,
|
||||
'ringing_at' => timestamps[:ringing_at] || current_timestamp
|
||||
}.compact
|
||||
data
|
||||
end
|
||||
end
|
||||
|
||||
def message_type
|
||||
direction == 'outbound' ? 'outgoing' : 'incoming'
|
||||
end
|
||||
|
||||
def sender
|
||||
return user if direction == 'outbound'
|
||||
|
||||
conversation.contact
|
||||
end
|
||||
|
||||
def validate_sender!
|
||||
return unless direction == 'outbound'
|
||||
|
||||
raise ArgumentError, 'Agent sender required for outbound calls' unless user
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,94 @@
|
||||
class Voice::CallSessionSyncService
|
||||
attr_reader :conversation, :call_sid, :message_call_sid, :from_number, :to_number, :direction
|
||||
|
||||
def initialize(conversation:, call_sid:, leg:, message_call_sid: nil)
|
||||
@conversation = conversation
|
||||
@call_sid = call_sid
|
||||
@message_call_sid = message_call_sid || call_sid
|
||||
@from_number = leg[:from_number]
|
||||
@to_number = leg[:to_number]
|
||||
@direction = leg[:direction]
|
||||
end
|
||||
|
||||
def perform
|
||||
ActiveRecord::Base.transaction do
|
||||
attrs = refreshed_attributes
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
sync_voice_call_message!(attrs)
|
||||
end
|
||||
|
||||
conversation
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def refreshed_attributes
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_direction'] = direction
|
||||
attrs['call_status'] ||= 'ringing'
|
||||
attrs['conference_sid'] ||= Voice::Conference::Name.for(conversation)
|
||||
attrs['meta'] ||= {}
|
||||
attrs['meta']['initiated_at'] ||= current_timestamp
|
||||
attrs
|
||||
end
|
||||
|
||||
def sync_voice_call_message!(attrs)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: direction,
|
||||
payload: {
|
||||
call_sid: message_call_sid,
|
||||
status: attrs['call_status'],
|
||||
conference_sid: attrs['conference_sid'],
|
||||
from_number: origin_number_for(direction),
|
||||
to_number: target_number_for(direction)
|
||||
},
|
||||
user: agent_for(attrs),
|
||||
timestamps: {
|
||||
created_at: attrs.dig('meta', 'initiated_at'),
|
||||
ringing_at: attrs.dig('meta', 'ringing_at')
|
||||
}
|
||||
)
|
||||
end
|
||||
|
||||
def origin_number_for(current_direction)
|
||||
return outbound_origin if current_direction == 'outbound'
|
||||
|
||||
from_number.presence || inbox_number
|
||||
end
|
||||
|
||||
def target_number_for(current_direction)
|
||||
return conversation.contact&.phone_number || to_number if current_direction == 'outbound'
|
||||
|
||||
to_number || conversation.contact&.phone_number
|
||||
end
|
||||
|
||||
def agent_for(attrs)
|
||||
agent_id = attrs['agent_id']
|
||||
return nil unless agent_id
|
||||
|
||||
agent = conversation.account.users.find_by(id: agent_id)
|
||||
raise ArgumentError, 'Agent sender required for outbound call sync' if direction == 'outbound' && agent.nil?
|
||||
|
||||
agent
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
|
||||
def outbound_origin
|
||||
inbox_number || from_number
|
||||
end
|
||||
|
||||
def inbox_number
|
||||
conversation.inbox&.channel&.phone_number
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,66 @@
|
||||
class Voice::CallStatus::Manager
|
||||
pattr_initialize [:conversation!, :call_sid]
|
||||
|
||||
ALLOWED_STATUSES = %w[ringing in-progress completed no-answer failed].freeze
|
||||
TERMINAL_STATUSES = %w[completed no-answer failed].freeze
|
||||
|
||||
def process_status_update(status, duration: nil, timestamp: nil)
|
||||
return unless ALLOWED_STATUSES.include?(status)
|
||||
|
||||
current_status = conversation.additional_attributes&.dig('call_status')
|
||||
return if current_status == status
|
||||
|
||||
apply_status(status, duration: duration, timestamp: timestamp)
|
||||
update_message(status)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def apply_status(status, duration:, timestamp:)
|
||||
attrs = (conversation.additional_attributes || {}).dup
|
||||
attrs['call_status'] = status
|
||||
|
||||
if status == 'in-progress'
|
||||
attrs['call_started_at'] ||= timestamp || now_seconds
|
||||
elsif TERMINAL_STATUSES.include?(status)
|
||||
attrs['call_ended_at'] = timestamp || now_seconds
|
||||
attrs['call_duration'] = resolved_duration(attrs, duration, timestamp)
|
||||
end
|
||||
|
||||
conversation.update!(
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def resolved_duration(attrs, provided_duration, timestamp)
|
||||
return provided_duration if provided_duration
|
||||
|
||||
started_at = attrs['call_started_at']
|
||||
return unless started_at && timestamp
|
||||
|
||||
[timestamp - started_at.to_i, 0].max
|
||||
end
|
||||
|
||||
def update_message(status)
|
||||
message = conversation.messages
|
||||
.where(content_type: 'voice_call')
|
||||
.order(created_at: :desc)
|
||||
.first
|
||||
return unless message
|
||||
|
||||
data = (message.content_attributes || {}).dup
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = status
|
||||
|
||||
message.update!(content_attributes: data)
|
||||
end
|
||||
|
||||
def now_seconds
|
||||
current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
class Voice::Conference::Manager
|
||||
pattr_initialize [:conversation!, :event!, :call_sid!, :participant_label]
|
||||
|
||||
def process
|
||||
case event
|
||||
when 'start'
|
||||
ensure_conference_sid!
|
||||
mark_ringing!
|
||||
when 'join'
|
||||
mark_in_progress! if agent_participant?
|
||||
when 'leave'
|
||||
handle_leave!
|
||||
when 'end'
|
||||
finalize_conference!
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def status_manager
|
||||
@status_manager ||= Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
def ensure_conference_sid!
|
||||
attrs = conversation.additional_attributes || {}
|
||||
return if attrs['conference_sid'].present?
|
||||
|
||||
attrs['conference_sid'] = Voice::Conference::Name.for(conversation)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
end
|
||||
|
||||
def mark_ringing!
|
||||
return if current_status
|
||||
|
||||
status_manager.process_status_update('ringing')
|
||||
end
|
||||
|
||||
def mark_in_progress!
|
||||
status_manager.process_status_update('in-progress', timestamp: current_timestamp)
|
||||
end
|
||||
|
||||
def handle_leave!
|
||||
case current_status
|
||||
when 'ringing'
|
||||
status_manager.process_status_update('no-answer', timestamp: current_timestamp)
|
||||
when 'in-progress'
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
end
|
||||
end
|
||||
|
||||
def finalize_conference!
|
||||
return if %w[completed no-answer failed].include?(current_status)
|
||||
|
||||
status_manager.process_status_update('completed', timestamp: current_timestamp)
|
||||
end
|
||||
|
||||
def current_status
|
||||
conversation.additional_attributes&.dig('call_status')
|
||||
end
|
||||
|
||||
def agent_participant?
|
||||
participant_label.to_s.start_with?('agent')
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
Time.zone.now.to_i
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,5 @@
|
||||
module Voice::Conference::Name
|
||||
def self.for(conversation)
|
||||
"conf_account_#{conversation.account_id}_conv_#{conversation.display_id}"
|
||||
end
|
||||
end
|
||||
@@ -1,82 +1,99 @@
|
||||
class Voice::InboundCallBuilder
|
||||
pattr_initialize [:account!, :inbox!, :from_number!, :to_number, :call_sid!]
|
||||
attr_reader :account, :inbox, :from_number, :call_sid
|
||||
|
||||
attr_reader :conversation
|
||||
|
||||
def perform
|
||||
contact = find_or_create_contact!
|
||||
contact_inbox = find_or_create_contact_inbox!(contact)
|
||||
@conversation = find_or_create_conversation!(contact, contact_inbox)
|
||||
create_call_message_if_needed!
|
||||
self
|
||||
def self.perform!(account:, inbox:, from_number:, call_sid:)
|
||||
new(account: account, inbox: inbox, from_number: from_number, call_sid: call_sid).perform!
|
||||
end
|
||||
|
||||
def twiml_response
|
||||
response = Twilio::TwiML::VoiceResponse.new
|
||||
response.say(message: 'Please wait while we connect you to an agent')
|
||||
response.to_s
|
||||
def initialize(account:, inbox:, from_number:, call_sid:)
|
||||
@account = account
|
||||
@inbox = inbox
|
||||
@from_number = from_number
|
||||
@call_sid = call_sid
|
||||
end
|
||||
|
||||
def perform!
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact = ensure_contact!
|
||||
contact_inbox = ensure_contact_inbox!(contact)
|
||||
conversation = find_conversation || create_conversation!(contact, contact_inbox)
|
||||
conversation.reload
|
||||
update_conversation!(conversation, timestamp)
|
||||
build_voice_message!(conversation, timestamp)
|
||||
conversation
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def find_or_create_conversation!(contact, contact_inbox)
|
||||
account.conversations.find_or_create_by!(
|
||||
account_id: account.id,
|
||||
inbox_id: inbox.id,
|
||||
identifier: call_sid
|
||||
) do |conv|
|
||||
conv.contact_id = contact.id
|
||||
conv.contact_inbox_id = contact_inbox.id
|
||||
conv.additional_attributes = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing'
|
||||
}
|
||||
def ensure_contact!
|
||||
account.contacts.find_or_create_by!(phone_number: from_number) do |record|
|
||||
record.name = from_number if record.name.blank?
|
||||
end
|
||||
end
|
||||
|
||||
def create_call_message!
|
||||
content_attrs = call_message_content_attributes
|
||||
def ensure_contact_inbox!(contact)
|
||||
ContactInbox.find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
) do |record|
|
||||
record.source_id = from_number
|
||||
end
|
||||
end
|
||||
|
||||
@conversation.messages.create!(
|
||||
account_id: account.id,
|
||||
def find_conversation
|
||||
return if call_sid.blank?
|
||||
|
||||
account.conversations.includes(:contact).find_by(identifier: call_sid)
|
||||
end
|
||||
|
||||
def create_conversation!(contact, contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
message_type: :incoming,
|
||||
sender: @conversation.contact,
|
||||
content: 'Voice Call',
|
||||
content_type: 'voice_call',
|
||||
content_attributes: content_attrs
|
||||
contact_id: contact.id,
|
||||
status: :open,
|
||||
identifier: call_sid
|
||||
)
|
||||
end
|
||||
|
||||
def create_call_message_if_needed!
|
||||
return if @conversation.messages.voice_calls.exists?
|
||||
def update_conversation!(conversation, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'inbound',
|
||||
'call_status' => 'ringing',
|
||||
'conference_sid' => Voice::Conference::Name.for(conversation),
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
create_call_message!
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def call_message_content_attributes
|
||||
{
|
||||
data: {
|
||||
def build_voice_message!(conversation, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: 'inbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conversation_id: @conversation.display_id,
|
||||
call_direction: 'inbound',
|
||||
conference_sid: conversation.additional_attributes['conference_sid'],
|
||||
from_number: from_number,
|
||||
to_number: to_number,
|
||||
meta: {
|
||||
created_at: Time.current.to_i,
|
||||
ringing_at: Time.current.to_i
|
||||
}
|
||||
}
|
||||
}
|
||||
to_number: inbox.channel&.phone_number
|
||||
},
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
)
|
||||
end
|
||||
|
||||
def find_or_create_contact!
|
||||
account.contacts.find_by(phone_number: from_number) ||
|
||||
account.contacts.create!(phone_number: from_number, name: 'Unknown Caller')
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def find_or_create_contact_inbox!(contact)
|
||||
ContactInbox.where(contact_id: contact.id, inbox_id: inbox.id, source_id: from_number).first_or_create!
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
class Voice::OutboundCallBuilder
|
||||
attr_reader :account, :inbox, :user, :contact
|
||||
|
||||
def self.perform!(account:, inbox:, user:, contact:)
|
||||
new(account: account, inbox: inbox, user: user, contact: contact).perform!
|
||||
end
|
||||
|
||||
def initialize(account:, inbox:, user:, contact:)
|
||||
@account = account
|
||||
@inbox = inbox
|
||||
@user = user
|
||||
@contact = contact
|
||||
end
|
||||
|
||||
def perform!
|
||||
raise ArgumentError, 'Contact phone number required' if contact.phone_number.blank?
|
||||
raise ArgumentError, 'Agent required' if user.blank?
|
||||
|
||||
timestamp = current_timestamp
|
||||
|
||||
ActiveRecord::Base.transaction do
|
||||
contact_inbox = ensure_contact_inbox!
|
||||
conversation = create_conversation!(contact_inbox)
|
||||
conversation.reload
|
||||
conference_sid = Voice::Conference::Name.for(conversation)
|
||||
call_sid = initiate_call!
|
||||
update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
{ conversation: conversation, call_sid: call_sid }
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def ensure_contact_inbox!
|
||||
ContactInbox.find_or_create_by!(
|
||||
contact_id: contact.id,
|
||||
inbox_id: inbox.id
|
||||
) do |record|
|
||||
record.source_id = contact.phone_number
|
||||
end
|
||||
end
|
||||
|
||||
def create_conversation!(contact_inbox)
|
||||
account.conversations.create!(
|
||||
contact_inbox_id: contact_inbox.id,
|
||||
inbox_id: inbox.id,
|
||||
contact_id: contact.id,
|
||||
status: :open
|
||||
)
|
||||
end
|
||||
|
||||
def initiate_call!
|
||||
inbox.channel.initiate_call(
|
||||
to: contact.phone_number
|
||||
)[:call_sid]
|
||||
end
|
||||
|
||||
def update_conversation!(conversation, call_sid, conference_sid, timestamp)
|
||||
attrs = {
|
||||
'call_direction' => 'outbound',
|
||||
'call_status' => 'ringing',
|
||||
'agent_id' => user.id,
|
||||
'conference_sid' => conference_sid,
|
||||
'meta' => { 'initiated_at' => timestamp }
|
||||
}
|
||||
|
||||
conversation.update!(
|
||||
identifier: call_sid,
|
||||
additional_attributes: attrs,
|
||||
last_activity_at: current_time
|
||||
)
|
||||
end
|
||||
|
||||
def build_voice_message!(conversation, call_sid, conference_sid, timestamp)
|
||||
Voice::CallMessageBuilder.perform!(
|
||||
conversation: conversation,
|
||||
direction: 'outbound',
|
||||
payload: {
|
||||
call_sid: call_sid,
|
||||
status: 'ringing',
|
||||
conference_sid: conference_sid,
|
||||
from_number: inbox.channel&.phone_number,
|
||||
to_number: contact.phone_number
|
||||
},
|
||||
user: user,
|
||||
timestamps: { created_at: timestamp, ringing_at: timestamp }
|
||||
)
|
||||
end
|
||||
|
||||
def current_timestamp
|
||||
@current_timestamp ||= current_time.to_i
|
||||
end
|
||||
|
||||
def current_time
|
||||
@current_time ||= Time.zone.now
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,32 @@
|
||||
class Voice::Provider::TwilioAdapter
|
||||
def initialize(channel)
|
||||
@channel = channel
|
||||
end
|
||||
|
||||
def initiate_call(to:, _conference_sid: nil, _agent_id: nil)
|
||||
cfg = @channel.provider_config_hash
|
||||
|
||||
host = ENV.fetch('FRONTEND_URL')
|
||||
phone_digits = @channel.phone_number.delete_prefix('+')
|
||||
callback_url = "#{host}/twilio/voice/call/#{phone_digits}"
|
||||
|
||||
params = {
|
||||
from: @channel.phone_number,
|
||||
to: to,
|
||||
url: callback_url,
|
||||
status_callback: "#{host}/twilio/voice/status/#{phone_digits}",
|
||||
status_callback_event: %w[initiated ringing answered completed],
|
||||
status_callback_method: 'POST'
|
||||
}
|
||||
|
||||
call = twilio_client(cfg).calls.create(**params)
|
||||
|
||||
{ call_sid: call.sid }
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def twilio_client(config)
|
||||
Twilio::REST::Client.new(config['account_sid'], config['auth_token'])
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,60 @@
|
||||
class Voice::StatusUpdateService
|
||||
pattr_initialize [:account!, :call_sid!, :call_status]
|
||||
pattr_initialize [:account!, :call_sid!, :call_status, { payload: {} }]
|
||||
|
||||
TWILIO_STATUS_MAP = {
|
||||
'queued' => 'ringing',
|
||||
'initiated' => 'ringing',
|
||||
'ringing' => 'ringing',
|
||||
'in-progress' => 'in-progress',
|
||||
'inprogress' => 'in-progress',
|
||||
'answered' => 'in-progress',
|
||||
'completed' => 'completed',
|
||||
'busy' => 'no-answer',
|
||||
'no-answer' => 'no-answer',
|
||||
'failed' => 'failed',
|
||||
'canceled' => 'failed'
|
||||
}.freeze
|
||||
|
||||
def perform
|
||||
normalized_status = normalize_status(call_status)
|
||||
return if normalized_status.blank?
|
||||
|
||||
conversation = account.conversations.find_by(identifier: call_sid)
|
||||
return unless conversation
|
||||
return if call_status.to_s.strip.empty?
|
||||
|
||||
update_conversation!(conversation)
|
||||
update_last_call_message!(conversation)
|
||||
Voice::CallStatus::Manager.new(
|
||||
conversation: conversation,
|
||||
call_sid: call_sid
|
||||
).process_status_update(
|
||||
normalized_status,
|
||||
duration: payload_duration,
|
||||
timestamp: payload_timestamp
|
||||
)
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def update_conversation!(conversation)
|
||||
attrs = (conversation.additional_attributes || {}).merge('call_status' => call_status)
|
||||
conversation.update!(additional_attributes: attrs)
|
||||
def normalize_status(status)
|
||||
return if status.to_s.strip.empty?
|
||||
|
||||
TWILIO_STATUS_MAP[status.to_s.downcase]
|
||||
end
|
||||
|
||||
def update_last_call_message!(conversation)
|
||||
msg = conversation.messages.voice_calls.order(created_at: :desc).first
|
||||
return unless msg
|
||||
def payload_duration
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
data = msg.content_attributes.is_a?(Hash) ? msg.content_attributes : {}
|
||||
data['data'] ||= {}
|
||||
data['data']['status'] = call_status
|
||||
msg.update!(content_attributes: data)
|
||||
duration = payload['CallDuration'] || payload['call_duration']
|
||||
duration&.to_i
|
||||
end
|
||||
|
||||
def payload_timestamp
|
||||
return unless payload.is_a?(Hash)
|
||||
|
||||
ts = payload['Timestamp'] || payload['timestamp']
|
||||
return unless ts
|
||||
|
||||
Time.zone.parse(ts).to_i
|
||||
rescue ArgumentError
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
@@ -42,4 +42,11 @@ module ChatwootApp
|
||||
def self.advanced_search_allowed?
|
||||
enterprise? && ENV.fetch('OPENSEARCH_URL', nil).present?
|
||||
end
|
||||
|
||||
def self.otel_enabled?
|
||||
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
|
||||
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
|
||||
|
||||
otel_provider.present? && secret_key.present? && otel_provider == 'langfuse'
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require 'opentelemetry_config'
|
||||
require_relative 'llm_instrumentation_constants'
|
||||
|
||||
module Integrations::LlmInstrumentation
|
||||
include Integrations::LlmInstrumentationConstants
|
||||
|
||||
def tracer
|
||||
@tracer ||= OpentelemetryConfig.tracer
|
||||
end
|
||||
|
||||
def instrument_llm_call(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
setup_span_attributes(span, params)
|
||||
result = yield
|
||||
record_completion(span, result)
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
|
||||
yield
|
||||
end
|
||||
|
||||
def instrument_agent_session(params)
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(params[:span_name]) do |span|
|
||||
set_metadata_attributes(span, params)
|
||||
|
||||
# By default, the input and output of a trace are set from the root observation
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, params[:messages].to_json)
|
||||
result = yield
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
|
||||
result
|
||||
end
|
||||
rescue StandardError => e
|
||||
ChatwootExceptionTracker.new(e, account: params[:account]).capture_exception
|
||||
yield
|
||||
end
|
||||
|
||||
def instrument_tool_call(tool_name, arguments)
|
||||
# There is no error handling because tools can fail and LLMs should be
|
||||
# aware of those failures and factor them into their response.
|
||||
return yield unless ChatwootApp.otel_enabled?
|
||||
|
||||
tracer.in_span(format(TOOL_SPAN_NAME, tool_name)) do |span|
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_INPUT, arguments.to_json)
|
||||
result = yield
|
||||
span.set_attribute(ATTR_LANGFUSE_OBSERVATION_OUTPUT, result.to_json)
|
||||
result
|
||||
end
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def setup_span_attributes(span, params)
|
||||
set_request_attributes(span, params)
|
||||
set_prompt_messages(span, params[:messages])
|
||||
set_metadata_attributes(span, params)
|
||||
end
|
||||
|
||||
def record_completion(span, result)
|
||||
set_completion_attributes(span, result) if result.is_a?(Hash)
|
||||
end
|
||||
|
||||
def set_request_attributes(span, params)
|
||||
span.set_attribute(ATTR_GEN_AI_PROVIDER, 'openai')
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_MODEL, params[:model])
|
||||
span.set_attribute(ATTR_GEN_AI_REQUEST_TEMPERATURE, params[:temperature]) if params[:temperature]
|
||||
end
|
||||
|
||||
def set_prompt_messages(span, messages)
|
||||
messages.each_with_index do |msg, idx|
|
||||
role = msg[:role] || msg['role']
|
||||
content = msg[:content] || msg['content']
|
||||
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_ROLE, idx), role)
|
||||
span.set_attribute(format(ATTR_GEN_AI_PROMPT_CONTENT, idx), content.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def set_metadata_attributes(span, params)
|
||||
session_id = params[:conversation_id].present? ? "#{params[:account_id]}_#{params[:conversation_id]}" : nil
|
||||
span.set_attribute(ATTR_LANGFUSE_USER_ID, params[:account_id].to_s) if params[:account_id]
|
||||
span.set_attribute(ATTR_LANGFUSE_SESSION_ID, session_id) if session_id.present?
|
||||
span.set_attribute(ATTR_LANGFUSE_TAGS, [params[:feature_name]].to_json)
|
||||
|
||||
return unless params[:metadata].is_a?(Hash)
|
||||
|
||||
params[:metadata].each do |key, value|
|
||||
span.set_attribute(format(ATTR_LANGFUSE_METADATA, key), value.to_s)
|
||||
end
|
||||
end
|
||||
|
||||
def set_completion_attributes(span, result)
|
||||
set_completion_message(span, result)
|
||||
set_usage_metrics(span, result)
|
||||
set_error_attributes(span, result)
|
||||
end
|
||||
|
||||
def set_completion_message(span, result)
|
||||
message = result[:message] || result.dig('choices', 0, 'message', 'content')
|
||||
return if message.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_ROLE, 'assistant')
|
||||
span.set_attribute(ATTR_GEN_AI_COMPLETION_CONTENT, message)
|
||||
end
|
||||
|
||||
def set_usage_metrics(span, result)
|
||||
usage = result[:usage] || result['usage']
|
||||
return if usage.blank?
|
||||
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_INPUT_TOKENS, usage['prompt_tokens']) if usage['prompt_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_OUTPUT_TOKENS, usage['completion_tokens']) if usage['completion_tokens']
|
||||
span.set_attribute(ATTR_GEN_AI_USAGE_TOTAL_TOKENS, usage['total_tokens']) if usage['total_tokens']
|
||||
end
|
||||
|
||||
def set_error_attributes(span, result)
|
||||
error = result[:error] || result['error']
|
||||
return if error.blank?
|
||||
|
||||
error_code = result[:error_code] || result['error_code']
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR, error.to_json)
|
||||
span.set_attribute(ATTR_GEN_AI_RESPONSE_ERROR_CODE, error_code) if error_code
|
||||
span.status = OpenTelemetry::Trace::Status.error("API Error: #{error_code}")
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,31 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
module Integrations::LlmInstrumentationConstants
|
||||
# OpenTelemetry attribute names following GenAI semantic conventions
|
||||
# https://opentelemetry.io/docs/specs/semconv/gen-ai/
|
||||
ATTR_GEN_AI_PROVIDER = 'gen_ai.provider.name'
|
||||
ATTR_GEN_AI_REQUEST_MODEL = 'gen_ai.request.model'
|
||||
ATTR_GEN_AI_REQUEST_TEMPERATURE = 'gen_ai.request.temperature'
|
||||
ATTR_GEN_AI_PROMPT_ROLE = 'gen_ai.prompt.%d.role'
|
||||
ATTR_GEN_AI_PROMPT_CONTENT = 'gen_ai.prompt.%d.content'
|
||||
ATTR_GEN_AI_COMPLETION_ROLE = 'gen_ai.completion.0.role'
|
||||
ATTR_GEN_AI_COMPLETION_CONTENT = 'gen_ai.completion.0.content'
|
||||
ATTR_GEN_AI_USAGE_INPUT_TOKENS = 'gen_ai.usage.input_tokens'
|
||||
ATTR_GEN_AI_USAGE_OUTPUT_TOKENS = 'gen_ai.usage.output_tokens'
|
||||
ATTR_GEN_AI_USAGE_TOTAL_TOKENS = 'gen_ai.usage.total_tokens'
|
||||
ATTR_GEN_AI_RESPONSE_ERROR = 'gen_ai.response.error'
|
||||
ATTR_GEN_AI_RESPONSE_ERROR_CODE = 'gen_ai.response.error_code'
|
||||
|
||||
TOOL_SPAN_NAME = 'tool.%s'
|
||||
|
||||
# Langfuse-specific attributes
|
||||
# https://langfuse.com/integrations/native/opentelemetry#property-mapping
|
||||
ATTR_LANGFUSE_USER_ID = 'langfuse.user.id'
|
||||
ATTR_LANGFUSE_SESSION_ID = 'langfuse.session.id'
|
||||
ATTR_LANGFUSE_TAGS = 'langfuse.trace.tags'
|
||||
ATTR_LANGFUSE_METADATA = 'langfuse.trace.metadata.%s'
|
||||
ATTR_LANGFUSE_TRACE_INPUT = 'langfuse.trace.input'
|
||||
ATTR_LANGFUSE_TRACE_OUTPUT = 'langfuse.trace.output'
|
||||
ATTR_LANGFUSE_OBSERVATION_INPUT = 'langfuse.observation.input'
|
||||
ATTR_LANGFUSE_OBSERVATION_OUTPUT = 'langfuse.observation.output'
|
||||
end
|
||||
@@ -1,4 +1,6 @@
|
||||
class Integrations::OpenaiBaseService
|
||||
include Integrations::LlmInstrumentation
|
||||
|
||||
# gpt-4o-mini supports 128,000 tokens
|
||||
# 1 token is approx 4 characters
|
||||
# sticking with 120000 to be safe
|
||||
@@ -87,21 +89,57 @@ class Integrations::OpenaiBaseService
|
||||
end
|
||||
|
||||
def make_api_call(body)
|
||||
headers = {
|
||||
parsed_body = JSON.parse(body)
|
||||
instrumentation_params = build_instrumentation_params(parsed_body)
|
||||
|
||||
instrument_llm_call(instrumentation_params) do
|
||||
execute_api_request(body, parsed_body['messages'])
|
||||
end
|
||||
end
|
||||
|
||||
def build_instrumentation_params(parsed_body)
|
||||
{
|
||||
span_name: "llm.#{event_name}",
|
||||
account_id: hook.account_id,
|
||||
conversation_id: conversation&.display_id,
|
||||
feature_name: event_name,
|
||||
model: parsed_body['model'],
|
||||
messages: parsed_body['messages'],
|
||||
temperature: parsed_body['temperature']
|
||||
}
|
||||
end
|
||||
|
||||
def execute_api_request(body, messages)
|
||||
Rails.logger.info("OpenAI API request: #{body}")
|
||||
response = HTTParty.post(api_url, headers: api_headers, body: body)
|
||||
Rails.logger.info("OpenAI API response: #{response.body}")
|
||||
|
||||
parse_api_response(response, messages)
|
||||
end
|
||||
|
||||
def api_headers
|
||||
{
|
||||
'Content-Type' => 'application/json',
|
||||
'Authorization' => "Bearer #{hook.settings['api_key']}"
|
||||
}
|
||||
end
|
||||
|
||||
Rails.logger.info("OpenAI API request: #{body}")
|
||||
response = HTTParty.post(api_url, headers: headers, body: body)
|
||||
Rails.logger.info("OpenAI API response: #{response.body}")
|
||||
def parse_api_response(response, messages)
|
||||
return build_error_response(response, messages) unless response.success?
|
||||
|
||||
return { error: response.parsed_response, error_code: response.code } unless response.success?
|
||||
parsed_response = JSON.parse(response.body)
|
||||
build_success_response(parsed_response, messages)
|
||||
end
|
||||
|
||||
choices = JSON.parse(response.body)['choices']
|
||||
def build_error_response(response, messages)
|
||||
{ error: response.parsed_response, error_code: response.code, request_messages: messages }
|
||||
end
|
||||
|
||||
return { message: choices.first['message']['content'] } if choices.present?
|
||||
def build_success_response(parsed_response, messages)
|
||||
choices = parsed_response['choices']
|
||||
usage = parsed_response['usage']
|
||||
message_content = choices.present? ? choices.first['message']['content'] : nil
|
||||
|
||||
{ message: nil }
|
||||
{ message: message_content, usage: usage, request_messages: messages }
|
||||
end
|
||||
end
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
require 'opentelemetry/sdk'
|
||||
require 'opentelemetry/exporter/otlp'
|
||||
require 'base64'
|
||||
|
||||
module OpentelemetryConfig
|
||||
class << self
|
||||
def tracer
|
||||
initialize! unless initialized?
|
||||
OpenTelemetry.tracer_provider.tracer('chatwoot')
|
||||
end
|
||||
|
||||
def initialized?
|
||||
@initialized ||= false
|
||||
end
|
||||
|
||||
def initialize!
|
||||
return if @initialized
|
||||
return mark_initialized unless langfuse_provider?
|
||||
return mark_initialized unless langfuse_credentials_present?
|
||||
|
||||
configure_opentelemetry
|
||||
mark_initialized
|
||||
rescue StandardError => e
|
||||
Rails.logger.error "Failed to configure OpenTelemetry: #{e.message}"
|
||||
mark_initialized
|
||||
end
|
||||
|
||||
def reset!
|
||||
@initialized = false
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def mark_initialized
|
||||
@initialized = true
|
||||
end
|
||||
|
||||
def langfuse_provider?
|
||||
otel_provider = InstallationConfig.find_by(name: 'OTEL_PROVIDER')&.value
|
||||
otel_provider == 'langfuse'
|
||||
end
|
||||
|
||||
def langfuse_credentials_present?
|
||||
endpoint = InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value
|
||||
public_key = InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value
|
||||
secret_key = InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
|
||||
|
||||
if endpoint.blank? || public_key.blank? || secret_key.blank?
|
||||
Rails.logger.error 'OpenTelemetry disabled (LANGFUSE_BASE_URL, LANGFUSE_PUBLIC_KEY or LANGFUSE_SECRET_KEY is missing)'
|
||||
return false
|
||||
end
|
||||
|
||||
true
|
||||
end
|
||||
|
||||
def langfuse_credentials
|
||||
{
|
||||
endpoint: InstallationConfig.find_by(name: 'LANGFUSE_BASE_URL')&.value,
|
||||
public_key: InstallationConfig.find_by(name: 'LANGFUSE_PUBLIC_KEY')&.value,
|
||||
secret_key: InstallationConfig.find_by(name: 'LANGFUSE_SECRET_KEY')&.value
|
||||
}
|
||||
end
|
||||
|
||||
def traces_endpoint
|
||||
credentials = langfuse_credentials
|
||||
"#{credentials[:endpoint]}/api/public/otel/v1/traces"
|
||||
end
|
||||
|
||||
def exporter_config
|
||||
credentials = langfuse_credentials
|
||||
auth_header = Base64.strict_encode64("#{credentials[:public_key]}:#{credentials[:secret_key]}")
|
||||
|
||||
config = {
|
||||
endpoint: traces_endpoint,
|
||||
headers: { 'Authorization' => "Basic #{auth_header}" }
|
||||
}
|
||||
|
||||
config[:ssl_verify_mode] = OpenSSL::SSL::VERIFY_NONE if Rails.env.development?
|
||||
config
|
||||
end
|
||||
|
||||
def configure_opentelemetry
|
||||
OpenTelemetry::SDK.configure do |c|
|
||||
c.service_name = 'chatwoot'
|
||||
exporter = OpenTelemetry::Exporter::OTLP::Exporter.new(**exporter_config)
|
||||
c.add_span_processor(OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter))
|
||||
Rails.logger.info 'OpenTelemetry initialized and configured to export to Langfuse'
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
+2
-1
@@ -79,8 +79,9 @@
|
||||
"md5": "^2.3.0",
|
||||
"mitt": "^3.0.1",
|
||||
"opus-recorder": "^8.0.5",
|
||||
"qrcode": "^1.5.4",
|
||||
"pinia": "^3.0.4",
|
||||
"posthog-js": "^1.260.2",
|
||||
"qrcode": "^1.5.4",
|
||||
"semver": "7.6.3",
|
||||
"snakecase-keys": "^8.0.1",
|
||||
"timezone-phone-codes": "^0.0.2",
|
||||
|
||||
Generated
+94
@@ -157,6 +157,9 @@ importers:
|
||||
opus-recorder:
|
||||
specifier: ^8.0.5
|
||||
version: 8.0.5
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2))
|
||||
posthog-js:
|
||||
specifier: ^1.260.2
|
||||
version: 1.260.3
|
||||
@@ -1371,6 +1374,15 @@ packages:
|
||||
'@vue/devtools-api@6.6.4':
|
||||
resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==}
|
||||
|
||||
'@vue/devtools-api@7.7.8':
|
||||
resolution: {integrity: sha512-BtFcAmDbtXGwurWUFf8ogIbgZyR+rcVES1TSNEI8Em80fD8Anu+qTRN1Fc3J6vdRHlVM3fzPV1qIo+B4AiqGzw==}
|
||||
|
||||
'@vue/devtools-kit@7.7.8':
|
||||
resolution: {integrity: sha512-4Y8op+AoxOJhB9fpcEF6d5vcJXWKgHxC3B0ytUB8zz15KbP9g9WgVzral05xluxi2fOeAy6t140rdQ943GcLRQ==}
|
||||
|
||||
'@vue/devtools-shared@7.7.8':
|
||||
resolution: {integrity: sha512-XHpO3jC5nOgYr40M9p8Z4mmKfTvUxKyRcUnpBAYg11pE78eaRFBKb0kG5yKLroMuJeeNH9LWmKp2zMU5LUc7CA==}
|
||||
|
||||
'@vue/reactivity@3.5.12':
|
||||
resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==}
|
||||
|
||||
@@ -1623,6 +1635,9 @@ packages:
|
||||
birpc@0.1.1:
|
||||
resolution: {integrity: sha512-B64AGL4ug2IS2jvV/zjTYDD1L+2gOJTT7Rv+VaK7KVQtQOo/xZbCDsh7g727ipckmU+QJYRqo5RcifVr0Kgcmg==}
|
||||
|
||||
birpc@2.8.0:
|
||||
resolution: {integrity: sha512-Bz2a4qD/5GRhiHSwj30c/8kC8QGj12nNDwz3D4ErQ4Xhy35dsSDvF+RA/tWpjyU0pdGtSDiEk6B5fBGE1qNVhw==}
|
||||
|
||||
boolbase@1.0.0:
|
||||
resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==}
|
||||
|
||||
@@ -1827,6 +1842,10 @@ packages:
|
||||
constant-case@3.0.4:
|
||||
resolution: {integrity: sha512-I2hSBi7Vvs7BEuJDr5dDHfzb/Ruj3FyvFyh7KLilAjNQw3Be+xgqUBA2W6scVEcL0hL1dwPRtIqEPVUCKkSsyQ==}
|
||||
|
||||
copy-anything@4.0.5:
|
||||
resolution: {integrity: sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.38.1:
|
||||
resolution: {integrity: sha512-OP35aUorbU3Zvlx7pjsFdu1rGNnD4pgw/CWoYzRY3t2EzoVT7shKHY1dlAy3f41cGIO7ZDPQimhGFTlEYkG/Hw==}
|
||||
|
||||
@@ -2607,6 +2626,9 @@ packages:
|
||||
peerDependencies:
|
||||
vite: 5.4.21
|
||||
|
||||
hookable@5.5.3:
|
||||
resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==}
|
||||
|
||||
hotkeys-js@3.8.7:
|
||||
resolution: {integrity: sha512-ckAx3EkUr5XjDwjEHDorHxRO2Kb7z6Z2Sxul4MbBkN8Nho7XDslQsgMJT+CiJ5Z4TgRxxvKHEpuLE3imzqy4Lg==}
|
||||
|
||||
@@ -2840,6 +2862,10 @@ packages:
|
||||
is-weakref@1.0.2:
|
||||
resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==}
|
||||
|
||||
is-what@5.5.0:
|
||||
resolution: {integrity: sha512-oG7cgbmg5kLYae2N5IVd3jm2s+vldjxJzK1pcu9LfpGuQ93MQSzo0okvRna+7y5ifrD+20FE8FvjusyGaz14fw==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
isarray@2.0.5:
|
||||
resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==}
|
||||
|
||||
@@ -3443,6 +3469,9 @@ packages:
|
||||
resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==}
|
||||
engines: {node: '>= 14.16'}
|
||||
|
||||
perfect-debounce@1.0.0:
|
||||
resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==}
|
||||
|
||||
picocolors@1.0.1:
|
||||
resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==}
|
||||
|
||||
@@ -3465,6 +3494,15 @@ packages:
|
||||
resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
pinia@3.0.4:
|
||||
resolution: {integrity: sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==}
|
||||
peerDependencies:
|
||||
typescript: '>=4.5.0'
|
||||
vue: ^3.5.11
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
pirates@4.0.6:
|
||||
resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -3872,6 +3910,9 @@ packages:
|
||||
rfdc@1.3.0:
|
||||
resolution: {integrity: sha512-V2hovdzFbOi77/WajaSMXk2OLm+xNIeQdMMuB7icj7bk6zi2F8GGAxigcnDFpJHbNyNcgyJDiP+8nOrY5cZGrA==}
|
||||
|
||||
rfdc@1.4.1:
|
||||
resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==}
|
||||
|
||||
rimraf@3.0.2:
|
||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
@@ -4044,6 +4085,10 @@ packages:
|
||||
spark-md5@3.0.2:
|
||||
resolution: {integrity: sha512-wcFzz9cDfbuqe0FZzfi2or1sgyIrsDwmPwfZC4hiNidPdPINjeUwNfv5kldczoEAcjl9Y1L3SM7Uz2PUEQzxQw==}
|
||||
|
||||
speakingurl@14.0.1:
|
||||
resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
sprintf-js@1.0.3:
|
||||
resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==}
|
||||
|
||||
@@ -4130,6 +4175,10 @@ packages:
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
hasBin: true
|
||||
|
||||
superjson@2.2.5:
|
||||
resolution: {integrity: sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
supports-color@7.2.0:
|
||||
resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -5842,6 +5891,24 @@ snapshots:
|
||||
|
||||
'@vue/devtools-api@6.6.4': {}
|
||||
|
||||
'@vue/devtools-api@7.7.8':
|
||||
dependencies:
|
||||
'@vue/devtools-kit': 7.7.8
|
||||
|
||||
'@vue/devtools-kit@7.7.8':
|
||||
dependencies:
|
||||
'@vue/devtools-shared': 7.7.8
|
||||
birpc: 2.8.0
|
||||
hookable: 5.5.3
|
||||
mitt: 3.0.1
|
||||
perfect-debounce: 1.0.0
|
||||
speakingurl: 14.0.1
|
||||
superjson: 2.2.5
|
||||
|
||||
'@vue/devtools-shared@7.7.8':
|
||||
dependencies:
|
||||
rfdc: 1.4.1
|
||||
|
||||
'@vue/reactivity@3.5.12':
|
||||
dependencies:
|
||||
'@vue/shared': 3.5.12
|
||||
@@ -6137,6 +6204,8 @@ snapshots:
|
||||
|
||||
birpc@0.1.1: {}
|
||||
|
||||
birpc@2.8.0: {}
|
||||
|
||||
boolbase@1.0.0: {}
|
||||
|
||||
boxen@8.0.1:
|
||||
@@ -6383,6 +6452,10 @@ snapshots:
|
||||
tslib: 2.8.1
|
||||
upper-case: 2.0.2
|
||||
|
||||
copy-anything@4.0.5:
|
||||
dependencies:
|
||||
is-what: 5.5.0
|
||||
|
||||
core-js@3.38.1: {}
|
||||
|
||||
countries-and-timezones@3.6.0: {}
|
||||
@@ -7341,6 +7414,8 @@ snapshots:
|
||||
- terser
|
||||
- utf-8-validate
|
||||
|
||||
hookable@5.5.3: {}
|
||||
|
||||
hotkeys-js@3.8.7: {}
|
||||
|
||||
html-encoding-sniffer@3.0.0:
|
||||
@@ -7558,6 +7633,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
|
||||
is-what@5.5.0: {}
|
||||
|
||||
isarray@2.0.5: {}
|
||||
|
||||
isexe@2.0.0: {}
|
||||
@@ -8215,6 +8292,8 @@ snapshots:
|
||||
|
||||
pathval@2.0.0: {}
|
||||
|
||||
perfect-debounce@1.0.0: {}
|
||||
|
||||
picocolors@1.0.1: {}
|
||||
|
||||
picocolors@1.1.0: {}
|
||||
@@ -8227,6 +8306,13 @@ snapshots:
|
||||
|
||||
pify@2.3.0: {}
|
||||
|
||||
pinia@3.0.4(typescript@5.6.2)(vue@3.5.12(typescript@5.6.2)):
|
||||
dependencies:
|
||||
'@vue/devtools-api': 7.7.8
|
||||
vue: 3.5.12(typescript@5.6.2)
|
||||
optionalDependencies:
|
||||
typescript: 5.6.2
|
||||
|
||||
pirates@4.0.6: {}
|
||||
|
||||
pkcs7@1.0.4:
|
||||
@@ -8686,6 +8772,8 @@ snapshots:
|
||||
|
||||
rfdc@1.3.0: {}
|
||||
|
||||
rfdc@1.4.1: {}
|
||||
|
||||
rimraf@3.0.2:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
@@ -8897,6 +8985,8 @@ snapshots:
|
||||
|
||||
spark-md5@3.0.2: {}
|
||||
|
||||
speakingurl@14.0.1: {}
|
||||
|
||||
sprintf-js@1.0.3: {}
|
||||
|
||||
stackback@0.0.2: {}
|
||||
@@ -8992,6 +9082,10 @@ snapshots:
|
||||
pirates: 4.0.6
|
||||
ts-interface-checker: 0.1.13
|
||||
|
||||
superjson@2.2.5:
|
||||
dependencies:
|
||||
copy-anything: 4.0.5
|
||||
|
||||
supports-color@7.2.0:
|
||||
dependencies:
|
||||
has-flag: 4.0.0
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user