Merge remote-tracking branch 'origin/develop' into feat/whatsapp-call

# Conflicts:
#	db/migrate/20260408170902_create_calls.rb
#	db/schema.rb
#	enterprise/app/models/call.rb
This commit is contained in:
Tanmay Deep Sharma
2026-04-21 12:21:04 +07:00
745 changed files with 33498 additions and 1811 deletions
@@ -66,8 +66,7 @@ const selectionModel = computed({
return;
}
const shouldSelectAll =
newSet.size === props.visibleContactIds.length && newSet.size > 0;
const shouldSelectAll = props.visibleContactIds.every(id => newSet.has(id));
emit('toggleAll', shouldSelectAll);
},
});
@@ -151,7 +151,13 @@ const openBulkDeleteDialog = () => {
};
const toggleSelectAll = shouldSelect => {
selectedContactIds.value = shouldSelect ? [...visibleContactIds.value] : [];
const currentSelection = new Set(selectedContactIds.value);
if (shouldSelect) {
visibleContactIds.value.forEach(id => currentSelection.add(id));
} else {
visibleContactIds.value.forEach(id => currentSelection.delete(id));
}
selectedContactIds.value = Array.from(currentSelection);
};
const toggleContactSelection = ({ id, value }) => {
@@ -190,16 +196,28 @@ const getCommonFetchParams = (page = 1) => ({
label: activeLabel.value,
});
const fetchContacts = async (page = 1) => {
clearSelection();
const fetchContacts = async (page = 1, options = {}) => {
const { clearSelection: shouldClearSelection = true } = options;
if (shouldClearSelection) {
clearSelection();
}
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/get', getCommonFetchParams(page));
updatePageParam(page);
};
const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
const fetchSavedOrAppliedFilteredContact = async (
payload,
page = 1,
options = {}
) => {
if (!activeSegmentId.value && !hasAppliedFilters.value) return;
clearSelection();
const { clearSelection: shouldClearSelection = true } = options;
if (shouldClearSelection) {
clearSelection();
}
await store.dispatch('contacts/filter', {
...getCommonFetchParams(page),
queryPayload: payload,
@@ -207,8 +225,12 @@ const fetchSavedOrAppliedFilteredContact = async (payload, page = 1) => {
updatePageParam(page);
};
const fetchActiveContacts = async (page = 1) => {
clearSelection();
const fetchActiveContacts = async (page = 1, options = {}) => {
const { clearSelection: shouldClearSelection = true } = options;
if (shouldClearSelection) {
clearSelection();
}
await store.dispatch('contacts/clearContactFilters');
await store.dispatch('contacts/active', {
page,
@@ -217,28 +239,36 @@ const fetchActiveContacts = async (page = 1) => {
updatePageParam(page);
};
const searchContacts = debounce(async (value, page = 1, append = false) => {
if (!append) {
clearSelection();
searchPageNumber.value = 1;
}
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
const searchContacts = debounce(
async (value, page = 1, append = false, options = {}) => {
const { clearSelection: shouldClearSelection = true } = options;
if (!value) {
updatePageParam(page);
await fetchContacts(page);
return;
}
if (!append) {
searchPageNumber.value = 1;
updatePageParam(page, value);
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
append,
});
searchPageNumber.value = page;
}, DEBOUNCE_DELAY);
if (shouldClearSelection) {
clearSelection();
}
}
await store.dispatch('contacts/clearContactFilters');
searchValue.value = value;
if (!value) {
updatePageParam(page);
await fetchContacts(page, { clearSelection: false });
return;
}
updatePageParam(page, value);
await store.dispatch('contacts/search', {
...getCommonFetchParams(page),
search: encodeURIComponent(value),
append,
});
searchPageNumber.value = page;
},
DEBOUNCE_DELAY
);
const loadMoreSearchResults = async () => {
if (!hasMore.value || isLoadingMore.value) return;
@@ -256,19 +286,26 @@ const loadMoreSearchResults = async () => {
isLoadingMore.value = false;
};
const fetchContactsBasedOnContext = async page => {
clearSelection();
const fetchContactsBasedOnContext = async (page, options = {}) => {
const { clearSelection: shouldClearSelection = true } = options;
if (shouldClearSelection) {
clearSelection();
}
updatePageParam(page, searchValue.value);
if (isFetchingList.value) return;
if (searchQuery.value) {
await searchContacts(searchQuery.value, page);
await searchContacts(searchQuery.value, page, false, {
clearSelection: shouldClearSelection,
});
return;
}
// Reset the search value when we change the view
searchValue.value = '';
// If we're on the active route, fetch active contacts
if (isActiveView.value) {
await fetchActiveContacts(page);
await fetchActiveContacts(page, {
clearSelection: shouldClearSelection,
});
return;
}
// If there are applied filters or active segment with query
@@ -278,13 +315,20 @@ const fetchContactsBasedOnContext = async page => {
) {
const queryPayload =
activeSegment.value?.query || filterQueryGenerator(appliedFilters.value);
await fetchSavedOrAppliedFilteredContact(queryPayload, page);
await fetchSavedOrAppliedFilteredContact(queryPayload, page, {
clearSelection: shouldClearSelection,
});
return;
}
// Default case: fetch regular contacts + label
await fetchContacts(page);
await fetchContacts(page, {
clearSelection: shouldClearSelection,
});
};
const onPageChange = page =>
fetchContactsBasedOnContext(page, { clearSelection: false });
const assignLabels = async labels => {
if (!labels.length || !selectedContactIds.value.length) {
return;
@@ -338,7 +382,9 @@ const handleSort = async ({ sort, order }) => {
});
if (searchQuery.value) {
await searchContacts(searchValue.value);
await searchContacts(searchValue.value, pageNumber.value, false, {
clearSelection: false,
});
return;
}
@@ -360,17 +406,6 @@ const createContact = async contact => {
await store.dispatch('contacts/create', contact);
};
watch(
contacts,
newContacts => {
const idsOnPage = newContacts.map(contact => contact.id);
selectedContactIds.value = selectedContactIds.value.filter(id =>
idsOnPage.includes(id)
);
},
{ deep: true }
);
watch(hasSelection, value => {
if (!value) {
bulkDeleteDialogRef.value?.close?.();
@@ -416,7 +451,9 @@ watch(searchQuery, value => {
onMounted(async () => {
if (!activeSegmentId.value) {
if (searchQuery.value) {
await searchContacts(searchQuery.value, pageNumber.value);
await searchContacts(searchQuery.value, pageNumber.value, false, {
clearSelection: false,
});
return;
}
if (isActiveView.value) {
@@ -452,8 +489,10 @@ onMounted(async () => {
:use-infinite-scroll="isSearchView"
:has-more="hasMore"
:is-loading-more="isLoadingMore"
@update:current-page="fetchContactsBasedOnContext"
@search="searchContacts"
@update:current-page="onPageChange"
@search="
value => searchContacts(value, 1, false, { clearSelection: false })
"
@update:sort="handleSort"
@apply-filter="fetchSavedOrAppliedFilteredContact"
@clear-filters="fetchContacts"
@@ -485,6 +524,7 @@ onMounted(async () => {
:button-label="t('CONTACTS_LAYOUT.EMPTY_STATE.BUTTON_LABEL')"
@create="createContact"
/>
<div
v-else-if="showEmptyText"
class="flex items-center justify-center py-10"
@@ -493,6 +533,7 @@ onMounted(async () => {
{{ emptyStateMessage }}
</span>
</div>
<div v-else class="flex flex-col gap-4 pt-4 pb-6">
<ContactsList
:contacts="contacts"
@@ -1,49 +1,131 @@
<script>
<script setup>
import { computed, ref, watch, onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useStore, useMapGetter } from 'dashboard/composables/store';
import { frontendURL, conversationUrl } from 'dashboard/helper/URLHelper';
import {
isOnMentionsView,
isOnUnattendedView,
isOnFoldersView,
} from 'dashboard/store/modules/conversations/helpers/actionHelpers';
import ConversationCard from 'dashboard/components/widgets/conversation/ConversationCard.vue';
import { mapGetters } from 'vuex';
import ContextMenu from 'dashboard/components/ui/ContextMenu.vue';
import ConversationContextMenu from 'dashboard/components/widgets/conversation/contextMenu/Index.vue';
import Spinner from 'dashboard/components-next/spinner/Spinner.vue';
export default {
components: {
ConversationCard,
Spinner,
},
props: {
contactId: {
type: [String, Number],
required: true,
},
conversationId: {
type: [String, Number],
required: true,
},
},
computed: {
conversations() {
return this.$store.getters['contactConversations/getContactConversation'](
this.contactId
);
},
previousConversations() {
return this.conversations.filter(
conversation => conversation.id !== Number(this.conversationId)
);
},
...mapGetters({
uiFlags: 'contactConversations/getUIFlags',
}),
},
watch: {
contactId(newContactId, prevContactId) {
if (newContactId && newContactId !== prevContactId) {
this.$store.dispatch('contactConversations/get', newContactId);
}
},
},
mounted() {
this.$store.dispatch('contactConversations/get', this.contactId);
},
const props = defineProps({
contactId: { type: [String, Number], required: true },
conversationId: { type: [String, Number], required: true },
});
const store = useStore();
const route = useRoute();
const router = useRouter();
const currentChat = useMapGetter('getSelectedChat');
const uiFlags = useMapGetter('contactConversations/getUIFlags');
const contactGetter = useMapGetter('contacts/getContact');
const inboxGetter = useMapGetter('inboxes/getInbox');
const activeInbox = useMapGetter('getSelectedInbox');
const inboxesList = useMapGetter('inboxes/getInboxes');
const showInboxName = computed(
() => !activeInbox.value && inboxesList.value.length > 1
);
const contactConversationGetter = useMapGetter(
'contactConversations/getContactConversation'
);
const conversations = computed(() =>
contactConversationGetter.value(props.contactId)
);
const previousConversations = computed(() =>
conversations.value.filter(c => c.id !== Number(props.conversationId))
);
const activeContextChat = ref(null);
const showContextMenu = ref(false);
const contextMenu = ref({ x: null, y: null });
const buildConversationUrl = conversationId => {
const {
params: { accountId, inbox_id: inboxId, label, teamId },
name,
} = route;
let conversationType = '';
if (isOnMentionsView({ route: { name } })) {
conversationType = 'mention';
} else if (isOnUnattendedView({ route: { name } })) {
conversationType = 'unattended';
}
return frontendURL(
conversationUrl({
accountId,
activeInbox: inboxId,
id: conversationId,
label,
teamId,
foldersId: isOnFoldersView({ route: { name } }) ? route.params.id : 0,
conversationType,
})
);
};
const conversationPath = computed(() => {
if (!activeContextChat.value) return '';
return buildConversationUrl(activeContextChat.value.id);
});
const onCardClick = (conversation, e) => {
const path = buildConversationUrl(conversation.id);
if (!path) return;
if (e.metaKey || e.ctrlKey) {
e.preventDefault();
window.open(
`${window.chatwootConfig.hostURL}${path}`,
'_blank',
'noopener,noreferrer'
);
return;
}
router.push({ path });
};
const openContextMenu = (conversation, e) => {
e.preventDefault();
activeContextChat.value = conversation;
contextMenu.value.x = e.pageX || e.clientX;
contextMenu.value.y = e.pageY || e.clientY;
showContextMenu.value = true;
};
const closeContextMenu = () => {
showContextMenu.value = false;
contextMenu.value.x = null;
contextMenu.value.y = null;
activeContextChat.value = null;
};
watch(
() => props.contactId,
(newId, oldId) => {
if (newId && newId !== oldId) {
showContextMenu.value = false;
activeContextChat.value = null;
store.dispatch('contactConversations/get', newId);
}
}
);
onMounted(() => {
store.dispatch('contactConversations/get', props.contactId);
});
</script>
<template>
@@ -53,18 +135,43 @@ export default {
{{ $t('CONTACT_PANEL.CONVERSATIONS.NO_RECORDS_FOUND') }}
</span>
</div>
<div v-else class="contact-conversation--list">
<div
v-else
class="contact-conversation--list [&>.conversation:last-child]:!border-b-0 [&>.conversation:last-child:hover]:!border-b-0 [&>.conversation:last-child]:!rounded-b-lg"
>
<ConversationCard
v-for="conversation in previousConversations"
:key="conversation.id"
:chat="conversation"
:hide-inbox-name="false"
:current-contact="contactGetter(conversation.meta?.sender?.id) || {}"
:assignee="conversation.meta?.assignee || {}"
:inbox="inboxGetter(conversation.inbox_id) || {}"
:is-active-chat="currentChat.id === conversation.id"
:show-inbox-name="showInboxName"
hide-thumbnail
enable-context-menu
compact
:allowed-context-menu-options="['open-new-tab', 'copy-link']"
@click="onCardClick(conversation, $event)"
@contextmenu="openContextMenu(conversation, $event)"
/>
</div>
<ContextMenu
v-if="showContextMenu && activeContextChat"
:x="contextMenu.x"
:y="contextMenu.y"
@close="closeContextMenu"
>
<ConversationContextMenu
:status="activeContextChat.status"
:inbox-id="activeContextChat.inbox_id"
:priority="activeContextChat.priority"
:chat-id="activeContextChat.id"
:has-unread-messages="activeContextChat.unread_count > 0"
:conversation-labels="activeContextChat.labels"
:conversation-url="conversationPath"
:allowed-options="['open-new-tab', 'copy-link']"
@close="closeContextMenu"
/>
</ContextMenu>
</div>
<div v-else class="flex items-center justify-center py-5">
<Spinner />
@@ -25,6 +25,7 @@ const getActionValue = (key, params) => {
add_label: resolveLabels(labels.value, params),
remove_label: resolveLabels(labels.value, params),
assign_agent: resolveAgents(agents.value, params),
remove_assigned_agent: null,
mute_conversation: null,
snooze_conversation: null,
resolve_conversation: null,
@@ -1,6 +1,10 @@
<script>
import { mapGetters } from 'vuex';
import { useAlert } from 'dashboard/composables';
import {
DuplicateContactException,
ExceptionWithMessage,
} from 'shared/helpers/CustomErrors';
import { dynamicTime } from 'shared/helpers/timeHelper';
import { useAdmin } from 'dashboard/composables/useAdmin';
import ContactInfoRow from './ContactInfoRow.vue';
@@ -8,17 +12,11 @@ import Avatar from 'next/avatar/Avatar.vue';
import SocialIcons from './SocialIcons.vue';
import EditContact from './EditContact.vue';
import ContactMergeModal from 'dashboard/modules/contact/ContactMergeModal.vue';
import ContactDeleteModal from 'dashboard/modules/contact/ContactDeleteModal.vue';
import ComposeConversation from 'dashboard/components-next/NewConversation/ComposeConversation.vue';
import { BUS_EVENTS } from 'shared/constants/busEvents';
import NextButton from 'dashboard/components-next/button/Button.vue';
import VoiceCallButton from 'dashboard/components-next/Contacts/VoiceCallButton.vue';
import {
isAConversationRoute,
isAInboxViewRoute,
getConversationDashboardRoute,
} from '../../../../helper/routeHelpers';
import { emitter } from 'shared/helpers/mitt';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
@@ -29,7 +27,9 @@ export default {
ComposeConversation,
SocialIcons,
ContactMergeModal,
ContactDeleteModal,
VoiceCallButton,
InlineInput,
},
props: {
contact: {
@@ -51,7 +51,8 @@ export default {
data() {
return {
showEditModal: false,
showDeleteModal: false,
isEditingName: false,
editName: '',
};
},
computed: {
@@ -91,10 +92,6 @@ export default {
telegram,
};
},
// Delete Modal
confirmDeleteMessage() {
return ` ${this.contact.name}?`;
},
},
watch: {
'contact.id': {
@@ -109,28 +106,6 @@ export default {
toggleEditModal() {
this.showEditModal = !this.showEditModal;
},
openComposeConversationModal(toggleFn) {
toggleFn();
// Flag to prevent triggering drag n drop,
// When compose modal is active
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, true);
},
closeComposeConversationModal() {
// Flag to enable drag n drop,
// When compose modal is closed
emitter.emit(BUS_EVENTS.NEW_CONVERSATION_MODAL, false);
},
toggleDeleteModal() {
this.showDeleteModal = !this.showDeleteModal;
},
confirmDeletion() {
this.deleteContact(this.contact);
this.closeDelete();
},
closeDelete() {
this.showDeleteModal = false;
this.showEditModal = false;
},
findCountryFlag(countryCode, cityAndCountry) {
try {
if (!countryCode) {
@@ -143,35 +118,57 @@ export default {
return '';
}
},
async deleteContact({ id }) {
try {
await this.$store.dispatch('contacts/delete', id);
this.$emit('panelClose');
useAlert(this.$t('DELETE_CONTACT.API.SUCCESS_MESSAGE'));
if (isAConversationRoute(this.$route.name)) {
this.$router.push({
name: getConversationDashboardRoute(this.$route.name),
});
} else if (isAInboxViewRoute(this.$route.name)) {
this.$router.push({
name: 'inbox_view',
});
} else if (this.$route.name !== 'contacts_dashboard') {
this.$router.push({
name: 'contacts_dashboard',
});
}
} catch (error) {
useAlert(
error.message
? error.message
: this.$t('DELETE_CONTACT.API.ERROR_MESSAGE')
);
startEditingName() {
this.editName = this.contact.name || '';
this.isEditingName = true;
this.$nextTick(() => {
this.$refs.nameInput?.focus();
});
},
saveNameEdit() {
if (!this.isEditingName) return;
this.isEditingName = false;
const trimmed = this.editName.trim();
if (trimmed && trimmed !== this.contact.name) {
this.updateContactField({ name: trimmed });
}
},
openMergeModal() {
this.$refs.mergeModal?.open();
cancelNameEdit() {
this.isEditingName = false;
},
onFieldUpdate(field, value) {
this.updateContactField({ [field]: value });
},
async updateContactField(attrs) {
const contactId = this.contact.id;
try {
await this.$store.dispatch('contacts/update', {
id: contactId,
...attrs,
});
useAlert(this.$t('CONTACT_FORM.SUCCESS_MESSAGE'));
await this.$store.dispatch('contacts/fetchContactableInbox', contactId);
} catch (error) {
if (error instanceof DuplicateContactException) {
const detail = error.contactErrorDetail;
if (detail) {
useAlert(detail);
} else {
const invalidAttrs = Array.isArray(error.data) ? error.data : [];
if (invalidAttrs.includes('email')) {
useAlert(this.$t('CONTACT_FORM.FORM.EMAIL_ADDRESS.DUPLICATE'));
} else if (invalidAttrs.includes('phone_number')) {
useAlert(this.$t('CONTACT_FORM.FORM.PHONE_NUMBER.DUPLICATE'));
} else {
useAlert(this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
} else if (error instanceof ExceptionWithMessage) {
useAlert(error.data);
} else {
useAlert(error.message || this.$t('CONTACT_FORM.ERROR_MESSAGE'));
}
}
},
},
};
@@ -194,10 +191,26 @@ export default {
<div class="flex flex-col items-start gap-1.5 min-w-0 w-full">
<div v-if="showAvatar" class="flex items-center w-full min-w-0 gap-3">
<InlineInput
v-if="isEditingName"
ref="nameInput"
v-model="editName"
custom-input-class="!text-base !font-medium"
class="!w-fit"
@enter-press="saveNameEdit"
@escape-press="cancelNameEdit"
@blur="saveNameEdit"
/>
<h3
class="flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12"
v-else
class="group/name flex-shrink max-w-full min-w-0 my-0 text-base capitalize break-words text-n-slate-12 cursor-pointer hover:text-n-slate-12/80"
:title="$t('CONTACT_PANEL.CLICK_TO_EDIT')"
@click="startEditingName"
>
{{ contact.name }}
<span
class="i-lucide-pencil text-xs text-n-slate-10 opacity-0 group-hover/name:opacity-100 transition-opacity ml-1 align-middle"
/>
</h3>
<div class="flex flex-row items-center gap-2">
<span
@@ -231,6 +244,8 @@ export default {
emoji="✉️"
:title="$t('CONTACT_PANEL.EMAIL_ADDRESS')"
show-copy
editable
@update="value => onFieldUpdate('email', value)"
/>
<ContactInfoRow
:href="contact.phone_number ? `tel:${contact.phone_number}` : ''"
@@ -239,6 +254,8 @@ export default {
emoji="📞"
:title="$t('CONTACT_PANEL.PHONE_NUMBER')"
show-copy
editable
@update="value => onFieldUpdate('phone_number', value)"
/>
<ContactInfoRow
v-if="contact.identifier"
@@ -252,6 +269,16 @@ export default {
icon="building-bank"
emoji="🏢"
:title="$t('CONTACT_PANEL.COMPANY')"
editable
@update="
value =>
updateContactField({
additional_attributes: {
...additionalAttributes,
company_name: value,
},
})
"
/>
<ContactInfoRow
v-if="location || additionalAttributes.location"
@@ -264,19 +291,14 @@ export default {
</div>
</div>
<div class="flex items-center w-full mt-0.5 gap-2">
<ComposeConversation
:contact-id="String(contact.id)"
is-modal
@close="closeComposeConversationModal"
>
<template #trigger="{ toggle }">
<ComposeConversation :contact-id="String(contact.id)">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.NEW_MESSAGE')"
icon="i-ph-chat-circle-dots"
slate
faded
sm
@click="openComposeConversationModal(toggle)"
/>
</template>
</ComposeConversation>
@@ -297,45 +319,41 @@ export default {
sm
@click="toggleEditModal"
/>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
@click="openMergeModal"
/>
<NextButton
<ContactMergeModal :primary-contact="contact">
<template #trigger>
<NextButton
v-tooltip.top-end="$t('CONTACT_PANEL.MERGE_CONTACT')"
icon="i-ph-arrows-merge"
slate
faded
sm
:disabled="uiFlags.isMerging"
/>
</template>
</ContactMergeModal>
<ContactDeleteModal
v-if="isAdmin"
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
@click="toggleDeleteModal"
/>
:contact="contact"
@deleted="$emit('panelClose')"
>
<template #trigger>
<NextButton
v-tooltip.top-end="$t('DELETE_CONTACT.BUTTON_LABEL')"
icon="i-ph-trash"
slate
faded
sm
ruby
:disabled="uiFlags.isDeleting"
/>
</template>
</ContactDeleteModal>
</div>
<EditContact
v-if="showEditModal"
:show="showEditModal"
:contact="contact"
@cancel="toggleEditModal"
/>
<ContactMergeModal ref="mergeModal" :primary-contact="contact" />
</div>
<woot-delete-modal
v-if="showDeleteModal"
v-model:show="showDeleteModal"
:on-close="closeDelete"
:on-confirm="confirmDeletion"
:title="$t('DELETE_CONTACT.CONFIRM.TITLE')"
:message="$t('DELETE_CONTACT.CONFIRM.MESSAGE')"
:message-value="confirmDeleteMessage"
:confirm-text="$t('DELETE_CONTACT.CONFIRM.YES')"
:reject-text="$t('DELETE_CONTACT.CONFIRM.NO')"
/>
</div>
</template>
@@ -3,11 +3,13 @@ import { useAlert } from 'dashboard/composables';
import EmojiOrIcon from 'shared/components/EmojiOrIcon.vue';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import NextButton from 'dashboard/components-next/button/Button.vue';
import InlineInput from 'dashboard/components-next/inline-input/InlineInput.vue';
export default {
components: {
EmojiOrIcon,
NextButton,
InlineInput,
},
props: {
href: {
@@ -30,6 +32,21 @@ export default {
type: Boolean,
default: false,
},
editable: {
type: Boolean,
default: false,
},
title: {
type: String,
default: '',
},
},
emits: ['update'],
data() {
return {
isEditing: false,
editValue: '',
};
},
methods: {
async onCopy(e) {
@@ -37,14 +54,53 @@ export default {
await copyTextToClipboard(this.value);
useAlert(this.$t('CONTACT_PANEL.COPY_SUCCESSFUL'));
},
startEditing() {
if (!this.editable) return;
this.editValue = this.value || '';
this.isEditing = true;
this.$nextTick(() => {
this.$refs.editInput?.focus();
});
},
saveEdit() {
if (!this.isEditing) return;
this.isEditing = false;
const trimmed = this.editValue.trim();
if (trimmed !== (this.value || '')) {
this.$emit('update', trimmed);
}
},
cancelEdit() {
this.isEditing = false;
},
},
};
</script>
<template>
<div class="w-full h-5 ltr:-ml-1 rtl:-mr-1">
<div class="group/row w-full h-5 ltr:-ml-1 rtl:-mr-1">
<!-- Inline edit mode -->
<div v-if="isEditing" class="flex items-center gap-2">
<EmojiOrIcon
:icon="icon"
:emoji="emoji"
icon-size="14"
class="flex-shrink-0 ltr:ml-1 rtl:mr-1"
/>
<InlineInput
ref="editInput"
v-model="editValue"
:placeholder="title"
class="!w-fit"
@enter-press="saveEdit"
@escape-press="cancelEdit"
@blur="saveEdit"
/>
</div>
<!-- Read mode with link -->
<a
v-if="href"
v-else-if="href"
:href="href"
class="flex items-center gap-2 text-n-slate-11 hover:underline"
>
@@ -73,8 +129,18 @@ export default {
icon="i-lucide-clipboard"
@click="onCopy"
/>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click.prevent="startEditing"
/>
</a>
<!-- Read mode without link -->
<div v-else class="flex items-center gap-2 text-n-slate-11">
<EmojiOrIcon
:icon="icon"
@@ -90,6 +156,15 @@ export default {
<span v-else class="text-sm text-n-slate-11">
{{ $t('CONTACT_PANEL.NOT_AVAILABLE') }}
</span>
<NextButton
v-if="editable"
ghost
xs
slate
class="ltr:-ml-1 rtl:-mr-1 opacity-0 group-hover/row:opacity-100 transition-opacity"
icon="i-lucide-pencil"
@click="startEditing"
/>
</div>
</div>
</template>
@@ -1,74 +1,70 @@
<script>
import { mapGetters } from 'vuex';
<script setup>
import { useStore } from 'dashboard/composables/store';
import { useMapGetter } from 'dashboard/composables/store';
import { useKeyboardEvents } from 'dashboard/composables/useKeyboardEvents';
import ContactForm from './ContactForm.vue';
import Button from 'dashboard/components-next/button/Button.vue';
export default {
components: {
ContactForm,
},
props: {
show: {
type: Boolean,
default: false,
},
contact: {
type: Object,
default: () => ({}),
},
},
emits: ['cancel', 'update:show'],
computed: {
...mapGetters({
uiFlags: 'contacts/getUIFlags',
}),
localShow: {
get() {
return this.show;
},
set(value) {
this.$emit('update:show', value);
},
},
},
const props = defineProps({
show: { type: Boolean, default: false },
contact: { type: Object, default: () => ({}) },
});
methods: {
onCancel() {
this.$emit('cancel');
},
onSuccess() {
this.$emit('cancel');
},
async onSubmit(contactItem) {
await this.$store.dispatch('contacts/update', contactItem);
await this.$store.dispatch(
'contacts/fetchContactableInbox',
this.contact.id
);
},
},
const emit = defineEmits(['cancel']);
const store = useStore();
const uiFlags = useMapGetter('contacts/getUIFlags');
const onCancel = () => emit('cancel');
const onSubmit = async contactItem => {
await store.dispatch('contacts/update', contactItem);
await store.dispatch('contacts/fetchContactableInbox', props.contact.id);
};
// Restore Escape-to-close behavior that was provided by woot-modal before
// this drawer was reimplemented as a plain fixed panel.
useKeyboardEvents({
Escape: {
action: () => {
if (props.show) onCancel();
},
allowOnFocusedInput: true,
},
});
</script>
<template>
<woot-modal
v-model:show="localShow"
:on-close="onCancel"
modal-type="right-aligned"
<transition
enter-active-class="transition duration-200 ease-out"
enter-from-class="ltr:translate-x-full rtl:-translate-x-full opacity-0"
leave-active-class="transition duration-150 ease-in"
leave-to-class="ltr:translate-x-[30%] rtl:-translate-x-[30%] opacity-0"
>
<div class="flex flex-col h-auto overflow-auto">
<woot-modal-header
:header-title="`${$t('EDIT_CONTACT.TITLE')} - ${
contact.name || contact.email
}`"
:header-content="$t('EDIT_CONTACT.DESC')"
/>
<div
v-if="show"
class="fixed inset-y-0 ltr:right-0 rtl:left-0 z-50 flex flex-col w-[30rem] max-w-full h-full bg-n-surface-2 ltr:border-l rtl:border-r border-n-weak shadow-lg overflow-auto"
>
<div class="flex items-center justify-between px-8 pt-8 pb-2">
<div>
<h2 class="text-lg font-medium text-n-slate-12 mb-1">
{{
`${$t('EDIT_CONTACT.TITLE')} - ${contact.name || contact.email}`
}}
</h2>
<p class="text-sm text-n-slate-11 mb-0">
{{ $t('EDIT_CONTACT.DESC') }}
</p>
</div>
<Button icon="i-lucide-x" slate ghost sm @click="onCancel" />
</div>
<ContactForm
:contact="contact"
:in-progress="uiFlags.isUpdating"
:on-submit="onSubmit"
@success="onSuccess"
@success="onCancel"
@cancel="onCancel"
/>
</div>
</woot-modal>
</transition>
</template>
@@ -40,11 +40,10 @@ const articleLink = computed(() => {
);
});
const saveArticle = async ({ ...values }, isAsync = false) => {
const actionToDispatch = isAsync ? 'articles/updateAsync' : 'articles/update';
const saveArticle = async ({ ...values }) => {
isUpdating.value = true;
try {
await store.dispatch(actionToDispatch, {
await store.dispatch('articles/update', {
portalSlug,
articleId: articleSlug,
...values,
@@ -62,10 +61,6 @@ const saveArticle = async ({ ...values }, isAsync = false) => {
}
};
const saveArticleAsync = async ({ ...values }) => {
saveArticle({ ...values }, true);
};
const isCategoryArticles = computed(() => {
return (
route.name === 'portals_categories_articles_index' ||
@@ -112,7 +107,6 @@ onMounted(fetchArticleDetails);
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="saveArticle"
@save-article-async="saveArticleAsync"
@preview-article="previewArticle"
@go-back="goBackToArticles"
/>
@@ -39,7 +39,7 @@ const createNewArticle = async ({ title, content }) => {
if (title) article.value.title = title;
if (content) article.value.content = content;
if (!article.value.title) return;
if (!article.value.title || isUpdating.value) return;
isUpdating.value = true;
try {
@@ -86,7 +86,7 @@ const goBackToArticles = () => {
:article="article"
:is-updating="isUpdating"
:is-saved="isSaved"
@save-article="createNewArticle"
@create-article="createNewArticle"
@go-back="goBackToArticles"
@set-author="setAuthorId"
@set-category="setCategoryId"
@@ -14,6 +14,12 @@ export const AUTOMATIONS = {
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'private_note',
name: 'PRIVATE_NOTE',
inputType: 'search_select',
filterOperators: OPERATOR_TYPES_1,
},
{
key: 'content',
name: 'MESSAGE_CONTAINS',
@@ -84,6 +90,14 @@ export const AUTOMATIONS = {
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'remove_assigned_agent',
name: 'REMOVE_ASSIGNED_AGENT',
},
{
key: 'remove_assigned_team',
name: 'REMOVE_ASSIGNED_TEAM',
},
{
key: 'add_label',
name: 'ADD_LABEL',
@@ -212,6 +226,14 @@ export const AUTOMATIONS = {
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'remove_assigned_agent',
name: 'REMOVE_ASSIGNED_AGENT',
},
{
key: 'remove_assigned_team',
name: 'REMOVE_ASSIGNED_TEAM',
},
{
key: 'assign_agent',
name: 'ASSIGN_AGENT',
@@ -344,6 +366,14 @@ export const AUTOMATIONS = {
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'remove_assigned_agent',
name: 'REMOVE_ASSIGNED_AGENT',
},
{
key: 'remove_assigned_team',
name: 'REMOVE_ASSIGNED_TEAM',
},
{
key: 'assign_agent',
name: 'ASSIGN_AGENT',
@@ -470,6 +500,14 @@ export const AUTOMATIONS = {
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'remove_assigned_agent',
name: 'REMOVE_ASSIGNED_AGENT',
},
{
key: 'remove_assigned_team',
name: 'REMOVE_ASSIGNED_TEAM',
},
{
key: 'assign_agent',
name: 'ASSIGN_AGENT',
@@ -586,6 +624,14 @@ export const AUTOMATIONS = {
key: 'assign_team',
name: 'ASSIGN_TEAM',
},
{
key: 'remove_assigned_agent',
name: 'REMOVE_ASSIGNED_AGENT',
},
{
key: 'remove_assigned_team',
name: 'REMOVE_ASSIGNED_TEAM',
},
{
key: 'send_email_to_team',
name: 'SEND_EMAIL_TO_TEAM',
@@ -644,6 +690,16 @@ export const AUTOMATION_ACTION_TYPES = [
label: 'ASSIGN_TEAM',
inputType: 'search_select',
},
{
key: 'remove_assigned_agent',
label: 'REMOVE_ASSIGNED_AGENT',
inputType: null,
},
{
key: 'remove_assigned_team',
label: 'REMOVE_ASSIGNED_TEAM',
inputType: null,
},
{
key: 'add_label',
label: 'ADD_LABEL',
@@ -112,9 +112,33 @@ export default {
...mapGetters({
accountId: 'getCurrentAccountId',
isFeatureEnabledonAccount: 'accounts/isFeatureEnabledonAccount',
isOnChatwootCloud: 'globalConfig/isOnChatwootCloud',
uiFlags: 'inboxes/getUIFlags',
portals: 'portals/allPortals',
}),
isInboundEmailEnabled() {
return this.isFeatureEnabledonAccount(
this.accountId,
FEATURE_FLAGS.INBOUND_EMAILS
);
},
showContinuityToggle() {
if (this.isInboundEmailEnabled) return true;
return this.isOnChatwootCloud;
},
isContinuityDisabled() {
return this.isOnChatwootCloud && !this.isInboundEmailEnabled;
},
continuityDescription() {
if (this.isContinuityDisabled) {
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_DISABLED_TEXT'
);
}
return this.$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
);
},
selectedTabKey() {
return this.tabs[this.selectedTabIndex]?.key;
},
@@ -542,7 +566,8 @@ export default {
welcome_tagline: this.channelWelcomeTagline || '',
selectedFeatureFlags: this.selectedFeatureFlags,
reply_time: this.replyTime || 'in_a_few_minutes',
continuity_via_email: this.continuityViaEmail,
continuity_via_email:
this.isInboundEmailEnabled && this.continuityViaEmail,
},
};
if (this.avatarFile) {
@@ -1148,15 +1173,15 @@ export default {
/>
<SettingsToggleSection
v-if="isAWebWidgetInbox"
v-if="isAWebWidgetInbox && showContinuityToggle"
v-model="continuityViaEmail"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.ENABLE_CONTINUITY_VIA_EMAIL_SUB_TEXT'
)
:description="continuityDescription"
:hide-toggle="isContinuityDisabled"
:class="
isContinuityDisabled ? 'cursor-not-allowed opacity-50' : ''
"
/>
</SettingsAccordion>
@@ -19,6 +19,11 @@ export const MACRO_ACTION_TYPES = [
label: 'REMOVE_LABEL',
inputType: 'multi_select',
},
{
key: 'remove_assigned_agent',
label: 'REMOVE_ASSIGNED_AGENT',
inputType: null,
},
{
key: 'remove_assigned_team',
label: 'REMOVE_ASSIGNED_TEAM',
@@ -17,6 +17,7 @@ export const resolveActionName = key => {
export const resolveTeamIds = (teams, ids) => {
return ids
.map(id => {
if (id === 'nil') return 'None';
const team = teams.find(i => i.id === id);
return team ? team.name : '';
})
@@ -35,6 +36,8 @@ export const resolveLabels = (labels, ids) => {
export const resolveAgents = (agents, ids) => {
return ids
.map(id => {
if (id === 'nil') return 'None';
if (id === 'self') return 'Self';
const agent = agents.find(i => i.id === id);
return agent ? agent.name : '';
})