mirror of
				https://github.com/lingble/chatwoot.git
				synced 2025-10-31 02:57:57 +00:00 
			
		
		
		
	feat: Add support for bulk action for Captain FAQs (#10905)
Co-authored-by: Pranav <pranav@chatwoot.com> Co-authored-by: Pranav <pranavrajs@gmail.com>
This commit is contained in:
		
							
								
								
									
										9
									
								
								app/javascript/dashboard/api/captain/bulkActions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										9
									
								
								app/javascript/dashboard/api/captain/bulkActions.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,9 @@ | |||||||
|  | import ApiClient from '../ApiClient'; | ||||||
|  |  | ||||||
|  | class CaptainBulkActionsAPI extends ApiClient { | ||||||
|  |   constructor() { | ||||||
|  |     super('captain/bulk_actions', { accountScoped: true }); | ||||||
|  |   } | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export default new CaptainBulkActionsAPI(); | ||||||
| @@ -4,6 +4,10 @@ defineProps({ | |||||||
|     type: String, |     type: String, | ||||||
|     default: 'col', |     default: 'col', | ||||||
|   }, |   }, | ||||||
|  |   selectable: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['click']); | const emit = defineEmits(['click']); | ||||||
| @@ -18,10 +22,11 @@ const handleClick = () => { | |||||||
|     class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" |     class="flex flex-col w-full shadow outline-1 outline outline-n-container group/cardLayout rounded-2xl bg-n-solid-2" | ||||||
|   > |   > | ||||||
|     <div |     <div | ||||||
|       class="flex w-full gap-3 px-6 py-5" |       class="flex w-full gap-3 py-5" | ||||||
|       :class=" |       :class="[ | ||||||
|         layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center' |         layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center', | ||||||
|       " |         selectable ? 'px-10 py-6' : 'px-6', | ||||||
|  |       ]" | ||||||
|       @click="handleClick" |       @click="handleClick" | ||||||
|     > |     > | ||||||
|       <slot /> |       <slot /> | ||||||
|   | |||||||
| @@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper'; | |||||||
| import CardLayout from 'dashboard/components-next/CardLayout.vue'; | import CardLayout from 'dashboard/components-next/CardLayout.vue'; | ||||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; | ||||||
| import Policy from 'dashboard/components/policy.vue'; | import Policy from 'dashboard/components/policy.vue'; | ||||||
|  |  | ||||||
| const props = defineProps({ | const props = defineProps({ | ||||||
| @@ -46,14 +47,27 @@ const props = defineProps({ | |||||||
|     type: Number, |     type: Number, | ||||||
|     required: true, |     required: true, | ||||||
|   }, |   }, | ||||||
|  |   isSelected: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   selectable: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
| }); | }); | ||||||
|  |  | ||||||
| const emit = defineEmits(['action', 'navigate']); | const emit = defineEmits(['action', 'navigate', 'select', 'hover']); | ||||||
|  |  | ||||||
| const { t } = useI18n(); | const { t } = useI18n(); | ||||||
|  |  | ||||||
| const [showActionsDropdown, toggleDropdown] = useToggle(); | const [showActionsDropdown, toggleDropdown] = useToggle(); | ||||||
|  |  | ||||||
|  | const modelValue = computed({ | ||||||
|  |   get: () => props.isSelected, | ||||||
|  |   set: () => emit('select', props.id), | ||||||
|  | }); | ||||||
|  |  | ||||||
| const statusAction = computed(() => { | const statusAction = computed(() => { | ||||||
|   if (props.status === 'pending') { |   if (props.status === 'pending') { | ||||||
|     return [ |     return [ | ||||||
| @@ -102,8 +116,17 @@ const handleDocumentableClick = () => { | |||||||
| </script> | </script> | ||||||
|  |  | ||||||
| <template> | <template> | ||||||
|   <CardLayout :class="{ 'rounded-md': compact }"> |   <CardLayout | ||||||
|     <div class="flex justify-between w-full gap-1"> |     selectable | ||||||
|  |     class="relative" | ||||||
|  |     :class="{ 'rounded-md': compact }" | ||||||
|  |     @mouseenter="emit('hover', true)" | ||||||
|  |     @mouseleave="emit('hover', false)" | ||||||
|  |   > | ||||||
|  |     <div v-show="selectable" class="absolute top-7 ltr:left-4 rtl:right-4"> | ||||||
|  |       <Checkbox v-model="modelValue" /> | ||||||
|  |     </div> | ||||||
|  |     <div class="flex relative justify-between w-full gap-1"> | ||||||
|       <span class="text-base text-n-slate-12 line-clamp-1"> |       <span class="text-base text-n-slate-12 line-clamp-1"> | ||||||
|         {{ question }} |         {{ question }} | ||||||
|       </span> |       </span> | ||||||
| @@ -148,7 +171,7 @@ const handleDocumentableClick = () => { | |||||||
|             v-if="documentable.type === 'Captain::Document'" |             v-if="documentable.type === 'Captain::Document'" | ||||||
|             class="inline-flex items-center gap-1 truncate over" |             class="inline-flex items-center gap-1 truncate over" | ||||||
|           > |           > | ||||||
|             <i class="i-ph-chat-circle-dots text-base" /> |             <i class="i-ph-files-light text-base" /> | ||||||
|             <span class="max-w-96 truncate" :title="documentable.name"> |             <span class="max-w-96 truncate" :title="documentable.name"> | ||||||
|               {{ documentable.name }} |               {{ documentable.name }} | ||||||
|             </span> |             </span> | ||||||
|   | |||||||
| @@ -0,0 +1,59 @@ | |||||||
|  | <script setup> | ||||||
|  | import { ref, computed } from 'vue'; | ||||||
|  | import { useStore } from 'dashboard/composables/store'; | ||||||
|  | import { useI18n } from 'vue-i18n'; | ||||||
|  | import { useAlert } from 'dashboard/composables'; | ||||||
|  | import Dialog from 'dashboard/components-next/dialog/Dialog.vue'; | ||||||
|  |  | ||||||
|  | const props = defineProps({ | ||||||
|  |   type: { | ||||||
|  |     type: String, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  |   bulkIds: { | ||||||
|  |     type: Object, | ||||||
|  |     required: true, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['deleteSuccess']); | ||||||
|  |  | ||||||
|  | const { t } = useI18n(); | ||||||
|  | const store = useStore(); | ||||||
|  | const bulkDeleteDialogRef = ref(null); | ||||||
|  | const i18nKey = computed(() => props.type.toUpperCase()); | ||||||
|  |  | ||||||
|  | const handleBulkDelete = async ids => { | ||||||
|  |   if (!ids) return; | ||||||
|  |  | ||||||
|  |   try { | ||||||
|  |     await store.dispatch( | ||||||
|  |       'captainBulkActions/handleBulkDelete', | ||||||
|  |       Array.from(props.bulkIds) | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     emit('deleteSuccess'); | ||||||
|  |     useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`)); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.ERROR_MESSAGE`)); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleDialogConfirm = async () => { | ||||||
|  |   await handleBulkDelete(Array.from(props.bulkIds)); | ||||||
|  |   bulkDeleteDialogRef.value?.close(); | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | defineExpose({ dialogRef: bulkDeleteDialogRef }); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Dialog | ||||||
|  |     ref="bulkDeleteDialogRef" | ||||||
|  |     type="alert" | ||||||
|  |     :title="t(`CAPTAIN.${i18nKey}.BULK_DELETE.TITLE`)" | ||||||
|  |     :description="t(`CAPTAIN.${i18nKey}.BULK_DELETE.DESCRIPTION`)" | ||||||
|  |     :confirm-button-label="t(`CAPTAIN.${i18nKey}.BULK_DELETE.CONFIRM`)" | ||||||
|  |     @confirm="handleDialogConfirm" | ||||||
|  |   /> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,47 @@ | |||||||
|  | <script setup> | ||||||
|  | import Checkbox from './Checkbox.vue'; | ||||||
|  | import { ref } from 'vue'; | ||||||
|  |  | ||||||
|  | const defaultValue = ref(false); | ||||||
|  | const isChecked = ref(false); | ||||||
|  | const checkedValue = ref(true); | ||||||
|  | const indeterminateValue = ref(true); | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <Story title="Components/Checkbox" :layout="{ type: 'grid', width: '250px' }"> | ||||||
|  |     <Variant title="States"> | ||||||
|  |       <div class="p-2 space-y-4"> | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Default:</span> | ||||||
|  |           <Checkbox v-model="defaultValue" /> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Checked:</span> | ||||||
|  |           <Checkbox v-model="checkedValue" /> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Indeterminate:</span> | ||||||
|  |           <Checkbox v-model="indeterminateValue" indeterminate /> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Indeterminate disabled:</span> | ||||||
|  |           <Checkbox v-model="indeterminateValue" indeterminate disabled /> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Disabled:</span> | ||||||
|  |           <Checkbox v-model="defaultValue" disabled /> | ||||||
|  |         </div> | ||||||
|  |  | ||||||
|  |         <div class="flex items-center justify-between gap-4"> | ||||||
|  |           <span>Disabled Checked:</span> | ||||||
|  |           <Checkbox v-model="isChecked" disabled /> | ||||||
|  |         </div> | ||||||
|  |       </div> | ||||||
|  |     </Variant> | ||||||
|  |   </Story> | ||||||
|  | </template> | ||||||
| @@ -0,0 +1,63 @@ | |||||||
|  | <script setup> | ||||||
|  | defineProps({ | ||||||
|  |   indeterminate: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  |   disabled: { | ||||||
|  |     type: Boolean, | ||||||
|  |     default: false, | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const emit = defineEmits(['change']); | ||||||
|  |  | ||||||
|  | const modelValue = defineModel('modelValue', { | ||||||
|  |   type: Boolean, | ||||||
|  |   default: false, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleChange = event => { | ||||||
|  |   modelValue.value = event.target.checked; | ||||||
|  |   emit('change', event); | ||||||
|  | }; | ||||||
|  | </script> | ||||||
|  |  | ||||||
|  | <template> | ||||||
|  |   <div class="relative w-4 h-4"> | ||||||
|  |     <input | ||||||
|  |       :checked="modelValue" | ||||||
|  |       :indeterminate="indeterminate" | ||||||
|  |       type="checkbox" | ||||||
|  |       :disabled="disabled" | ||||||
|  |       class="peer absolute inset-0 z-10 h-4 w-4 disabled:opacity-50 appearance-none rounded border border-n-slate-6 ring-transparent transition-all duration-200 checked:border-n-brand checked:bg-n-brand dark:border-gray-600 dark:checked:border-n-brand indeterminate:border-n-brand indeterminate:bg-n-brand hover:enabled:bg-n-blue-border cursor-pointer" | ||||||
|  |       @change="handleChange" | ||||||
|  |     /> | ||||||
|  |     <!-- Checkmark SVG --> | ||||||
|  |     <svg | ||||||
|  |       viewBox="0 0 14 14" | ||||||
|  |       fill="none" | ||||||
|  |       class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-checked:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         d="M3 8L6 11L11 3.5" | ||||||
|  |         stroke-width="2" | ||||||
|  |         stroke-linecap="round" | ||||||
|  |         stroke-linejoin="round" | ||||||
|  |       /> | ||||||
|  |     </svg> | ||||||
|  |     <!-- Minus/Indeterminate SVG --> | ||||||
|  |     <svg | ||||||
|  |       viewBox="0 0 14 14" | ||||||
|  |       fill="none" | ||||||
|  |       class="pointer-events-none absolute w-3.5 h-3.5 z-20 stroke-white opacity-0 peer-indeterminate:opacity-100 transition-opacity duration-200 left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" | ||||||
|  |     > | ||||||
|  |       <path | ||||||
|  |         d="M3 7L11 7" | ||||||
|  |         stroke-width="2" | ||||||
|  |         stroke-linecap="round" | ||||||
|  |         stroke-linejoin="round" | ||||||
|  |       /> | ||||||
|  |     </svg> | ||||||
|  |   </div> | ||||||
|  | </template> | ||||||
| @@ -437,6 +437,20 @@ | |||||||
|       "DOCUMENTABLE": { |       "DOCUMENTABLE": { | ||||||
|         "CONVERSATION": "Conversation #{id}" |         "CONVERSATION": "Conversation #{id}" | ||||||
|       }, |       }, | ||||||
|  |       "SELECTED": "{count} selected", | ||||||
|  |       "BULK_APPROVE_BUTTON": "Approve", | ||||||
|  |       "BULK_DELETE_BUTTON": "Delete", | ||||||
|  |       "BULK_APPROVE": { | ||||||
|  |         "SUCCESS_MESSAGE": "FAQs approved successfully", | ||||||
|  |         "ERROR_MESSAGE": "There was an error approving the FAQs, please try again." | ||||||
|  |       }, | ||||||
|  |       "BULK_DELETE": { | ||||||
|  |         "TITLE": "Delete FAQs?", | ||||||
|  |         "DESCRIPTION": "Are you sure you want to delete the selected FAQs? This action cannot be undone.", | ||||||
|  |         "CONFIRM": "Yes, delete all", | ||||||
|  |         "SUCCESS_MESSAGE": "FAQs deleted successfully", | ||||||
|  |         "ERROR_MESSAGE": "There was an error deleting the FAQs, please try again." | ||||||
|  |       }, | ||||||
|       "DELETE": { |       "DELETE": { | ||||||
|         "TITLE": "Are you sure to delete the FAQ?", |         "TITLE": "Are you sure to delete the FAQ?", | ||||||
|         "DESCRIPTION": "", |         "DESCRIPTION": "", | ||||||
|   | |||||||
| @@ -8,8 +8,10 @@ import { useRouter } from 'vue-router'; | |||||||
| import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | import { FEATURE_FLAGS } from 'dashboard/featureFlags'; | ||||||
|  |  | ||||||
| import Button from 'dashboard/components-next/button/Button.vue'; | import Button from 'dashboard/components-next/button/Button.vue'; | ||||||
|  | import Checkbox from 'dashboard/components-next/checkbox/Checkbox.vue'; | ||||||
| import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; | ||||||
| import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; | import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; | ||||||
|  | import BulkDeleteDialog from 'dashboard/components-next/captain/pageComponents/BulkDeleteDialog.vue'; | ||||||
| import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; | import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; | ||||||
| import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue'; | import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue'; | ||||||
| import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue'; | import AssistantSelector from 'dashboard/components-next/captain/pageComponents/AssistantSelector.vue'; | ||||||
| @@ -28,6 +30,7 @@ const isFetching = computed(() => uiFlags.value.fetchingList); | |||||||
|  |  | ||||||
| const selectedResponse = ref(null); | const selectedResponse = ref(null); | ||||||
| const deleteDialog = ref(null); | const deleteDialog = ref(null); | ||||||
|  | const bulkDeleteDialog = ref(null); | ||||||
|  |  | ||||||
| const selectedStatus = ref('all'); | const selectedStatus = ref('all'); | ||||||
| const selectedAssistant = ref('all'); | const selectedAssistant = ref('all'); | ||||||
| @@ -129,7 +132,69 @@ const fetchResponses = (page = 1) => { | |||||||
|   store.dispatch('captainResponses/get', filterParams); |   store.dispatch('captainResponses/get', filterParams); | ||||||
| }; | }; | ||||||
|  |  | ||||||
| const onPageChange = page => fetchResponses(page); | // Bulk action | ||||||
|  | const bulkSelectedIds = ref(new Set()); | ||||||
|  | const hoveredCard = ref(null); | ||||||
|  |  | ||||||
|  | const bulkSelectionState = computed(() => { | ||||||
|  |   const selectedCount = bulkSelectedIds.value.size; | ||||||
|  |   const totalCount = responses.value?.length || 0; | ||||||
|  |  | ||||||
|  |   return { | ||||||
|  |     hasSelected: selectedCount > 0, | ||||||
|  |     isIndeterminate: selectedCount > 0 && selectedCount < totalCount, | ||||||
|  |     allSelected: totalCount > 0 && selectedCount === totalCount, | ||||||
|  |   }; | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const bulkCheckbox = computed({ | ||||||
|  |   get: () => bulkSelectionState.value.allSelected, | ||||||
|  |   set: value => { | ||||||
|  |     bulkSelectedIds.value = value | ||||||
|  |       ? new Set(responses.value.map(r => r.id)) | ||||||
|  |       : new Set(); | ||||||
|  |   }, | ||||||
|  | }); | ||||||
|  |  | ||||||
|  | const handleCardHover = (isHovered, id) => { | ||||||
|  |   hoveredCard.value = isHovered ? id : null; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleCardSelect = id => { | ||||||
|  |   const selected = new Set(bulkSelectedIds.value); | ||||||
|  |   selected[selected.has(id) ? 'delete' : 'add'](id); | ||||||
|  |   bulkSelectedIds.value = selected; | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const handleBulkApprove = async () => { | ||||||
|  |   try { | ||||||
|  |     await store.dispatch( | ||||||
|  |       'captainBulkActions/handleBulkApprove', | ||||||
|  |       Array.from(bulkSelectedIds.value) | ||||||
|  |     ); | ||||||
|  |  | ||||||
|  |     // Clear selection | ||||||
|  |     bulkSelectedIds.value = new Set(); | ||||||
|  |     useAlert(t('CAPTAIN.RESPONSES.BULK_APPROVE.SUCCESS_MESSAGE')); | ||||||
|  |   } catch (error) { | ||||||
|  |     useAlert( | ||||||
|  |       error?.message || t('CAPTAIN.RESPONSES.BULK_APPROVE.ERROR_MESSAGE') | ||||||
|  |     ); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
|  | const onPageChange = page => { | ||||||
|  |   // Store current selection state before fetching new page | ||||||
|  |   const wasAllPageSelected = bulkSelectionState.value.allSelected; | ||||||
|  |   const hadPartialSelection = bulkSelectedIds.value.size > 0; | ||||||
|  |  | ||||||
|  |   fetchResponses(page); | ||||||
|  |  | ||||||
|  |   // Reset selection if we had any selections on page change | ||||||
|  |   if (wasAllPageSelected || hadPartialSelection) { | ||||||
|  |     bulkSelectedIds.value = new Set(); | ||||||
|  |   } | ||||||
|  | }; | ||||||
|  |  | ||||||
| const onDeleteSuccess = () => { | const onDeleteSuccess = () => { | ||||||
|   if (responses.value?.length === 0 && responseMeta.value?.page > 1) { |   if (responses.value?.length === 0 && responseMeta.value?.page > 1) { | ||||||
| @@ -137,6 +202,20 @@ const onDeleteSuccess = () => { | |||||||
|   } |   } | ||||||
| }; | }; | ||||||
|  |  | ||||||
|  | const onBulkDeleteSuccess = () => { | ||||||
|  |   // Only fetch if no records left | ||||||
|  |   if (responses.value?.length === 0) { | ||||||
|  |     const page = | ||||||
|  |       responseMeta.value?.page > 1 | ||||||
|  |         ? responseMeta.value.page - 1 | ||||||
|  |         : responseMeta.value.page; | ||||||
|  |     fetchResponses(page); | ||||||
|  |   } | ||||||
|  |  | ||||||
|  |   // Clear selection | ||||||
|  |   bulkSelectedIds.value = new Set(); | ||||||
|  | }; | ||||||
|  |  | ||||||
| const handleStatusFilterChange = ({ value }) => { | const handleStatusFilterChange = ({ value }) => { | ||||||
|   selectedStatus.value = value; |   selectedStatus.value = value; | ||||||
|   isStatusFilterOpen.value = false; |   isStatusFilterOpen.value = false; | ||||||
| @@ -177,7 +256,11 @@ onMounted(() => { | |||||||
|     </template> |     </template> | ||||||
|  |  | ||||||
|     <template #controls> |     <template #controls> | ||||||
|       <div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3"> |       <div | ||||||
|  |         v-if="shouldShowDropdown" | ||||||
|  |         class="mb-4 -mt-3 flex justify-between items-center" | ||||||
|  |       > | ||||||
|  |         <div v-if="!bulkSelectionState.hasSelected" class="flex gap-3"> | ||||||
|           <OnClickOutside @trigger="isStatusFilterOpen = false"> |           <OnClickOutside @trigger="isStatusFilterOpen = false"> | ||||||
|             <Button |             <Button | ||||||
|               :label="selectedStatusLabel" |               :label="selectedStatusLabel" | ||||||
| @@ -201,6 +284,49 @@ onMounted(() => { | |||||||
|             @update="handleAssistantFilterChange" |             @update="handleAssistantFilterChange" | ||||||
|           /> |           /> | ||||||
|         </div> |         </div> | ||||||
|  |  | ||||||
|  |         <transition | ||||||
|  |           name="slide-fade" | ||||||
|  |           enter-active-class="transition-all duration-300 ease-out" | ||||||
|  |           enter-from-class="opacity-0 transform ltr:-translate-x-4 rtl:translate-x-4" | ||||||
|  |           enter-to-class="opacity-100 transform translate-x-0" | ||||||
|  |           leave-active-class="hidden opacity-0" | ||||||
|  |         > | ||||||
|  |           <div | ||||||
|  |             v-if="bulkSelectionState.hasSelected" | ||||||
|  |             class="flex items-center gap-3 ltr:pl-4 rtl:pr-4" | ||||||
|  |           > | ||||||
|  |             <div class="flex items-center gap-1.5"> | ||||||
|  |               <Checkbox | ||||||
|  |                 v-model="bulkCheckbox" | ||||||
|  |                 :indeterminate="bulkSelectionState.isIndeterminate" | ||||||
|  |               /> | ||||||
|  |               <span class="text-sm text-n-slate-10 tabular-nums"> | ||||||
|  |                 {{ | ||||||
|  |                   $t('CAPTAIN.RESPONSES.SELECTED', { | ||||||
|  |                     count: bulkSelectedIds.size, | ||||||
|  |                   }) | ||||||
|  |                 }} | ||||||
|  |               </span> | ||||||
|  |             </div> | ||||||
|  |             <div class="h-4 w-px bg-n-strong" /> | ||||||
|  |             <div class="flex gap-2"> | ||||||
|  |               <Button | ||||||
|  |                 :label="$t('CAPTAIN.RESPONSES.BULK_APPROVE_BUTTON')" | ||||||
|  |                 sm | ||||||
|  |                 slate | ||||||
|  |                 @click="handleBulkApprove" | ||||||
|  |               /> | ||||||
|  |               <Button | ||||||
|  |                 :label="$t('CAPTAIN.RESPONSES.BULK_DELETE_BUTTON')" | ||||||
|  |                 sm | ||||||
|  |                 slate | ||||||
|  |                 @click="bulkDeleteDialog.dialogRef.open()" | ||||||
|  |               /> | ||||||
|  |             </div> | ||||||
|  |           </div> | ||||||
|  |         </transition> | ||||||
|  |       </div> | ||||||
|     </template> |     </template> | ||||||
|  |  | ||||||
|     <template #body> |     <template #body> | ||||||
| @@ -218,8 +344,12 @@ onMounted(() => { | |||||||
|           :status="response.status" |           :status="response.status" | ||||||
|           :created-at="response.created_at" |           :created-at="response.created_at" | ||||||
|           :updated-at="response.updated_at" |           :updated-at="response.updated_at" | ||||||
|  |           :is-selected="bulkSelectedIds.has(response.id)" | ||||||
|  |           :selectable="hoveredCard === response.id || bulkSelectedIds.size > 0" | ||||||
|           @action="handleAction" |           @action="handleAction" | ||||||
|           @navigate="handleNavigationAction" |           @navigate="handleNavigationAction" | ||||||
|  |           @select="handleCardSelect" | ||||||
|  |           @hover="isHovered => handleCardHover(isHovered, response.id)" | ||||||
|         /> |         /> | ||||||
|       </div> |       </div> | ||||||
|     </template> |     </template> | ||||||
| @@ -232,6 +362,14 @@ onMounted(() => { | |||||||
|       @delete-success="onDeleteSuccess" |       @delete-success="onDeleteSuccess" | ||||||
|     /> |     /> | ||||||
|  |  | ||||||
|  |     <BulkDeleteDialog | ||||||
|  |       v-if="bulkSelectedIds" | ||||||
|  |       ref="bulkDeleteDialog" | ||||||
|  |       :bulk-ids="bulkSelectedIds" | ||||||
|  |       type="Responses" | ||||||
|  |       @delete-success="onBulkDeleteSuccess" | ||||||
|  |     /> | ||||||
|  |  | ||||||
|     <CreateResponseDialog |     <CreateResponseDialog | ||||||
|       v-if="dialogType" |       v-if="dialogType" | ||||||
|       ref="createDialog" |       ref="createDialog" | ||||||
|   | |||||||
							
								
								
									
										56
									
								
								app/javascript/dashboard/store/captain/bulkActions.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										56
									
								
								app/javascript/dashboard/store/captain/bulkActions.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,56 @@ | |||||||
|  | import CaptainBulkActionsAPI from 'dashboard/api/captain/bulkActions'; | ||||||
|  | import { createStore } from './storeFactory'; | ||||||
|  | import { throwErrorMessage } from 'dashboard/store/utils/api'; | ||||||
|  |  | ||||||
|  | export default createStore({ | ||||||
|  |   name: 'CaptainBulkAction', | ||||||
|  |   API: CaptainBulkActionsAPI, | ||||||
|  |   actions: mutations => ({ | ||||||
|  |     processBulkAction: async function processBulkAction( | ||||||
|  |       { commit }, | ||||||
|  |       { type, actionType, ids } | ||||||
|  |     ) { | ||||||
|  |       commit(mutations.SET_UI_FLAG, { isUpdating: true }); | ||||||
|  |       try { | ||||||
|  |         const response = await CaptainBulkActionsAPI.create({ | ||||||
|  |           type: type, | ||||||
|  |           ids, | ||||||
|  |           fields: { status: actionType }, | ||||||
|  |         }); | ||||||
|  |         commit(mutations.SET_UI_FLAG, { isUpdating: false }); | ||||||
|  |         return response.data; | ||||||
|  |       } catch (error) { | ||||||
|  |         commit(mutations.SET_UI_FLAG, { isUpdating: false }); | ||||||
|  |         return throwErrorMessage(error); | ||||||
|  |       } | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     handleBulkDelete: async function handleBulkDelete({ dispatch }, ids) { | ||||||
|  |       const response = await dispatch('processBulkAction', { | ||||||
|  |         type: 'AssistantResponse', | ||||||
|  |         actionType: 'delete', | ||||||
|  |         ids, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // Update the response store after successful API call | ||||||
|  |       await dispatch('captainResponses/removeBulkResponses', ids, { | ||||||
|  |         root: true, | ||||||
|  |       }); | ||||||
|  |       return response; | ||||||
|  |     }, | ||||||
|  |  | ||||||
|  |     handleBulkApprove: async function handleBulkApprove({ dispatch }, ids) { | ||||||
|  |       const response = await dispatch('processBulkAction', { | ||||||
|  |         type: 'AssistantResponse', | ||||||
|  |         actionType: 'approve', | ||||||
|  |         ids, | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       // Update response store after successful API call | ||||||
|  |       await dispatch('captainResponses/updateBulkResponses', response, { | ||||||
|  |         root: true, | ||||||
|  |       }); | ||||||
|  |       return response; | ||||||
|  |     }, | ||||||
|  |   }), | ||||||
|  | }); | ||||||
| @@ -4,4 +4,29 @@ import { createStore } from './storeFactory'; | |||||||
| export default createStore({ | export default createStore({ | ||||||
|   name: 'CaptainResponse', |   name: 'CaptainResponse', | ||||||
|   API: CaptainResponseAPI, |   API: CaptainResponseAPI, | ||||||
|  |   actions: mutations => ({ | ||||||
|  |     removeBulkResponses: ({ commit, state }, ids) => { | ||||||
|  |       const updatedRecords = state.records.filter( | ||||||
|  |         record => !ids.includes(record.id) | ||||||
|  |       ); | ||||||
|  |       commit(mutations.SET, updatedRecords); | ||||||
|  |     }, | ||||||
|  |     updateBulkResponses: ({ commit, state }, approvedResponses) => { | ||||||
|  |       // Create a map of updated responses for faster lookup | ||||||
|  |       const updatedResponsesMap = approvedResponses.reduce((map, response) => { | ||||||
|  |         map[response.id] = response; | ||||||
|  |         return map; | ||||||
|  |       }, {}); | ||||||
|  |  | ||||||
|  |       // Update existing records with updated data | ||||||
|  |       const updatedRecords = state.records.map(record => { | ||||||
|  |         if (updatedResponsesMap[record.id]) { | ||||||
|  |           return updatedResponsesMap[record.id]; // Replace with the updated response | ||||||
|  |         } | ||||||
|  |         return record; | ||||||
|  |       }); | ||||||
|  |  | ||||||
|  |       commit(mutations.SET, updatedRecords); | ||||||
|  |     }, | ||||||
|  |   }), | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -50,6 +50,7 @@ import captainAssistants from './captain/assistant'; | |||||||
| import captainDocuments from './captain/document'; | import captainDocuments from './captain/document'; | ||||||
| import captainResponses from './captain/response'; | import captainResponses from './captain/response'; | ||||||
| import captainInboxes from './captain/inboxes'; | import captainInboxes from './captain/inboxes'; | ||||||
|  | import captainBulkActions from './captain/bulkActions'; | ||||||
| const plugins = []; | const plugins = []; | ||||||
|  |  | ||||||
| export default createStore({ | export default createStore({ | ||||||
| @@ -104,6 +105,7 @@ export default createStore({ | |||||||
|     captainDocuments, |     captainDocuments, | ||||||
|     captainResponses, |     captainResponses, | ||||||
|     captainInboxes, |     captainInboxes, | ||||||
|  |     captainBulkActions, | ||||||
|   }, |   }, | ||||||
|   plugins, |   plugins, | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -54,6 +54,7 @@ Rails.application.routes.draw do | |||||||
|             end |             end | ||||||
|             resources :documents, only: [:index, :show, :create, :destroy] |             resources :documents, only: [:index, :show, :create, :destroy] | ||||||
|             resources :assistant_responses |             resources :assistant_responses | ||||||
|  |             resources :bulk_actions, only: [:create] | ||||||
|           end |           end | ||||||
|           resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do |           resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do | ||||||
|             delete :avatar, on: :member |             delete :avatar, on: :member | ||||||
|   | |||||||
| @@ -0,0 +1,51 @@ | |||||||
|  | class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::BaseController | ||||||
|  |   before_action :current_account | ||||||
|  |   before_action -> { check_authorization(Captain::Assistant) } | ||||||
|  |   before_action :validate_params | ||||||
|  |   before_action :type_matches? | ||||||
|  |  | ||||||
|  |   MODEL_TYPE = ['AssistantResponse'].freeze | ||||||
|  |  | ||||||
|  |   def create | ||||||
|  |     @responses = process_bulk_action | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   private | ||||||
|  |  | ||||||
|  |   def validate_params | ||||||
|  |     return if params[:type].present? && params[:ids].present? && params[:fields].present? | ||||||
|  |  | ||||||
|  |     render json: { success: false }, status: :unprocessable_entity | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def type_matches? | ||||||
|  |     return if MODEL_TYPE.include?(params[:type]) | ||||||
|  |  | ||||||
|  |     render json: { success: false }, status: :unprocessable_entity | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def process_bulk_action | ||||||
|  |     case params[:type] | ||||||
|  |     when 'AssistantResponse' | ||||||
|  |       handle_assistant_responses | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def handle_assistant_responses | ||||||
|  |     responses = Current.account.captain_assistant_responses.where(id: params[:ids]) | ||||||
|  |     return unless responses.exists? | ||||||
|  |  | ||||||
|  |     case params[:fields][:status] | ||||||
|  |     when 'approve' | ||||||
|  |       responses.pending.update(status: 'approved') | ||||||
|  |       responses | ||||||
|  |     when 'delete' | ||||||
|  |       responses.destroy_all | ||||||
|  |       [] | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def permitted_params | ||||||
|  |     params.permit(:type, ids: [], fields: [:status]) | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -1,7 +1,7 @@ | |||||||
| class Captain::Tools::FirecrawlService | class Captain::Tools::FirecrawlService | ||||||
|   def initialize |   def initialize | ||||||
|     @api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value |     @api_key = InstallationConfig.find_by!(name: 'CAPTAIN_FIRECRAWL_API_KEY').value | ||||||
|     raise 'Missing API key' if @api_key.nil? |     raise 'Missing API key' if @api_key.empty? | ||||||
|   end |   end | ||||||
|  |  | ||||||
|   def perform(url, webhook_url, crawl_limit = 10) |   def perform(url, webhook_url, crawl_limit = 10) | ||||||
|   | |||||||
| @@ -0,0 +1,3 @@ | |||||||
|  | json.array! @responses do |response| | ||||||
|  |   json.partial! 'api/v1/models/captain/assistant_response', formats: [:json], resource: response | ||||||
|  | end | ||||||
| @@ -0,0 +1,143 @@ | |||||||
|  | require 'rails_helper' | ||||||
|  |  | ||||||
|  | RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do | ||||||
|  |   let(:account) { create(:account) } | ||||||
|  |   let(:assistant) { create(:captain_assistant, account: account) } | ||||||
|  |   let(:admin) { create(:user, account: account, role: :administrator) } | ||||||
|  |   let(:agent) { create(:user, account: account, role: :agent) } | ||||||
|  |   let!(:pending_responses) do | ||||||
|  |     create_list( | ||||||
|  |       :captain_assistant_response, | ||||||
|  |       2, | ||||||
|  |       assistant: assistant, | ||||||
|  |       account: account, | ||||||
|  |       status: 'pending' | ||||||
|  |     ) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   def json_response | ||||||
|  |     JSON.parse(response.body, symbolize_names: true) | ||||||
|  |   end | ||||||
|  |  | ||||||
|  |   describe 'POST /api/v1/accounts/:account_id/captain/bulk_actions' do | ||||||
|  |     context 'when approving responses' do | ||||||
|  |       let(:valid_params) do | ||||||
|  |         { | ||||||
|  |           type: 'AssistantResponse', | ||||||
|  |           ids: pending_responses.map(&:id), | ||||||
|  |           fields: { status: 'approve' } | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'approves the responses and returns the updated records' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/captain/bulk_actions", | ||||||
|  |              params: valid_params, | ||||||
|  |              headers: admin.create_new_auth_token, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:ok) | ||||||
|  |         expect(json_response).to be_an(Array) | ||||||
|  |         expect(json_response.length).to eq(2) | ||||||
|  |  | ||||||
|  |         # Verify responses were approved | ||||||
|  |         pending_responses.each do |response| | ||||||
|  |           expect(response.reload.status).to eq('approved') | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when deleting responses' do | ||||||
|  |       let(:delete_params) do | ||||||
|  |         { | ||||||
|  |           type: 'AssistantResponse', | ||||||
|  |           ids: pending_responses.map(&:id), | ||||||
|  |           fields: { status: 'delete' } | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'deletes the responses and returns an empty array' do | ||||||
|  |         expect do | ||||||
|  |           post "/api/v1/accounts/#{account.id}/captain/bulk_actions", | ||||||
|  |                params: delete_params, | ||||||
|  |                headers: admin.create_new_auth_token, | ||||||
|  |                as: :json | ||||||
|  |         end.to change(Captain::AssistantResponse, :count).by(-2) | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:ok) | ||||||
|  |         expect(json_response).to eq([]) | ||||||
|  |  | ||||||
|  |         # Verify responses were deleted | ||||||
|  |         pending_responses.each do |response| | ||||||
|  |           expect { response.reload }.to raise_error(ActiveRecord::RecordNotFound) | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with invalid type' do | ||||||
|  |       let(:invalid_params) do | ||||||
|  |         { | ||||||
|  |           type: 'InvalidType', | ||||||
|  |           ids: pending_responses.map(&:id), | ||||||
|  |           fields: { status: 'approve' } | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns unprocessable entity status' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/captain/bulk_actions", | ||||||
|  |              params: invalid_params, | ||||||
|  |              headers: admin.create_new_auth_token, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unprocessable_entity) | ||||||
|  |         expect(json_response[:success]).to be(false) | ||||||
|  |  | ||||||
|  |         # Verify no changes were made | ||||||
|  |         pending_responses.each do |response| | ||||||
|  |           expect(response.reload.status).to eq('pending') | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with missing parameters' do | ||||||
|  |       let(:missing_params) do | ||||||
|  |         { | ||||||
|  |           type: 'AssistantResponse', | ||||||
|  |           fields: { status: 'approve' } | ||||||
|  |         } | ||||||
|  |       end | ||||||
|  |  | ||||||
|  |       it 'returns unprocessable entity status' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/captain/bulk_actions", | ||||||
|  |              params: missing_params, | ||||||
|  |              headers: admin.create_new_auth_token, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unprocessable_entity) | ||||||
|  |         expect(json_response[:success]).to be(false) | ||||||
|  |  | ||||||
|  |         # Verify no changes were made | ||||||
|  |         pending_responses.each do |response| | ||||||
|  |           expect(response.reload.status).to eq('pending') | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'with unauthorized user' do | ||||||
|  |       let(:unauthorized_user) { create(:user, account: create(:account)) } | ||||||
|  |  | ||||||
|  |       it 'returns unauthorized status' do | ||||||
|  |         post "/api/v1/accounts/#{account.id}/captain/bulk_actions", | ||||||
|  |              params: { type: 'AssistantResponse', ids: [1], fields: { status: 'approve' } }, | ||||||
|  |              headers: unauthorized_user.create_new_auth_token, | ||||||
|  |              as: :json | ||||||
|  |  | ||||||
|  |         expect(response).to have_http_status(:unauthorized) | ||||||
|  |  | ||||||
|  |         # Verify no changes were made | ||||||
|  |         pending_responses.each do |response| | ||||||
|  |           expect(response.reload.status).to eq('pending') | ||||||
|  |         end | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |   end | ||||||
|  | end | ||||||
| @@ -32,6 +32,16 @@ RSpec.describe Captain::Tools::FirecrawlService do | |||||||
|         InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil) |         InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: nil) | ||||||
|       end |       end | ||||||
|  |  | ||||||
|  |       it 'raises an error' do | ||||||
|  |         expect { described_class.new }.to raise_error(NoMethodError) | ||||||
|  |       end | ||||||
|  |     end | ||||||
|  |  | ||||||
|  |     context 'when API key is empty' do | ||||||
|  |       before do | ||||||
|  |         InstallationConfig.find_by(name: 'CAPTAIN_FIRECRAWL_API_KEY').update(value: '') | ||||||
|  |       end | ||||||
|  |  | ||||||
|       it 'raises an error' do |       it 'raises an error' do | ||||||
|         expect { described_class.new }.to raise_error('Missing API key') |         expect { described_class.new }.to raise_error('Missing API key') | ||||||
|       end |       end | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user
	 Sivin Varghese
					Sivin Varghese