Merge branch 'feature/stripe_v2' into feature/stripe_v2_fe
This commit is contained in:
@@ -256,6 +256,8 @@ AZURE_APP_SECRET=
|
|||||||
## Change these values to fine tune performance
|
## Change these values to fine tune performance
|
||||||
# control the concurrency setting of sidekiq
|
# control the concurrency setting of sidekiq
|
||||||
# SIDEKIQ_CONCURRENCY=10
|
# SIDEKIQ_CONCURRENCY=10
|
||||||
|
# Enable verbose logging each time a job is dequeued in Sidekiq
|
||||||
|
# ENABLE_SIDEKIQ_DEQUEUE_LOGGER=false
|
||||||
|
|
||||||
|
|
||||||
# AI powered features
|
# AI powered features
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
|||||||
enqueue_conversation_job
|
enqueue_conversation_job
|
||||||
head :ok
|
head :ok
|
||||||
when 'Contact'
|
when 'Contact'
|
||||||
|
check_authorization_for_contact_action
|
||||||
enqueue_contact_job
|
enqueue_contact_job
|
||||||
head :ok
|
head :ok
|
||||||
else
|
else
|
||||||
@@ -34,14 +35,34 @@ class Api::V1::Accounts::BulkActionsController < Api::V1::Accounts::BaseControll
|
|||||||
)
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_contact_action?
|
||||||
|
params[:action_name] == 'delete'
|
||||||
|
end
|
||||||
|
|
||||||
|
def check_authorization_for_contact_action
|
||||||
|
authorize(Contact, :destroy?) if delete_contact_action?
|
||||||
|
end
|
||||||
|
|
||||||
def conversation_params
|
def conversation_params
|
||||||
params.permit(:type, :snoozed_until, ids: [], fields: [:status, :assignee_id, :team_id], labels: [add: [], remove: []])
|
# TODO: Align conversation payloads with the `{ action_name, action_attributes }`
|
||||||
|
# and then remove this method in favor of a common params method.
|
||||||
|
base = params.permit(
|
||||||
|
:snoozed_until,
|
||||||
|
fields: [:status, :assignee_id, :team_id]
|
||||||
|
)
|
||||||
|
append_common_bulk_attributes(base)
|
||||||
end
|
end
|
||||||
|
|
||||||
def contact_params
|
def contact_params
|
||||||
params.require(:ids)
|
# TODO: remove this method in favor of a common params method.
|
||||||
permitted = params.permit(:type, ids: [], labels: [add: []])
|
# once legacy conversation payloads are migrated.
|
||||||
permitted[:ids] = permitted[:ids].map(&:to_i) if permitted[:ids].present?
|
append_common_bulk_attributes({})
|
||||||
permitted
|
end
|
||||||
|
|
||||||
|
def append_common_bulk_attributes(base_params)
|
||||||
|
# NOTE: Conversation payloads historically diverged per action. Going forward we
|
||||||
|
# want all objects to share a common contract: `{ action_name, action_attributes }`
|
||||||
|
common = params.permit(:type, :action_name, ids: [], labels: [add: [], remove: []])
|
||||||
|
base_params.merge(common)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
+38
-35
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
|
|||||||
|
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
<Policy :permissions="['administrator']">
|
||||||
<Button
|
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
|
||||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
<Button
|
||||||
sm
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
link
|
sm
|
||||||
slate
|
link
|
||||||
class="hover:!no-underline text-n-slate-12"
|
slate
|
||||||
icon="i-lucide-chevron-down"
|
class="hover:!no-underline text-n-slate-12"
|
||||||
trailing-icon
|
icon="i-lucide-chevron-down"
|
||||||
@click="toggleDeleteSection()"
|
trailing-icon
|
||||||
/>
|
@click="toggleDeleteSection()"
|
||||||
|
/>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
|
||||||
:class="
|
:class="
|
||||||
showDeleteSection
|
showDeleteSection
|
||||||
? 'grid-rows-[1fr] opacity-100 mt-2'
|
? 'grid-rows-[1fr] opacity-100 mt-2'
|
||||||
: 'grid-rows-[0fr] opacity-0 mt-0'
|
: 'grid-rows-[0fr] opacity-0 mt-0'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="overflow-hidden min-h-0">
|
<div class="overflow-hidden min-h-0">
|
||||||
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
|
||||||
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
|
||||||
<Button
|
<Button
|
||||||
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
|
||||||
sm
|
sm
|
||||||
ruby
|
ruby
|
||||||
link
|
link
|
||||||
@click="openConfirmDeleteContactDialog()"
|
@click="openConfirmDeleteContactDialog()"
|
||||||
/>
|
/>
|
||||||
</span>
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<ConfirmContactDeleteDialog
|
||||||
<ConfirmContactDeleteDialog
|
ref="confirmDeleteContactDialogRef"
|
||||||
ref="confirmDeleteContactDialogRef"
|
:selected-contact="selectedContact"
|
||||||
:selected-contact="selectedContact"
|
/>
|
||||||
/>
|
</Policy>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import Button from 'dashboard/components-next/button/Button.vue';
|
|||||||
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
import ContactLabels from 'dashboard/components-next/Contacts/ContactLabels/ContactLabels.vue';
|
||||||
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
import ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
|
||||||
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
selectedContact: {
|
selectedContact: {
|
||||||
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
|
|||||||
@click="updateContact"
|
@click="updateContact"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<Policy :permissions="['administrator']">
|
||||||
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
<div
|
||||||
>
|
class="flex flex-col items-start w-full gap-4 pt-6 border-t border-n-strong"
|
||||||
<div class="flex flex-col gap-2">
|
>
|
||||||
<h6 class="text-base font-medium text-n-slate-12">
|
<div class="flex flex-col gap-2">
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
<h6 class="text-base font-medium text-n-slate-12">
|
||||||
</h6>
|
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
|
||||||
<span class="text-sm text-n-slate-11">
|
</h6>
|
||||||
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
<span class="text-sm text-n-slate-11">
|
||||||
</span>
|
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
||||||
|
color="ruby"
|
||||||
|
@click="openConfirmDeleteContactDialog"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<ConfirmContactDeleteDialog
|
||||||
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
|
ref="confirmDeleteContactDialogRef"
|
||||||
color="ruby"
|
:selected-contact="selectedContact"
|
||||||
@click="openConfirmDeleteContactDialog"
|
@go-to-contacts-list="emit('goToContactsList')"
|
||||||
/>
|
/>
|
||||||
</div>
|
</Policy>
|
||||||
<ConfirmContactDeleteDialog
|
|
||||||
ref="confirmDeleteContactDialogRef"
|
|
||||||
:selected-contact="selectedContact"
|
|
||||||
@go-to-contacts-list="emit('goToContactsList')"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -55,17 +56,17 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div
|
<ButtonGroup
|
||||||
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2 border border-n-weak rounded-full gap-2 p-1"
|
class="flex flex-col justify-center items-center absolute top-36 xl:top-24 ltr:right-2 rtl:left-2 bg-n-solid-2/90 backdrop-blur-lg border border-n-weak/50 rounded-full gap-1.5 p-1.5 shadow-sm transition-shadow duration-200 hover:shadow"
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
v-tooltip.top="$t('CONVERSATION.SIDEBAR.CONTACT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
sm
|
sm
|
||||||
class="!rounded-full"
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:!brightness-105 active:duration-75"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-n-alpha-2': isContactSidebarOpen,
|
'bg-n-alpha-2 active:shadow-sm': isContactSidebarOpen,
|
||||||
}"
|
}"
|
||||||
icon="i-ph-user-bold"
|
icon="i-ph-user-bold"
|
||||||
@click="handleConversationSidebarToggle"
|
@click="handleConversationSidebarToggle"
|
||||||
@@ -75,13 +76,14 @@ useKeyboardEvents(keyboardEvents);
|
|||||||
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
v-tooltip.bottom="$t('CONVERSATION.SIDEBAR.COPILOT')"
|
||||||
ghost
|
ghost
|
||||||
slate
|
slate
|
||||||
class="!rounded-full"
|
|
||||||
:class="{
|
|
||||||
'bg-n-alpha-2 !text-n-iris-9': isCopilotPanelOpen,
|
|
||||||
}"
|
|
||||||
sm
|
sm
|
||||||
|
class="!rounded-full transition-all duration-[250ms] ease-out active:!scale-95 active:duration-75"
|
||||||
|
:class="{
|
||||||
|
'bg-n-alpha-2 !text-n-iris-9 active:!brightness-105 active:shadow-sm':
|
||||||
|
isCopilotPanelOpen,
|
||||||
|
}"
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
@click="handleCopilotSidebarToggle"
|
@click="handleCopilotSidebarToggle"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -21,6 +21,10 @@ const props = defineProps({
|
|||||||
enableCannedResponses: { type: Boolean, default: true },
|
enableCannedResponses: { type: Boolean, default: true },
|
||||||
enabledMenuOptions: { type: Array, default: () => [] },
|
enabledMenuOptions: { type: Array, default: () => [] },
|
||||||
enableCaptainTools: { type: Boolean, default: false },
|
enableCaptainTools: { type: Boolean, default: false },
|
||||||
|
signature: { type: String, default: '' },
|
||||||
|
allowSignature: { type: Boolean, default: false },
|
||||||
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['update:modelValue']);
|
const emit = defineEmits(['update:modelValue']);
|
||||||
@@ -100,6 +104,10 @@ watch(
|
|||||||
:enable-canned-responses="enableCannedResponses"
|
:enable-canned-responses="enableCannedResponses"
|
||||||
:enabled-menu-options="enabledMenuOptions"
|
:enabled-menu-options="enabledMenuOptions"
|
||||||
:enable-captain-tools="enableCaptainTools"
|
:enable-captain-tools="enableCaptainTools"
|
||||||
|
:signature="signature"
|
||||||
|
:allow-signature="allowSignature"
|
||||||
|
:send-with-signature="sendWithSignature"
|
||||||
|
:channel-type="channelType"
|
||||||
@input="handleInput"
|
@input="handleInput"
|
||||||
@focus="handleFocus"
|
@focus="handleFocus"
|
||||||
@blur="handleBlur"
|
@blur="handleBlur"
|
||||||
|
|||||||
+5
-2
@@ -14,6 +14,7 @@ import {
|
|||||||
} from 'dashboard/helper/portalHelper';
|
} from 'dashboard/helper/portalHelper';
|
||||||
import wootConstants from 'dashboard/constants/globals';
|
import wootConstants from 'dashboard/constants/globals';
|
||||||
|
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
|
||||||
@@ -140,11 +141,12 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
@click="previewArticle"
|
@click="previewArticle"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center">
|
<ButtonGroup class="flex items-center">
|
||||||
<Button
|
<Button
|
||||||
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
:label="t('HELP_CENTER.EDIT_ARTICLE_PAGE.HEADER.PUBLISH')"
|
||||||
size="sm"
|
size="sm"
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none"
|
class="ltr:rounded-r-none rtl:rounded-l-none"
|
||||||
|
no-animation
|
||||||
:is-loading="isArticlePublishing"
|
:is-loading="isArticlePublishing"
|
||||||
:disabled="
|
:disabled="
|
||||||
status === ARTICLE_STATUSES.PUBLISHED ||
|
status === ARTICLE_STATUSES.PUBLISHED ||
|
||||||
@@ -159,6 +161,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
size="sm"
|
size="sm"
|
||||||
:disabled="!articleId"
|
:disabled="!articleId"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none"
|
class="ltr:rounded-l-none rtl:rounded-r-none"
|
||||||
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
@click.stop="showArticleActionMenu = !showArticleActionMenu"
|
||||||
/>
|
/>
|
||||||
@@ -170,7 +173,7 @@ const updateArticleStatus = async ({ value }) => {
|
|||||||
/>
|
/>
|
||||||
</OnClickOutside>
|
</OnClickOutside>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -92,7 +92,6 @@ const setSignature = () => {
|
|||||||
|
|
||||||
const toggleMessageSignature = () => {
|
const toggleMessageSignature = () => {
|
||||||
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
|
||||||
setSignature();
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Added this watch to dynamically set signature on target inbox change.
|
// Added this watch to dynamically set signature on target inbox change.
|
||||||
|
|||||||
+14
-8
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
|
|||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeTargetInbox = value => {
|
const removeSignatureFromMessage = () => {
|
||||||
v$.value.$reset();
|
// Always remove the signature from message content when inbox/contact is removed
|
||||||
// Remove the signature from message content
|
// to ensure no leftover signature content remains
|
||||||
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
|
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
||||||
if (props.sendWithSignature) {
|
? props.messageSignature
|
||||||
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
|
: extractTextFromMarkdown(props.messageSignature);
|
||||||
? props.messageSignature
|
if (signatureToRemove) {
|
||||||
: extractTextFromMarkdown(props.messageSignature);
|
|
||||||
state.message = removeSignature(state.message, signatureToRemove);
|
state.message = removeSignature(state.message, signatureToRemove);
|
||||||
}
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const removeTargetInbox = value => {
|
||||||
|
v$.value.$reset();
|
||||||
|
removeSignatureFromMessage();
|
||||||
emit('updateTargetInbox', value);
|
emit('updateTargetInbox', value);
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
};
|
};
|
||||||
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
|
|||||||
const clearSelectedContact = () => {
|
const clearSelectedContact = () => {
|
||||||
emit('clearSelectedContact');
|
emit('clearSelectedContact');
|
||||||
state.attachedFiles = [];
|
state.attachedFiles = [];
|
||||||
|
removeSignatureFromMessage();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onClickInsertEmoji = emoji => {
|
const onClickInsertEmoji = emoji => {
|
||||||
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
|
|||||||
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
|
||||||
:has-errors="validationStates.isMessageInvalid"
|
:has-errors="validationStates.isMessageInvalid"
|
||||||
:has-attachments="state.attachedFiles.length > 0"
|
:has-attachments="state.attachedFiles.length > 0"
|
||||||
|
:channel-type="inboxChannelType"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<AttachmentPreviews
|
<AttachmentPreviews
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ const props = defineProps({
|
|||||||
hasAttachments: { type: Boolean, default: false },
|
hasAttachments: { type: Boolean, default: false },
|
||||||
sendWithSignature: { type: Boolean, default: false },
|
sendWithSignature: { type: Boolean, default: false },
|
||||||
messageSignature: { type: String, default: '' },
|
messageSignature: { type: String, default: '' },
|
||||||
|
channelType: { type: String, default: '' },
|
||||||
});
|
});
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
@@ -90,6 +91,10 @@ const replaceText = async message => {
|
|||||||
"
|
"
|
||||||
enable-variables
|
enable-variables
|
||||||
:show-character-count="false"
|
:show-character-count="false"
|
||||||
|
:signature="messageSignature"
|
||||||
|
allow-signature
|
||||||
|
:send-with-signature="sendWithSignature"
|
||||||
|
:channel-type="channelType"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ const props = defineProps({
|
|||||||
icon: { type: [String, Object, Function], default: '' },
|
icon: { type: [String, Object, Function], default: '' },
|
||||||
trailingIcon: { type: Boolean, default: false },
|
trailingIcon: { type: Boolean, default: false },
|
||||||
isLoading: { type: Boolean, default: false },
|
isLoading: { type: Boolean, default: false },
|
||||||
|
noAnimation: { type: Boolean, default: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
const slots = useSlots();
|
const slots = useSlots();
|
||||||
@@ -179,12 +180,18 @@ const STYLE_CONFIG = {
|
|||||||
md: 'text-sm font-medium',
|
md: 'text-sm font-medium',
|
||||||
lg: 'text-base',
|
lg: 'text-base',
|
||||||
},
|
},
|
||||||
|
clickAnimation: {
|
||||||
|
xs: 'active:enabled:scale-[0.97]',
|
||||||
|
sm: 'active:enabled:scale-[0.97]',
|
||||||
|
md: 'active:enabled:scale-[0.98]',
|
||||||
|
lg: 'active:enabled:scale-[0.98]',
|
||||||
|
},
|
||||||
justify: {
|
justify: {
|
||||||
start: 'justify-start',
|
start: 'justify-start',
|
||||||
center: 'justify-center',
|
center: 'justify-center',
|
||||||
end: 'justify-end',
|
end: 'justify-end',
|
||||||
},
|
},
|
||||||
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-200 ease-in-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
base: 'inline-flex items-center min-w-0 gap-2 transition-all duration-100 ease-out border-0 rounded-lg outline-1 outline disabled:opacity-50',
|
||||||
};
|
};
|
||||||
|
|
||||||
const variantClasses = computed(() => {
|
const variantClasses = computed(() => {
|
||||||
@@ -221,6 +228,12 @@ const linkButtonClasses = computed(() => {
|
|||||||
|
|
||||||
return classes.join(' ');
|
return classes.join(' ');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const animationClasses = computed(() => {
|
||||||
|
return props.noAnimation
|
||||||
|
? ''
|
||||||
|
: STYLE_CONFIG.clickAnimation[computedSize.value];
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -230,6 +243,7 @@ const linkButtonClasses = computed(() => {
|
|||||||
[STYLE_CONFIG.base]: true,
|
[STYLE_CONFIG.base]: true,
|
||||||
[isLink ? linkButtonClasses : buttonClasses]: true,
|
[isLink ? linkButtonClasses : buttonClasses]: true,
|
||||||
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
[STYLE_CONFIG.fontSize[computedSize]]: true,
|
||||||
|
[animationClasses]: true,
|
||||||
[STYLE_CONFIG.justify[computedJustify]]: true,
|
[STYLE_CONFIG.justify[computedJustify]]: true,
|
||||||
'flex-row-reverse': trailingIcon && !isIconOnly,
|
'flex-row-reverse': trailingIcon && !isIconOnly,
|
||||||
}"
|
}"
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<script setup>
|
||||||
|
defineProps({
|
||||||
|
noAnimation: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="
|
||||||
|
noAnimation
|
||||||
|
? ''
|
||||||
|
: 'has-[button:not(:disabled):active]:scale-[0.98] transition-transform duration-150 ease-out'
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -96,6 +96,7 @@ watch(
|
|||||||
:label="selectedLabel"
|
:label="selectedLabel"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
|
no-animation
|
||||||
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
class="justify-between w-full !px-3 !py-2.5 text-n-slate-12 font-normal group-hover/combobox:border-n-slate-6 focus:outline-n-brand"
|
||||||
:class="{
|
:class="{
|
||||||
focused: open,
|
focused: open,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import { useUISettings } from 'dashboard/composables/useUISettings';
|
import { useUISettings } from 'dashboard/composables/useUISettings';
|
||||||
import { useMapGetter } from 'dashboard/composables/store';
|
import { useMapGetter } from 'dashboard/composables/store';
|
||||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||||
@@ -53,14 +54,17 @@ const toggleSidebar = () => {
|
|||||||
v-if="showCopilotLauncher"
|
v-if="showCopilotLauncher"
|
||||||
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
class="fixed bottom-4 ltr:right-4 rtl:left-4 z-50"
|
||||||
>
|
>
|
||||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
<ButtonGroup
|
||||||
|
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-woot-captain"
|
icon="i-woot-captain"
|
||||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
no-animation
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||||
lg
|
lg
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<template v-else />
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -47,6 +47,7 @@ const isReel = computed(() => {
|
|||||||
'max-w-48': isReel,
|
'max-w-48': isReel,
|
||||||
'max-w-full': !isReel,
|
'max-w-full': !isReel,
|
||||||
}"
|
}"
|
||||||
|
@click.stop
|
||||||
@error="handleError"
|
@error="handleError"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useRoute } from 'vue-router';
|
import { useRoute } from 'vue-router';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
isMobileSidebarOpen: {
|
isMobileSidebarOpen: {
|
||||||
@@ -45,14 +46,17 @@ const toggleSidebar = () => {
|
|||||||
},
|
},
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="rounded-full bg-n-alpha-2 p-1">
|
<ButtonGroup
|
||||||
|
class="rounded-full bg-n-alpha-2 backdrop-blur-lg p-1 shadow hover:shadow-md"
|
||||||
|
>
|
||||||
<Button
|
<Button
|
||||||
icon="i-lucide-menu"
|
icon="i-lucide-menu"
|
||||||
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl"
|
no-animation
|
||||||
|
class="!rounded-full !bg-n-solid-3 dark:!bg-n-alpha-2 !text-n-slate-12 text-xl transition-all duration-200 ease-out hover:brightness-110"
|
||||||
lg
|
lg
|
||||||
@click="toggleSidebar"
|
@click="toggleSidebar"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
</div>
|
</div>
|
||||||
<template v-else />
|
<template v-else />
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue';
|
import { computed, ref, onMounted, nextTick } from 'vue';
|
||||||
|
import { useResizeObserver } from '@vueuse/core';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
initialActiveTab: {
|
initialActiveTab: {
|
||||||
type: Number,
|
type: Number,
|
||||||
@@ -22,6 +24,32 @@ const emit = defineEmits(['tabChanged']);
|
|||||||
|
|
||||||
const activeTab = computed(() => props.initialActiveTab);
|
const activeTab = computed(() => props.initialActiveTab);
|
||||||
|
|
||||||
|
const tabRefs = ref([]);
|
||||||
|
const indicatorStyle = ref({});
|
||||||
|
const enableTransition = ref(false);
|
||||||
|
|
||||||
|
const activeElement = computed(() => tabRefs.value[activeTab.value]);
|
||||||
|
|
||||||
|
const updateIndicator = () => {
|
||||||
|
if (!activeElement.value) return;
|
||||||
|
|
||||||
|
indicatorStyle.value = {
|
||||||
|
left: `${activeElement.value.offsetLeft}px`,
|
||||||
|
width: `${activeElement.value.offsetWidth}px`,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
useResizeObserver(activeElement, () => {
|
||||||
|
if (enableTransition.value || !activeElement.value) updateIndicator();
|
||||||
|
});
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
updateIndicator();
|
||||||
|
nextTick(() => {
|
||||||
|
enableTransition.value = true;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
const selectTab = index => {
|
const selectTab = index => {
|
||||||
emit('tabChanged', props.tabs[index]);
|
emit('tabChanged', props.tabs[index]);
|
||||||
};
|
};
|
||||||
@@ -37,20 +65,30 @@ const showDivider = index => {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit">
|
<div
|
||||||
|
class="relative flex items-center h-8 rounded-lg bg-n-alpha-1 w-fit transition-all duration-200 ease-out has-[button:active]:scale-[1.01]"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="absolute rounded-lg bg-n-solid-active shadow-sm pointer-events-none h-8 outline-1 outline outline-n-container inset-y-0"
|
||||||
|
:class="{ 'transition-all duration-300 ease-out': enableTransition }"
|
||||||
|
:style="indicatorStyle"
|
||||||
|
/>
|
||||||
|
|
||||||
<template v-for="(tab, index) in tabs" :key="index">
|
<template v-for="(tab, index) in tabs" :key="index">
|
||||||
<button
|
<button
|
||||||
class="relative px-4 truncate py-1.5 text-sm border-0 outline-1 outline rounded-lg transition-colors duration-300 ease-in-out hover:text-n-brand"
|
:ref="el => (tabRefs[index] = el)"
|
||||||
|
class="relative z-10 px-4 truncate py-1.5 text-sm border-0 outline-1 outline-transparent rounded-lg transition-all duration-200 ease-out hover:text-n-brand active:scale-[1.02]"
|
||||||
:class="[
|
:class="[
|
||||||
activeTab === index
|
activeTab === index
|
||||||
? 'text-n-blue-text bg-n-solid-active outline-n-container dark:outline-transparent'
|
? 'text-n-blue-text scale-100'
|
||||||
: 'text-n-slate-10 outline-transparent h-8',
|
: 'text-n-slate-10 scale-[0.98]',
|
||||||
]"
|
]"
|
||||||
@click="selectTab(index)"
|
@click="selectTab(index)"
|
||||||
>
|
>
|
||||||
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
{{ tab.label }} {{ tab.count ? `(${tab.count})` : '' }}
|
||||||
</button>
|
</button>
|
||||||
<div
|
<div
|
||||||
|
v-if="index < tabs.length - 1"
|
||||||
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
class="w-px h-3.5 rounded my-auto transition-colors duration-300 ease-in-out"
|
||||||
:class="
|
:class="
|
||||||
showDivider(index)
|
showDivider(index)
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
CMD_RESOLVE_CONVERSATION,
|
CMD_RESOLVE_CONVERSATION,
|
||||||
} from 'dashboard/helper/commandbar/events';
|
} from 'dashboard/helper/commandbar/events';
|
||||||
|
|
||||||
|
import ButtonGroup from 'dashboard/components-next/buttonGroup/ButtonGroup.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
|
||||||
const store = useStore();
|
const store = useStore();
|
||||||
@@ -133,7 +134,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="relative flex items-center justify-end resolve-actions">
|
<div class="relative flex items-center justify-end resolve-actions">
|
||||||
<div
|
<ButtonGroup
|
||||||
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
class="rounded-lg shadow outline-1 outline flex-shrink-0"
|
||||||
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
:class="!showOpenButton ? 'outline-n-container' : 'outline-transparent'"
|
||||||
>
|
>
|
||||||
@@ -142,6 +143,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
|
:label="t('CONVERSATION.HEADER.RESOLVE_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdResolveConversation"
|
@click="onCmdResolveConversation"
|
||||||
@@ -151,6 +153,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
|
:label="t('CONVERSATION.HEADER.REOPEN_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
class="ltr:rounded-r-none rtl:rounded-l-none !outline-0"
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdOpenConversation"
|
@click="onCmdOpenConversation"
|
||||||
@@ -160,6 +163,7 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
|
:label="t('CONVERSATION.HEADER.OPEN_ACTION')"
|
||||||
size="sm"
|
size="sm"
|
||||||
color="slate"
|
color="slate"
|
||||||
|
no-animation
|
||||||
:is-loading="isLoading"
|
:is-loading="isLoading"
|
||||||
@click="onCmdOpenConversation"
|
@click="onCmdOpenConversation"
|
||||||
/>
|
/>
|
||||||
@@ -169,12 +173,13 @@ useEmitter(CMD_RESOLVE_CONVERSATION, onCmdResolveConversation);
|
|||||||
icon="i-lucide-chevron-down"
|
icon="i-lucide-chevron-down"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
size="sm"
|
size="sm"
|
||||||
|
no-animation
|
||||||
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
|
class="ltr:rounded-l-none rtl:rounded-r-none !outline-0"
|
||||||
color="slate"
|
color="slate"
|
||||||
trailing-icon
|
trailing-icon
|
||||||
@click="openDropdown"
|
@click="openDropdown"
|
||||||
/>
|
/>
|
||||||
</div>
|
</ButtonGroup>
|
||||||
<div
|
<div
|
||||||
v-if="showActionsDropdown"
|
v-if="showActionsDropdown"
|
||||||
v-on-clickaway="closeDropdown"
|
v-on-clickaway="closeDropdown"
|
||||||
|
|||||||
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function handleEmptyBodyWithSignature() {
|
function handleEmptyBodyWithSignature() {
|
||||||
const { schema, tr } = state;
|
const { schema, tr, doc } = state;
|
||||||
|
|
||||||
|
const isEmptyParagraph = node =>
|
||||||
|
node && node.type === schema.nodes.paragraph && node.content.size === 0;
|
||||||
|
|
||||||
|
// Check if empty paragraph already exists to prevent duplicates when toggling signatures
|
||||||
|
if (isEmptyParagraph(doc.firstChild)) {
|
||||||
|
focusEditorInputField('start');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
// create a paragraph node and
|
// create a paragraph node and
|
||||||
// start a transaction to append it at the end
|
// start a transaction to append it at the end
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ const translateValue = computed(() => {
|
|||||||
|
|
||||||
<template>
|
<template>
|
||||||
<button
|
<button
|
||||||
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0"
|
class="flex items-center w-auto h-8 p-1 transition-all border rounded-full bg-n-alpha-2 group relative duration-300 ease-in-out z-0 active:scale-[0.995] active:duration-75"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
:class="{
|
:class="{
|
||||||
'cursor-not-allowed': disabled,
|
'cursor-not-allowed': disabled,
|
||||||
|
|||||||
@@ -580,7 +580,18 @@
|
|||||||
"NO_LABELS_FOUND": "No labels available yet.",
|
"NO_LABELS_FOUND": "No labels available yet.",
|
||||||
"SELECTED_COUNT": "{count} selected",
|
"SELECTED_COUNT": "{count} selected",
|
||||||
"CLEAR_SELECTION": "Clear selection",
|
"CLEAR_SELECTION": "Clear selection",
|
||||||
"SELECT_ALL": "Select all ({count})"
|
"SELECT_ALL": "Select all ({count})",
|
||||||
|
"DELETE_CONTACTS": "Delete",
|
||||||
|
"DELETE_SUCCESS": "Contacts deleted successfully.",
|
||||||
|
"DELETE_FAILED": "Failed to delete contacts.",
|
||||||
|
"DELETE_DIALOG": {
|
||||||
|
"TITLE": "Delete selected contacts",
|
||||||
|
"SINGULAR_TITLE": "Delete selected contact",
|
||||||
|
"DESCRIPTION": "This will permanently delete {count} selected contacts. This action cannot be undone.",
|
||||||
|
"SINGULAR_DESCRIPTION": "This will permanently delete the selected contact. This action cannot be undone.",
|
||||||
|
"CONFIRM_MULTIPLE": "Delete contacts",
|
||||||
|
"CONFIRM_SINGLE": "Delete contact"
|
||||||
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
"COMPOSE_NEW_CONVERSATION": {
|
"COMPOSE_NEW_CONVERSATION": {
|
||||||
|
|||||||
+22
-1
@@ -6,6 +6,7 @@ import { vOnClickOutside } from '@vueuse/components';
|
|||||||
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
|
||||||
import Button from 'dashboard/components-next/button/Button.vue';
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
|
||||||
|
import Policy from 'dashboard/components/policy.vue';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
visibleContactIds: {
|
visibleContactIds: {
|
||||||
@@ -22,7 +23,12 @@ const props = defineProps({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
|
const emit = defineEmits([
|
||||||
|
'clearSelection',
|
||||||
|
'assignLabels',
|
||||||
|
'toggleAll',
|
||||||
|
'deleteSelected',
|
||||||
|
]);
|
||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
|
|
||||||
@@ -139,6 +145,21 @@ const handleAssignLabels = labels => {
|
|||||||
/>
|
/>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
|
<Policy :permissions="['administrator']">
|
||||||
|
<Button
|
||||||
|
v-tooltip.bottom="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||||
|
sm
|
||||||
|
faded
|
||||||
|
ruby
|
||||||
|
icon="i-lucide-trash"
|
||||||
|
:label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||||
|
:aria-label="t('CONTACTS_BULK_ACTIONS.DELETE_CONTACTS')"
|
||||||
|
:disabled="!selectedCount || isLoading"
|
||||||
|
:is-loading="isLoading"
|
||||||
|
class="!px-1.5 [&>span:nth-child(2)]:hidden"
|
||||||
|
@click="emit('deleteSelected')"
|
||||||
|
/>
|
||||||
|
</Policy>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</BulkSelectBar>
|
</BulkSelectBar>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/Con
|
|||||||
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
|
||||||
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
|
||||||
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
|
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
|
||||||
|
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
|
||||||
import BulkActionsAPI from 'dashboard/api/bulkActions';
|
import BulkActionsAPI from 'dashboard/api/bulkActions';
|
||||||
|
|
||||||
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
const DEFAULT_SORT_FIELD = 'last_activity_at';
|
||||||
@@ -64,7 +65,26 @@ const totalItems = computed(() => meta.value?.count);
|
|||||||
|
|
||||||
const selectedContactIds = ref([]);
|
const selectedContactIds = ref([]);
|
||||||
const isBulkActionLoading = ref(false);
|
const isBulkActionLoading = ref(false);
|
||||||
const hasSelection = computed(() => selectedContactIds.value.length > 0);
|
const bulkDeleteDialogRef = ref(null);
|
||||||
|
const selectedCount = computed(() => selectedContactIds.value.length);
|
||||||
|
const bulkDeleteDialogTitle = computed(() =>
|
||||||
|
selectedCount.value > 1
|
||||||
|
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.TITLE')
|
||||||
|
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_TITLE')
|
||||||
|
);
|
||||||
|
const bulkDeleteDialogDescription = computed(() =>
|
||||||
|
selectedCount.value > 1
|
||||||
|
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.DESCRIPTION', {
|
||||||
|
count: selectedCount.value,
|
||||||
|
})
|
||||||
|
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.SINGULAR_DESCRIPTION')
|
||||||
|
);
|
||||||
|
const bulkDeleteDialogConfirmLabel = computed(() =>
|
||||||
|
selectedCount.value > 1
|
||||||
|
? t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_MULTIPLE')
|
||||||
|
: t('CONTACTS_BULK_ACTIONS.DELETE_DIALOG.CONFIRM_SINGLE')
|
||||||
|
);
|
||||||
|
const hasSelection = computed(() => selectedCount.value > 0);
|
||||||
const activeSegment = computed(() => {
|
const activeSegment = computed(() => {
|
||||||
if (!activeSegmentId.value) return undefined;
|
if (!activeSegmentId.value) return undefined;
|
||||||
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
return segments.value.find(view => view.id === Number(activeSegmentId.value));
|
||||||
@@ -120,6 +140,11 @@ const clearSelection = () => {
|
|||||||
selectedContactIds.value = [];
|
selectedContactIds.value = [];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBulkDeleteDialog = () => {
|
||||||
|
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
|
||||||
|
bulkDeleteDialogRef.value?.open?.();
|
||||||
|
};
|
||||||
|
|
||||||
const toggleSelectAll = shouldSelect => {
|
const toggleSelectAll = shouldSelect => {
|
||||||
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
|
||||||
};
|
};
|
||||||
@@ -256,6 +281,29 @@ const assignLabels = async labels => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const deleteContacts = async () => {
|
||||||
|
if (!selectedContactIds.value.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
isBulkActionLoading.value = true;
|
||||||
|
try {
|
||||||
|
await BulkActionsAPI.create({
|
||||||
|
type: 'Contact',
|
||||||
|
ids: selectedContactIds.value,
|
||||||
|
action_name: 'delete',
|
||||||
|
});
|
||||||
|
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_SUCCESS'));
|
||||||
|
clearSelection();
|
||||||
|
await fetchContactsBasedOnContext(pageNumber.value);
|
||||||
|
bulkDeleteDialogRef.value?.close?.();
|
||||||
|
} catch (error) {
|
||||||
|
useAlert(t('CONTACTS_BULK_ACTIONS.DELETE_FAILED'));
|
||||||
|
} finally {
|
||||||
|
isBulkActionLoading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleSort = async ({ sort, order }) => {
|
const handleSort = async ({ sort, order }) => {
|
||||||
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
|
||||||
|
|
||||||
@@ -297,6 +345,12 @@ watch(
|
|||||||
{ deep: true }
|
{ deep: true }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
watch(hasSelection, value => {
|
||||||
|
if (!value) {
|
||||||
|
bulkDeleteDialogRef.value?.close?.();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
() => uiSettings.value?.contacts_sort_by,
|
() => uiSettings.value?.contacts_sort_by,
|
||||||
newSortBy => {
|
newSortBy => {
|
||||||
@@ -391,6 +445,7 @@ onMounted(async () => {
|
|||||||
@toggle-all="toggleSelectAll"
|
@toggle-all="toggleSelectAll"
|
||||||
@clear-selection="clearSelection"
|
@clear-selection="clearSelection"
|
||||||
@assign-labels="assignLabels"
|
@assign-labels="assignLabels"
|
||||||
|
@delete-selected="openBulkDeleteDialog"
|
||||||
/>
|
/>
|
||||||
<ContactEmptyState
|
<ContactEmptyState
|
||||||
v-if="showEmptyStateLayout"
|
v-if="showEmptyStateLayout"
|
||||||
@@ -408,12 +463,22 @@ onMounted(async () => {
|
|||||||
{{ emptyStateMessage }}
|
{{ emptyStateMessage }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="flex flex-col gap-4 px-6 pt-2 pb-6">
|
<div v-else class="flex flex-col gap-4 px-6 pt-4 pb-6">
|
||||||
<ContactsList
|
<ContactsList
|
||||||
:contacts="contacts"
|
:contacts="contacts"
|
||||||
:selected-contact-ids="selectedContactIds"
|
:selected-contact-ids="selectedContactIds"
|
||||||
@toggle-contact="toggleContactSelection"
|
@toggle-contact="toggleContactSelection"
|
||||||
/>
|
/>
|
||||||
|
<Dialog
|
||||||
|
v-if="selectedCount"
|
||||||
|
ref="bulkDeleteDialogRef"
|
||||||
|
type="alert"
|
||||||
|
:title="bulkDeleteDialogTitle"
|
||||||
|
:description="bulkDeleteDialogDescription"
|
||||||
|
:confirm-button-label="bulkDeleteDialogConfirmLabel"
|
||||||
|
:is-loading="isBulkActionLoading"
|
||||||
|
@confirm="deleteContacts"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</ContactsListLayout>
|
</ContactsListLayout>
|
||||||
|
|||||||
@@ -228,14 +228,17 @@ class Conversation < ApplicationRecord
|
|||||||
def determine_conversation_status
|
def determine_conversation_status
|
||||||
self.status = :resolved and return if contact.blocked?
|
self.status = :resolved and return if contact.blocked?
|
||||||
|
|
||||||
# Message template hooks aren't executed for conversations from campaigns
|
return handle_campaign_status if campaign.present?
|
||||||
# So making these conversations open for agent visibility
|
|
||||||
return if campaign.present?
|
|
||||||
|
|
||||||
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
|
# TODO: make this an inbox config instead of assuming bot conversations should start as pending
|
||||||
self.status = :pending if inbox.active_bot?
|
self.status = :pending if inbox.active_bot?
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def handle_campaign_status
|
||||||
|
# If campaign has no sender (bot-initiated) and inbox has active bot, let bot handle it
|
||||||
|
self.status = :pending if campaign.sender_id.nil? && inbox.active_bot?
|
||||||
|
end
|
||||||
|
|
||||||
def notify_conversation_creation
|
def notify_conversation_creation
|
||||||
dispatcher_dispatch(CONVERSATION_CREATED)
|
dispatcher_dispatch(CONVERSATION_CREATED)
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ class Messages::SearchDataPresenter < SimpleDelegator
|
|||||||
{
|
{
|
||||||
**searchable_content,
|
**searchable_content,
|
||||||
**message_attributes,
|
**message_attributes,
|
||||||
**search_additional_data,
|
additional_attributes: additional_attributes_data,
|
||||||
conversation: conversation_data
|
conversation: conversation_data
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
@@ -49,7 +49,7 @@ class Messages::SearchDataPresenter < SimpleDelegator
|
|||||||
{ id: conversation.display_id }
|
{ id: conversation.display_id }
|
||||||
end
|
end
|
||||||
|
|
||||||
def search_additional_data
|
def additional_attributes_data
|
||||||
{
|
{
|
||||||
campaign_id: additional_attributes&.dig('campaign_id'),
|
campaign_id: additional_attributes&.dig('campaign_id'),
|
||||||
automation_rule_id: content_attributes&.dig('automation_rule_id')
|
automation_rule_id: content_attributes&.dig('automation_rule_id')
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class Contacts::BulkActionService
|
|||||||
end
|
end
|
||||||
|
|
||||||
def perform
|
def perform
|
||||||
|
return delete_contacts if delete_requested?
|
||||||
return assign_labels if labels_to_add.any?
|
return assign_labels if labels_to_add.any?
|
||||||
|
|
||||||
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
Rails.logger.warn("Unknown contact bulk operation payload: #{@params.keys}")
|
||||||
@@ -22,6 +23,13 @@ class Contacts::BulkActionService
|
|||||||
).perform
|
).perform
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_contacts
|
||||||
|
Contacts::BulkDeleteService.new(
|
||||||
|
account: @account,
|
||||||
|
contact_ids: ids
|
||||||
|
).perform
|
||||||
|
end
|
||||||
|
|
||||||
def ids
|
def ids
|
||||||
Array(@params[:ids]).compact
|
Array(@params[:ids]).compact
|
||||||
end
|
end
|
||||||
@@ -29,4 +37,8 @@ class Contacts::BulkActionService
|
|||||||
def labels_to_add
|
def labels_to_add
|
||||||
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
@labels_to_add ||= Array(@params.dig(:labels, :add)).reject(&:blank?)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
def delete_requested?
|
||||||
|
@params[:action_name] == 'delete'
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,18 @@
|
|||||||
|
class Contacts::BulkDeleteService
|
||||||
|
def initialize(account:, contact_ids: [])
|
||||||
|
@account = account
|
||||||
|
@contact_ids = Array(contact_ids).compact
|
||||||
|
end
|
||||||
|
|
||||||
|
def perform
|
||||||
|
return if @contact_ids.blank?
|
||||||
|
|
||||||
|
contacts.find_each(&:destroy!)
|
||||||
|
end
|
||||||
|
|
||||||
|
private
|
||||||
|
|
||||||
|
def contacts
|
||||||
|
@account.contacts.where(id: @contact_ids)
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -39,7 +39,14 @@ class LlmFormatter::ConversationLlmFormatter < LlmFormatter::DefaultLlmFormatter
|
|||||||
end
|
end
|
||||||
|
|
||||||
def format_message(message)
|
def format_message(message)
|
||||||
sender = message.message_type == 'incoming' ? 'User' : 'Support agent'
|
sender = case message.sender_type
|
||||||
|
when 'User'
|
||||||
|
'Support Agent'
|
||||||
|
when 'Contact'
|
||||||
|
'User'
|
||||||
|
else
|
||||||
|
'Bot'
|
||||||
|
end
|
||||||
sender = "[Private Note] #{sender}" if message.private?
|
sender = "[Private Note] #{sender}" if message.private?
|
||||||
"#{sender}: #{message.content}\n"
|
"#{sender}: #{message.content}\n"
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -18,8 +18,10 @@ end
|
|||||||
Sidekiq.configure_server do |config|
|
Sidekiq.configure_server do |config|
|
||||||
config.redis = Redis::Config.app
|
config.redis = Redis::Config.app
|
||||||
|
|
||||||
config.server_middleware do |chain|
|
if ActiveModel::Type::Boolean.new.cast(ENV.fetch('ENABLE_SIDEKIQ_DEQUEUE_LOGGER', false))
|
||||||
chain.add ChatwootDequeuedLogger
|
config.server_middleware do |chain|
|
||||||
|
chain.add ChatwootDequeuedLogger
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
# skip the default start stop logging
|
# skip the default start stop logging
|
||||||
|
|||||||
+1
-1
@@ -33,7 +33,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@breezystack/lamejs": "^1.2.7",
|
"@breezystack/lamejs": "^1.2.7",
|
||||||
"@chatwoot/ninja-keys": "1.2.3",
|
"@chatwoot/ninja-keys": "1.2.3",
|
||||||
"@chatwoot/prosemirror-schema": "1.2.1",
|
"@chatwoot/prosemirror-schema": "1.2.3",
|
||||||
"@chatwoot/utils": "^0.0.51",
|
"@chatwoot/utils": "^0.0.51",
|
||||||
"@formkit/core": "^1.6.7",
|
"@formkit/core": "^1.6.7",
|
||||||
"@formkit/vue": "^1.6.7",
|
"@formkit/vue": "^1.6.7",
|
||||||
|
|||||||
Generated
+5
-5
@@ -20,8 +20,8 @@ importers:
|
|||||||
specifier: 1.2.3
|
specifier: 1.2.3
|
||||||
version: 1.2.3
|
version: 1.2.3
|
||||||
'@chatwoot/prosemirror-schema':
|
'@chatwoot/prosemirror-schema':
|
||||||
specifier: 1.2.1
|
specifier: 1.2.3
|
||||||
version: 1.2.1
|
version: 1.2.3
|
||||||
'@chatwoot/utils':
|
'@chatwoot/utils':
|
||||||
specifier: ^0.0.51
|
specifier: ^0.0.51
|
||||||
version: 0.0.51
|
version: 0.0.51
|
||||||
@@ -406,8 +406,8 @@ packages:
|
|||||||
'@chatwoot/ninja-keys@1.2.3':
|
'@chatwoot/ninja-keys@1.2.3':
|
||||||
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
resolution: {integrity: sha512-xM8d9P5ikDMZm2WbaCTk/TW5HFauylrU3cJ75fq5je6ixKwyhl/0kZbVN/vbbZN4+AUX/OaSIn6IJbtCgIF67g==}
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.2.1':
|
'@chatwoot/prosemirror-schema@1.2.3':
|
||||||
resolution: {integrity: sha512-UbiEvG5tgi1d0lMbkaqxgTh7vHfywEYKLQo1sxqp4Q7aLZh4QFtbLzJ2zyBtu4Nhipe+guFfEJdic7i43MP/XQ==}
|
resolution: {integrity: sha512-q/EfirVK9jt8FJAx3Gf6y3LoVadmYVLknbYvPrkUe81WO0f2mkZ/kY2UQgpUISVvOGEkCH4bkfYMp5UQ+Buz3g==}
|
||||||
|
|
||||||
'@chatwoot/utils@0.0.51':
|
'@chatwoot/utils@0.0.51':
|
||||||
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
|
resolution: {integrity: sha512-WlEmWfOTzR7YZRUWzn5Wpm15/BRudpwqoNckph8TohyDbiim1CP4UZGa+qjajxTbNGLLhtKlm0Xl+X16+5Wceg==}
|
||||||
@@ -4768,7 +4768,7 @@ snapshots:
|
|||||||
hotkeys-js: 3.8.7
|
hotkeys-js: 3.8.7
|
||||||
lit: 2.2.6
|
lit: 2.2.6
|
||||||
|
|
||||||
'@chatwoot/prosemirror-schema@1.2.1':
|
'@chatwoot/prosemirror-schema@1.2.3':
|
||||||
dependencies:
|
dependencies:
|
||||||
markdown-it-sup: 2.0.0
|
markdown-it-sup: 2.0.0
|
||||||
prosemirror-commands: 1.6.0
|
prosemirror-commands: 1.6.0
|
||||||
|
|||||||
@@ -195,37 +195,6 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
|||||||
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
|
expect(Conversation.first.label_list).to contain_exactly('support', 'priority_customer')
|
||||||
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
|
expect(Conversation.second.label_list).to contain_exactly('support', 'priority_customer')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'enqueues contact bulk action job with permitted params' do
|
|
||||||
contact_one = create(:contact, account: account)
|
|
||||||
contact_two = create(:contact, account: account)
|
|
||||||
|
|
||||||
previous_adapter = ActiveJob::Base.queue_adapter
|
|
||||||
ActiveJob::Base.queue_adapter = :test
|
|
||||||
|
|
||||||
expect do
|
|
||||||
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
|
||||||
headers: agent.create_new_auth_token,
|
|
||||||
params: {
|
|
||||||
type: 'Contact',
|
|
||||||
ids: [contact_one.id, contact_two.id],
|
|
||||||
labels: { add: %w[vip support] },
|
|
||||||
extra: 'ignored'
|
|
||||||
}
|
|
||||||
end.to have_enqueued_job(Contacts::BulkActionJob).with(
|
|
||||||
account.id,
|
|
||||||
agent.id,
|
|
||||||
hash_including(
|
|
||||||
'ids' => [contact_one.id, contact_two.id],
|
|
||||||
'labels' => hash_including('add' => %w[vip support])
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(response).to have_http_status(:success)
|
|
||||||
ensure
|
|
||||||
ActiveJob::Base.queue_adapter = previous_adapter
|
|
||||||
clear_enqueued_jobs
|
|
||||||
end
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -256,4 +225,49 @@ RSpec.describe 'Api::V1::Accounts::BulkActionsController', type: :request do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
describe 'POST /api/v1/accounts/{account.id}/bulk_actions (contacts)' do
|
||||||
|
context 'when it is an authenticated user' do
|
||||||
|
let!(:agent) { create(:user, account: account, role: :agent) }
|
||||||
|
|
||||||
|
it 'enqueues Contacts::BulkActionJob with permitted params' do
|
||||||
|
contact_one = create(:contact, account: account)
|
||||||
|
contact_two = create(:contact, account: account)
|
||||||
|
|
||||||
|
expect do
|
||||||
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: {
|
||||||
|
type: 'Contact',
|
||||||
|
ids: [contact_one.id, contact_two.id],
|
||||||
|
labels: { add: %w[vip support] },
|
||||||
|
extra: 'ignored'
|
||||||
|
}
|
||||||
|
end.to have_enqueued_job(Contacts::BulkActionJob).with(
|
||||||
|
account.id,
|
||||||
|
agent.id,
|
||||||
|
hash_including(
|
||||||
|
'ids' => [contact_one.id.to_s, contact_two.id.to_s],
|
||||||
|
'labels' => hash_including('add' => %w[vip support])
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:success)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns unauthorized for delete action when user is not admin' do
|
||||||
|
contact = create(:contact, account: account)
|
||||||
|
|
||||||
|
post "/api/v1/accounts/#{account.id}/bulk_actions",
|
||||||
|
headers: agent.create_new_auth_token,
|
||||||
|
params: {
|
||||||
|
type: 'Contact',
|
||||||
|
ids: [contact.id],
|
||||||
|
action_name: 'delete'
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(response).to have_http_status(:unauthorized)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ FactoryBot.define do
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
trait :bot_message do
|
||||||
|
message_type { 'outgoing' }
|
||||||
|
after(:build) do |message|
|
||||||
|
message.sender = nil
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
after(:build) do |message|
|
after(:build) do |message|
|
||||||
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
|
message.sender ||= message.outgoing? ? create(:user, account: message.account) : create(:contact, account: message.account)
|
||||||
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
|
message.inbox ||= message.conversation&.inbox || create(:inbox, account: message.account)
|
||||||
|
|||||||
@@ -576,9 +576,38 @@ RSpec.describe Conversation do
|
|||||||
expect(conversation.status).to eq('pending')
|
expect(conversation.status).to eq('pending')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'returns conversation as open if campaign is present' do
|
context 'with campaigns' do
|
||||||
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: create(:campaign))
|
let(:user) { create(:user, account: bot_inbox.inbox.account) }
|
||||||
expect(conversation.status).to eq('open')
|
|
||||||
|
it 'returns conversation as open if campaign has a sender' do
|
||||||
|
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: user)
|
||||||
|
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
||||||
|
expect(conversation.status).to eq('open')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns conversation as pending if campaign has no sender (bot-initiated) and bot is active' do
|
||||||
|
campaign = create(:campaign, inbox: bot_inbox.inbox, account: bot_inbox.inbox.account, sender: nil)
|
||||||
|
conversation = create(:conversation, inbox: bot_inbox.inbox, campaign: campaign)
|
||||||
|
expect(conversation.status).to eq('pending')
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with campaigns in inbox without bot' do
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:inbox) { create(:inbox, account: account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
it 'returns conversation as open if campaign has no sender but no bot is active' do
|
||||||
|
campaign = create(:campaign, inbox: inbox, account: account, sender: nil)
|
||||||
|
conversation = create(:conversation, inbox: inbox, campaign: campaign)
|
||||||
|
expect(conversation.status).to eq('open')
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns conversation as open if campaign has a sender' do
|
||||||
|
campaign = create(:campaign, inbox: inbox, account: account, sender: user)
|
||||||
|
conversation = create(:conversation, inbox: inbox, campaign: campaign)
|
||||||
|
expect(conversation.status).to eq('open')
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
|
|||||||
@@ -67,11 +67,11 @@ RSpec.describe Messages::SearchDataPresenter do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'includes campaign_id' do
|
it 'includes campaign_id' do
|
||||||
expect(presenter.search_data[:campaign_id]).to eq('123')
|
expect(presenter.search_data[:additional_attributes][:campaign_id]).to eq('123')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'includes automation_rule_id' do
|
it 'includes automation_rule_id' do
|
||||||
expect(presenter.search_data[:automation_rule_id]).to eq('456')
|
expect(presenter.search_data[:additional_attributes][:automation_rule_id]).to eq('456')
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Contacts::BulkActionService do
|
||||||
|
subject(:service) { described_class.new(account: account, user: user, params: params) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let(:user) { create(:user, account: account) }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
context 'when delete action is requested via action_name' do
|
||||||
|
let(:params) { { ids: [1, 2], action_name: 'delete' } }
|
||||||
|
|
||||||
|
it 'delegates to the bulk delete service' do
|
||||||
|
bulk_delete_service = instance_double(Contacts::BulkDeleteService, perform: true)
|
||||||
|
|
||||||
|
expect(Contacts::BulkDeleteService).to receive(:new)
|
||||||
|
.with(account: account, contact_ids: [1, 2])
|
||||||
|
.and_return(bulk_delete_service)
|
||||||
|
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'when labels are provided' do
|
||||||
|
let(:params) { { ids: [10, 20], labels: { add: %w[vip support] }, extra: 'ignored' } }
|
||||||
|
|
||||||
|
it 'delegates to the bulk assign labels service with permitted params' do
|
||||||
|
bulk_assign_service = instance_double(Contacts::BulkAssignLabelsService, perform: true)
|
||||||
|
|
||||||
|
expect(Contacts::BulkAssignLabelsService).to receive(:new)
|
||||||
|
.with(account: account, contact_ids: [10, 20], labels: %w[vip support])
|
||||||
|
.and_return(bulk_assign_service)
|
||||||
|
|
||||||
|
service.perform
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
require 'rails_helper'
|
||||||
|
|
||||||
|
RSpec.describe Contacts::BulkDeleteService do
|
||||||
|
subject(:service) { described_class.new(account: account, contact_ids: contact_ids) }
|
||||||
|
|
||||||
|
let(:account) { create(:account) }
|
||||||
|
let!(:contact_one) { create(:contact, account: account) }
|
||||||
|
let!(:contact_two) { create(:contact, account: account) }
|
||||||
|
let(:contact_ids) { [contact_one.id, contact_two.id] }
|
||||||
|
|
||||||
|
describe '#perform' do
|
||||||
|
it 'deletes the provided contacts' do
|
||||||
|
expect { service.perform }
|
||||||
|
.to change { account.contacts.exists?(contact_one.id) }.from(true).to(false)
|
||||||
|
.and change { account.contacts.exists?(contact_two.id) }.from(true).to(false)
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns when no contact ids are provided' do
|
||||||
|
empty_service = described_class.new(account: account, contact_ids: [])
|
||||||
|
|
||||||
|
expect { empty_service.perform }.not_to change(Contact, :count)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
@@ -28,6 +28,14 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
|||||||
content: 'Hello, I need help'
|
content: 'Hello, I need help'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
create(
|
||||||
|
:message,
|
||||||
|
:bot_message,
|
||||||
|
conversation: conversation,
|
||||||
|
message_type: 'outgoing',
|
||||||
|
content: 'Thanks for reaching out, an agent will reach out to you soon'
|
||||||
|
)
|
||||||
|
|
||||||
create(
|
create(
|
||||||
:message,
|
:message,
|
||||||
conversation: conversation,
|
conversation: conversation,
|
||||||
@@ -40,7 +48,8 @@ RSpec.describe LlmFormatter::ConversationLlmFormatter do
|
|||||||
"Channel: #{conversation.inbox.channel.name}",
|
"Channel: #{conversation.inbox.channel.name}",
|
||||||
'Message History:',
|
'Message History:',
|
||||||
'User: Hello, I need help',
|
'User: Hello, I need help',
|
||||||
'Support agent: How can I assist you today?',
|
'Bot: Thanks for reaching out, an agent will reach out to you soon',
|
||||||
|
'Support Agent: How can I assist you today?',
|
||||||
''
|
''
|
||||||
].join("\n")
|
].join("\n")
|
||||||
|
|
||||||
|
|||||||
@@ -15,11 +15,11 @@ RSpec.describe Widget::TokenService, type: :service do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'uses the configured value for token expiry' do
|
it 'uses the configured value for token expiry' do
|
||||||
freeze_time do
|
travel_to '2025-01-01' do
|
||||||
token = service.generate_token
|
token = service.generate_token
|
||||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||||
expect(decoded['iat']).to eq(Time.now.to_i)
|
expect(decoded['iat']).to eq(Time.zone.now.to_i)
|
||||||
expect(decoded['exp']).to eq(30.days.from_now.to_i)
|
expect(decoded['exp']).to eq(Time.zone.now.to_i + 30.days.to_i)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
@@ -30,11 +30,11 @@ RSpec.describe Widget::TokenService, type: :service do
|
|||||||
end
|
end
|
||||||
|
|
||||||
it 'uses the default expiry' do
|
it 'uses the default expiry' do
|
||||||
freeze_time do
|
travel_to '2025-01-01' do
|
||||||
token = service.generate_token
|
token = service.generate_token
|
||||||
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
decoded = JWT.decode(token, Rails.application.secret_key_base, true, algorithm: 'HS256').first
|
||||||
expect(decoded['iat']).to eq(Time.now.to_i)
|
expect(decoded['iat']).to eq(Time.zone.now.to_i)
|
||||||
expect(decoded['exp']).to eq(180.days.from_now.to_i)
|
expect(decoded['exp']).to eq(Time.zone.now.to_i + 180.days.to_i)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user