Merge branch 'develop' into feat/voice-as-twilio-capability

This commit is contained in:
Muhsin Keloth
2026-04-01 12:41:07 +04:00
committed by GitHub
66 changed files with 1341 additions and 335 deletions
+3 -1
View File
@@ -98,7 +98,9 @@ export default {
mql.onchange = e => setColorTheme(e.matches);
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
async initializeAccount() {
await this.$store.dispatch('accounts/get');
@@ -106,6 +106,10 @@ select {
&[disabled] {
@apply field-disabled;
}
option:not(:disabled) {
@apply bg-n-solid-2 text-n-slate-12;
}
}
// Textarea
@@ -12,6 +12,7 @@ import {
import CardLayout from 'dashboard/components-next/CardLayout.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue';
import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue';
const props = defineProps({
id: {
@@ -34,14 +35,34 @@ const props = defineProps({
type: Number,
required: true,
},
isSelected: {
type: Boolean,
default: false,
},
selectable: {
type: Boolean,
default: false,
},
showSelectionControl: {
type: Boolean,
default: false,
},
showMenu: {
type: Boolean,
default: true,
},
});
const emit = defineEmits(['action']);
const emit = defineEmits(['action', 'select', 'hover']);
const { checkPermissions } = usePolicy();
const { t } = useI18n();
const [showActionsDropdown, toggleDropdown] = useToggle();
const modelValue = computed({
get: () => props.isSelected,
set: () => emit('select', props.id),
});
const menuItems = computed(() => {
const allOptions = [
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script>
<template>
<CardLayout>
<CardLayout
:selectable="selectable"
class="relative"
@mouseenter="emit('hover', true)"
@mouseleave="emit('hover', false)"
>
<div
v-show="showSelectionControl"
class="absolute top-7 ltr:left-3 rtl:right-3"
>
<Checkbox v-model="modelValue" />
</div>
<div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1">
{{ name }}
</span>
<div class="flex gap-2 items-center">
<div v-if="showMenu" class="flex gap-2 items-center">
<div
v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group"
@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n();
const store = useStore();
const bulkDeleteDialogRef = ref(null);
const i18nKey = computed(() => props.type.toUpperCase());
const i18nKey = computed(() => {
const i18nTypeMap = {
AssistantResponse: 'RESPONSES',
AssistantDocument: 'DOCUMENTS',
};
return i18nTypeMap[props.type];
});
const handleBulkDelete = async ids => {
if (!ids) return;
try {
await store.dispatch(
'captainBulkActions/handleBulkDelete',
Array.from(props.bulkIds)
);
await store.dispatch('captainBulkActions/handleBulkDelete', {
ids: Array.from(props.bulkIds),
type: props.type,
});
emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));
@@ -253,6 +253,9 @@ export default {
if (this.isAnInstagramChannel) {
return MESSAGE_MAX_LENGTH.INSTAGRAM;
}
if (this.isATelegramChannel) {
return MESSAGE_MAX_LENGTH.TELEGRAM;
}
if (this.isATiktokChannel) {
return MESSAGE_MAX_LENGTH.TIKTOK;
}
@@ -545,7 +548,10 @@ export default {
},
setCopilotAcceptedMessage(message, replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
this.copilotAcceptedMessages[key] = trimContent(message || '');
this.copilotAcceptedMessages[key] = trimContent(
message || '',
this.maxLength
);
},
clearCopilotAcceptedMessage(replyType = this.replyType) {
const key = this.getDraftKey(this.conversationIdByRoute, replyType);
@@ -603,7 +609,7 @@ export default {
saveDraft(conversationId, replyType) {
if (this.message || this.message === '') {
const key = this.getDraftKey(conversationId, replyType);
const draftToSave = trimContent(this.message || '');
const draftToSave = trimContent(this.message || '', this.maxLength);
this.$store.dispatch('draftMessages/set', {
key,
@@ -738,6 +738,17 @@
"DOCUMENTS": {
"HEADER": "Documents",
"ADD_NEW": "Create a new document",
"SELECTED": "{count} selected",
"SELECT_ALL": "Select all ({count})",
"UNSELECT_ALL": "Unselect all ({count})",
"BULK_DELETE_BUTTON": "Delete",
"BULK_DELETE": {
"TITLE": "Delete documents?",
"DESCRIPTION": "Are you sure you want to delete the selected documents? This action cannot be undone.",
"CONFIRM": "Yes, delete all",
"SUCCESS_MESSAGE": "Documents deleted successfully",
"ERROR_MESSAGE": "There was an error deleting the documents, please try again."
},
"RELATED_RESPONSES": {
"TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document."
@@ -4,9 +4,13 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.vue';
import BulkSelectBar from 'dashboard/components-next/captain/assistant/BulkSelectBar.vue';
import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue';
import Policy from 'dashboard/components/policy.vue';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.vue';
@@ -14,9 +18,12 @@ import CreateDocumentDialog from 'dashboard/components-next/captain/pageComponen
import DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
import { useI18n } from 'vue-i18n';
const route = useRoute();
const store = useStore();
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags');
@@ -25,9 +32,13 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedAssistantId = computed(() => Number(route.params.assistantId));
const canManageDocuments = computed(() => checkPermissions(['administrator']));
const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null);
const bulkDeleteDialog = ref(null);
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open();
@@ -78,7 +89,14 @@ const fetchDocuments = (page = 1) => {
store.dispatch('captainDocuments/get', filterParams);
};
const onPageChange = page => fetchDocuments(page);
const onPageChange = page => {
const hadSelection = bulkSelectedIds.value.size > 0;
fetchDocuments(page);
if (hadSelection) {
bulkSelectedIds.value = new Set();
}
};
const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) {
@@ -86,6 +104,58 @@ const onDeleteSuccess = () => {
}
};
const buildSelectedCountLabel = computed(() => {
const count = documents.value?.length || 0;
const isAllSelected = bulkSelectedIds.value.size === count && count > 0;
return isAllSelected
? t('CAPTAIN.DOCUMENTS.UNSELECT_ALL', { count })
: t('CAPTAIN.DOCUMENTS.SELECT_ALL', { count });
});
const selectedCountLabel = computed(() => {
return t('CAPTAIN.DOCUMENTS.SELECTED', {
count: bulkSelectedIds.value.size,
});
});
const hasBulkSelection = computed(() => bulkSelectedIds.value.size > 0);
const shouldShowSelectionControl = docId => {
return (
canManageDocuments.value &&
(hoveredCard.value === docId || hasBulkSelection.value)
);
};
const handleCardHover = (isHovered, id) => {
hoveredCard.value = isHovered ? id : null;
};
const handleCardSelect = id => {
if (!canManageDocuments.value) return;
const selected = new Set(bulkSelectedIds.value);
selected[selected.has(id) ? 'delete' : 'add'](id);
bulkSelectedIds.value = selected;
};
const fetchDocumentsAfterBulkAction = () => {
const hasNoDocumentsLeft = documents.value?.length === 0;
const currentPage = documentsMeta.value?.page;
if (hasNoDocumentsLeft) {
const pageToFetch = currentPage > 1 ? currentPage - 1 : currentPage;
fetchDocuments(pageToFetch);
} else {
fetchDocuments(currentPage);
}
bulkSelectedIds.value = new Set();
};
const onBulkDeleteSuccess = () => {
fetchDocumentsAfterBulkAction();
};
onMounted(() => {
fetchDocuments();
});
@@ -106,6 +176,21 @@ onMounted(() => {
@update:current-page="onPageChange"
@click="handleCreateDocument"
>
<template #subHeader>
<Policy :permissions="['administrator']">
<BulkSelectBar
v-model="bulkSelectedIds"
:all-items="documents"
:select-all-label="buildSelectedCountLabel"
:selected-count-label="selectedCountLabel"
:delete-label="$t('CAPTAIN.DOCUMENTS.BULK_DELETE_BUTTON')"
class="w-fit"
:class="{ 'mb-2': bulkSelectedIds.size > 0 }"
@bulk-delete="bulkDeleteDialog.dialogRef.open()"
/>
</Policy>
</template>
<template #knowMore>
<FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
@@ -138,7 +223,13 @@ onMounted(() => {
:external-link="doc.external_link"
:assistant="doc.assistant"
:created-at="doc.created_at"
:is-selected="canManageDocuments && bulkSelectedIds.has(doc.id)"
:selectable="canManageDocuments"
:show-selection-control="shouldShowSelectionControl(doc.id)"
:show-menu="!bulkSelectedIds.has(doc.id)"
@action="handleAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, doc.id)"
/>
</div>
</template>
@@ -162,5 +253,12 @@ onMounted(() => {
type="Documents"
@delete-success="onDeleteSuccess"
/>
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantDocument"
@delete-success="onBulkDeleteSuccess"
/>
</PageLayout>
</template>
@@ -316,7 +316,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
type="AssistantResponse"
@delete-success="onBulkDeleteSuccess"
/>
@@ -361,7 +361,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
type="AssistantResponse"
@delete-success="onBulkDeleteSuccess"
/>
@@ -103,7 +103,10 @@ export default {
const { name, locale, id, domain, support_email, features } =
this.getAccount(this.accountId);
this.$root.$i18n.locale = this.uiSettings?.locale || locale;
const effectiveLocale = this.uiSettings?.locale || locale;
if (effectiveLocale) {
this.$root.$i18n.locale = effectiveLocale;
}
this.name = name;
this.locale = locale;
this.id = id;
@@ -129,11 +132,9 @@ export default {
support_email: this.supportEmail,
});
// If user locale is set, update the locale with user locale
if (this.uiSettings?.locale) {
this.$root.$i18n.locale = this.uiSettings?.locale;
} else {
// If user locale is not set, update the locale with account locale
this.$root.$i18n.locale = this.locale;
const updatedLocale = this.uiSettings?.locale || this.locale;
if (updatedLocale) {
this.$root.$i18n.locale = updatedLocale;
}
this.getAccount(this.id).locale = this.locale;
useAlert(this.$t('GENERAL_SETTINGS.UPDATE.SUCCESS'));
@@ -57,7 +57,10 @@ export default {
},
});
} catch (error) {
useAlert(this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE'));
useAlert(
error.message ||
this.$t('INBOX_MGMT.ADD.API_CHANNEL.API.ERROR_MESSAGE')
);
}
},
},
@@ -25,17 +25,26 @@ export default createStore({
}
},
handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) {
handleBulkDelete: async function handleBulkDelete(
{ dispatch },
{ type = 'AssistantResponse', ids }
) {
const response = await dispatch('processBulkAction', {
type: 'AssistantResponse',
type,
actionType: 'delete',
ids,
});
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
if (type === 'AssistantResponse') {
// Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, {
root: true,
});
} else if (type === 'AssistantDocument') {
await dispatch('captainDocuments/removeBulkRecords', ids, {
root: true,
});
}
return response;
},
@@ -4,4 +4,12 @@ import { createStore } from '../storeFactory';
export default createStore({
name: 'CaptainDocument',
API: CaptainDocumentAPI,
actions: mutations => ({
removeBulkRecords({ commit, getters }, ids) {
const records = getters.getRecords.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, records);
},
}),
});
@@ -220,9 +220,8 @@ export const actions = {
sendAnalyticsEvent(channel.type);
return response.data;
} catch (error) {
const errorMessage = error?.response?.data?.message;
commit(types.default.SET_INBOXES_UI_FLAG, { isCreating: false });
throw new Error(errorMessage);
return throwErrorMessage(error);
}
},
createWebsiteChannel: async ({ commit }, params) => {
+3 -1
View File
@@ -35,7 +35,9 @@ export default {
};
},
setLocale(locale) {
this.$root.$i18n.locale = locale;
if (locale) {
this.$root.$i18n.locale = locale;
}
},
},
};
+1 -1
View File
@@ -7,7 +7,7 @@
html,
body {
@apply antialiased h-full;
@apply antialiased h-full bg-n-slate-2 dark:bg-n-solid-1;
}
.is-mobile {
@@ -1,4 +1,4 @@
import { computed } from 'vue';
import { computed, watchEffect } from 'vue';
import { useMapGetter } from 'dashboard/composables/store';
const isDarkModeAuto = mode => mode === 'auto';
@@ -23,6 +23,10 @@ export function useDarkMode() {
calculatePrefersDarkMode(darkMode.value, systemPreference.value)
);
watchEffect(() => {
document.documentElement.classList.toggle('dark', prefersDarkMode.value);
});
return {
darkMode,
prefersDarkMode,
@@ -10,7 +10,7 @@ export default {
</script>
<template>
<div class="bg-white h-full">
<div class="bg-n-solid-1 h-full">
<IframeLoader :url="$route.query.link" />
</div>
</template>