Merge branch 'develop' into feat/CW-5624

This commit is contained in:
Sivin Varghese
2025-11-06 14:21:26 +05:30
committed by GitHub
46 changed files with 1188 additions and 140 deletions
@@ -5,6 +5,7 @@ import { useToggle } from '@vueuse/core';
import Button from 'dashboard/components-next/button/Button.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
defineProps({
selectedContact: {
@@ -24,42 +25,44 @@ const openConfirmDeleteContactDialog = () => {
</script>
<template>
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<Policy :permissions="['administrator']">
<div class="flex flex-col items-start border-t border-n-strong px-6 py-5">
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
sm
link
slate
class="hover:!no-underline text-n-slate-12"
icon="i-lucide-chevron-down"
trailing-icon
@click="toggleDeleteSection()"
/>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
<div
class="transition-all duration-300 ease-in-out grid w-full overflow-hidden"
:class="
showDeleteSection
? 'grid-rows-[1fr] opacity-100 mt-2'
: 'grid-rows-[0fr] opacity-0 mt-0'
"
>
<div class="overflow-hidden min-h-0">
<span class="inline-flex text-n-slate-11 text-sm items-center gap-1">
{{ t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.MESSAGE') }}
<Button
:label="t('CONTACTS_LAYOUT.CARD.DELETE_CONTACT.BUTTON')"
sm
ruby
link
@click="openConfirmDeleteContactDialog()"
/>
</span>
</div>
</div>
</div>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
/>
</Policy>
</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 ContactsForm from 'dashboard/components-next/Contacts/ContactsForm/ContactsForm.vue';
import ConfirmContactDeleteDialog from 'dashboard/components-next/Contacts/ContactsForm/ConfirmContactDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
selectedContact: {
@@ -174,27 +175,29 @@ const handleAvatarDelete = async () => {
@click="updateContact"
/>
</div>
<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">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
<Policy :permissions="['administrator']">
<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">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT') }}
</h6>
<span class="text-sm text-n-slate-11">
{{ t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT_DESCRIPTION') }}
</span>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
/>
</div>
<Button
:label="t('CONTACTS_LAYOUT.DETAILS.DELETE_CONTACT')"
color="ruby"
@click="openConfirmDeleteContactDialog"
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</div>
<ConfirmContactDeleteDialog
ref="confirmDeleteContactDialogRef"
:selected-contact="selectedContact"
@go-to-contacts-list="emit('goToContactsList')"
/>
</Policy>
</div>
</template>
@@ -21,6 +21,10 @@ const props = defineProps({
enableCannedResponses: { type: Boolean, default: true },
enabledMenuOptions: { type: Array, default: () => [] },
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']);
@@ -100,6 +104,10 @@ watch(
:enable-canned-responses="enableCannedResponses"
:enabled-menu-options="enabledMenuOptions"
:enable-captain-tools="enableCaptainTools"
:signature="signature"
:allow-signature="allowSignature"
:send-with-signature="sendWithSignature"
:channel-type="channelType"
@input="handleInput"
@focus="handleFocus"
@blur="handleBlur"
@@ -92,7 +92,6 @@ const setSignature = () => {
const toggleMessageSignature = () => {
setSignatureFlagForInbox(props.channelType, !sendWithSignature.value);
setSignature();
};
// Added this watch to dynamically set signature on target inbox change.
@@ -199,16 +199,20 @@ const handleInboxAction = ({ value, action, ...rest }) => {
state.attachedFiles = [];
};
const removeTargetInbox = value => {
v$.value.$reset();
// Remove the signature from message content
// Based on the Advance Editor (used in isEmailOrWebWidget) and Plain editor(all other inboxes except WhatsApp)
if (props.sendWithSignature) {
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
const removeSignatureFromMessage = () => {
// Always remove the signature from message content when inbox/contact is removed
// to ensure no leftover signature content remains
const signatureToRemove = inboxTypes.value.isEmailOrWebWidget
? props.messageSignature
: extractTextFromMarkdown(props.messageSignature);
if (signatureToRemove) {
state.message = removeSignature(state.message, signatureToRemove);
}
};
const removeTargetInbox = value => {
v$.value.$reset();
removeSignatureFromMessage();
emit('updateTargetInbox', value);
state.attachedFiles = [];
};
@@ -216,6 +220,7 @@ const removeTargetInbox = value => {
const clearSelectedContact = () => {
emit('clearSelectedContact');
state.attachedFiles = [];
removeSignatureFromMessage();
};
const onClickInsertEmoji = emoji => {
@@ -354,6 +359,7 @@ const shouldShowMessageEditor = computed(() => {
:is-email-or-web-widget-inbox="inboxTypes.isEmailOrWebWidget"
:has-errors="validationStates.isMessageInvalid"
:has-attachments="state.attachedFiles.length > 0"
:channel-type="inboxChannelType"
/>
<AttachmentPreviews
@@ -17,6 +17,7 @@ const props = defineProps({
hasAttachments: { type: Boolean, default: false },
sendWithSignature: { type: Boolean, default: false },
messageSignature: { type: String, default: '' },
channelType: { type: String, default: '' },
});
const { t } = useI18n();
@@ -90,6 +91,10 @@ const replaceText = async message => {
"
enable-variables
:show-character-count="false"
:signature="messageSignature"
allow-signature
:send-with-signature="sendWithSignature"
:channel-type="channelType"
/>
</template>
<template v-else>
@@ -47,6 +47,7 @@ const isReel = computed(() => {
'max-w-48': isReel,
'max-w-full': !isReel,
}"
@click.stop
@error="handleError"
/>
</div>
@@ -302,7 +302,16 @@ function isBodyEmpty(content) {
}
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
// start a transaction to append it at the end
@@ -580,7 +580,18 @@
"NO_LABELS_FOUND": "No labels available yet.",
"SELECTED_COUNT": "{count} selected",
"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": {
@@ -6,6 +6,7 @@ import { vOnClickOutside } from '@vueuse/components';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import LabelActions from 'dashboard/components/widgets/conversation/conversationBulkActions/LabelActions.vue';
import Policy from 'dashboard/components/policy.vue';
const props = defineProps({
visibleContactIds: {
@@ -22,7 +23,12 @@ const props = defineProps({
},
});
const emit = defineEmits(['clearSelection', 'assignLabels', 'toggleAll']);
const emit = defineEmits([
'clearSelection',
'assignLabels',
'toggleAll',
'deleteSelected',
]);
const { t } = useI18n();
@@ -139,6 +145,21 @@ const handleAssignLabels = labels => {
/>
</transition>
</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>
</template>
</BulkSelectBar>
@@ -13,6 +13,7 @@ import ContactEmptyState from 'dashboard/components-next/Contacts/EmptyState/Con
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
import ContactsList from 'dashboard/components-next/Contacts/Pages/ContactsList.vue';
import ContactsBulkActionBar from '../components/ContactsBulkActionBar.vue';
import Dialog from 'dashboard/components-next/dialog/Dialog.vue';
import BulkActionsAPI from 'dashboard/api/bulkActions';
const DEFAULT_SORT_FIELD = 'last_activity_at';
@@ -64,7 +65,26 @@ const totalItems = computed(() => meta.value?.count);
const selectedContactIds = ref([]);
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(() => {
if (!activeSegmentId.value) return undefined;
return segments.value.find(view => view.id === Number(activeSegmentId.value));
@@ -120,6 +140,11 @@ const clearSelection = () => {
selectedContactIds.value = [];
};
const openBulkDeleteDialog = () => {
if (!selectedContactIds.value.length || isBulkActionLoading.value) return;
bulkDeleteDialogRef.value?.open?.();
};
const toggleSelectAll = shouldSelect => {
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 }) => {
Object.assign(sortState, { activeSort: sort, activeOrdering: order });
@@ -297,6 +345,12 @@ watch(
{ deep: true }
);
watch(hasSelection, value => {
if (!value) {
bulkDeleteDialogRef.value?.close?.();
}
});
watch(
() => uiSettings.value?.contacts_sort_by,
newSortBy => {
@@ -391,6 +445,7 @@ onMounted(async () => {
@toggle-all="toggleSelectAll"
@clear-selection="clearSelection"
@assign-labels="assignLabels"
@delete-selected="openBulkDeleteDialog"
/>
<ContactEmptyState
v-if="showEmptyStateLayout"
@@ -408,12 +463,22 @@ onMounted(async () => {
{{ emptyStateMessage }}
</span>
</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
:contacts="contacts"
:selected-contact-ids="selectedContactIds"
@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>
</template>
</ContactsListLayout>