refactor(deals): C2 review fixes — watcher-driven draft + named toggles

Code-review fixes для commit 4e77947 (C2 FilterChip popovers):

I-4 (latent interaction bug): Удалена двойная open-path в FilterChip
activator. v-menu сам управляет projectMenuOpen/managerMenuOpen через
activatorProps. Draft-state копируется при menu open → true через
watch(menuOpen, ...). Раньше:
- Activator click: menuOpen=true
- @click on FilterChip: onRedesignFilterClick → menuOpen=true (duplicate)
- Re-click для close: activator toggles false → onRedesignFilterClick
  forces true back → menu не закрывается.

I-2 (inline toggle extract): Multi-line ternary @click заменён на
named methods toggleProjectDraft(proj) / toggleManagerDraft(name).
Консистентно с existing clearProjectDraft / clearManagerDraft. Также
unit-testable независимо от template.

onRedesignFilterClick остаётся для Status chip read-only behavior (P2
backlog Sprint 5). defineExpose обновлён: убран onRedesignFilterClick,
добавлены toggleProjectDraft/toggleManagerDraft/clearProjectDraft/
clearManagerDraft (для будущих spec'ов).

Vitest 3/3 C2-specs обновлены на прямой trigger projectMenuOpen=true
+ $nextTick (watcher seeds draft). Регрессий 0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 08:43:49 +03:00
parent 4e779471fd
commit c09c52ea76
2 changed files with 45 additions and 26 deletions
+36 -23
View File
@@ -13,7 +13,7 @@
*
* Источник статусов — composables/leadStatuses.ts (snapshot из db/schema.sql:2130).
*/
import { computed, defineAsyncComponent, onMounted, reactive, ref } from 'vue';
import { computed, defineAsyncComponent, onMounted, reactive, ref, watch } from 'vue';
import { DEALS_TABS, MOCK_DEALS, type MockDeal } from '../composables/mockDeals';
import { mapApiDeal } from '../composables/dealsApiMapper';
import { usePolling } from '../composables/usePolling';
@@ -38,22 +38,30 @@ import { buildCsvString, triggerBlobDownload, triggerCsvDownload } from '../comp
const { rowHeight } = useDensity();
// Sprint 1 C2: popovers для Проект/Менеджер chip'ов. Draft-state накапливает
// выбор в v-menu, при «Применить» переносится в filterProjects/filterManagers.
// Status chip остаётся read-only (P2 backlog в Sprint 5).
// выбор внутри v-menu; копируется из filterProjects/filterManagers при открытии
// (watch на menu open=true); переносится обратно в filterProjects/Managers на
// «Применить» button. Status chip — read-only (P2 backlog Sprint 5).
const projectMenuOpen = ref(false);
const managerMenuOpen = ref(false);
const projectMenuDraft = ref<string[]>([]);
const managerMenuDraft = ref<string[]>([]);
// При открытии меню — копируем текущий filter в draft (snapshot-on-open).
// При закрытии без apply — draft остаётся, но не влияет на filterProjects
// (apply нужен явно).
watch(projectMenuOpen, (isOpen) => {
if (isOpen) projectMenuDraft.value = [...filterProjects.value];
});
watch(managerMenuOpen, (isOpen) => {
if (isOpen) managerMenuDraft.value = [...filterManagers.value];
});
function onRedesignFilterClick(name: string): void {
if (name === 'Проект') {
projectMenuDraft.value = [...filterProjects.value];
projectMenuOpen.value = true;
} else if (name === 'Менеджер') {
managerMenuDraft.value = [...filterManagers.value];
managerMenuOpen.value = true;
// Status chip — read-only summary (P2 backlog Sprint 5).
// Project/Manager managed by v-menu activator (no manual click handler needed).
if (name === 'Статус') {
// no-op — placeholder для будущей реализации
}
// 'Статус' — read-only summary, popover откроется в Sprint 5 (P2).
}
function applyProjectFilter(): void {
@@ -74,6 +82,18 @@ function clearManagerDraft(): void {
managerMenuDraft.value = [];
}
function toggleProjectDraft(proj: string): void {
projectMenuDraft.value = projectMenuDraft.value.includes(proj)
? projectMenuDraft.value.filter((p) => p !== proj)
: [...projectMenuDraft.value, proj];
}
function toggleManagerDraft(name: string): void {
managerMenuDraft.value = managerMenuDraft.value.includes(name)
? managerMenuDraft.value.filter((m) => m !== name)
: [...managerMenuDraft.value, name];
}
const auth = useAuthStore();
const leadStatusesStore = useLeadStatusesStore();
@@ -386,7 +406,10 @@ defineExpose({
managerMenuDraft,
applyProjectFilter,
applyManagerFilter,
onRedesignFilterClick,
clearProjectDraft,
clearManagerDraft,
toggleProjectDraft,
toggleManagerDraft,
});
const leadStatuses = computed(() => leadStatusesStore.statuses);
@@ -518,7 +541,6 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
label="Проект"
:count="filterProjects.length"
:active="filterProjects.length > 0"
@click="onRedesignFilterClick('Проект')"
/>
</span>
</template>
@@ -532,11 +554,7 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
v-for="proj in availableProjects"
:key="proj"
class="py-1"
@click="
projectMenuDraft.includes(proj)
? (projectMenuDraft = projectMenuDraft.filter((p) => p !== proj))
: (projectMenuDraft = [...projectMenuDraft, proj])
"
@click="toggleProjectDraft(proj)"
>
<template #prepend>
<v-checkbox-btn :model-value="projectMenuDraft.includes(proj)" />
@@ -569,7 +587,6 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
label="Менеджер"
:count="filterManagers.length"
:active="filterManagers.length > 0"
@click="onRedesignFilterClick('Менеджер')"
/>
</span>
</template>
@@ -583,11 +600,7 @@ const waitingPay = computed(() => dealsState.filter((d) => d.statusSlug === 'wai
v-for="mgr in availableManagers"
:key="mgr.name"
class="py-1"
@click="
managerMenuDraft.includes(mgr.name)
? (managerMenuDraft = managerMenuDraft.filter((m) => m !== mgr.name))
: (managerMenuDraft = [...managerMenuDraft, mgr.name])
"
@click="toggleManagerDraft(mgr.name)"
>
<template #prepend>
<v-checkbox-btn :model-value="managerMenuDraft.includes(mgr.name)" />
+9 -3
View File
@@ -67,8 +67,9 @@ describe('FilterChip popovers (Sprint 1 C2)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.projectMenuOpen).toBe(false);
// Trigger chip click via exposed handler
vm.onRedesignFilterClick('Проект');
// Trigger via direct ref assignment (v-menu activator manages this ref).
// watch(projectMenuOpen) will seed projectMenuDraft on open=true.
vm.projectMenuOpen = true;
await wrapper.vm.$nextTick();
expect(vm.projectMenuOpen).toBe(true);
});
@@ -84,7 +85,9 @@ describe('FilterChip popovers (Sprint 1 C2)', () => {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
expect(vm.managerMenuOpen).toBe(false);
vm.onRedesignFilterClick('Менеджер');
// Trigger via direct ref assignment (v-menu activator manages this ref).
// watch(managerMenuOpen) will seed managerMenuDraft on open=true.
vm.managerMenuOpen = true;
await wrapper.vm.$nextTick();
expect(vm.managerMenuOpen).toBe(true);
});
@@ -99,6 +102,9 @@ describe('FilterChip popovers (Sprint 1 C2)', () => {
await new Promise((r) => setTimeout(r, 50));
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const vm = wrapper.vm as any;
// Open menu (watch seeds draft from filterProjects), then override draft manually.
vm.projectMenuOpen = true;
await wrapper.vm.$nextTick();
vm.projectMenuDraft = ['demo-project-1', 'demo-project-2'];
vm.applyProjectFilter();
await wrapper.vm.$nextTick();