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 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"

View File

@@ -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`));

View File

@@ -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."

View File

@@ -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>

View File

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

View File

@@ -361,7 +361,7 @@ onMounted(() => {
v-if="bulkSelectedIds"
ref="bulkDeleteDialog"
:bulk-ids="bulkSelectedIds"
type="Responses"
type="AssistantResponse"
@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', {
type: 'AssistantResponse',
type,
actionType: 'delete',
ids,
});
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;
},

View File

@@ -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);
},
}),
});

View File

@@ -4,7 +4,7 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
before_action :validate_params
before_action :type_matches?
MODEL_TYPE = ['AssistantResponse'].freeze
MODEL_TYPE = %w[AssistantResponse AssistantDocument].freeze
def create
@responses = process_bulk_action
@@ -28,6 +28,8 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
case params[:type]
when 'AssistantResponse'
handle_assistant_responses
when 'AssistantDocument'
handle_documents
end
end
@@ -45,6 +47,16 @@ class Api::V1::Accounts::Captain::BulkActionsController < Api::V1::Accounts::Bas
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
params.permit(:type, ids: [], fields: [:status])
end

View File

@@ -14,6 +14,14 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
status: 'pending'
)
end
let!(:documents) do
create_list(
:captain_document,
2,
assistant: assistant,
account: account
)
end
def json_response
JSON.parse(response.body, symbolize_names: true)
@@ -98,6 +106,28 @@ RSpec.describe 'Api::V1::Accounts::Captain::BulkActions', type: :request do
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
let(:missing_params) do
{