feat(projects-bulk): wire 3 new dialogs into BulkActionsBar

Add RegionsBulkDialog / DaysBulkDialog / LimitBulkDialog to
BulkActionsBar with open-state refs (regionsOpen/daysOpen/limitOpen),
runBulk helper via store.bulkUpdate, and flex-wrap layout.
Update spec: fix existing tests (bulkAction → bulkUpdate), add 3 new
dialog-wiring tests (7/7 pass; full suite 614+3skipped/0failed).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-12 15:23:29 +03:00
parent 4b6ab8f113
commit df92ac02ff
2 changed files with 138 additions and 11 deletions
@@ -1,40 +1,91 @@
<template>
<v-card class="bulk-actions-bar" elevation="6">
<DevIndexBadge :index="20" label="BulkActionsBar" :dialog-mode="true" style="top: 4px; right: 4px;" />
<v-card-text class="d-flex align-center gap-3">
<v-card-text class="d-flex align-center gap-3 flex-wrap">
<strong>Выбрано: {{ store.selectedIds.size }}</strong>
<v-divider vertical />
<v-btn color="primary" variant="outlined" data-testid="bulk-regions" @click="regionsOpen = true">
🌍 Регионы
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-days" @click="daysOpen = true">
📅 Дни сбора
</v-btn>
<v-btn color="primary" variant="outlined" data-testid="bulk-limit" @click="limitOpen = true">
🎯 Лимит лидов
</v-btn>
<v-divider vertical />
<v-btn color="warning" prepend-icon="mdi-pause" data-testid="bulk-pause" @click="confirmAndRun('pause')">
Приостановить
</v-btn>
<v-btn color="success" prepend-icon="mdi-play" data-testid="bulk-resume" @click="confirmAndRun('resume')">
Возобновить
</v-btn>
<v-divider vertical />
<v-btn color="error" prepend-icon="mdi-archive" data-testid="bulk-archive" @click="confirmAndRun('archive')">
Архивировать
</v-btn>
<v-spacer />
<v-btn variant="text" data-testid="bulk-clear" @click="store.clearSelection">Снять выбор</v-btn>
</v-card-text>
<RegionsBulkDialog
v-model="regionsOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_regions', ...p })"
/>
<DaysBulkDialog
v-model="daysOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_days', ...p })"
/>
<LimitBulkDialog
v-model="limitOpen"
:count="store.selectedIds.size"
@apply="(p) => runBulk({ action: 'update_limit', ...p })"
/>
</v-card>
</template>
<script setup lang="ts">
import { ref } from 'vue';
import { useProjectsStore } from '../../stores/projectsStore';
import DevIndexBadge from '../DevIndexBadge.vue';
import RegionsBulkDialog from './RegionsBulkDialog.vue';
import DaysBulkDialog from './DaysBulkDialog.vue';
import LimitBulkDialog from './LimitBulkDialog.vue';
const store = useProjectsStore();
const regionsOpen = ref(false);
const daysOpen = ref(false);
const limitOpen = ref(false);
const messages: Record<string, string> = {
pause: 'Приостановить выбранные проекты?',
resume: 'Возобновить выбранные проекты?',
archive: 'Архивировать выбранные проекты?\nДействие необратимо в Plan 5 (восстановление потребует ручного запроса).',
};
function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
async function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
if (!window.confirm(messages[action])) return;
store.bulkAction(action);
await runBulk({ action });
}
async function runBulk(payload: Parameters<typeof store.bulkUpdate>[0]) {
const result = await store.bulkUpdate(payload);
if (result.skipped.length > 0) {
window.alert(`Применено: ${result.updated}. Пропущено: ${result.skipped.length} (конфликт с уже доставленными лидами).`);
}
}
defineExpose({ regionsOpen, daysOpen, limitOpen });
</script>
<style scoped>
@@ -44,5 +95,6 @@ function confirmAndRun(action: 'pause' | 'resume' | 'archive') {
left: 50%;
transform: translateX(-50%);
z-index: 10;
max-width: calc(100vw - 48px);
}
</style>
+83 -8
View File
@@ -2,14 +2,31 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
import { mount } from '@vue/test-utils';
import { createPinia, setActivePinia } from 'pinia';
import { createVuetify } from 'vuetify';
import axios from 'axios';
import BulkActionsBar from '../../resources/js/components/projects/BulkActionsBar.vue';
import { useProjectsStore } from '../../resources/js/stores/projectsStore';
const factory = () =>
mount(BulkActionsBar, {
global: { plugins: [createVuetify()] },
vi.mock('axios');
const defaultStubs = {
VDialog: { template: '<div><slot /></div>' },
DevIndexBadge: true,
RegionsBulkDialog: true,
DaysBulkDialog: true,
LimitBulkDialog: true,
};
const factory = () => {
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 0, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
return mount(BulkActionsBar, {
global: {
plugins: [createVuetify()],
stubs: defaultStubs,
},
});
};
beforeEach(() => {
setActivePinia(createPinia());
@@ -25,20 +42,20 @@ describe('BulkActionsBar', () => {
expect(wrapper.text()).toContain('2');
});
it('clicking Pause triggers store.bulkAction("pause") after confirm', async () => {
it('clicking Pause triggers store.bulkUpdate("pause") after confirm', async () => {
const store = useProjectsStore();
store.selectedIds.add(1);
const spy = vi.spyOn(store, 'bulkAction').mockResolvedValue();
const spy = vi.spyOn(store, 'bulkUpdate').mockResolvedValue({ updated: 1, skipped: [], warnings: [] });
window.confirm = vi.fn(() => true);
const wrapper = factory();
await wrapper.find('[data-testid="bulk-pause"]').trigger('click');
expect(spy).toHaveBeenCalledWith('pause');
expect(spy).toHaveBeenCalledWith({ action: 'pause' });
});
it('cancel confirm — bulkAction NOT called', async () => {
it('cancel confirm — bulkUpdate NOT called', async () => {
const store = useProjectsStore();
store.selectedIds.add(1);
const spy = vi.spyOn(store, 'bulkAction').mockResolvedValue();
const spy = vi.spyOn(store, 'bulkUpdate').mockResolvedValue({ updated: 0, skipped: [], warnings: [] });
window.confirm = vi.fn(() => false);
const wrapper = factory();
await wrapper.find('[data-testid="bulk-pause"]').trigger('click');
@@ -53,3 +70,61 @@ describe('BulkActionsBar', () => {
expect(store.selectedIds.size).toBe(0);
});
});
describe('BulkActionsBar — extended', () => {
it('renders 3 new dialog-open buttons (Regions/Days/Limit)', async () => {
const { wrapper } = await (async () => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
const store = useProjectsStore();
store.selectedIds.add(1);
store.selectedIds.add(2);
const wrapper = mount(BulkActionsBar, {
global: {
plugins: [createVuetify()],
stubs: defaultStubs,
},
});
return { wrapper, store };
})();
expect(wrapper.find('[data-testid="bulk-regions"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-days"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-limit"]').exists()).toBe(true);
});
it('opens RegionsBulkDialog on regions button click', async () => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
const store = useProjectsStore();
store.selectedIds.add(1);
store.selectedIds.add(2);
const wrapper = mount(BulkActionsBar, {
global: {
plugins: [createVuetify()],
stubs: defaultStubs,
},
});
await wrapper.find('[data-testid="bulk-regions"]').trigger('click');
expect((wrapper.vm as any).regionsOpen).toBe(true);
});
it('keeps existing pause/resume/archive buttons', async () => {
setActivePinia(createPinia());
vi.mocked(axios.post).mockResolvedValue({ data: { updated: 1, skipped: [], warnings: [] } });
vi.mocked(axios.get).mockResolvedValue({ data: { data: [], meta: { total: 0 } } });
const store = useProjectsStore();
store.selectedIds.add(1);
store.selectedIds.add(2);
const wrapper = mount(BulkActionsBar, {
global: {
plugins: [createVuetify()],
stubs: defaultStubs,
},
});
expect(wrapper.find('[data-testid="bulk-pause"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-resume"]').exists()).toBe(true);
expect(wrapper.find('[data-testid="bulk-archive"]').exists()).toBe(true);
});
});