mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-30 18:47:51 +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,
|
||||
default: 'col',
|
||||
},
|
||||
selectable: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
});
|
||||
|
||||
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"
|
||||
>
|
||||
<div
|
||||
class="flex w-full gap-3 px-6 py-5"
|
||||
:class="
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center'
|
||||
"
|
||||
class="flex w-full gap-3 py-5"
|
||||
:class="[
|
||||
layout === 'col' ? 'flex-col' : 'flex-row justify-between items-center',
|
||||
selectable ? 'px-10 py-6' : 'px-6',
|
||||
]"
|
||||
@click="handleClick"
|
||||
>
|
||||
<slot />
|
||||
|
||||
@@ -7,6 +7,7 @@ import { dynamicTime } from 'shared/helpers/timeHelper';
|
||||
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';
|
||||
import Policy from 'dashboard/components/policy.vue';
|
||||
|
||||
const props = defineProps({
|
||||
@@ -46,14 +47,27 @@ const props = defineProps({
|
||||
type: Number,
|
||||
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 [showActionsDropdown, toggleDropdown] = useToggle();
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.isSelected,
|
||||
set: () => emit('select', props.id),
|
||||
});
|
||||
|
||||
const statusAction = computed(() => {
|
||||
if (props.status === 'pending') {
|
||||
return [
|
||||
@@ -102,8 +116,17 @@ const handleDocumentableClick = () => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<CardLayout :class="{ 'rounded-md': compact }">
|
||||
<div class="flex justify-between w-full gap-1">
|
||||
<CardLayout
|
||||
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">
|
||||
{{ question }}
|
||||
</span>
|
||||
@@ -148,7 +171,7 @@ const handleDocumentableClick = () => {
|
||||
v-if="documentable.type === 'Captain::Document'"
|
||||
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">
|
||||
{{ documentable.name }}
|
||||
</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": {
|
||||
"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": {
|
||||
"TITLE": "Are you sure to delete the FAQ?",
|
||||
"DESCRIPTION": "",
|
||||
|
||||
@@ -8,8 +8,10 @@ import { useRouter } from 'vue-router';
|
||||
import { FEATURE_FLAGS } from 'dashboard/featureFlags';
|
||||
|
||||
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 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 CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.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 deleteDialog = ref(null);
|
||||
const bulkDeleteDialog = ref(null);
|
||||
|
||||
const selectedStatus = ref('all');
|
||||
const selectedAssistant = ref('all');
|
||||
@@ -129,7 +132,69 @@ const fetchResponses = (page = 1) => {
|
||||
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 = () => {
|
||||
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 }) => {
|
||||
selectedStatus.value = value;
|
||||
isStatusFilterOpen.value = false;
|
||||
@@ -177,29 +256,76 @@ onMounted(() => {
|
||||
</template>
|
||||
|
||||
<template #controls>
|
||||
<div v-if="shouldShowDropdown" class="mb-4 -mt-3 flex gap-3">
|
||||
<OnClickOutside @trigger="isStatusFilterOpen = false">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
<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">
|
||||
<Button
|
||||
:label="selectedStatusLabel"
|
||||
icon="i-lucide-chevron-down"
|
||||
size="sm"
|
||||
color="slate"
|
||||
trailing-icon
|
||||
class="max-w-48"
|
||||
@click="isStatusFilterOpen = !isStatusFilterOpen"
|
||||
/>
|
||||
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
<DropdownMenu
|
||||
v-if="isStatusFilterOpen"
|
||||
:menu-items="statusOptions"
|
||||
class="mt-2"
|
||||
@action="handleStatusFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</OnClickOutside>
|
||||
<AssistantSelector
|
||||
:assistant-id="selectedAssistant"
|
||||
@update="handleAssistantFilterChange"
|
||||
/>
|
||||
</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>
|
||||
|
||||
@@ -218,8 +344,12 @@ onMounted(() => {
|
||||
:status="response.status"
|
||||
:created-at="response.created_at"
|
||||
:updated-at="response.updated_at"
|
||||
:is-selected="bulkSelectedIds.has(response.id)"
|
||||
:selectable="hoveredCard === response.id || bulkSelectedIds.size > 0"
|
||||
@action="handleAction"
|
||||
@navigate="handleNavigationAction"
|
||||
@select="handleCardSelect"
|
||||
@hover="isHovered => handleCardHover(isHovered, response.id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
@@ -232,6 +362,14 @@ onMounted(() => {
|
||||
@delete-success="onDeleteSuccess"
|
||||
/>
|
||||
|
||||
<BulkDeleteDialog
|
||||
v-if="bulkSelectedIds"
|
||||
ref="bulkDeleteDialog"
|
||||
:bulk-ids="bulkSelectedIds"
|
||||
type="Responses"
|
||||
@delete-success="onBulkDeleteSuccess"
|
||||
/>
|
||||
|
||||
<CreateResponseDialog
|
||||
v-if="dialogType"
|
||||
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({
|
||||
name: 'CaptainResponse',
|
||||
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 captainResponses from './captain/response';
|
||||
import captainInboxes from './captain/inboxes';
|
||||
import captainBulkActions from './captain/bulkActions';
|
||||
const plugins = [];
|
||||
|
||||
export default createStore({
|
||||
@@ -104,6 +105,7 @@ export default createStore({
|
||||
captainDocuments,
|
||||
captainResponses,
|
||||
captainInboxes,
|
||||
captainBulkActions,
|
||||
},
|
||||
plugins,
|
||||
});
|
||||
|
||||
@@ -54,6 +54,7 @@ Rails.application.routes.draw do
|
||||
end
|
||||
resources :documents, only: [:index, :show, :create, :destroy]
|
||||
resources :assistant_responses
|
||||
resources :bulk_actions, only: [:create]
|
||||
end
|
||||
resources :agent_bots, only: [:index, :create, :show, :update, :destroy] do
|
||||
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
|
||||
def initialize
|
||||
@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
|
||||
|
||||
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)
|
||||
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
|
||||
expect { described_class.new }.to raise_error('Missing API key')
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user