feat: support bulk select and delete for documents (#13907)

This commit is contained in:
Sivin Varghese
2026-03-26 19:48:12 +05:30
committed by GitHub
parent 4c4b70da25
commit 4517c50227
10 changed files with 224 additions and 18 deletions

View File

@@ -12,6 +12,7 @@ import {
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';
const props = defineProps({ const props = defineProps({
id: { id: {
@@ -34,14 +35,34 @@ const props = defineProps({
type: Number, type: Number,
required: true, 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 { checkPermissions } = usePolicy();
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 menuItems = computed(() => { const menuItems = computed(() => {
const allOptions = [ const allOptions = [
@@ -79,12 +100,23 @@ const handleAction = ({ action, value }) => {
</script> </script>
<template> <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"> <div class="flex gap-1 justify-between w-full">
<span class="text-base text-n-slate-12 line-clamp-1"> <span class="text-base text-n-slate-12 line-clamp-1">
{{ name }} {{ name }}
</span> </span>
<div class="flex gap-2 items-center"> <div v-if="showMenu" class="flex gap-2 items-center">
<div <div
v-on-clickaway="() => toggleDropdown(false)" v-on-clickaway="() => toggleDropdown(false)"
class="flex relative items-center group" class="flex relative items-center group"

View File

@@ -21,16 +21,22 @@ const emit = defineEmits(['deleteSuccess']);
const { t } = useI18n(); const { t } = useI18n();
const store = useStore(); const store = useStore();
const bulkDeleteDialogRef = ref(null); 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 => { const handleBulkDelete = async ids => {
if (!ids) return; if (!ids) return;
try { try {
await store.dispatch( await store.dispatch('captainBulkActions/handleBulkDelete', {
'captainBulkActions/handleBulkDelete', ids: Array.from(props.bulkIds),
Array.from(props.bulkIds) type: props.type,
); });
emit('deleteSuccess'); emit('deleteSuccess');
useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`)); useAlert(t(`CAPTAIN.${i18nKey.value}.BULK_DELETE.SUCCESS_MESSAGE`));

View File

@@ -738,6 +738,17 @@
"DOCUMENTS": { "DOCUMENTS": {
"HEADER": "Documents", "HEADER": "Documents",
"ADD_NEW": "Create a new document", "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": { "RELATED_RESPONSES": {
"TITLE": "Related FAQs", "TITLE": "Related FAQs",
"DESCRIPTION": "These FAQs are generated directly from the document." "DESCRIPTION": "These FAQs are generated directly from the document."

View File

@@ -4,9 +4,13 @@ import { useMapGetter, useStore } from 'dashboard/composables/store';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import { FEATURE_FLAGS } from 'dashboard/featureFlags'; import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { useAccount } from 'dashboard/composables/useAccount'; import { useAccount } from 'dashboard/composables/useAccount';
import { usePolicy } from 'dashboard/composables/usePolicy';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
import DocumentCard from 'dashboard/components-next/captain/assistant/DocumentCard.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 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 RelatedResponses from 'dashboard/components-next/captain/pageComponents/document/RelatedResponses.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 DocumentPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/DocumentPageEmptyState.vue';
import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue'; import FeatureSpotlightPopover from 'dashboard/components-next/feature-spotlight/FeatureSpotlightPopover.vue';
import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue'; import LimitBanner from 'dashboard/components-next/captain/pageComponents/document/LimitBanner.vue';
import { useI18n } from 'vue-i18n';
const route = useRoute(); const route = useRoute();
const store = useStore(); const store = useStore();
const { t } = useI18n();
const { checkPermissions } = usePolicy();
const { isOnChatwootCloud } = useAccount(); const { isOnChatwootCloud } = useAccount();
const uiFlags = useMapGetter('captainDocuments/getUIFlags'); const uiFlags = useMapGetter('captainDocuments/getUIFlags');
@@ -25,9 +32,13 @@ const isFetching = computed(() => uiFlags.value.fetchingList);
const documentsMeta = useMapGetter('captainDocuments/getMeta'); const documentsMeta = useMapGetter('captainDocuments/getMeta');
const selectedAssistantId = computed(() => Number(route.params.assistantId)); const selectedAssistantId = computed(() => Number(route.params.assistantId));
const canManageDocuments = computed(() => checkPermissions(['administrator']));
const selectedDocument = ref(null); const selectedDocument = ref(null);
const deleteDocumentDialog = ref(null); const deleteDocumentDialog = ref(null);
const bulkDeleteDialog = ref(null);
const bulkSelectedIds = ref(new Set());
const hoveredCard = ref(null);
const handleDelete = () => { const handleDelete = () => {
deleteDocumentDialog.value.dialogRef.open(); deleteDocumentDialog.value.dialogRef.open();
@@ -78,7 +89,14 @@ const fetchDocuments = (page = 1) => {
store.dispatch('captainDocuments/get', filterParams); 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 = () => { const onDeleteSuccess = () => {
if (documents.value?.length === 0 && documentsMeta.value?.page > 1) { 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(() => { onMounted(() => {
fetchDocuments(); fetchDocuments();
}); });
@@ -106,6 +176,21 @@ onMounted(() => {
@update:current-page="onPageChange" @update:current-page="onPageChange"
@click="handleCreateDocument" @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> <template #knowMore>
<FeatureSpotlightPopover <FeatureSpotlightPopover
:button-label="$t('CAPTAIN.HEADER_KNOW_MORE')" :button-label="$t('CAPTAIN.HEADER_KNOW_MORE')"
@@ -138,7 +223,13 @@ onMounted(() => {
:external-link="doc.external_link" :external-link="doc.external_link"
:assistant="doc.assistant" :assistant="doc.assistant"
:created-at="doc.created_at" :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" @action="handleAction"
@select="handleCardSelect"
@hover="isHovered => handleCardHover(isHovered, doc.id)"
/> />
</div> </div>
</template> </template>
@@ -162,5 +253,12 @@ onMounted(() => {
type="Documents" type="Documents"
@delete-success="onDeleteSuccess" @delete-success="onDeleteSuccess"
/> />
<BulkDeleteDialog
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="AssistantDocument"
@delete-success="onBulkDeleteSuccess"
/>
</PageLayout> </PageLayout>
</template> </template>

View File

@@ -316,7 +316,7 @@ onMounted(() => {
v-if="bulkSelectedIds" v-if="bulkSelectedIds"
ref="bulkDeleteDialog" ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds" :bulk-ids="bulkSelectedIds"
type="Responses" type="AssistantResponse"
@delete-success="onBulkDeleteSuccess" @delete-success="onBulkDeleteSuccess"
/> />

View File

@@ -361,7 +361,7 @@ onMounted(() => {
v-if="bulkSelectedIds" v-if="bulkSelectedIds"
ref="bulkDeleteDialog" ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds" :bulk-ids="bulkSelectedIds"
type="Responses" type="AssistantResponse"
@delete-success="onBulkDeleteSuccess" @delete-success="onBulkDeleteSuccess"
/> />

View File

@@ -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', { const response = await dispatch('processBulkAction', {
type: 'AssistantResponse', type,
actionType: 'delete', actionType: 'delete',
ids, ids,
}); });
if (type === 'AssistantResponse') {
// Update the response store after successful API call // Update the response store after successful API call
await dispatch('captainResponses/removeBulkResponses', ids, { await dispatch('captainResponses/removeBulkResponses', ids, {
root: true, root: true,
}); });
} else if (type === 'AssistantDocument') {
await dispatch('captainDocuments/removeBulkRecords', ids, {
root: true,
});
}
return response; return response;
}, },

View File

@@ -4,4 +4,12 @@ import { createStore } from '../storeFactory';
export default createStore({ export default createStore({
name: 'CaptainDocument', name: 'CaptainDocument',
API: CaptainDocumentAPI, API: CaptainDocumentAPI,
actions: mutations => ({
removeBulkRecords({ commit, getters }, ids) {
const records = getters.getRecords.filter(
record => !ids.includes(record.id)
);
commit(mutations.SET, records);
},
}),
}); });

View File

@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
before_action :validate_params before_action :validate_params
before_action :type_matches? before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create def create
@responses = process_bulk_action @responses = process_bulk_action
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
case params[:type] case params[:type]
when 'AssistantResponse' when 'AssistantResponse'
handle_assistant_responses handle_assistant_responses
when 'AssistantDocument'
handle_documents
end end
end end
@@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
end end
end end
def handle_documents
return [] unless params[:fields][:status] == 'delete'
documents = Current.account.captain_documents.where(id: params[:ids])
return [] unless documents.exists?
documents.destroy_all
[]
end
def permitted_params def permitted_params
params.permit(:type, ids: [], fields: [:status]) params.permit(:type, ids: [], fields: [:status])
end end

View File

@@ -14,6 +14,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
status: 'pending' status: 'pending'
) )
end end
let!(:documents) do
create_list(
:captain_document,
2,
assistant: assistant,
account: account
)
end
def json_response def json_response
JSON.parse(response.body, symbolize_names: true) JSON.parse(response.body, symbolize_names: true)
@@ -98,6 +106,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
end end
end end
context 'when deleting documents' do
let(:document_delete_params) do
{
type: 'AssistantDocument',
ids: documents.map(&:id),
fields: { status: 'delete' }
}
end
it 'deletes the documents and returns an empty array' do
expect do
post "/api/v1/accounts/#{account.id}/captain/bulk_actions",
params: document_delete_params,
headers: admin.create_new_auth_token,
as: :json
end.to change(Captain::Document, :count).by(-2)
expect(response).to have_http_status(:ok)
expect(json_response).to eq([])
end
end
context 'with missing parameters' do context 'with missing parameters' do
let(:missing_params) do let(:missing_params) do
{ {