Compare commits

...

10 Commits

Author SHA1 Message Date
Тимур Городецкий cb810972c4 feat: searchable unit picker in the work form
Units dictionary has around a hundred entries; typing now filters the
list and Enter picks the first match. Only dictionary values can be
chosen, as before. Generic SearchableSelect lives in shared until the
ui-kit gets one.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий 7ca71c6b6c fix: reject work finish date earlier than start date
The legacy works form saved any dates. The finish date picker now has
the start date as minimum and saving shows an error. Found in T-WRK.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий 3128190b5c fix: retry AAD client creation after a failed token request
The rejected promise was cached, so a single network hiccup while
getting the Dataverse or API token broke every request to that resource
until the page was reloaded. Seen once in T-OBJ-11.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:07 +05:00
Тимур Городецкий d2852df976 refactor: remove drag and drop from the objects tree
Decided during manual testing: the legacy drop-inside zone had zero
height, so drops only worked before/after a node and moves happened by
accident. Sections are re-parented via Edit, as before.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-19 10:07:06 +05:00
Тимур Городецкий 8ed1a02967 fix: prevent duplicate project resource assignments
With two tabs open (or two users) the same profession or company could
be assigned twice, because only the list loaded in the tab was checked.
Before creating an assignment the server is now asked whether it already
exists; if so its id is reused. Found in T-RES-03.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий c69a11d167 feat: add clear button to the project search field
Clears only the search text and reopens the list, the selected project
stays. Requested during manual testing.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий 45c974d0c6 fix: use fresh project status and rights after reload
After F5 without projectId in the address the hub and sidebar showed the
status and edit right saved in sessionStorage, so a project switched to
Архивный or a revoked edit0 right was ignored until the project was
picked again. The fresh list from SharePoint now always wins; the saved
copy is used only while the list is loading. Found in T-HUB-07/09.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 12:33:38 +05:00
Тимур Городецкий 650bce809a docs: align hub status scenarios with real SharePoint choices
Status choices in the Проекты list are Текущий / Архивный, so the legacy
archive rule matches real data; the projects portal writes its own
status to the API catalogue, which StroyBot does not read.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 11:45:04 +05:00
Тимур Городецкий bb7093de20 docs: fix session restore scenario, record ui-kit look decision
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:59:36 +05:00
Тимур Городецкий 355a1300ce docs: record verified SharePoint schema for access and objects lists
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-16 10:37:03 +05:00
33 changed files with 436 additions and 310 deletions
+11 -5
View File
@@ -89,13 +89,15 @@
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only) - [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)» - [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.» - [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …» - [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
- [ ] «Работы» → раздел Работы с `objectId` - [ ] «Работы» → раздел Работы с `objectId`
- [ ] После любой операции дерево перезагружается - [ ] После любой операции дерево перезагружается
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup) - [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
- [ ] Права: «Доступы» `projectId`/`userId` → `edit0` - [ ] Права: «Доступы» `projectId`/`userId` → `edit0`
🐞 Перетаскивание меняет только родителя, порядок не сохраняется. 🐞 Удаление узла не удаляет детей. 🐞 Зона сброса «внутрь» нулевой высоты — на практике узел можно бросить только «до» / «после» (перенесено как есть). 🐞 Удаление узла не удаляет детей.
ℹ️ Экспорт падает с 500 на стороне `api.tnsu.kz`: «Failed to acquire Graph token … SPO_TENANT_ID, SPO_CLIENT_ID, SPO_CERT_PATH, SPO_CERT_PASS» — у API не настроен доступ к Graph (проверка 2026-10-16). Наш запрос доходит до API корректно; чинится на сервере.
**Сознательные отличия от objects:** **Сознательные отличия от objects:**
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же. - `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
@@ -119,7 +121,7 @@
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`) - [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
- [ ] Проект находится подъёмом по родителям объекта до непустого `project` - [ ] Проект находится подъёмом по родителям объекта до непустого `project`
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было). 🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
**Сознательные отличия от works:** **Сознательные отличия от works:**
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же. - `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
@@ -127,6 +129,8 @@
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же. - PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`). - Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`. - Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
--- ---
@@ -241,6 +245,7 @@
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP. - Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль. - Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`. - Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая.
--- ---
@@ -265,5 +270,6 @@
## Открытые вопросы ## Открытые вопросы
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте. - ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте. - ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
- ✅ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий. Проверено 2026-10-16: `level` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
+7 -5
View File
@@ -6,6 +6,7 @@
- Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️. - Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️.
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»). - Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше. - Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
- **Оформление — в стиле ui-kit** (решение 2026-10-16). Отличия во внешнем виде от старых страниц (раскладка, цвета, регистр подписей) **багом не считаются**. Сравниваем функции, тексты, данные, права. Баг по виду — только если что-то не видно, обрезано, наезжает или не нажимается.
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо. - Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
--- ---
@@ -60,8 +61,8 @@
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты. - [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта. - [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
- [ ] **T-APP-05** F5 на любом разделе → остались в нём. - [ ] **T-APP-05** F5 на любом разделе → остались в нём.
- [ ] **T-APP-06** Закрыть вкладку, открыть web part заново (без `projectId` в адресе) → проект восстановился из сессии. - [ ] **T-APP-06** В **той же вкладке** удалить из адреса `&page=…&projectId=…`, Enter, заново добавить web part → на главной уже выбран прежний проект, меню — со всеми разделами. (Выбор хранится в `sessionStorage` — он живёт только в пределах вкладки; после закрытия вкладки не сохраняется — так было и в старом main.)
- [ ] **T-APP-07** Шрифты: заголовки — с засечками (Lora), текст — Inter. Сравнить «на глаз» со старым main. - [ ] **T-APP-07** Шрифты подгрузились: заголовки разделов — с засечками (Lora), остальной текст — Inter.
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх. - [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main) ## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
@@ -72,8 +73,9 @@
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка. - [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы. - [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику». - [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
- [ ] **T-HUB-08** Статус **завершен** (роль Е) → неактивна только «Отправить отчёт». - [-] **T-HUB-08** Статус «завершен» — **не применимо**: в SharePoint у поля «Статус» только «Текущий» и «Архивный» (проверено 2026-10-16). Правило в коде остаётся на случай, если вариант появится.
- [ ] **T-HUB-09** Статус **архивный** → активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть статус **текущий**. - [ ] **T-HUB-09** Статус **«Архивный»** (менять в SharePoint, список «Проекты») → бейдж золотой, активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть **«Текущий»**.
- ⚠️ Статус меняем **в списке SharePoint**, а не на портале проектов: портал пишет статус («Текущий» / «Архив») в справочник API, StroyBot его не читает.
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main). - [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке. - [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна. - [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
@@ -101,7 +103,7 @@
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект». - [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет. - [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»). - [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено. - [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было). - [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы. - [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта. - [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
@@ -4,6 +4,7 @@ import { useRouter } from '../routing/RouterContext';
import { IAccessibleProject } from './projectTypes'; import { IAccessibleProject } from './projectTypes';
import { fetchAccessibleProjects } from './projectRepository'; import { fetchAccessibleProjects } from './projectRepository';
import { readStoredProject, storeProject } from './projectSession'; import { readStoredProject, storeProject } from './projectSession';
import { resolveCurrentProject } from './projectRules';
export type ProjectsLoadState = 'loading' | 'ready' | 'error'; export type ProjectsLoadState = 'loading' | 'ready' | 'error';
@@ -51,13 +52,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
if (isCancelled) return; if (isCancelled) return;
setProjects(loaded); setProjects(loaded);
setLoadState('ready'); setLoadState('ready');
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main). // В сессии могли остаться устаревшие статус и права — сохраняем свежие.
setStoredProject((stored) => { setStoredProject((stored) => {
const fresh = stored ? findProject(loaded, stored.id) : undefined; const fresh = stored ? findProject(loaded, stored.id) : undefined;
if (!stored || !fresh) return stored; if (!fresh) return stored;
const updated = { ...stored, canEdit: fresh.canEdit }; storeProject(fresh);
storeProject(updated); return fresh;
return updated;
}); });
}) })
.catch((error: unknown) => { .catch((error: unknown) => {
@@ -71,13 +71,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
}, [services, reloadKey]); }, [services, reloadKey]);
const routeProjectId = route.projectId ? Number(route.projectId) : undefined; const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
let currentProject: IAccessibleProject | undefined; const currentProject = resolveCurrentProject(projects, routeProjectId, storedProject);
if (routeProjectId === undefined) {
currentProject = storedProject;
} else {
currentProject =
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
}
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined); const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
const selectProject = React.useCallback( const selectProject = React.useCallback(
@@ -5,6 +5,7 @@ import {
isSectionAvailable, isSectionAvailable,
mergeAccessRows, mergeAccessRows,
pickRecentProjectIds, pickRecentProjectIds,
resolveCurrentProject,
sortProjects sortProjects
} from './projectRules'; } from './projectRules';
import { IAccessibleProject } from './projectTypes'; import { IAccessibleProject } from './projectTypes';
@@ -71,6 +72,25 @@ describe('getStatusKind', () => {
}); });
}); });
describe('resolveCurrentProject', () => {
const stored = project({ id: 51, status: 'Текущий', canEdit: true });
const fresh = project({ id: 51, status: 'Архив', canEdit: false });
it('свежие статус и права из списка главнее сохранённых в сессии (баг T-HUB-07/09)', () => {
expect(resolveCurrentProject([fresh], undefined, stored)).toBe(fresh);
expect(resolveCurrentProject([fresh], 51, stored)).toBe(fresh);
});
it('пока список не загрузился — сохранённый выбор', () => {
expect(resolveCurrentProject([], undefined, stored)).toBe(stored);
});
it('проект из адреса, которого нет ни в списке, ни в сессии, — не выбран', () => {
expect(resolveCurrentProject([fresh], 7, stored)).toBeUndefined();
expect(resolveCurrentProject([], undefined, undefined)).toBeUndefined();
});
});
describe('isSectionAvailable', () => { describe('isSectionAvailable', () => {
it('архивный проект — закрыты все разделы, кроме главной', () => { it('архивный проект — закрыты все разделы, кроме главной', () => {
const archived = project({ status: 'архивный' }); const archived = project({ status: 'архивный' });
@@ -85,6 +85,23 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b
return true; return true;
} }
/**
* Текущий проект: id из адреса, а без него — сохранённый в сессии выбор.
* Данные (статус, права) берутся из свежего списка с сервера; сохранённая копия — только пока список
* не загрузился или если проекта в нём нет. Иначе после смены статуса / прав F5 показывал бы старые.
*/
export function resolveCurrentProject(
projects: IAccessibleProject[],
routeProjectId: number | undefined,
storedProject: IAccessibleProject | undefined
): IAccessibleProject | undefined {
const targetId = routeProjectId ?? storedProject?.id;
if (targetId === undefined) return undefined;
const fresh = projects.filter((project) => project.id === targetId)[0];
if (fresh) return fresh;
return storedProject?.id === targetId ? storedProject : undefined;
}
/** Выгрузка за период недоступна только для архивного проекта. */ /** Выгрузка за период недоступна только для архивного проекта. */
export function isExportAvailable(project: IAccessibleProject): boolean { export function isExportAvailable(project: IAccessibleProject): boolean {
return getStatusKind(project.status) !== 'archive'; return getStatusKind(project.status) !== 'archive';
@@ -50,6 +50,25 @@ $dropdown-max-height: 340px;
color: $gray-900; color: $gray-900;
} }
.clearButton {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
padding: $sp-1;
margin-right: $sp-2;
border: 0;
border-radius: $r-full;
background: transparent;
color: $gray-400;
cursor: pointer;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.selectedBadge { .selectedBadge {
flex-shrink: 0; flex-shrink: 0;
} }
@@ -40,6 +40,14 @@ export const ProjectPicker: React.FC = () => {
setIsOpen(true); setIsOpen(true);
}; };
// Очистка не сбрасывает выбранный проект — только текст поиска, чтобы сразу искать другой.
const onClear = (event: React.MouseEvent): void => {
event.preventDefault();
setQuery('');
setIsOpen(true);
inputRef.current?.focus();
};
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор. // mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => { const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
event.preventDefault(); event.preventDefault();
@@ -67,6 +75,17 @@ export const ProjectPicker: React.FC = () => {
onFocus={onFocus} onFocus={onFocus}
onChange={onChange} onChange={onChange}
/> />
{query && (
<button
type="button"
className={styles.clearButton}
title={TEXTS.clear}
aria-label={TEXTS.clear}
onMouseDown={onClear}
>
<Icon name="close" size="sm" />
</button>
)}
{currentProject?.status && ( {currentProject?.status && (
<span className={styles.selectedBadge}> <span className={styles.selectedBadge}>
<ProjectStatusBadge status={currentProject.status} /> <ProjectStatusBadge status={currentProject.status} />
+1
View File
@@ -4,6 +4,7 @@ export const HUB_TEXTS = {
picker: { picker: {
label: 'Проект:', label: 'Проект:',
placeholder: 'Выберите проект', placeholder: 'Выберите проект',
clear: 'Очистить',
loading: 'Загрузка проектов…', loading: 'Загрузка проектов…',
noProjects: 'Нет доступных проектов', noProjects: 'Нет доступных проектов',
notFound: 'Проекты не найдены', notFound: 'Проекты не найдены',
@@ -1,32 +0,0 @@
import * as React from 'react';
import { DropPosition } from './objectsTypes';
import { IObjectDrag } from './useObjectDrag';
import styles from './ObjectsPage.module.scss';
interface Props {
targetId: number;
position: DropPosition;
drag: IObjectDrag;
}
const POSITION_CLASS: Record<DropPosition, string> = {
before: styles.dropZoneEdge,
inside: styles.dropZoneInside,
after: styles.dropZoneEdge
};
/** Тонкая полоса до / внутри / после узла, куда можно бросить перетаскиваемый объект. */
export const DropZone: React.FC<Props> = ({ targetId, position, drag }) => {
const isActive = drag.activeZone?.targetId === targetId && drag.activeZone.position === position;
const className = [styles.dropZone, POSITION_CLASS[position], isActive ? styles.dropZoneActive : '']
.filter(Boolean)
.join(' ');
return (
<div
className={className}
onDragOver={(event) => drag.onZoneDragOver({ targetId, position }, event)}
onDragLeave={drag.onZoneDragLeave}
/>
);
};
@@ -3,8 +3,6 @@ import { Icon } from '@tnsu/ui-kit';
import { IMenuAnchor } from '../_tree/ContextMenu'; import { IMenuAnchor } from '../_tree/ContextMenu';
import { ObjectNode } from './objectsTypes'; import { ObjectNode } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { DropZone } from './DropZone';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -12,7 +10,6 @@ interface Props {
node: ObjectNode; node: ObjectNode;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
} }
@@ -20,19 +17,14 @@ interface Props {
const ROW_BASE_INDENT_PX = 12; const ROW_BASE_INDENT_PX = 12;
const ROW_LEVEL_INDENT_PX = 22; const ROW_LEVEL_INDENT_PX = 22;
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string { function rowClassName(hasChildren: boolean, isSelected: boolean): string {
return [ return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
styles.nodeRow,
hasChildren ? styles.nodeRowParent : '',
isSelected ? styles.nodeRowSelected : '',
isDragging ? styles.nodeRowDragging : ''
]
.filter(Boolean) .filter(Boolean)
.join(' '); .join(' ');
} }
/** Узел дерева объектов с зонами сброса и (если раскрыт) дочерними узлами. */ /** Узел дерева объектов и (если раскрыт) его дочерние узлы. */
export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onOpenMenu }) => { export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, onOpenMenu }) => {
const hasChildren = node.children.length > 0; const hasChildren = node.children.length > 0;
const isExpanded = view.expandedIds.has(node.id); const isExpanded = view.expandedIds.has(node.id);
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
return ( return (
<div> <div>
<DropZone targetId={node.id} position="before" drag={drag} />
<div <div
className={rowClassName(hasChildren, view.selectedId === node.id, drag.draggingId === node.id)} className={rowClassName(hasChildren, view.selectedId === node.id)}
style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }} style={{ paddingLeft: ROW_BASE_INDENT_PX + node.level * ROW_LEVEL_INDENT_PX }}
draggable={canEdit}
onDragStart={(event) => drag.onDragStart(node.id, event)}
onDragEnd={drag.onDragEnd}
onClick={() => view.clickRow(node)} onClick={() => view.clickRow(node)}
onContextMenu={onContextMenu} onContextMenu={onContextMenu}
> >
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
</button> </button>
)} )}
</div> </div>
<DropZone targetId={node.id} position="inside" drag={drag} />
{hasChildren && isExpanded && ( {hasChildren && isExpanded && (
<div> <div>
{node.children.map((child) => ( {node.children.map((child) => (
<ObjectTreeNode <ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={child.id}
node={child}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
)} )}
<DropZone targetId={node.id} position="after" drag={drag} />
</div> </div>
); );
}; };
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
} }
} }
.nodeRowDragging {
opacity: 0.45;
}
.nodeToggle { .nodeToggle {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -182,32 +178,6 @@ $menu-button-size: 28px;
} }
} }
// ── Зоны сброса при перетаскивании ──────────────────────────────────────────
// Высоты 1:1 со старым web part: зона «внутрь» нулевой высоты (см. открытые вопросы в чек-листе).
.dropZone {
margin: 0 $sp-2;
border-radius: 1px;
transition: background 0.12s, height 0.12s;
}
.dropZoneEdge {
height: 2px;
&.dropZoneActive {
height: 3px;
background: $gold-400;
}
}
.dropZoneInside {
height: 0;
&.dropZoneActive {
height: 2px;
background: rgba($gold-400, 0.3);
}
}
// ── Модалка ───────────────────────────────────────────────────────────────── // ── Модалка ─────────────────────────────────────────────────────────────────
.modalBody { .modalBody {
display: flex; display: flex;
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
import { useObjectActions } from './useObjectActions'; import { useObjectActions } from './useObjectActions';
import { useObjectsExport } from './useObjectsExport'; import { useObjectsExport } from './useObjectsExport';
import { useObjectTreeView } from './useObjectTreeView'; import { useObjectTreeView } from './useObjectTreeView';
import { useObjectDrag } from './useObjectDrag';
import { ObjectsHeader } from './ObjectsHeader'; import { ObjectsHeader } from './ObjectsHeader';
import { ObjectsTree } from './ObjectsTree'; import { ObjectsTree } from './ObjectsTree';
import { ObjectModal } from './ObjectModal'; import { ObjectModal } from './ObjectModal';
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
); );
const data = useObjectsData(repository, projectId); const data = useObjectsData(repository, projectId);
const actions = useObjectActions({ const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
repository,
projectTitle: data.projectTitle,
tree: data.tree,
reload: data.reload
});
const exporter = useObjectsExport(repository, projectId); const exporter = useObjectsExport(repository, projectId);
const view = useObjectTreeView(data.tree); const view = useObjectTreeView(data.tree);
const drag = useObjectDrag(data.tree, (dragId, targetId, position) => {
actions.moveObject(dragId, targetId, position).catch(() => undefined);
});
const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined); const [menu, setMenu] = React.useState<IOpenMenu | undefined>(undefined);
const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined); const [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
tree={data.tree} tree={data.tree}
canEdit={data.canEdit} canEdit={data.canEdit}
view={view} view={view}
drag={drag}
onOpenMenu={(node, anchor) => setMenu({ node, anchor })} onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
onAddRoot={openAddRoot} onAddRoot={openAddRoot}
/> />
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
import { countNodes } from '../_tree/treeRules'; import { countNodes } from '../_tree/treeRules';
import { ObjectNode, ObjectTree } from './objectsTypes'; import { ObjectNode, ObjectTree } from './objectsTypes';
import { IObjectTreeView } from './useObjectTreeView'; import { IObjectTreeView } from './useObjectTreeView';
import { IObjectDrag } from './useObjectDrag';
import { ObjectTreeNode } from './ObjectTreeNode'; import { ObjectTreeNode } from './ObjectTreeNode';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
import styles from './ObjectsPage.module.scss'; import styles from './ObjectsPage.module.scss';
@@ -14,13 +13,12 @@ interface Props {
tree: ObjectTree; tree: ObjectTree;
canEdit: boolean; canEdit: boolean;
view: IObjectTreeView; view: IObjectTreeView;
drag: IObjectDrag;
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void; onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
onAddRoot: () => void; onAddRoot: () => void;
} }
/** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */ /** Панель «N объектов / Развернуть / Свернуть» и дерево объектов (или «Объекты не найдены»). */
export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpenMenu, onAddRoot }) => ( export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, onOpenMenu, onAddRoot }) => (
<> <>
<div className={styles.toolbar}> <div className={styles.toolbar}>
<span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span> <span className={styles.totalCount}>{OBJECTS_TEXTS.toolbar.total(countNodes(tree.roots))}</span>
@@ -48,16 +46,9 @@ export const ObjectsTree: React.FC<Props> = ({ tree, canEdit, view, drag, onOpen
/> />
)} )}
<div className={styles.tree} onDrop={drag.onDrop}> <div className={styles.tree}>
{tree.roots.map((node) => ( {tree.roots.map((node) => (
<ObjectTreeNode <ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
key={node.id}
node={node}
canEdit={canEdit}
view={view}
drag={drag}
onOpenMenu={onOpenMenu}
/>
))} ))}
</div> </div>
</> </>
@@ -174,17 +174,6 @@ describe('запись', () => {
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 }); expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, Title: 'Новое', parentId: 3 });
}); });
it('перенос: меняется только parentId, в корень — null', async () => {
const { repository, sharePoint } = setup([ENTITY_TYPE_ROUTE, { match: '/items(', response: { status: 204 } }]);
await repository.moveObject(7, 2);
await repository.moveObject(7, undefined);
expect(sharePoint.requests[1].request.headers).toEqual(MERGE_HEADERS);
expect(bodyOf(sharePoint, 1)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: 2 });
expect(bodyOf(sharePoint, 2)).toEqual({ __metadata: { type: ENTITY_TYPE }, parentId: null });
});
it('ошибка записи: error.message.value → error.message → statusText', async () => { it('ошибка записи: error.message.value → error.message → statusText', async () => {
const verbose = setup([ const verbose = setup([
ENTITY_TYPE_ROUTE, ENTITY_TYPE_ROUTE,
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>; createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */ /** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>; updateObject(itemId: number, title: string, newParentId: number | undefined): Promise<void>;
/** Перенос (drag & drop): меняется только родитель; undefined — в корень. */
moveObject(itemId: number, newParentId: number | undefined): Promise<void>;
deleteObject(itemId: number): Promise<void>; deleteObject(itemId: number): Promise<void>;
downloadObjectsExcel(projectId: string): Promise<Blob>; downloadObjectsExcel(projectId: string): Promise<Blob>;
} }
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId; if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body); await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
}, },
moveObject: async (itemId, newParentId) => {
// Перенос в корень сбрасывает lookup явным null.
const body = { ...(await metadata()), [OBJECT_FIELDS.parentWrite]: newParentId ?? null };
await postVerbose(deps, objectItemUrl(deps.siteUrl, itemId), MERGE_HEADERS, body);
},
deleteObject: (itemId) => deleteObject(deps, itemId), deleteObject: (itemId) => deleteObject(deps, itemId),
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId) downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
}; };
@@ -1,7 +1,6 @@
import { import {
buildObjectTree, buildObjectTree,
canAddChild, canAddChild,
canDrop,
getChangedParentId, getChangedParentId,
getDeleteConfirmMessage, getDeleteConfirmMessage,
getErrorText, getErrorText,
@@ -9,8 +8,7 @@ import {
getParentDisplay, getParentDisplay,
getParentOptions, getParentOptions,
getSubmitLabel, getSubmitLabel,
isParentSelectable, isParentSelectable
resolveMove
} from './objectsRules'; } from './objectsRules';
import { buildObjectMenuItems } from './objectMenu'; import { buildObjectMenuItems } from './objectMenu';
import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes'; import { IProjectObject, ObjectModalMode, ObjectNode, ObjectTree } from './objectsTypes';
@@ -100,26 +98,6 @@ describe('модалка', () => {
}); });
}); });
describe('перетаскивание', () => {
it('нельзя бросить в себя и в своих потомков', () => {
expect(canDrop(tree, 1, 1)).toBe(false);
expect(canDrop(tree, 1, 4)).toBe(false);
expect(canDrop(tree, 4, 1)).toBe(true);
});
it('«внутрь» — родитель = цель, «до/после» — родитель цели', () => {
expect(resolveMove(tree, 4, 3, 'inside')).toEqual({ parentId: 3 });
expect(resolveMove(tree, 4, 3, 'before')).toEqual({ parentId: 1 });
expect(resolveMove(tree, 2, 5, 'after')).toEqual({ parentId: undefined });
});
it('родитель не меняется или перенос запрещён — ничего не делаем', () => {
expect(resolveMove(tree, 2, 3, 'after')).toBeUndefined();
expect(resolveMove(tree, 1, 5, 'before')).toBeUndefined();
expect(resolveMove(tree, 1, 4, 'inside')).toBeUndefined();
});
});
describe('удаление и ошибки', () => { describe('удаление и ошибки', () => {
it('подтверждение с числом дочерних', () => { it('подтверждение с числом дочерних', () => {
expect(getDeleteConfirmMessage(node(1))).toEqual( expect(getDeleteConfirmMessage(node(1))).toEqual(
@@ -1,6 +1,6 @@
import { ISelectOption } from '@tnsu/ui-kit'; import { ISelectOption } from '@tnsu/ui-kit';
import { buildTree, isDescendant } from '../_tree/treeRules'; import { buildTree, isDescendant } from '../_tree/treeRules';
import { DropPosition, IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, IProjectObject, ObjectNode, ObjectTree } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
// Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции. // Правила страницы «Объекты» (stroy-bot-objects), вынесенные из DOM-кода в чистые функции.
@@ -72,33 +72,6 @@ export function getChangedParentId(node: ObjectNode | undefined, selectValue: st
return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined; return !isNaN(parentId) && parentId !== node.item.parentId ? parentId : undefined;
} }
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
export function canDrop(tree: ObjectTree, dragId: number, targetId: number): boolean {
return dragId !== targetId && !isDescendant(tree.byId, dragId, targetId);
}
export interface IMoveResult {
/** undefined — перенос в корень. */
parentId: number | undefined;
}
/**
* Новый родитель после перетаскивания: «внутрь» — целевой узел, «до/после» — родитель целевого.
* undefined — переносить нечего (родитель не меняется или перенос запрещён).
*/
export function resolveMove(
tree: ObjectTree,
dragId: number,
targetId: number,
position: DropPosition
): IMoveResult | undefined {
const dragNode = tree.byId.get(dragId);
if (!dragNode || !canDrop(tree, dragId, targetId)) return undefined;
const parentId = position === 'inside' ? targetId : tree.byId.get(targetId)?.item.parentId;
return dragNode.item.parentId === parentId ? undefined : { parentId };
}
export function getDeleteConfirmMessage(node: ObjectNode): string { export function getDeleteConfirmMessage(node: ObjectNode): string {
return OBJECTS_TEXTS.deleteConfirm( return OBJECTS_TEXTS.deleteConfirm(
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id), node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
node: ObjectNode | undefined; node: ObjectNode | undefined;
} }
/** Куда бросили узел относительно целевого: до, внутрь, после. */
export type DropPosition = 'before' | 'inside' | 'after';
/** /**
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent). * Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
* Пустой lookup приходит как null — читается через `?.`. * Пустой lookup приходит как null — читается через `?.`.
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
deleteFallbackTitle: (id: number): string => `ID ${id}`, deleteFallbackTitle: (id: number): string => `ID ${id}`,
serverError: (status: number): string => `Ошибка сервера: ${status}`, serverError: (status: number): string => `Ошибка сервера: ${status}`,
saveError: (message: string): string => `Ошибка сохранения: ${message}`, saveError: (message: string): string => `Ошибка сохранения: ${message}`,
moveError: (message: string): string => `Не удалось переместить: ${message}`,
deleteError: (message: string): string => `Не удалось удалить: ${message}`, deleteError: (message: string): string => `Не удалось удалить: ${message}`,
exportError: (message: string): string => `Не удалось скачать файл: ${message}` exportError: (message: string): string => `Не удалось скачать файл: ${message}`
} as const; } as const;
@@ -1,28 +1,26 @@
import * as React from 'react'; import * as React from 'react';
import { confirmModal } from '@tnsu/ui-kit'; import { confirmModal } from '@tnsu/ui-kit';
import { IObjectsRepository } from './objectsRepository'; import { IObjectsRepository } from './objectsRepository';
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules'; import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes'; import { IObjectModalTarget, ObjectNode } from './objectsTypes';
import { OBJECTS_TEXTS } from './texts'; import { OBJECTS_TEXTS } from './texts';
interface IActionsDeps { interface IActionsDeps {
repository: IObjectsRepository; repository: IObjectsRepository;
projectTitle: string | undefined; projectTitle: string | undefined;
tree: ObjectTree;
reload: () => void; reload: () => void;
} }
export interface IObjectActions { export interface IObjectActions {
/** Ошибка переноса или удаления (раньше — alert). */ /** Ошибка удаления (раньше — alert). */
actionError: string | undefined; actionError: string | undefined;
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */ /** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>; saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
moveObject: (dragId: number, targetId: number, position: DropPosition) => Promise<void>;
deleteObject: (node: ObjectNode) => Promise<void>; deleteObject: (node: ObjectNode) => Promise<void>;
} }
/** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */ /** Пишущие действия над деревом объектов. После каждого успешного действия дерево перечитывается. */
export function useObjectActions({ repository, projectTitle, tree, reload }: IActionsDeps): IObjectActions { export function useObjectActions({ repository, projectTitle, reload }: IActionsDeps): IObjectActions {
const [actionError, setActionError] = React.useState<string | undefined>(undefined); const [actionError, setActionError] = React.useState<string | undefined>(undefined);
const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => { const saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
reload(); reload();
}; };
const moveObject = async (dragId: number, targetId: number, position: DropPosition): Promise<void> => {
const move = resolveMove(tree, dragId, targetId, position);
if (!move) return;
setActionError(undefined);
try {
await repository.moveObject(dragId, move.parentId);
reload();
} catch (error) {
setActionError(OBJECTS_TEXTS.moveError(getErrorText(error)));
}
};
const deleteObject = async (node: ObjectNode): Promise<void> => { const deleteObject = async (node: ObjectNode): Promise<void> => {
const isConfirmed = await confirmModal({ const isConfirmed = await confirmModal({
message: getDeleteConfirmMessage(node), message: getDeleteConfirmMessage(node),
@@ -67,5 +53,5 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
} }
}; };
return { actionError, saveObject, moveObject, deleteObject }; return { actionError, saveObject, deleteObject };
} }
@@ -1,72 +0,0 @@
import * as React from 'react';
import { canDrop } from './objectsRules';
import { DropPosition, ObjectTree } from './objectsTypes';
export interface IDropZone {
targetId: number;
position: DropPosition;
}
export interface IObjectDrag {
draggingId: number | undefined;
/** Подсвеченная зона сброса. */
activeZone: IDropZone | undefined;
onDragStart: (nodeId: number, event: React.DragEvent) => void;
onDragEnd: () => void;
onZoneDragOver: (zone: IDropZone, event: React.DragEvent) => void;
onZoneDragLeave: () => void;
onDrop: (event: React.DragEvent) => void;
}
/**
* Перетаскивание узлов — как в stroy-bot-objects: узел бросается на зону до / внутрь / после
* другого узла. Бросок засчитывается в последнюю «разрешённую» зону, над которой был курсор.
*/
export function useObjectDrag(
tree: ObjectTree,
onMove: (dragId: number, targetId: number, position: DropPosition) => void
): IObjectDrag {
const [draggingId, setDraggingId] = React.useState<number | undefined>(undefined);
const [activeZone, setActiveZone] = React.useState<IDropZone | undefined>(undefined);
// Последняя разрешённая зона: в старом коде dragleave снимал только подсветку, а цель запоминалась.
const dropTargetRef = React.useRef<IDropZone | undefined>(undefined);
const reset = (): void => {
setDraggingId(undefined);
setActiveZone(undefined);
dropTargetRef.current = undefined;
};
const onDragStart = (nodeId: number, event: React.DragEvent): void => {
setDraggingId(nodeId);
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', String(nodeId));
};
const onZoneDragOver = (zone: IDropZone, event: React.DragEvent): void => {
if (draggingId === undefined || !canDrop(tree, draggingId, zone.targetId)) return;
event.preventDefault();
event.dataTransfer.dropEffect = 'move';
setActiveZone(zone);
dropTargetRef.current = zone;
};
const onDrop = (event: React.DragEvent): void => {
event.preventDefault();
const dragId = draggingId;
const target = dropTargetRef.current;
reset();
if (dragId === undefined || !target) return;
onMove(dragId, target.targetId, target.position);
};
return {
draggingId,
activeZone,
onDragStart,
onDragEnd: reset,
onZoneDragOver,
onZoneDragLeave: () => setActiveZone(undefined),
onDrop
};
}
@@ -3,6 +3,7 @@ import {
assignedItemsUrl, assignedItemsUrl,
assignedItemUrl, assignedItemUrl,
deleteAssignedResource, deleteAssignedResource,
existingAssignmentUrl,
fetchAllItems, fetchAllItems,
fetchProjectResources, fetchProjectResources,
poolItemsUrl, poolItemsUrl,
@@ -105,13 +106,29 @@ describe('fetchProjectResources', () => {
}); });
describe('addAssignedResource', () => { describe('addAssignedResource', () => {
/** Проверка на дубль: привязки ещё нет. */
const notAssignedYet = { match: '$top=1', response: { body: { value: [] } } };
it('сначала ищет существующую привязку этого элемента к проекту', () => {
expect(existingAssignmentUrl(SITE, 'professions', '5', 12)).toEqual(
`${items('Профессии по проекту')}?$select=Id&$top=1&$filter=projectIdId eq 5 and professionIdId eq 12`
);
});
it('привязка уже есть (добавили в другой вкладке) — новая не создаётся, возвращается её Id', async () => {
const client = createFakeHttpClient([{ match: '$top=1', response: { body: { value: [{ Id: 77 }] } } }]);
expect(await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' })).toEqual(77);
expect(client.requests.some((r) => r.request.method === 'POST')).toBe(false);
});
it('профессия: POST в items с телом как в assigner, возвращает Id', async () => { it('профессия: POST в items с телом как в assigner, возвращает Id', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 101 } } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 101 } } }]);
const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' }); const id = await addAssignedResource(client, SITE, 'professions', '5', { id: 12, title: 'Бетонщик' });
expect(id).toEqual(101); expect(id).toEqual(101);
expect(client.requests[0]).toEqual({ expect(client.requests[1]).toEqual({
url: items('Профессии по проекту'), url: items('Профессии по проекту'),
request: { request: {
method: 'POST', method: 'POST',
@@ -126,16 +143,16 @@ describe('addAssignedResource', () => {
}); });
it('компания: поле companyIdId', async () => { it('компания: поле companyIdId', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 201, body: { Id: 7 } } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 201, body: { Id: 7 } } }]);
await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' }); await addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'ТОО Строй' });
expect(client.requests[0].url).toEqual(items('Компании по проекту')); expect(client.requests[1].url).toEqual(items('Компании по проекту'));
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}'); expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
}); });
it('ошибка сервера пробрасывается', async () => { it('ошибка сервера пробрасывается', async () => {
const client = createFakeHttpClient([{ match: 'items', response: { status: 500, body: 'boom' } }]); const client = createFakeHttpClient([notAssignedYet, { match: 'items', response: { status: 500, body: 'boom' } }]);
await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow( await expect(addAssignedResource(client, SITE, 'companies', '5', { id: 3, title: 'X' })).rejects.toThrow(
'500' '500'
); );
@@ -130,7 +130,20 @@ export async function fetchProjectResources(
}; };
} }
/** Создаёт привязку и возвращает id новой записи. */ /** Уже существующая привязка этого элемента к проекту (`{поле}Id` — id из lookup). */
export function existingAssignmentUrl(siteUrl: string, kind: ResourceKind, projectId: string, itemId: number): string {
const { assignedList, lookupField } = RESOURCE_LISTS[kind];
return (
`${listItemsUrl(siteUrl, assignedList)}?$select=Id&$top=1` +
`&$filter=${PROJECT_LOOKUP_ID_FIELD} eq ${Number(projectId)} and ${lookupField}Id eq ${itemId}`
);
}
/**
* Создаёт привязку и возвращает её id. Если такая привязка уже есть (её создали в другой вкладке
* или другой пользователь, пока у нас был открыт старый список), новая не создаётся — возвращается
* id существующей. В старом assigner такой проверки не было, и получались дубли.
*/
export async function addAssignedResource( export async function addAssignedResource(
client: IHttpClient, client: IHttpClient,
siteUrl: string, siteUrl: string,
@@ -138,6 +151,14 @@ export async function addAssignedResource(
projectId: string, projectId: string,
item: IResourceItem item: IResourceItem
): Promise<number> { ): Promise<number> {
const existing = await getJson<{ value: Array<{ Id: number }> }>(
client,
existingAssignmentUrl(siteUrl, kind, projectId, item.id)
);
if (existing.value.length > 0) {
return existing.value[0].Id;
}
const body = { const body = {
Title: item.title, Title: item.title,
[PROJECT_LOOKUP_ID_FIELD]: Number(projectId), [PROJECT_LOOKUP_ID_FIELD]: Number(projectId),
@@ -2,6 +2,7 @@ import * as React from 'react';
import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit'; import { Alert, Button, Dialog, FormField, Select, TextInput } from '@tnsu/ui-kit';
import { ITree } from '../_tree/treeRules'; import { ITree } from '../_tree/treeRules';
import { FormInput } from '../_tree/FormInput'; import { FormInput } from '../_tree/FormInput';
import { SearchableSelect } from '../../shared/components/SearchableSelect';
import { IWorkEditor } from './useWorkEditor'; import { IWorkEditor } from './useWorkEditor';
import { parentOptions, unitOptions } from './worksRules'; import { parentOptions, unitOptions } from './worksRules';
import { IWork } from './worksTypes'; import { IWork } from './worksTypes';
@@ -60,12 +61,15 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
/> />
</FormField> </FormField>
<FormField label={TEXTS.unit} htmlFor="work-unit" full> <FormField label={TEXTS.unit} htmlFor="work-unit" full>
<Select <SearchableSelect
id="work-unit" id="work-unit"
value={form.unit} value={form.unit}
onChange={(value) => setField('unit', value)} onChange={(value) => setField('unit', value)}
options={unitOptions(units, form.unit)} options={unitOptions(units, form.unit)}
placeholder={TEXTS.unitPlaceholder} placeholder={TEXTS.unitPlaceholder}
searchPlaceholder={TEXTS.unitSearch}
notFoundText={TEXTS.unitNotFound}
clearLabel={TEXTS.cancel}
/> />
</FormField> </FormField>
<FormField label={TEXTS.volume} htmlFor="work-volume" full> <FormField label={TEXTS.volume} htmlFor="work-volume" full>
@@ -93,7 +97,14 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
<TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} /> <TextInput id="work-start" type="date" value={form.start} onChange={(value) => setField('start', value)} />
</FormField> </FormField>
<FormField label={TEXTS.finish} htmlFor="work-finish" full> <FormField label={TEXTS.finish} htmlFor="work-finish" full>
<TextInput id="work-finish" type="date" value={form.finish} onChange={(value) => setField('finish', value)} /> {/* min — календарь не даёт выбрать окончание раньше начала. */}
<TextInput
id="work-finish"
type="date"
value={form.finish}
min={form.start || undefined}
onChange={(value) => setField('finish', value)}
/>
</FormField> </FormField>
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>} {editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
</div> </div>
+4 -1
View File
@@ -45,7 +45,10 @@ export const WORKS_TEXTS = {
add: 'Добавить', add: 'Добавить',
save: 'Сохранить', save: 'Сохранить',
saving: 'Сохранение...', saving: 'Сохранение...',
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.' saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.',
dateRangeError: 'Дата окончания не может быть раньше даты начала',
unitSearch: 'Начните вводить ед. изм.',
unitNotFound: 'Ничего не найдено'
}, },
confirmDelete: { confirmDelete: {
title: 'Подтвердите удаление', title: 'Подтвердите удаление',
@@ -1,6 +1,6 @@
import * as React from 'react'; import * as React from 'react';
import { buildWorkBody, IWorksRepository } from './worksRepository'; import { buildWorkBody, IWorksRepository } from './worksRepository';
import { isTitleValid, needsClearParent, resetFormKeepParent, workToForm } from './worksRules'; import { isDateRangeValid, isTitleValid, needsClearParent, resetFormKeepParent, workToForm } from './worksRules';
import { EMPTY_WORK_FORM, IWork, IWorkForm, WorkModalMode } from './worksTypes'; import { EMPTY_WORK_FORM, IWork, IWorkForm, WorkModalMode } from './worksTypes';
import { WORKS_TEXTS } from './texts'; import { WORKS_TEXTS } from './texts';
@@ -63,6 +63,11 @@ export function useWorkEditor({ repository, objectId, reloadWorks }: IEditorDeps
titleInputRef.current?.focus(); titleInputRef.current?.focus();
return; return;
} }
// В старом stroy-bot-works проверки не было — исправлено при ручной проверке (T-WRK).
if (!isDateRangeValid(form)) {
setState((prev) => ({ ...prev, isTitleInvalid: false, saveError: WORKS_TEXTS.modal.dateRangeError }));
return;
}
setState((prev) => ({ ...prev, isTitleInvalid: false, isSaving: true, saveError: undefined })); setState((prev) => ({ ...prev, isTitleInvalid: false, isSaving: true, saveError: undefined }));
try { try {
const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : ''; const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : '';
@@ -2,6 +2,7 @@ import {
buildWorkTree, buildWorkTree,
formatDateRange, formatDateRange,
formatVolume, formatVolume,
isDateRangeValid,
isTitleValid, isTitleValid,
needsClearParent, needsClearParent,
parentOptions, parentOptions,
@@ -80,6 +81,13 @@ describe('форма', () => {
}); });
}); });
it('окончание не раньше начала; пустые даты не проверяются (баг старого кода исправлен)', () => {
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '2026-10-16' })).toBe(false);
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '2026-10-19' })).toBe(true);
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '2026-10-19', finish: '' })).toBe(true);
expect(isDateRangeValid({ ...EMPTY_WORK_FORM, start: '', finish: '2026-10-16' })).toBe(true);
});
it('наименование обязательно (пробелы не считаются)', () => { it('наименование обязательно (пробелы не считаются)', () => {
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false); expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false);
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: 'Бетон' })).toBe(true); expect(isTitleValid({ ...EMPTY_WORK_FORM, title: 'Бетон' })).toBe(true);
@@ -84,6 +84,11 @@ export function resetFormKeepParent(form: IWorkForm): IWorkForm {
return { title: '', unit: '', volume: '', parentId: form.parentId, start: '', finish: '' }; return { title: '', unit: '', volume: '', parentId: form.parentId, start: '', finish: '' };
} }
/** Окончание не раньше начала (даты YYYY-MM-DD сравниваются строками). Пустые даты не проверяются. */
export function isDateRangeValid(form: IWorkForm): boolean {
return !form.start || !form.finish || form.start <= form.finish;
}
export function isTitleValid(form: IWorkForm): boolean { export function isTitleValid(form: IWorkForm): boolean {
return form.title.trim() !== ''; return form.title.trim() !== '';
} }
@@ -23,9 +23,14 @@ export function createSharePointClient(context: WebPartContext): IHttpClient {
export function createAadClient(context: WebPartContext, resource: string): IHttpClient { export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
let clientPromise: Promise<AadHttpClient> | undefined; let clientPromise: Promise<AadHttpClient> | undefined;
// Неудачную попытку не запоминаем: иначе одна сетевая осечка при получении токена
// ломала бы все запросы к этому ресурсу до перезагрузки страницы.
const getClient = (): Promise<AadHttpClient> => { const getClient = (): Promise<AadHttpClient> => {
if (!clientPromise) { if (!clientPromise) {
clientPromise = context.aadHttpClientFactory.getClient(resource); clientPromise = context.aadHttpClientFactory.getClient(resource).catch((error: unknown) => {
clientPromise = undefined;
throw error;
});
} }
return clientPromise; return clientPromise;
}; };
@@ -0,0 +1,96 @@
@import '../../../../styles/design-tokens';
// Длинный справочник прокручивается внутри списка, а не растягивает модалку.
$dropdown-max-height: 260px;
.wrapper {
position: relative;
width: 100%;
}
.field {
display: flex;
align-items: center;
background: $white;
border: 1px solid $gray-300;
border-radius: $r-md;
padding: 0 $sp-3;
transition: border-color 0.15s, box-shadow 0.15s;
}
.fieldOpen {
border-color: $gold-500;
box-shadow: 0 0 0 3px rgba(212, 160, 30, 0.12);
}
.input {
flex: 1;
min-width: 0;
border: 0;
outline: 0;
background: transparent;
padding: $sp-2 0;
font-family: $font-sans;
font-size: $fs-body;
color: $gray-900;
}
.clearButton {
display: inline-flex;
align-items: center;
padding: $sp-1;
margin-right: $sp-1;
border: 0;
border-radius: $r-full;
background: transparent;
color: $gray-400;
cursor: pointer;
&:hover {
background: $gray-100;
color: $gray-700;
}
}
.chevron {
display: inline-flex;
color: $gray-500;
pointer-events: none;
}
.dropdown {
position: absolute;
top: calc(100% + #{$sp-1});
left: 0;
right: 0;
z-index: 30;
max-height: $dropdown-max-height;
overflow-y: auto;
background: $white;
border: 1px solid $gray-200;
border-radius: $r-md;
box-shadow: $sh-3;
padding: $sp-1 0;
}
.option {
padding: $sp-2 $sp-3;
font-size: $fs-body;
color: $gray-900;
cursor: pointer;
&:hover {
background: $navy-50;
}
}
.optionSelected {
font-weight: 600;
background: $gold-50;
}
.message {
padding: $sp-3;
font-size: $fs-sm;
color: $gray-500;
}
@@ -0,0 +1,110 @@
import * as React from 'react';
import { Icon, ISelectOption } from '@tnsu/ui-kit';
import { useOutsideClose } from '../hooks/useOutsideClose';
import { filterOptions } from '../utils/filterOptions';
import styles from './SearchableSelect.module.scss';
interface Props {
id?: string;
value: string;
options: ISelectOption[];
onChange: (value: string) => void;
/** Текст в пустом поле, когда ничего не выбрано. */
placeholder: string;
/** Подсказка в поле, пока пользователь ищет. */
searchPlaceholder: string;
notFoundText: string;
clearLabel: string;
}
/**
* Выпадающий список с поиском: можно прокрутить или начать печатать — список отфильтруется.
* Выбрать можно только значение из списка (свободный текст не сохраняется). В ui-kit такого
* компонента пока нет — живёт здесь, позже переедет в кит.
*/
export const SearchableSelect: React.FC<Props> = (props) => {
const { id, value, options, onChange, placeholder, searchPlaceholder, notFoundText, clearLabel } = props;
const [isOpen, setIsOpen] = React.useState<boolean>(false);
const [query, setQuery] = React.useState<string>('');
const wrapperRef = React.useRef<HTMLDivElement>(null);
const inputRef = React.useRef<HTMLInputElement>(null);
const selectedLabel = options.filter((option) => option.value === value)[0]?.label ?? value;
const filtered = filterOptions(options, query);
const close = (): void => {
setIsOpen(false);
setQuery('');
};
useOutsideClose(wrapperRef, isOpen, close);
const pick = (option: ISelectOption): void => {
onChange(option.value);
close();
inputRef.current?.blur();
};
const onKeyDown = (event: React.KeyboardEvent<HTMLInputElement>): void => {
// Enter выбирает первое найденное — быстрее, чем тянуться к мышке.
if (event.key === 'Enter' && isOpen && filtered.length > 0) {
event.preventDefault();
pick(filtered[0]);
}
};
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
const onClear = (event: React.MouseEvent): void => {
event.preventDefault();
onChange('');
close();
};
return (
<div className={styles.wrapper} ref={wrapperRef}>
<div className={isOpen ? `${styles.field} ${styles.fieldOpen}` : styles.field}>
<input
id={id}
ref={inputRef}
type="text"
className={styles.input}
value={isOpen ? query : selectedLabel}
placeholder={isOpen ? searchPlaceholder : placeholder}
autoComplete="off"
onFocus={() => setIsOpen(true)}
onChange={(event) => {
setQuery(event.target.value);
setIsOpen(true);
}}
onKeyDown={onKeyDown}
/>
{value && !isOpen && (
<button type="button" className={styles.clearButton} title={clearLabel} aria-label={clearLabel} onMouseDown={onClear}>
<Icon name="close" size="sm" />
</button>
)}
<span className={styles.chevron} aria-hidden="true">
<Icon name="chevron-down" size="sm" />
</span>
</div>
{isOpen && (
<div className={styles.dropdown} role="listbox">
{filtered.length === 0 && <div className={styles.message}>{notFoundText}</div>}
{filtered.map((option) => (
<div
key={option.value}
role="option"
aria-selected={option.value === value}
className={option.value === value ? `${styles.option} ${styles.optionSelected}` : styles.option}
onMouseDown={(event) => {
event.preventDefault();
pick(option);
}}
>
{option.label}
</div>
))}
</div>
)}
</div>
);
};
@@ -0,0 +1,18 @@
import { filterOptions } from './filterOptions';
const units = ['м', 'м2', 'м3', 'кг', 'Комплект'].map((label) => ({ value: label, label }));
describe('filterOptions', () => {
it('пустой запрос — весь список', () => {
expect(filterOptions(units, ' ')).toHaveLength(5);
});
it('подстрока без учёта регистра', () => {
expect(filterOptions(units, 'м3').map((o) => o.value)).toEqual(['м3']);
expect(filterOptions(units, 'КОМ').map((o) => o.value)).toEqual(['Комплект']);
});
it('ничего не найдено — пустой список', () => {
expect(filterOptions(units, 'литр')).toEqual([]);
});
});
@@ -0,0 +1,8 @@
import { ISelectOption } from '@tnsu/ui-kit';
/** Поиск по подписи: подстрока без учёта регистра. Пустой запрос — весь список. */
export function filterOptions(options: ISelectOption[], query: string): ISelectOption[] {
const normalized = query.trim().toLowerCase();
if (!normalized) return options;
return options.filter((option) => option.label.toLowerCase().indexOf(normalized) !== -1);
}