docs(spec): PDD regions field — autocomplete + bitmask binding
In-place port региона multi-select autocomplete в ProjectDetailsDrawer. Закрывает Out-of-plan «Region multi-select autocomplete» из parent spec (2026-05-14-project-details-drawer-design.md §7). Подход A (утверждён 2026-05-14): - v-autocomplete :items="REGIONS.filter(r => r.code !== 0)" (без sentinel) - reverse-decompose existing region_mask в codes[] при reseedFromProject - watch selectedRegions → encode mask + mode (include когда пусто, exclude иначе) - 3 новых vitest case: render chips / select-encodes / clear-resets Backend без изменений (region_mask + region_mode payload уже в Task 5 onSave). Backport reverse-decompose в NewProjectDialog (TODO line 172) — out of scope. cspell-words.txt +1 (иммутабельны).
This commit is contained in:
@@ -1144,3 +1144,4 @@ qitem
|
||||
skreview
|
||||
юнит
|
||||
pdd
|
||||
иммутабельны
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
# Design Spec: ProjectDetailsDrawer — поле «Регионы»
|
||||
|
||||
**Дата:** 2026-05-14 (вечер)
|
||||
**Триггер:** Запрос заказчика после visual smoke ProjectDetailsDrawer (screenshot 14.05 вечер) — добавить редактирование региона в drawer-форму.
|
||||
**Текущее состояние:** ProjectDetailsDrawer MVP shipped (commits `9d88955..0d7f505`), редактирует name/limit/days. `region_mask` + `region_mode` отправляются в PATCH payload (Task 5 уже их включает), но UI-контрола нет — поля иммутабельны для пользователя.
|
||||
**Связано:** [PDD design spec §7](./2026-05-14-project-details-drawer-design.md) Out-of-plan пункт "Region multi-select autocomplete" — этой spec'ой закрывается.
|
||||
|
||||
---
|
||||
|
||||
## 1. Поведение (UX-контракт)
|
||||
|
||||
| Состояние project | `selectedRegions[]` в UI | `form.region_mask` | `form.region_mode` |
|
||||
|---|---|---|---|
|
||||
| `region_mask=0` (вся РФ по умолчанию) | пустой массив | 0 | `'include'` |
|
||||
| `region_mask=6` (bits 1+2: Адыгея + Башкортостан) | `[1, 2]` → 2 chips | 6 | (legacy: сохраняем `include` или `exclude` как пришло) |
|
||||
| User добавляет регион (например code:3) | `[1, 2, 3]` → 3 chips | encoded: 14 (bits 1+2+3) | `'exclude'` |
|
||||
| User снимает все | `[]` → 0 chips | 0 | `'include'` |
|
||||
|
||||
**Семантика бэкенда (legacy за пределы Task 5):** `region_mode='include'+mask=0` ≡ «принимать всю РФ»; `region_mode='exclude'+mask≠0` ≡ «принимать всю РФ, КРОМЕ перечисленных». Эта конвенция установлена в [NewProjectDialog.vue:141-153](../../../app/resources/js/views/projects/NewProjectDialog.vue#L141) и сохраняется без изменений.
|
||||
|
||||
**Edge case:** project уже сохранён с `region_mode='include'+mask≠0` (legacy/inconsistent данные) — UI decompose'ит mask в codes, показывает chips. Если пользователь нажмёт «сохранить» — watch перепишет mode='exclude'. Это normalization (не deletion) — приемлемо.
|
||||
|
||||
---
|
||||
|
||||
## 2. Архитектура
|
||||
|
||||
**Подход A: In-place port** (утверждён 2026-05-14). Альтернативы (composable extraction, defer) отклонены — single use, YAGNI.
|
||||
|
||||
**Изменения:**
|
||||
|
||||
- [app/resources/js/components/projects/ProjectDetailsDrawer.vue](../../../app/resources/js/components/projects/ProjectDetailsDrawer.vue) — добавить:
|
||||
- Import `REGIONS` из `../../constants/regions`.
|
||||
- `const selectedRegions = ref<number[]>([]);`
|
||||
- `const selectableRegions = REGIONS.filter(r => r.code !== 0);` (исключить «Вся РФ» sentinel — fixes latent NewProjectDialog bug).
|
||||
- Helper `maskToCodes(mask: number): number[]` — reverse-decompose bits 1..31.
|
||||
- В `reseedFromProject(p)`: `selectedRegions.value = maskToCodes(p.region_mask ?? 0);`
|
||||
- Watch `selectedRegions` → encode в `form.region_mask` + `form.region_mode`.
|
||||
- Template: `<v-autocomplete v-model="selectedRegions">` блок между «Лимит» и «Дни приёма».
|
||||
- [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts) — +3 теста.
|
||||
|
||||
**Backend — без изменений.** [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) уже validates `region_mask` + `region_mode`. Task 5 onSave уже их шлёт.
|
||||
|
||||
---
|
||||
|
||||
## 3. Компоненты и интерфейсы
|
||||
|
||||
### `ProjectDetailsDrawer.vue` — script additions
|
||||
|
||||
```ts
|
||||
import { REGIONS } from '../../constants/regions';
|
||||
|
||||
const selectedRegions = ref<number[]>([]);
|
||||
const selectableRegions = REGIONS.filter(r => r.code !== 0);
|
||||
|
||||
function maskToCodes(mask: number): number[] {
|
||||
const codes: number[] = [];
|
||||
for (let i = 1; i <= 31; i++) if (mask & (1 << i)) codes.push(i);
|
||||
return codes;
|
||||
}
|
||||
|
||||
// inside reseedFromProject(p), AFTER form.region_mask line:
|
||||
selectedRegions.value = maskToCodes(p.region_mask ?? 0);
|
||||
|
||||
watch(selectedRegions, (codes) => {
|
||||
if (codes.length === 0) {
|
||||
form.region_mask = 0;
|
||||
form.region_mode = 'include';
|
||||
} else {
|
||||
form.region_mask = codes.reduce((acc, c) => (c >= 1 && c <= 31 ? acc | (1 << c) : acc), 0);
|
||||
form.region_mode = 'exclude';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
### Template insertion (между Лимит и Дни)
|
||||
|
||||
```vue
|
||||
<div class="pdd-field">
|
||||
<span class="pdd-label">Регионы (пусто = вся РФ)</span>
|
||||
<v-autocomplete
|
||||
v-model="selectedRegions"
|
||||
:items="selectableRegions"
|
||||
item-title="name"
|
||||
item-value="code"
|
||||
multiple
|
||||
chips
|
||||
clearable
|
||||
density="comfortable"
|
||||
hide-details
|
||||
data-testid="pdd-regions"
|
||||
/>
|
||||
</div>
|
||||
```
|
||||
|
||||
`<v-autocomplete>` уже доступен через vite-plugin-vuetify auto-import.
|
||||
|
||||
---
|
||||
|
||||
## 4. Data flow
|
||||
|
||||
```
|
||||
Mount drawer / swap project
|
||||
→ reseedFromProject(p)
|
||||
→ form.region_mask = p.region_mask ?? 0
|
||||
→ selectedRegions.value = maskToCodes(form.region_mask)
|
||||
→ autocomplete отображает chips
|
||||
|
||||
User selects/deselects region in autocomplete
|
||||
→ selectedRegions reactive update
|
||||
→ watch fires
|
||||
→ form.region_mask + form.region_mode updated
|
||||
|
||||
Save click
|
||||
→ onSave (existing, Task 5)
|
||||
→ payload.region_mask = form.region_mask
|
||||
→ payload.region_mode = form.region_mode
|
||||
→ axios.patch(/api/projects/{id}, payload)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Error handling
|
||||
|
||||
- `p.region_mask` undefined / null → `?? 0` → empty codes → safe.
|
||||
- `mask` с битом 0 (sentinel «вся РФ» mistakenly set от bugged NewProjectDialog) → `maskToCodes` skips bit 0 (loop `for i=1..31`) → бит игнорируется в UI. Не отображается как chip. Save затем перепишет mask без bit 0.
|
||||
- `codes >31` — REGIONS array ограничен 1..31, поэтому this branch unreachable через UI. Defensive `c >= 1 && c <= 31` guard в watch на случай programmatic mutation. Plan 6: bigint upgrade.
|
||||
- Network error при Save — обрабатывается existing onSave (silently caught, не часть этой spec'и).
|
||||
|
||||
---
|
||||
|
||||
## 6. Тестирование
|
||||
|
||||
### Vitest jsdom — расширить [app/tests/Frontend/ProjectDetailsDrawer.spec.ts](../../../app/tests/Frontend/ProjectDetailsDrawer.spec.ts)
|
||||
|
||||
**Тест 1: `renders region chips when project has non-zero region_mask`**
|
||||
|
||||
```ts
|
||||
it('renders region chips when project has non-zero region_mask', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' }; // bits 1+2
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
await wrapper.vm.$nextTick();
|
||||
const text = wrapper.text();
|
||||
expect(text).toContain('Адыгея'); // code 1
|
||||
expect(text).toContain('Башкортостан'); // code 2
|
||||
});
|
||||
```
|
||||
|
||||
**Тест 2: `selecting regions encodes mask + sets mode='exclude'`**
|
||||
|
||||
```ts
|
||||
it('selecting regions encodes mask + sets mode=exclude', async () => {
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: sampleProject } });
|
||||
// sampleProject имеет region_mask=0, mode='include' начально
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', [3, 5]); // 2 regions
|
||||
await wrapper.vm.$nextTick();
|
||||
// Inspect via exposed form ref OR via PATCH payload after save click
|
||||
// Easier path: spy axios + click save, assert payload.region_mask
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: sampleProject } });
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 40, region_mode: 'exclude' }), // bits 3+5 = 8+32 = 40
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
**Тест 3: `clearing all regions resets mask=0 + mode='include'`**
|
||||
|
||||
```ts
|
||||
it('clearing all regions resets mask=0 + mode=include', async () => {
|
||||
const withRegions: Project = { ...sampleProject, region_mask: 6, region_mode: 'exclude' };
|
||||
const wrapper = mount(ProjectDetailsDrawer, { props: { project: withRegions } });
|
||||
const autocomplete = wrapper.getComponent({ name: 'VAutocomplete' });
|
||||
await autocomplete.vm.$emit('update:model-value', []);
|
||||
await wrapper.vm.$nextTick();
|
||||
(axios.patch as ReturnType<typeof vi.fn>).mockResolvedValueOnce({ data: { data: withRegions } });
|
||||
await wrapper.get('[data-testid="pdd-save"]').trigger('click');
|
||||
await wrapper.vm.$nextTick();
|
||||
await wrapper.vm.$nextTick();
|
||||
expect(axios.patch).toHaveBeenCalledWith(
|
||||
'/api/projects/42',
|
||||
expect.objectContaining({ region_mask: 0, region_mode: 'include' }),
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### Visual smoke (manual, заказчик)
|
||||
|
||||
1. Открыть /projects → выбрать проект «Доставка еды (СМС)» → drawer открывается.
|
||||
2. В drawer'е увидеть поле «Регионы (пусто = вся РФ)» между Лимитом и Днями.
|
||||
3. Кликнуть в input → dropdown показывает 31 регион (без «Вся РФ»).
|
||||
4. Выбрать Москва + СПб → появляются 2 chips.
|
||||
5. Click Сохранить → toast, PATCH в Network tab содержит `region_mask` non-zero + `region_mode=exclude`.
|
||||
6. Re-open drawer того же проекта → 2 chips сохранены (reverse-decompose работает).
|
||||
7. Click ✕ на chip → chip убирается.
|
||||
8. Кнопка clearable (X справа от input) → все chips удалены, mask=0 + mode=include на save.
|
||||
|
||||
### Регрессии (must-not-break)
|
||||
|
||||
- 14 PDD unit tests + 5 integration → все green.
|
||||
- Vue-tsc / ESLint — 0 errors.
|
||||
- Pre-commit hooks — 0 issues.
|
||||
|
||||
---
|
||||
|
||||
## 7. Out of scope
|
||||
|
||||
- NewProjectDialog reverse-decompose TODO (line 172) — отдельный sweep.
|
||||
- Composable `useRegionsBitmask` extraction — Plan 6 если будет 3-й consumer.
|
||||
- Bigint mask для region codes >31 (нужен schema change PostgreSQL) — Plan 6.
|
||||
- Кастомный chip-design (currently Vuetify default) — Quiet Luxury polish.
|
||||
- Mobile-адаптация autocomplete (touch keyboard / full-screen modal) — Plan 6.
|
||||
- Server-side validation на duplicate codes — UI gateways через Set semantics в v-autocomplete.
|
||||
|
||||
---
|
||||
|
||||
## 8. Open questions
|
||||
|
||||
Нет. UX-вопрос (исключить «Вся РФ» из dropdown) закрыт заказчиком 2026-05-14.
|
||||
|
||||
---
|
||||
|
||||
## 9. Связанные документы
|
||||
|
||||
- [2026-05-14-project-details-drawer-design.md](./2026-05-14-project-details-drawer-design.md) — parent design spec, §7 Out-of-plan «Region multi-select autocomplete» закрывается этой spec'ой.
|
||||
- [NewProjectDialog.vue](../../../app/resources/js/views/projects/NewProjectDialog.vue) — source recipe (lines 78-87 template + 141-153 watch).
|
||||
- [constants/regions.ts](../../../app/resources/js/constants/regions.ts) — REGIONS array (32 entries).
|
||||
- [UpdateProjectRequest.php](../../../app/app/Http/Requests/UpdateProjectRequest.php) — backend validation contract.
|
||||
Reference in New Issue
Block a user