Compare commits
10 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| cb810972c4 | |||
| 7ca71c6b6c | |||
| 3128190b5c | |||
| d2852df976 | |||
| 8ed1a02967 | |||
| c69a11d167 | |||
| 45c974d0c6 | |||
| 650bce809a | |||
| bb7093de20 | |||
| 355a1300ce |
@@ -89,13 +89,15 @@
|
||||
- [ ] Модалка: «Название *» (обязательно, Enter — сохранить), «Родительский объект» (select только при правке Раздела: узлы уровня 0, сортировка ru, без себя и потомков), «Проект» (read-only)
|
||||
- [ ] Заголовки модалки: «Добавить Объект-пятно / Раздел / Вид работ», «Редактировать …», «— (корневой)»
|
||||
- [ ] Удаление: подтверждение с числом дочерних, «Это действие нельзя отменить.»
|
||||
- [ ] Drag & drop (только `canEdit`): до / внутрь / после, запрет на себя и потомков, ошибка «Не удалось переместить: …»
|
||||
- [-] ~~Drag & drop~~ — **убрано по решению 2026-10-16** (неудобно, зона «внутрь» не работала, лишние случайные переносы). Родителя Раздела меняют через «Редактировать».
|
||||
- [ ] «Работы» → раздел Работы с `objectId`
|
||||
- [ ] После любой операции дерево перезагружается
|
||||
- [ ] Список «Объекты»: `Title`, `project` (текст = название проекта), `parent` (self-lookup)
|
||||
- [ ] Права: «Доступы» `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:**
|
||||
- `alert()` → сообщение `Alert` на странице (ошибки переноса, удаления, экспорта) или в модалке (ошибка сохранения). Тексты те же.
|
||||
@@ -119,7 +121,7 @@
|
||||
- [ ] Навигационное свойство родителя определяется через `EntityDefinitions` (fallback `cree9_parentid`)
|
||||
- [ ] Проект находится подъёмом по родителям объекта до непустого `project`
|
||||
|
||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Нет проверки «начало ≤ окончание». 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||
🐞 Путь объекта (`#headerTitle`) не выводится. 🐞 Отступы в списке «Родительская работа» — обычные пробелы, браузер их схлопывает (как было).
|
||||
|
||||
**Сознательные отличия от works:**
|
||||
- `alert()` → сообщение `Alert`: ошибка сохранения — в модалке, ошибки удаления — над таблицей. Тексты те же.
|
||||
@@ -127,6 +129,8 @@
|
||||
- PATCH и DELETE идут через тот же AAD-клиент Dataverse, а не через `fetch` с токеном вручную; заголовки те же.
|
||||
- Адрес сайта — настройка «URL сайта SharePoint» (раньше `pageContext.web.absoluteUrl`), Dataverse — настройка «URL Dataverse»; имя таблицы — константа (раньше настройка `worksTable`).
|
||||
- Таблица — `DataTable` из ui-kit; меню ⋮ — общее меню из `pages/_tree`.
|
||||
- 🔧 **Исправлено при проверке T-WRK (2026-10-19):** дату окончания нельзя поставить раньше даты начала — календарь не даёт, а при сохранении ошибка «Дата окончания не может быть раньше даты начала» (в старом коде проверки не было).
|
||||
- ✨ **Улучшено по просьбе (2026-10-19):** «Ед. изм.» — список с поиском: можно начать печатать, Enter выбирает первое найденное. Выбрать можно только значение из справочника, как и раньше.
|
||||
|
||||
---
|
||||
|
||||
@@ -241,6 +245,7 @@
|
||||
- Текст после префикса ошибки («Ошибка загрузки данных: …» и т.п.) — сообщение `HttpError` (`{status} {statusText}: {ответ сервера}`), а не формат ошибки PnP.
|
||||
- Счётчик во вкладке — часть подписи («Профессии (0)»): `count` в Tabs ui-kit скрывает ноль.
|
||||
- Кнопки «+» / «−» — `Button` ui-kit (с подсказками «Добавить» / «Удалить»), сообщение об ошибке закрывается крестиком рядом с `Alert`.
|
||||
- 🔧 **Исправлено (найдено при проверке T-RES, 2026-10-16):** перед добавлением проверяется, нет ли уже такой привязки на сервере. Раньше при двух открытых вкладках (или двух пользователях) одна профессия / компания добавлялась дважды. Если привязка уже есть — новая запись не создаётся, показывается существующая.
|
||||
|
||||
---
|
||||
|
||||
@@ -265,5 +270,6 @@
|
||||
|
||||
## Открытые вопросы
|
||||
|
||||
- Схема списка **«Доступы»**: в репозиториях встречаются и поля `projectId`/`userId`, и lookup `user`/`project`. Проверить на сайте.
|
||||
- Схема списка **«Объекты»**: родитель читается то как `parentId`, то как `parent/Id`. Проверить на сайте.
|
||||
- ✅ Схема списка **«Доступы»** проверена на сайте (2026-10-16): `project` — Lookup на «Проекты», `user` — Person, права `read` / `edit0` / `aproove`. SharePoint отдаёт к ним `projectId` / `userId` — поэтому оба способа из старых репозиториев (`project/Id` + `$expand` и `projectId` / `userId`) обращаются к одним и тем же полям. Расхождения нет.
|
||||
- ✅ Схема списка **«Объекты»** проверена на сайте (2026-10-16): `parent` — Lookup («Объект»), SharePoint отдаёт к нему `parentId` — чтение через `parent/Id` и запись через `parentId` обращаются к одному полю. `project` — Text (название проекта), как и ожидает код.
|
||||
- ✅ В «Объектах» есть поля `level` (Number) и `skipLevelUpdate` (Boolean), которые не писал ни один старый репозиторий. Проверено 2026-10-16: `level` пустой и у старых настоящих объектов — поле никто не заполняет и ни на что не влияет. Новое приложение его тоже не трогает.
|
||||
|
||||
+7
-5
@@ -6,6 +6,7 @@
|
||||
- Всё, что **записывает** данные (создать / изменить / удалить / импорт / утвердить), — **только на проекте «ТЕСТ StroyBot»**. Такие сценарии помечены ✍️.
|
||||
- Всё, что только **читает**, проверяем на настоящих проектах — **сравнением со старой страницей** (см. «Главный приём»).
|
||||
- Нашёл расхождение — не исправляй ничего сам, запиши по шаблону «Как сообщить о баге» и иди дальше.
|
||||
- **Оформление — в стиле ui-kit** (решение 2026-10-16). Отличия во внешнем виде от старых страниц (раскладка, цвета, регистр подписей) **багом не считаются**. Сравниваем функции, тексты, данные, права. Баг по виду — только если что-то не видно, обрезано, наезжает или не нажимается.
|
||||
- Отметки: `[x]` — совпало, `[!]` — баг (номер бага рядом), `[-]` — не применимо.
|
||||
|
||||
---
|
||||
@@ -60,8 +61,8 @@
|
||||
- [ ] **T-APP-03** Нажать «назад» в браузере → вернулись на главную. «Вперёд» → снова объекты.
|
||||
- [ ] **T-APP-04** Скопировать адрес раздела, открыть в новой вкладке → открылся тот же раздел того же проекта.
|
||||
- [ ] **T-APP-05** F5 на любом разделе → остались в нём.
|
||||
- [ ] **T-APP-06** Закрыть вкладку, открыть web part заново (без `projectId` в адресе) → проект восстановился из сессии.
|
||||
- [ ] **T-APP-07** Шрифты: заголовки — с засечками (Lora), текст — Inter. Сравнить «на глаз» со старым main.
|
||||
- [ ] **T-APP-06** В **той же вкладке** удалить из адреса `&page=…&projectId=…`, Enter, заново добавить web part → на главной уже выбран прежний проект, меню — со всеми разделами. (Выбор хранится в `sessionStorage` — он живёт только в пределах вкладки; после закрытия вкладки не сохраняется — так было и в старом main.)
|
||||
- [ ] **T-APP-07** Шрифты подгрузились: заголовки разделов — с засечками (Lora), остальной текст — Inter.
|
||||
- [ ] **T-APP-08** Узкое окно (< 768px) → меню прячется, появляется кнопка-бургер, меню открывается поверх.
|
||||
|
||||
## 2. Главная — `T-HUB` (сравнение со старым stroy-bot-main)
|
||||
@@ -72,8 +73,9 @@
|
||||
- [ ] **T-HUB-05** Без выбранного проекта все карточки неактивны, видна подсказка.
|
||||
- [ ] **T-HUB-06** «ТЕСТ StroyBot», роль Е, статус **текущий** → все 6 карточек активны; меню — все разделы.
|
||||
- [ ] **T-HUB-07** Роль **Р** → неактивна только «Отправить отчёт»; в меню нет «Отправить отчёт» и «Привязка к справочнику».
|
||||
- [ ] **T-HUB-08** Статус **завершен** (роль Е) → неактивна только «Отправить отчёт».
|
||||
- [ ] **T-HUB-09** Статус **архивный** → активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть статус **текущий**.
|
||||
- [-] **T-HUB-08** Статус «завершен» — **не применимо**: в SharePoint у поля «Статус» только «Текущий» и «Архивный» (проверено 2026-10-16). Правило в коде остаётся на случай, если вариант появится.
|
||||
- [ ] **T-HUB-09** Статус **«Архивный»** (менять в SharePoint, список «Проекты») → бейдж золотой, активен только «Архив», выгрузка неактивна, в меню только «Проект». Вернуть **«Текущий»**.
|
||||
- ⚠️ Статус меняем **в списке SharePoint**, а не на портале проектов: портал пишет статус («Текущий» / «Архив») в справочник API, StroyBot его не читает.
|
||||
- [ ] **T-HUB-10** «Архив проекта» → в новой вкладке папка `Документы/Архив/{проект}` (та же, что открывает старый main).
|
||||
- [ ] **T-HUB-11** Ctrl+клик по карточке «Объекты» → раздел открылся в новой вкладке.
|
||||
- [ ] **T-HUB-12** Выгрузка: «?» показывает 3 строки и закрывается кликом мимо. Даты 13.10–19.10 → «Отчёт за период»; 12.10–18.10 (Пн–Вс) → «Еженедельный»; один день → «Ежедневный»; начало позже конца → ошибка, кнопка неактивна.
|
||||
@@ -101,7 +103,7 @@
|
||||
- [ ] ✍️ **T-OBJ-05** Меню ⋮ у «Корпус 1» → «Добавить объект» → «Раздел» «Фундамент». У «Фундамент» → «Вид работ» «Бетонирование». У вида работ нет пункта «Добавить объект».
|
||||
- [ ] ✍️ **T-OBJ-06** Пустое название → сообщение «Поле обязательно…»; Enter сохраняет.
|
||||
- [ ] ✍️ **T-OBJ-07** Редактировать «Фундамент» → сменить родителя (нужен второй объект-пятно «Корпус 2»).
|
||||
- [ ] ✍️ **T-OBJ-08** Перетащить объект на другой → родитель сменился; на своего потомка — запрещено.
|
||||
- [-] **T-OBJ-08** Перетаскивание — **убрано** (решение 2026-10-16). Проверить, что строку дерева мышкой больше не перетащить.
|
||||
- [ ] ✍️ **T-OBJ-09** Удалить объект с детьми → подтверждение с числом детей; дети остаются (🐞 так было).
|
||||
- [ ] **T-OBJ-10** «Экспорт» → скачался `objects-{id}.xlsx`, содержимое как у старой страницы.
|
||||
- [ ] **T-OBJ-11** Меню ⋮ → «Работы» → открылся раздел «Работы» этого объекта.
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useRouter } from '../routing/RouterContext';
|
||||
import { IAccessibleProject } from './projectTypes';
|
||||
import { fetchAccessibleProjects } from './projectRepository';
|
||||
import { readStoredProject, storeProject } from './projectSession';
|
||||
import { resolveCurrentProject } from './projectRules';
|
||||
|
||||
export type ProjectsLoadState = 'loading' | 'ready' | 'error';
|
||||
|
||||
@@ -51,13 +52,12 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
if (isCancelled) return;
|
||||
setProjects(loaded);
|
||||
setLoadState('ready');
|
||||
// В сессии могли остаться устаревшие права — обновляем по свежим данным (как в main).
|
||||
// В сессии могли остаться устаревшие статус и права — сохраняем свежие.
|
||||
setStoredProject((stored) => {
|
||||
const fresh = stored ? findProject(loaded, stored.id) : undefined;
|
||||
if (!stored || !fresh) return stored;
|
||||
const updated = { ...stored, canEdit: fresh.canEdit };
|
||||
storeProject(updated);
|
||||
return updated;
|
||||
if (!fresh) return stored;
|
||||
storeProject(fresh);
|
||||
return fresh;
|
||||
});
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
@@ -71,13 +71,7 @@ export const ProjectProvider: React.FC<{ children: React.ReactNode }> = ({ child
|
||||
}, [services, reloadKey]);
|
||||
|
||||
const routeProjectId = route.projectId ? Number(route.projectId) : undefined;
|
||||
let currentProject: IAccessibleProject | undefined;
|
||||
if (routeProjectId === undefined) {
|
||||
currentProject = storedProject;
|
||||
} else {
|
||||
currentProject =
|
||||
findProject(projects, routeProjectId) ?? (storedProject?.id === routeProjectId ? storedProject : undefined);
|
||||
}
|
||||
const currentProject = resolveCurrentProject(projects, routeProjectId, storedProject);
|
||||
const currentProjectId = route.projectId ?? (storedProject ? String(storedProject.id) : undefined);
|
||||
|
||||
const selectProject = React.useCallback(
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isSectionAvailable,
|
||||
mergeAccessRows,
|
||||
pickRecentProjectIds,
|
||||
resolveCurrentProject,
|
||||
sortProjects
|
||||
} from './projectRules';
|
||||
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', () => {
|
||||
it('архивный проект — закрыты все разделы, кроме главной', () => {
|
||||
const archived = project({ status: 'архивный' });
|
||||
|
||||
@@ -85,6 +85,23 @@ export function isSectionAvailable(page: PageId, project: IAccessibleProject): b
|
||||
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 {
|
||||
return getStatusKind(project.status) !== 'archive';
|
||||
|
||||
@@ -50,6 +50,25 @@ $dropdown-max-height: 340px;
|
||||
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 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,14 @@ export const ProjectPicker: React.FC = () => {
|
||||
setIsOpen(true);
|
||||
};
|
||||
|
||||
// Очистка не сбрасывает выбранный проект — только текст поиска, чтобы сразу искать другой.
|
||||
const onClear = (event: React.MouseEvent): void => {
|
||||
event.preventDefault();
|
||||
setQuery('');
|
||||
setIsOpen(true);
|
||||
inputRef.current?.focus();
|
||||
};
|
||||
|
||||
// mousedown, а не click: иначе поле теряет фокус раньше, чем срабатывает выбор.
|
||||
const onPick = (event: React.MouseEvent, project: IAccessibleProject): void => {
|
||||
event.preventDefault();
|
||||
@@ -67,6 +75,17 @@ export const ProjectPicker: React.FC = () => {
|
||||
onFocus={onFocus}
|
||||
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 && (
|
||||
<span className={styles.selectedBadge}>
|
||||
<ProjectStatusBadge status={currentProject.status} />
|
||||
|
||||
@@ -4,6 +4,7 @@ export const HUB_TEXTS = {
|
||||
picker: {
|
||||
label: 'Проект:',
|
||||
placeholder: 'Выберите проект',
|
||||
clear: 'Очистить',
|
||||
loading: 'Загрузка проектов…',
|
||||
noProjects: 'Нет доступных проектов',
|
||||
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 { ObjectNode } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { DropZone } from './DropZone';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
|
||||
@@ -12,7 +10,6 @@ interface Props {
|
||||
node: ObjectNode;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
}
|
||||
|
||||
@@ -20,19 +17,14 @@ interface Props {
|
||||
const ROW_BASE_INDENT_PX = 12;
|
||||
const ROW_LEVEL_INDENT_PX = 22;
|
||||
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean, isDragging: boolean): string {
|
||||
return [
|
||||
styles.nodeRow,
|
||||
hasChildren ? styles.nodeRowParent : '',
|
||||
isSelected ? styles.nodeRowSelected : '',
|
||||
isDragging ? styles.nodeRowDragging : ''
|
||||
]
|
||||
function rowClassName(hasChildren: boolean, isSelected: boolean): string {
|
||||
return [styles.nodeRow, hasChildren ? styles.nodeRowParent : '', isSelected ? styles.nodeRowSelected : '']
|
||||
.filter(Boolean)
|
||||
.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 isExpanded = view.expandedIds.has(node.id);
|
||||
|
||||
@@ -58,13 +50,9 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
|
||||
return (
|
||||
<div>
|
||||
<DropZone targetId={node.id} position="before" drag={drag} />
|
||||
<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 }}
|
||||
draggable={canEdit}
|
||||
onDragStart={(event) => drag.onDragStart(node.id, event)}
|
||||
onDragEnd={drag.onDragEnd}
|
||||
onClick={() => view.clickRow(node)}
|
||||
onContextMenu={onContextMenu}
|
||||
>
|
||||
@@ -93,22 +81,13 @@ export const ObjectTreeNode: React.FC<Props> = ({ node, canEdit, view, drag, onO
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<DropZone targetId={node.id} position="inside" drag={drag} />
|
||||
{hasChildren && isExpanded && (
|
||||
<div>
|
||||
{node.children.map((child) => (
|
||||
<ObjectTreeNode
|
||||
key={child.id}
|
||||
node={child}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={child.id} node={child} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<DropZone targetId={node.id} position="after" drag={drag} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -91,10 +91,6 @@ $menu-button-size: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
.nodeRowDragging {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.nodeToggle {
|
||||
display: flex;
|
||||
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 {
|
||||
display: flex;
|
||||
|
||||
@@ -10,7 +10,6 @@ import { useObjectsData } from './useObjectsData';
|
||||
import { useObjectActions } from './useObjectActions';
|
||||
import { useObjectsExport } from './useObjectsExport';
|
||||
import { useObjectTreeView } from './useObjectTreeView';
|
||||
import { useObjectDrag } from './useObjectDrag';
|
||||
import { ObjectsHeader } from './ObjectsHeader';
|
||||
import { ObjectsTree } from './ObjectsTree';
|
||||
import { ObjectModal } from './ObjectModal';
|
||||
@@ -42,17 +41,9 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
);
|
||||
|
||||
const data = useObjectsData(repository, projectId);
|
||||
const actions = useObjectActions({
|
||||
repository,
|
||||
projectTitle: data.projectTitle,
|
||||
tree: data.tree,
|
||||
reload: data.reload
|
||||
});
|
||||
const actions = useObjectActions({ repository, projectTitle: data.projectTitle, reload: data.reload });
|
||||
const exporter = useObjectsExport(repository, projectId);
|
||||
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 [modalTarget, setModalTarget] = React.useState<IObjectModalTarget | undefined>(undefined);
|
||||
|
||||
@@ -105,7 +96,6 @@ export const ObjectsPageContent: React.FC<Props> = ({ projectId }) => {
|
||||
tree={data.tree}
|
||||
canEdit={data.canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={(node, anchor) => setMenu({ node, anchor })}
|
||||
onAddRoot={openAddRoot}
|
||||
/>
|
||||
|
||||
@@ -5,7 +5,6 @@ import { IMenuAnchor } from '../_tree/ContextMenu';
|
||||
import { countNodes } from '../_tree/treeRules';
|
||||
import { ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { IObjectTreeView } from './useObjectTreeView';
|
||||
import { IObjectDrag } from './useObjectDrag';
|
||||
import { ObjectTreeNode } from './ObjectTreeNode';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
import styles from './ObjectsPage.module.scss';
|
||||
@@ -14,13 +13,12 @@ interface Props {
|
||||
tree: ObjectTree;
|
||||
canEdit: boolean;
|
||||
view: IObjectTreeView;
|
||||
drag: IObjectDrag;
|
||||
onOpenMenu: (node: ObjectNode, anchor: IMenuAnchor) => void;
|
||||
onAddRoot: () => void;
|
||||
}
|
||||
|
||||
/** Панель «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}>
|
||||
<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) => (
|
||||
<ObjectTreeNode
|
||||
key={node.id}
|
||||
node={node}
|
||||
canEdit={canEdit}
|
||||
view={view}
|
||||
drag={drag}
|
||||
onOpenMenu={onOpenMenu}
|
||||
/>
|
||||
<ObjectTreeNode key={node.id} node={node} canEdit={canEdit} view={view} onOpenMenu={onOpenMenu} />
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -174,17 +174,6 @@ describe('запись', () => {
|
||||
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 () => {
|
||||
const verbose = setup([
|
||||
ENTITY_TYPE_ROUTE,
|
||||
|
||||
@@ -165,8 +165,6 @@ export interface IObjectsRepository {
|
||||
createObject(projectTitle: string, title: string, parentId: number | undefined): Promise<void>;
|
||||
/** newParentId — только при смене родителя Раздела; undefined — поле не трогаем. */
|
||||
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>;
|
||||
downloadObjectsExcel(projectId: string): Promise<Blob>;
|
||||
}
|
||||
@@ -211,11 +209,6 @@ export function createObjectsRepository(deps: IObjectsRepositoryDeps): IObjectsR
|
||||
if (newParentId !== undefined) body[OBJECT_FIELDS.parentWrite] = newParentId;
|
||||
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),
|
||||
downloadObjectsExcel: (projectId) => downloadObjectsExcel(deps, projectId)
|
||||
};
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
buildObjectTree,
|
||||
canAddChild,
|
||||
canDrop,
|
||||
getChangedParentId,
|
||||
getDeleteConfirmMessage,
|
||||
getErrorText,
|
||||
@@ -9,8 +8,7 @@ import {
|
||||
getParentDisplay,
|
||||
getParentOptions,
|
||||
getSubmitLabel,
|
||||
isParentSelectable,
|
||||
resolveMove
|
||||
isParentSelectable
|
||||
} from './objectsRules';
|
||||
import { buildObjectMenuItems } from './objectMenu';
|
||||
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('удаление и ошибки', () => {
|
||||
it('подтверждение с числом дочерних', () => {
|
||||
expect(getDeleteConfirmMessage(node(1))).toEqual(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { ISelectOption } from '@tnsu/ui-kit';
|
||||
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';
|
||||
|
||||
// Правила страницы «Объекты» (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;
|
||||
}
|
||||
|
||||
/** Бросать узел можно куда угодно, кроме себя и своих потомков. */
|
||||
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 {
|
||||
return OBJECTS_TEXTS.deleteConfirm(
|
||||
node.item.title || OBJECTS_TEXTS.deleteFallbackTitle(node.id),
|
||||
|
||||
@@ -22,9 +22,6 @@ export interface IObjectModalTarget {
|
||||
node: ObjectNode | undefined;
|
||||
}
|
||||
|
||||
/** Куда бросили узел относительно целевого: до, внутрь, после. */
|
||||
export type DropPosition = 'before' | 'inside' | 'after';
|
||||
|
||||
/**
|
||||
* Строка «Объекты» в ответе SharePoint ($select=Id,Title,parent/Id&$expand=parent).
|
||||
* Пустой lookup приходит как null — читается через `?.`.
|
||||
|
||||
@@ -58,7 +58,6 @@ export const OBJECTS_TEXTS = {
|
||||
deleteFallbackTitle: (id: number): string => `ID ${id}`,
|
||||
serverError: (status: number): string => `Ошибка сервера: ${status}`,
|
||||
saveError: (message: string): string => `Ошибка сохранения: ${message}`,
|
||||
moveError: (message: string): string => `Не удалось переместить: ${message}`,
|
||||
deleteError: (message: string): string => `Не удалось удалить: ${message}`,
|
||||
exportError: (message: string): string => `Не удалось скачать файл: ${message}`
|
||||
} as const;
|
||||
|
||||
@@ -1,28 +1,26 @@
|
||||
import * as React from 'react';
|
||||
import { confirmModal } from '@tnsu/ui-kit';
|
||||
import { IObjectsRepository } from './objectsRepository';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText, resolveMove } from './objectsRules';
|
||||
import { DropPosition, IObjectModalTarget, ObjectNode, ObjectTree } from './objectsTypes';
|
||||
import { getChangedParentId, getDeleteConfirmMessage, getErrorText } from './objectsRules';
|
||||
import { IObjectModalTarget, ObjectNode } from './objectsTypes';
|
||||
import { OBJECTS_TEXTS } from './texts';
|
||||
|
||||
interface IActionsDeps {
|
||||
repository: IObjectsRepository;
|
||||
projectTitle: string | undefined;
|
||||
tree: ObjectTree;
|
||||
reload: () => void;
|
||||
}
|
||||
|
||||
export interface IObjectActions {
|
||||
/** Ошибка переноса или удаления (раньше — alert). */
|
||||
/** Ошибка удаления (раньше — alert). */
|
||||
actionError: string | undefined;
|
||||
/** Создание / правка из модалки. Ошибку бросает дальше — модалка покажет её у себя. */
|
||||
saveObject: (target: IObjectModalTarget, title: string, parentValue: string) => Promise<void>;
|
||||
moveObject: (dragId: number, targetId: number, position: DropPosition) => 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 saveObject = async (target: IObjectModalTarget, title: string, parentValue: string): Promise<void> => {
|
||||
@@ -39,18 +37,6 @@ export function useObjectActions({ repository, projectTitle, tree, reload }: IAc
|
||||
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 isConfirmed = await confirmModal({
|
||||
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,
|
||||
assignedItemUrl,
|
||||
deleteAssignedResource,
|
||||
existingAssignmentUrl,
|
||||
fetchAllItems,
|
||||
fetchProjectResources,
|
||||
poolItemsUrl,
|
||||
@@ -105,13 +106,29 @@ describe('fetchProjectResources', () => {
|
||||
});
|
||||
|
||||
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 () => {
|
||||
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: 'Бетонщик' });
|
||||
|
||||
expect(id).toEqual(101);
|
||||
expect(client.requests[0]).toEqual({
|
||||
expect(client.requests[1]).toEqual({
|
||||
url: items('Профессии по проекту'),
|
||||
request: {
|
||||
method: 'POST',
|
||||
@@ -126,16 +143,16 @@ describe('addAssignedResource', () => {
|
||||
});
|
||||
|
||||
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: 'ТОО Строй' });
|
||||
|
||||
expect(client.requests[0].url).toEqual(items('Компании по проекту'));
|
||||
expect(client.requests[0].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
||||
expect(client.requests[1].url).toEqual(items('Компании по проекту'));
|
||||
expect(client.requests[1].request.body).toEqual('{"Title":"ТОО Строй","projectIdId":5,"companyIdId":3}');
|
||||
});
|
||||
|
||||
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(
|
||||
'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(
|
||||
client: IHttpClient,
|
||||
siteUrl: string,
|
||||
@@ -138,6 +151,14 @@ export async function addAssignedResource(
|
||||
projectId: string,
|
||||
item: IResourceItem
|
||||
): 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 = {
|
||||
Title: item.title,
|
||||
[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 { ITree } from '../_tree/treeRules';
|
||||
import { FormInput } from '../_tree/FormInput';
|
||||
import { SearchableSelect } from '../../shared/components/SearchableSelect';
|
||||
import { IWorkEditor } from './useWorkEditor';
|
||||
import { parentOptions, unitOptions } from './worksRules';
|
||||
import { IWork } from './worksTypes';
|
||||
@@ -60,12 +61,15 @@ export const WorkModal: React.FC<Props> = ({ editor, tree, units, objectTitle })
|
||||
/>
|
||||
</FormField>
|
||||
<FormField label={TEXTS.unit} htmlFor="work-unit" full>
|
||||
<Select
|
||||
<SearchableSelect
|
||||
id="work-unit"
|
||||
value={form.unit}
|
||||
onChange={(value) => setField('unit', value)}
|
||||
options={unitOptions(units, form.unit)}
|
||||
placeholder={TEXTS.unitPlaceholder}
|
||||
searchPlaceholder={TEXTS.unitSearch}
|
||||
notFoundText={TEXTS.unitNotFound}
|
||||
clearLabel={TEXTS.cancel}
|
||||
/>
|
||||
</FormField>
|
||||
<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)} />
|
||||
</FormField>
|
||||
<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>
|
||||
{editor.saveError && <Alert variant="err">{editor.saveError}</Alert>}
|
||||
</div>
|
||||
|
||||
@@ -45,7 +45,10 @@ export const WORKS_TEXTS = {
|
||||
add: 'Добавить',
|
||||
save: 'Сохранить',
|
||||
saving: 'Сохранение...',
|
||||
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.'
|
||||
saveError: 'Ошибка при сохранении. Проверьте права доступа к Dataverse.',
|
||||
dateRangeError: 'Дата окончания не может быть раньше даты начала',
|
||||
unitSearch: 'Начните вводить ед. изм.',
|
||||
unitNotFound: 'Ничего не найдено'
|
||||
},
|
||||
confirmDelete: {
|
||||
title: 'Подтвердите удаление',
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as React from 'react';
|
||||
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 { WORKS_TEXTS } from './texts';
|
||||
|
||||
@@ -63,6 +63,11 @@ export function useWorkEditor({ repository, objectId, reloadWorks }: IEditorDeps
|
||||
titleInputRef.current?.focus();
|
||||
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 }));
|
||||
try {
|
||||
const parentNavProperty = form.parentId.trim() ? await repository.getParentNavProperty() : '';
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
buildWorkTree,
|
||||
formatDateRange,
|
||||
formatVolume,
|
||||
isDateRangeValid,
|
||||
isTitleValid,
|
||||
needsClearParent,
|
||||
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('наименование обязательно (пробелы не считаются)', () => {
|
||||
expect(isTitleValid({ ...EMPTY_WORK_FORM, title: ' ' })).toBe(false);
|
||||
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: '' };
|
||||
}
|
||||
|
||||
/** Окончание не раньше начала (даты YYYY-MM-DD сравниваются строками). Пустые даты не проверяются. */
|
||||
export function isDateRangeValid(form: IWorkForm): boolean {
|
||||
return !form.start || !form.finish || form.start <= form.finish;
|
||||
}
|
||||
|
||||
export function isTitleValid(form: IWorkForm): boolean {
|
||||
return form.title.trim() !== '';
|
||||
}
|
||||
|
||||
@@ -23,9 +23,14 @@ export function createSharePointClient(context: WebPartContext): IHttpClient {
|
||||
export function createAadClient(context: WebPartContext, resource: string): IHttpClient {
|
||||
let clientPromise: Promise<AadHttpClient> | undefined;
|
||||
|
||||
// Неудачную попытку не запоминаем: иначе одна сетевая осечка при получении токена
|
||||
// ломала бы все запросы к этому ресурсу до перезагрузки страницы.
|
||||
const getClient = (): Promise<AadHttpClient> => {
|
||||
if (!clientPromise) {
|
||||
clientPromise = context.aadHttpClientFactory.getClient(resource);
|
||||
clientPromise = context.aadHttpClientFactory.getClient(resource).catch((error: unknown) => {
|
||||
clientPromise = undefined;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user